Compare commits

...
4 Commits
Author SHA1 Message Date
lobo 8681508cb6 feat(projects): rewrite project texts with verified facts
Build, Test & Push Frontend / docker (push) Successful in 50s
Build, Test & Push Frontend / quality-check (push) Successful in 1m18s
Old copy was generic and did not match the code. Texts now describe
the real features: PlayStation sync, file imports, tests, security.
Add per-game itch.io links, new chips, and a test that checks every
About project key exists in en and de.
2026-10-14 11:03:54 +02:00
lobo 106d39453d fix(about): show original company logos again
Remove the logo invert added in 07a9b8b and its test.
The inverted logos were not wanted, so the originals show again.
2026-10-14 09:04:46 +02:00
lobo 07a9b8bc11 fix(site): remove canvas frame, invert logos, new footer text
The canvas had a 1px frame. The company logos are opaque white, so
invert them to match the dark tiles. Core fetches the Auth public key
(JWKS), so the To Be Played diagram now shows that arrow.
2026-10-14 08:49:00 +02:00
lobo 93e9c59436 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.
2026-10-14 08:18:18 +02:00
15 changed files with 517 additions and 208 deletions

No files matched your search

+3 -4
View File
@@ -41,12 +41,11 @@ export class AssetsConstants {
'/assets/projects/diploma/6.webp' '/assets/projects/diploma/6.webp'
]; ];
static readonly CLAUDE_BOX_IMAGES = [ static readonly CLAUDE_BOX_DIAGRAM = '/assets/projects/claude-in-a-box/1.svg';
'/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 = [ static readonly TRIBBLE_IMAGES = [
'/assets/projects/tribble-the-homeserver/1.webp',
'/assets/projects/tribble-the-homeserver/2.webp', '/assets/projects/tribble-the-homeserver/2.webp',
'/assets/projects/tribble-the-homeserver/3.webp', '/assets/projects/tribble-the-homeserver/3.webp',
'/assets/projects/tribble-the-homeserver/4.webp' '/assets/projects/tribble-the-homeserver/4.webp'
+1 -1
View File
@@ -5,7 +5,7 @@
</main> </main>
<footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg"> <footer class="border-t border-black/8 p-fluid-md text-center opacity-80 bg-app-bg">
<small>© {{ currentYear }} Andreas Dahm - {{ `APP.COPYRIGHT` | translate }}</small> <small>© {{ currentYear }} Andreas Dahm. {{ 'APP.COPYRIGHT' | translate }}</small>
<br /> <br />
<small> <small>
{{ 'APP.AI_NOTICE' | translate }} {{ 'APP.AI_NOTICE' | translate }}
+38
View File
@@ -0,0 +1,38 @@
import { TestBed } from '@angular/core/testing';
import { Component, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core';
import { AppComponent } from './app.component';
import { TopbarComponent } from '../topbar/topbar.component';
import { ParticleBackgroundComponent } from '../../shared/components/particles-background/particles-background.component';
import { SeoService } from '../../service/seo.service';
@Component({ selector: 'app-topbar', template: '' })
class TopbarStub {}
@Component({ selector: 'app-particles-background', template: '' })
class ParticlesStub {}
describe('AppComponent', () => {
it('renders the copyright line with year, name and a closing dot', async () => {
TestBed.overrideComponent(AppComponent, {
remove: { imports: [TopbarComponent, ParticleBackgroundComponent] },
add: { imports: [TopbarStub, ParticlesStub] },
});
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
{ provide: SeoService, useValue: { init: vi.fn() } },
],
}).compileComponents();
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const text = fixture.nativeElement.querySelector('footer small').textContent.trim();
expect(text).toBe(`© ${new Date().getFullYear()} Andreas Dahm. APP.COPYRIGHT`);
});
});
+32 -1
View File
@@ -5,6 +5,8 @@ import { MatIconRegistry } from '@angular/material/icon';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideTranslateService } from '@ngx-translate/core'; import { provideTranslateService } from '@ngx-translate/core';
import { AboutComponent } from './about.component'; import { AboutComponent } from './about.component';
import en from '../../../assets/i18n/en.json';
import de from '../../../assets/i18n/de.json';
/** /**
* No translations are loaded, so the translate pipe echoes the key back. That is * No translations are loaded, so the translate pipe echoes the key back. That is
@@ -151,6 +153,26 @@ describe('AboutComponent', () => {
}); });
describe('projects', () => { describe('projects', () => {
it('every project key exists in en and de', () => {
const keys = fixture.componentInstance.projectKeys.flatMap(project => [
`${project.key}.TITLE`,
`${project.key}.DESCRIPTION`,
`${project.key}.LINK_INTERNAL`,
`${project.key}.HIGHLIGHTS.P1`,
`${project.key}.HIGHLIGHTS.P2`,
`${project.key}.HIGHLIGHTS.P3`,
...(project.externalLink ? [`${project.key}.LINK_EXTERNAL`] : []),
]);
const missing = Object.entries({ en, de }).flatMap(([language, translations]) =>
keys
.filter(key => typeof resolve(translations, key) !== 'string')
.map(key => `${language}: ${key}`)
);
expect(missing).toEqual([]);
});
it('lists To Be Played and links to its project dialog', () => { it('lists To Be Played and links to its project dialog', () => {
expect(textOf('mat-card strong')).toContain('ABOUT.PROJECT.TBP.TITLE'); expect(textOf('mat-card strong')).toContain('ABOUT.PROJECT.TBP.TITLE');
@@ -166,11 +188,14 @@ describe('AboutComponent', () => {
describe('education', () => { describe('education', () => {
it('shows only the post-school entries until the toggle button is clicked', () => { it('shows only the post-school entries until the toggle button is clicked', () => {
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE'); expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E6.WHERE');
expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E0.WHERE'); expect(textOf('[data-testid="education-where"]')).not.toContain('ABOUT.EDUCATION.E2.WHERE');
expect(textOf('[data-testid="education-where"]').length).toBe(3);
click('toggle-education'); click('toggle-education');
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E2.WHERE');
expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE'); expect(textOf('[data-testid="education-where"]')).toContain('ABOUT.EDUCATION.E0.WHERE');
expect(textOf('[data-testid="education-where"]').length).toBe(7);
}); });
}); });
@@ -188,4 +213,10 @@ describe('AboutComponent', () => {
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy(); expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
}); });
}); });
function resolve(translations: unknown, key: string): unknown {
return key
.split('.')
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], translations);
}
}); });
@@ -12,6 +12,14 @@
</ul> </ul>
</div> </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="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="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"> <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 { provideZonelessChangeDetection } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core'; import { provideTranslateService } from '@ngx-translate/core';
@@ -27,27 +27,46 @@ describe('ProjectDialogComponent', () => {
expect(() => register()).not.toThrow(); 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({ await TestBed.configureTestingModule({
imports: [ProjectDialogComponent], imports: [ProjectDialogComponent],
providers: [ providers: [
provideZonelessChangeDetection(), provideZonelessChangeDetection(),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }), provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
{ { provide: MAT_DIALOG_DATA, useValue: data },
provide: MAT_DIALOG_DATA,
useValue: {
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
},
},
], ],
}).compileComponents(); }).compileComponents();
const fixture = TestBed.createComponent(ProjectDialogComponent); const fixture = TestBed.createComponent(ProjectDialogComponent);
fixture.detectChanges(); 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'); const el: HTMLElement = fixture.nativeElement.querySelector('swiper-container');
expect(el).not.toBeNull(); expect(el).not.toBeNull();
expect(el instanceof customElements.get('swiper-container')!).toBe(true); 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-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle> <mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
</mat-card-header> </mat-card-header>
@if(project.images.length > 0) { @if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate"> <img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else { } @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]"> <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> <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-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title> <mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header> </mat-card-header>
@if(project.images.length > 0) { @if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate"> <img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else { } @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]"> <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> <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'); 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', () => { it('every project translation key exists in en and de', () => {
const keys = fixture.componentInstance.allProjects.flatMap(project => [ const keys = fixture.componentInstance.allProjects.flatMap(project => [
project.title, project.title,
+34 -14
View File
@@ -19,6 +19,7 @@ export interface Projects {
url: string, url: string,
source: string source: string
}[], }[],
diagram?: string,
icon: string, icon: string,
assets: string, assets: string,
links: { links: {
@@ -59,6 +60,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TBP.SHORT_DESCRIPTION', shortDescription: 'PROJECTS.TBP.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TBP.INTRODUCTION', introduction: 'PROJECTS.TBP.INTRODUCTION',
images: AssetsConstants.TBP_IMAGES.map(url => ({ url, source: '' })), images: AssetsConstants.TBP_IMAGES.map(url => ({ url, source: '' })),
diagram: AssetsConstants.TBP_DIAGRAM,
icon: 'playlist_add_check', icon: 'playlist_add_check',
assets: '', assets: '',
links: [ links: [
@@ -72,17 +74,19 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TBP.BULLET_2', 'PROJECTS.TBP.BULLET_2',
'PROJECTS.TBP.BULLET_3', 'PROJECTS.TBP.BULLET_3',
'PROJECTS.TBP.BULLET_4', 'PROJECTS.TBP.BULLET_4',
'PROJECTS.TBP.BULLET_5',
], ],
challenges: [ challenges: [
'PROJECTS.TBP.CHALLENGE_1', 'PROJECTS.TBP.CHALLENGE_1',
'PROJECTS.TBP.CHALLENGE_2', 'PROJECTS.TBP.CHALLENGE_2',
'PROJECTS.TBP.CHALLENGE_3', 'PROJECTS.TBP.CHALLENGE_3',
'PROJECTS.TBP.CHALLENGE_4',
], ],
learnings: [ learnings: [
'PROJECTS.TBP.LEARNING_1', 'PROJECTS.TBP.LEARNING_1',
'PROJECTS.TBP.LEARNING_2', 'PROJECTS.TBP.LEARNING_2',
], ],
isFeatured: false, isFeatured: true,
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker'] technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
}, },
{ {
@@ -90,7 +94,8 @@ export class ProjectsComponent implements OnInit, OnDestroy {
title: 'PROJECTS.CLAUDE_BOX.TITLE', title: 'PROJECTS.CLAUDE_BOX.TITLE',
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION', shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION', introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })), images: [],
diagram: AssetsConstants.CLAUDE_BOX_DIAGRAM,
icon: 'smart_toy', icon: 'smart_toy',
assets: '', assets: '',
links: [], links: [],
@@ -126,6 +131,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.PLAYGROUND.BULLET_2', 'PROJECTS.PLAYGROUND.BULLET_2',
'PROJECTS.PLAYGROUND.BULLET_3', 'PROJECTS.PLAYGROUND.BULLET_3',
'PROJECTS.PLAYGROUND.BULLET_4', 'PROJECTS.PLAYGROUND.BULLET_4',
'PROJECTS.PLAYGROUND.BULLET_5',
], ],
challenges: [ challenges: [
'PROJECTS.PLAYGROUND.CHALLENGE_1', 'PROJECTS.PLAYGROUND.CHALLENGE_1',
@@ -136,7 +142,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.PLAYGROUND.LEARNING_2', 'PROJECTS.PLAYGROUND.LEARNING_2',
], ],
isFeatured: false, isFeatured: false,
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'GitHub Actions', 'Docker'] technologies: ['Angular', 'TypeScript', 'Tailwind CSS', 'WebGPU', 'GLSL', 'Babylon.js', 'Gitea Actions', 'Docker']
}, },
{ {
identifier: "elmucho", identifier: "elmucho",
@@ -162,8 +168,8 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.EL_MUCHO.LEARNING_1', 'PROJECTS.EL_MUCHO.LEARNING_1',
'PROJECTS.EL_MUCHO.LEARNING_2', 'PROJECTS.EL_MUCHO.LEARNING_2',
], ],
isFeatured: true, isFeatured: false,
technologies: ['Unity', 'C#', 'Steamworks', 'Git'] technologies: ['Unity', 'C#', 'Steamworks']
}, },
{ {
identifier: "gamejams", identifier: "gamejams",
@@ -173,7 +179,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
images: AssetsConstants.GAME_JAMS_IMAGES.map(url => ({ url, source: '' })), images: AssetsConstants.GAME_JAMS_IMAGES.map(url => ({ url, source: '' })),
icon: 'videogame_asset', icon: 'videogame_asset',
assets: '', assets: '',
links: [{ name: 'PROJECTS.LINK_TO_PROJECT', url: 'https://itch.io/c/6628860/lobos-collection' }], links: [
{ name: 'Peter Blue and the carnival of death', url: 'https://lobothedark.itch.io/beansjam' },
{ name: 'Arkonauts', url: 'https://lobothedark.itch.io/arkonauts' },
{ name: 'Sloth Bros', url: 'https://lobothedark.itch.io/sloth-bros' },
{ name: 'PROJECTS.GAME_JAMS.LINK_COLLECTION', url: 'https://itch.io/c/6628860/lobos-collection' }
],
bulletPoints: [ bulletPoints: [
'PROJECTS.GAME_JAMS.BULLET_1', 'PROJECTS.GAME_JAMS.BULLET_1',
'PROJECTS.GAME_JAMS.BULLET_2', 'PROJECTS.GAME_JAMS.BULLET_2',
@@ -189,7 +200,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.GAME_JAMS.LEARNING_2', 'PROJECTS.GAME_JAMS.LEARNING_2',
], ],
isFeatured: false, isFeatured: false,
technologies: ['Unity', 'C#', 'Git'] technologies: ['Unity', 'C#']
}, },
{ {
identifier: "diploma", identifier: "diploma",
@@ -205,17 +216,19 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.DIPLOMA.BULLET_2', 'PROJECTS.DIPLOMA.BULLET_2',
'PROJECTS.DIPLOMA.BULLET_3', 'PROJECTS.DIPLOMA.BULLET_3',
'PROJECTS.DIPLOMA.BULLET_4', 'PROJECTS.DIPLOMA.BULLET_4',
'PROJECTS.DIPLOMA.BULLET_5',
], ],
challenges: [ challenges: [
'PROJECTS.DIPLOMA.CHALLENGE_1', 'PROJECTS.DIPLOMA.CHALLENGE_1',
'PROJECTS.DIPLOMA.CHALLENGE_2', 'PROJECTS.DIPLOMA.CHALLENGE_2',
'PROJECTS.DIPLOMA.CHALLENGE_3',
], ],
learnings: [ learnings: [
'PROJECTS.DIPLOMA.LEARNING_1', 'PROJECTS.DIPLOMA.LEARNING_1',
'PROJECTS.DIPLOMA.LEARNING_2', 'PROJECTS.DIPLOMA.LEARNING_2',
], ],
isFeatured: false, isFeatured: false,
technologies: ['Java', 'Performance', 'Algorithm', 'Simulation'] technologies: ['Java', 'BVH / k-DOP', 'Collision detection', 'Cloth simulation']
}, },
{ {
identifier: "tribble-the-homeserver", identifier: "tribble-the-homeserver",
@@ -223,28 +236,31 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION', shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TRIBBLE.INTRODUCTION', introduction: 'PROJECTS.TRIBBLE.INTRODUCTION',
images: [ images: [
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://upload.wikimedia.org/wikipedia/commons/0/03/Hostinger_Logo.png' }, { url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], 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/gitea' }, { url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/traefik' }
{ url: AssetsConstants.TRIBBLE_IMAGES[3], source: 'https://dashboardicons.com/icons/traefik' }
], ],
diagram: AssetsConstants.TRIBBLE_DIAGRAM,
icon: 'dns', icon: 'dns',
assets: '', assets: '',
links: [ links: [
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' }, { name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
{ name: 'Docker', url: 'https://www.docker.com/' }, { name: 'Docker', url: 'https://www.docker.com/' },
{ name: 'Portainer', url: 'https://www.portainer.io/' },
{ name: 'Traefik', url: 'https://traefik.io/' }, { name: 'Traefik', url: 'https://traefik.io/' },
{ name: 'Gitea', url: 'https://gitea.io/' }, { name: 'Gitea', url: 'https://gitea.io/' },
{ name: 'Jellyfin', url: 'https://jellyfin.org/' }, { name: 'Jellyfin', url: 'https://jellyfin.org/' },
{ name: 'AdGuard Home', url: 'https://adguard.com/en/adguard-home/overview.html' }, { name: 'AdGuard Home', url: 'https://adguard.com/en/adguard-home/overview.html' },
{ name: 'Paperless-ngx', url: 'https://paperless-ngx.com/' }, { name: 'Paperless-ngx', url: 'https://paperless-ngx.com/' },
{ name: 'Tailscale', url: 'https://tailscale.com/' } { name: 'Tailscale', url: 'https://tailscale.com/' },
{ name: 'Watchtower', url: 'https://containrrr.dev/watchtower/' }
], ],
bulletPoints: [ bulletPoints: [
'PROJECTS.TRIBBLE.BULLET_1', 'PROJECTS.TRIBBLE.BULLET_1',
'PROJECTS.TRIBBLE.BULLET_2', 'PROJECTS.TRIBBLE.BULLET_2',
'PROJECTS.TRIBBLE.BULLET_3', 'PROJECTS.TRIBBLE.BULLET_3',
'PROJECTS.TRIBBLE.BULLET_4', 'PROJECTS.TRIBBLE.BULLET_4',
'PROJECTS.TRIBBLE.BULLET_5',
], ],
challenges: [ challenges: [
'PROJECTS.TRIBBLE.CHALLENGE_1', 'PROJECTS.TRIBBLE.CHALLENGE_1',
@@ -255,13 +271,17 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TRIBBLE.LEARNING_2', 'PROJECTS.TRIBBLE.LEARNING_2',
], ],
isFeatured: false, 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', 'Watchtower']
} }
] ]
featuredProject = computed(() => this.allProjects.find(p => p.isFeatured)); featuredProject = computed(() => this.allProjects.find(p => p.isFeatured));
otherProjects = computed(() => this.allProjects.filter(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 { ngOnInit(): void {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => { this.dialogOpenFunction(); }, 10); setTimeout(() => { this.dialogOpenFunction(); }, 10);
@@ -7,3 +7,7 @@
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
} }
canvas {
box-shadow: none;
}
+95 -87
View File
@@ -1,7 +1,7 @@
{ {
"APP": { "APP": {
"TITLE": "Playground", "TITLE": "Playground",
"COPYRIGHT": "Bilder und Sourcecode sind urheberrechtlich geschützt, keine Nutzung ohne Zustimmung!", "COPYRIGHT": "Alle Rechte vorbehalten.",
"AI_NOTICE": "Entstanden mit einem KI-gestützten Engineering-Workflow.", "AI_NOTICE": "Entstanden mit einem KI-gestützten Engineering-Workflow.",
"AI_NOTICE_LINK": "Wie ich mit KI arbeite" "AI_NOTICE_LINK": "Wie ich mit KI arbeite"
}, },
@@ -187,13 +187,13 @@
"PROJECT": { "PROJECT": {
"TBP": { "TBP": {
"TITLE": "To Be Played", "TITLE": "To Be Played",
"DESCRIPTION": "Ein Steam-Backlog-Tracker, der meine Bibliothek importiert, Status, Bewertung und Notizen verwaltet und passend zur verfügbaren Zeit vorschlägt, was ich als Nächstes spiele. Drei selbst gehostete Dienste: Auth, Core und ein React-Frontend.", "DESCRIPTION": "Ein Backlog-Tracker für Steam und PlayStation, der meine Bibliotheken synchronisiert, Status, Bewertung und Notizen verwaltet und passend zur verfügbaren Zeit vorschlägt, was ich als Nächstes spielen kann. Drei selbst gehostete Dienste: Auth, Core und ein React-Frontend.",
"LINK_EXTERNAL": "tbp.andreas-dahm.eu", "LINK_EXTERNAL": "tbp.andreas-dahm.eu",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Steam-Sync von Spielen, Spielzeit, Erfolgen und Wunschliste, dazu die Spieldauer (Time to Beat) aus IGDB.", "P1": "Steam- und PlayStation-Sync von Spielen, Spielzeit, Erfolgen und Trophäen, dazu die Spieldauer (Time to Beat) aus IGDB.",
"P2": "Vorschläge für „Was spiele ich als Nächstes?“, gefiltert nach Zeitbudget und Stimmung.", "P2": "„Was spiele ich als Nächstes?“: sieben Reihen mit Vorschlägen, gefiltert nach Zeitbudget; ein Stimmungsfilter für die Zufallsvorschläge.",
"P3": "Der Auth-Service signiert die Tokens mit Ed25519, Core kennt nur den öffentlichen Schlüssel." "P3": "Auth signiert die Tokens mit Ed25519; Core holt den öffentlichen Schlüssel über die Key-ID und prüft nur."
} }
}, },
"CLAUDE_BOX": { "CLAUDE_BOX": {
@@ -208,55 +208,55 @@
}, },
"P2": { "P2": {
"TITLE": "Playground", "TITLE": "Playground",
"DESCRIPTION": "Dieses Projekt ist, wie der Name schon sagt, ein persönlicher Playground, der im Laufe der Zeit wachsen soll. Es präsentiert verschiedene private Projekte, an denen ich arbeite, und dokumentiert deren Fortschritt, zentrale Ideen und wichtige Meilensteine.", "DESCRIPTION": "Diese Seite. Ein Portfolio und ein Ort, um Algorithmen im Browser auszuprobieren: Pfadfindung, Sortieren, Fraktale in GLSL sowie eine Stoff- und eine Doppelpendel-Simulation als WebGPU-Compute-Shader. Angular, Deutsch und Englisch, gebaut und ausgeliefert von meiner eigenen CI.",
"LINK_EXTERNAL": "andreas-dahm.eu", "LINK_EXTERNAL": "andreas-dahm.eu",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Einsatz moderner Technologien und CI/CD-Pipelines (Angular 20+, Spring Boot 4, GitHub).", "P1": "Visualisierungen von Dijkstra bis zu 3D-Fraktalen per Raymarching, alles im Client.",
"P2": "Präsentation persönlicher Projekte und kontinuierliche Verbesserung algorithmischer Fähigkeiten.", "P2": "Zwei WebGPU-Compute-Simulationen (Stoff mit XPBD, Doppelpendel) mit CPU- oder WebGL-Fallback.",
"P3": "Vertiefung von JavaScript/TypeScript-, Angular- und Spring-Boot-Kenntnissen durch praktisches Arbeiten." "P3": "Pipeline auf Gitea Actions: Lint, i18n-Prüfung, Tests, Lighthouse, Docker-Image in meine eigene Registry."
} }
}, },
"P1": { "P1": {
"TITLE": "El Mucho", "TITLE": "El Mucho",
"DESCRIPTION": "Dies ist mein erstes veröffentlichtes Spiel auf Steam. Ich habe es gemeinsam mit meinem Bruder entwickelt und damit eines meiner persönlichen Ziele erreicht: ein eigenes Videospiel zu erstellen und zu veröffentlichen.", "DESCRIPTION": "Mein erstes veröffentlichtes Spiel, ein rundenbasiertes taktisches Rollenspiel, das ich mit meinem Bruder in etwa drei Jahren gemacht und 2022 auf Steam veröffentlicht habe. Er hat es entworfen, ich habe es gebaut.",
"LINK_EXTERNAL": "Steam Store", "LINK_EXTERNAL": "Steam Store",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Veröffentlichung eines Spiels auf Steam und Integration der Steam-API.", "P1": "Der gesamte Code in Unity (C#), die Builds, das Steam-Setup und der Release.",
"P2": "Konzeption, Planung und vollständige Entwicklung eines eigenen Spiels.", "P2": "16 Missionen, Erfolge, drei Sprachen (EN, DE, ES).",
"P3": "Implementierung komplexer Algorithmen wie einer eigenen A*-Pfadfindungslogik und Spiel-KI." "P3": "Eine eigene KI für die gegnerischen Einheiten und mein eigenes A*-Pathfinding auf dem Kartenraster."
} }
}, },
"P0": { "P0": {
"TITLE": "Game Jams", "TITLE": "Game Jams",
"DESCRIPTION": "Dieser Bereich zeigt einige meiner bisherigen Game-Jam-Beiträge. Game Jams sind eine großartige Möglichkeit, neue Spielideen auszuprobieren und schnell zu überprüfen, ob ein Game-Loop funktioniert. Die engen Zeitvorgaben fördern den Fokus, es ist faszinierend, was man in so kurzer Zeit alles schaffen kann.", "DESCRIPTION": "Drei 48-Stunden-Jam-Spiele aus der BeansJam-Reihe, in Teams aus zwei oder drei Leuten. Ich war jedes Mal Programmierer und Leiter. Alle sind auf itch.io spielbar.",
"LINK_EXTERNAL": "Itch.io Sammlung", "LINK_EXTERNAL": "Itch.io Sammlung",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Planung eines realistischen Projektumfangs mit einem Team, der innerhalb von 48 Stunden umsetzbar ist.", "P1": "Peter Blue and the carnival of death (2018), Arkonauts (2020), Sloth Bros (2022).",
"P2": "Lernen, fokussiert und effizient unter strengen Zeitvorgaben zu arbeiten.", "P2": "Freitag bis Sonntag: jedes Mal ein spielbares Spiel in 48 Stunden.",
"P3": "Die Freude zu erleben, in kurzer Zeit ein spielbares Projekt zu erstellen und andere damit spielen zu sehen." "P3": "Ein günstiger Weg, herauszufinden, ob eine Spielidee funktioniert, bevor man Jahre in sie steckt."
} }
}, },
"DIPLOMA": { "DIPLOMA": {
"TITLE": "Diplomarbeit - Kollisionserkennung und Behandlung von komplexen Kleidungsstücken.", "TITLE": "Diplomarbeit - Kollisionserkennung und Behandlung von komplexen Kleidungsstücken.",
"DESCRIPTION": "Die Diplomarbeit handelt von der Erkennung und der Behandlung von Kollisionen zwischen, sowie innerhalb, einzelnen Kleidungsstücken in Echtzeit. Das ist gerade aufgrund der Flexibilität von Stoffen und deren unterschiedlichen Eigenschaften besonders herausfordernd.", "DESCRIPTION": "Selbstdurchdringungen innerhalb eines Kleidungsteils, etwa bei einem Plisseerock, in der Stoffsimulation Vidya bei assyst. Ich habe Intersection Contour Minimization an Vidyas k-DOP-Hierarchie angepasst und die Antwort um Masse und Geschwindigkeit erweitert. FH Bingen, März 2010.",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Echtzeit behandlung von Kollisionserkennung und Behandlung.", "P1": "Erkennung auf der vorhandenen BVH mit k-DOPs; eine gemeinsame Hierarchie hat die Zeit für Update und Traversierung halbiert.",
"P2": "Verstehen und Einschätzen von wissenschaftlichen Arbeiten.", "P2": "Vier veröffentlichte Methoden verglichen und die globale Variante der Intersection Contour Minimization gewählt.",
"P3": "Adaption und Weiterentwicklung von vorausgegangenen Forschungsarbeiten." "P3": "Etwa 20 % schneller als der vorhandene Selbstkollisions-Detektor plus Layering, für den produktiven Einsatz vorgesehen."
} }
}, },
"TRIBBLE": { "TRIBBLE": {
"TITLE": "Homeserver 'Tribble'", "TITLE": "Homeserver 'Tribble'",
"DESCRIPTION": "In diesem Projekt geht es um die Einrichtung und Wartung meines eigenen Homeservers. Er betreibt mehrere Docker-Container wie Gitea, Jellyfin and more. Es ist eine großartige Lernerfahrung im Bereich Self-Hosting und Systemadministration.", "DESCRIPTION": "Ein Mini-PC zu Hause betreibt Gitea mit Actions-Runner und Registry, dazu Jellyfin, Paperless-ngx und AdGuard Home. Ein VPS liefert die öffentlichen Seiten aus und holt neue Images mit Watchtower über Tailscale. Läuft seit Mai 2024, Backups auf Google Drive.",
"LINK_INTERNAL": "Projektdetails", "LINK_INTERNAL": "Projektdetails",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Self-Hosting verschiedener Dienste mit Docker.", "P1": "Gitea-CI baut und testet diese Seite und To Be Played zu Hause; Watchtower spielt die Images auf dem VPS ein.",
"P2": "CI/CD-Pipeline für die persönliche Website mit Gitea.", "P2": "Traefik für TLS und Routing, Tailscale zwischen den beiden Maschinen.",
"P3": "Sicherer Fernzugriff mit Tailscale und Traefik." "P3": "Schwierig waren DNS und ein wiederholbares Setup, nicht die Dienste selbst."
} }
} }
}, },
@@ -301,6 +301,7 @@
"PROJECTS": { "PROJECTS": {
"DOWNLOAD": "Herunterladen", "DOWNLOAD": "Herunterladen",
"READ_MORE": "Mehr erfahren", "READ_MORE": "Mehr erfahren",
"DIAGRAM_ALT": "Architekturdiagramm",
"LINK_TO_PROJECT": "Zum Projekt", "LINK_TO_PROJECT": "Zum Projekt",
"CLOSE": "Schließen", "CLOSE": "Schließen",
"SECTION": { "SECTION": {
@@ -308,70 +309,75 @@
"LEARNINGS": "Learnings & Soft Skills" "LEARNINGS": "Learnings & Soft Skills"
}, },
"PLAYGROUND": { "PLAYGROUND": {
"TITLE": "Playground Portfolio", "TITLE": "Playground",
"SHORT_DESCRIPTION": "Full-Stack Portfolio mit interaktiven Algorithmus-Visualisierungen.", "SHORT_DESCRIPTION": "Diese Seite: ein Portfolio mit Algorithmus-Visualisierungen, die im Browser laufen, einige davon auf der GPU.",
"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.", "INTRODUCTION": "Auf dieser Seite probiere ich Dinge aus. Ich wollte sehen, wie weit ein normaler Browser mit den Algorithmen kommt, die ich mag. Deshalb läuft jede Visualisierung im Client: Pfadfindung, Labyrinth-Erzeugung, Sortieren, Conways Spiel des Lebens, Fraktale in GLSL-Shadern und zwei Physiksimulationen, die als Compute Shader auf WebGPU laufen. Der Rest ist eine normale Angular-App mit deutschen und englischen Texten, einem Dark Mode und einer CI-Pipeline, die sie baut, testet und als Docker-Image in meine eigene Registry schiebt.",
"BULLET_1": "Entwicklung mit Angular 19+ und Material Design.", "BULLET_1": "Pfadfindung mit Dijkstra und A*, Labyrinth-Erzeugung mit Prim und Kruskal, fünf Sortieralgorithmen mit Ton, Conways Spiel des Lebens und eine Vier-Farben-Karte.",
"BULLET_2": "Implementierung performanter Visualisierungen (WebGPU, Shader, Canvas).", "BULLET_2": "2D-Fraktale (Mandelbrot, Julia, Burning Ship, Newton) in einem GLSL-Shader und 3D-Fraktale (Mandelbulb, Mandelbox, Julia-Bulb) per Raymarching, mit Normalen aus dem Distanzfeld.",
"BULLET_3": "Automatisierte CI/CD-Pipelines und Containerisierung mit Docker.", "BULLET_3": "Ein Doppelpendel und eine Stoffsimulation laufen als Compute Shader auf WebGPU. Ohne WebGPU weichen sie auf die CPU oder WebGL aus und sagen das in einer Snackbar.",
"BULLET_4": "Internationalization (i18n) für globale Reichweite.", "BULLET_4": "Angular mit Material und Tailwind, Signals für den Zustand, Deutsch und Englisch und ein Service Worker (PWA).",
"CHALLENGE_1": "Optimierung der Render-Performance bei komplexen 3D-Fraktalen in Echtzeit.", "BULLET_5": "CI auf Gitea Actions: Lint, i18n-Prüfung, Tests, Produktions-Build und ein Lighthouse-Bericht. Auf main baut sie ein Docker-Image und schiebt es in meine eigene Registry.",
"CHALLENGE_2": "Architektur einer skalierbaren und wartbaren Frontend-Struktur für diverse Sub-Projekte.", "CHALLENGE_1": "Der Stoff-Solver auf der GPU. XPBD-Constraints, die sich einen Partikel teilen, lassen sich nicht parallel lösen, ohne dass es zu einem Race kommt. Ich gruppiere die Constraints per Graphfärbung in acht Phasen. So berühren sich innerhalb einer Phase nie zwei Constraints am selben Partikel, und jede Phase läuft voll parallel.",
"LEARNING_1": "Effektives UI/UX-Design für komplexe datengesteuerte Visualisierungen.", "CHALLENGE_2": "Nicht jeder Browser hat WebGPU. Beide Simulationen haben einen CPU- oder WebGL-Pfad und eine Snackbar, die sagt, dass der Fallback aktiv ist. So funktioniert die Seite überall, und niemand fragt sich, warum es langsamer ist.",
"LEARNING_2": "Modernstes State-Management und reaktive Programmierung in Angular." "LEARNING_1": "Compute Shader verändern, wie man über einen Algorithmus denkt. Das Datenlayout und die Reihenfolge der Schreibzugriffe zählen mehr als die Mathematik.",
"LEARNING_2": "Eine kleine CI mit einer Schlüssel-Prüfung für die Übersetzungen und einem Lighthouse-Lauf fängt die langweiligen Fehler ab, eine fehlende Übersetzung oder eine langsame Seite, bevor ich sie auf der Live-Seite sehe."
}, },
"TRIBBLE": { "TRIBBLE": {
"TITLE": "Self-Hosted Infrastructure", "TITLE": "Self-Hosted Infrastructure",
"SHORT_DESCRIPTION": "Home-Infrastruktur mit Docker, Traefik und sicherer VPN-Anbindung.", "SHORT_DESCRIPTION": "Home-Server für CI und private Dienste, dazu ein VPS für die öffentlichen Seiten.",
"INTRODUCTION": "Dokumentation und Aufbau einer privaten Cloud-Infrastruktur. Fokus liegt auf Datensouveränität, Automatisierung und Sicherheit.", "INTRODUCTION": "Zwei Maschinen. Zu Hause läuft auf einem Mini-PC namens „Tribble“ Gitea mit Actions-Runner und Container-Registry, dazu die privaten Dienste. Ein Hostinger-VPS liefert die öffentlichen Seiten aus, auch diese hier. Ein Git-Push baut das Image zu Hause, der VPS holt es mit Watchtower über Tailscale, und Traefik übernimmt TLS und Routing. Das Ganze läuft seit Mai 2024.",
"BULLET_1": "Zentrale Verwaltung via Docker-Compose und Portainer.", "BULLET_1": "Hardware: ein KODLIX GD50 Mini-PC mit 8-Kern-Intel-Core-i5, 32 GB RAM und einer 512-GB-SSD.",
"BULLET_2": "Automatisches SSL-Management und Reverse-Proxy mit Traefik.", "BULLET_2": "Gitea für Git, den Actions-Runner und die Container-Registry. Diese Seite und To Be Played werden dort gebaut und getestet.",
"BULLET_3": "Private Versionskontrolle (Gitea) und Medien-Streaming (Jellyfin).", "BULLET_3": "Private Dienste: Jellyfin für Medien, Paperless-ngx für Dokumente, AdGuard Home für DNS und Werbeblocker, Portainer, um die Container im Blick zu haben, und Traefik davor.",
"BULLET_4": "Netzwerkweite Ad-Blocking und DNS-Kontrolle via AdGuard Home.", "BULLET_4": "Tailscale verbindet die beiden Maschinen. Darüber holt der VPS neue Images aus der Registry zu Hause, und Watchtower tauscht den laufenden Container aus.",
"CHALLENGE_1": "Konfiguration sicherer Netzwerkschichten und Firewall-Regeln für Remote-Zugriff.", "BULLET_5": "Die Backups gehen auf Google Drive.",
"CHALLENGE_2": "Automatisierung von Backups und Recovery-Strategien für containerisierte Daten.", "CHALLENGE_1": "DNS. Namen müssen aus dem Haus, über Tailscale und aus dem öffentlichen Internet auf die richtige Adresse zeigen, und AdGuard Home sitzt mittendrin. Das richtig hinzubekommen hat länger gedauert als jeder einzelne Dienst.",
"LEARNING_1": "Tiefes Verständnis für moderne Netzwerkprotokolle und IT-Sicherheit.", "CHALLENGE_2": "Alles gleichzeitig zum Laufen zu bringen und es dann wiederholbar zu machen. Das erste Setup war eine lange Liste von Handgriffen. Ich habe es automatisiert, damit sich der Server neu aufsetzen lässt, ohne dass ich mir jeden Schritt merken muss.",
"LEARNING_2": "Effizientes Ressourcen-Management auf limitierten Server-Systemen." "LEARNING_1": "Selbst hosten kostet wenig Geld und viel Zeit. Die meiste Zeit geht in die Teile, die niemand sieht: DNS, Zertifikate und Backups.",
"LEARNING_2": "Ein Git-Push, der ohne Handgriff dazwischen in einem laufenden Container auf dem VPS endet, ist der Teil dieses Setups, den ich nicht mehr hergeben würde."
}, },
"EL_MUCHO": { "EL_MUCHO": {
"TITLE": "El Mucho (Steam Release)", "TITLE": "El Mucho (Steam Release)",
"SHORT_DESCRIPTION": "Rundenbasiertes Taktik-RPG, veröffentlicht auf Steam.", "SHORT_DESCRIPTION": "Ein rundenbasiertes taktisches Rollenspiel, das mein Bruder und ich auf Steam veröffentlicht haben.",
"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.", "INTRODUCTION": "El Mucho ist ein rundenbasiertes taktisches Rollenspiel in einer erfundenen Welt namens Liberika, inspiriert von Langrisser. Mein Bruder und ich haben es in etwa drei Jahren neben einem Vollzeitjob gebaut und auf Steam veröffentlicht: Early Access im Juli 2021, die Vollversion im Februar 2022. Die Aufteilung war klar. Er hatte die Idee und hat die Assets, das Leveldesign, das Testen und das Balancing gemacht. Ich habe alles Technische gemacht: den Code in Unity, die Builds, das Steam-Setup und den Release.",
"BULLET_1": "Komplette Engine-Entwicklung in Unity (C#).", "BULLET_1": "Unity und C#. Ein Entwickler für den gesamten Code.",
"BULLET_2": "Integration von Steamworks-Funktionen (Achievements, Cloud Saves).", "BULLET_2": "16 Missionen, Erfolge und Familienfreigabe auf Steam, veröffentlicht als Lobo Corp.",
"BULLET_3": "Entwicklung einer eigenen taktischen KI und Pfadfindungs-Logik.", "BULLET_3": "Englisch, Deutsch und Spanisch. Die spanische Übersetzung hat ein Freund gemacht, der Muttersprachler ist.",
"BULLET_4": "Management des gesamten Asset-Pipelines und Sound-Designs.", "BULLET_4": "Grafik und Musik sind teils Open Source, teils von meinem Bruder, teils von mir.",
"CHALLENGE_1": "Implementierung eines robusten rundenbasierten Systems mit komplexen Abhängigkeiten.", "CHALLENGE_1": "Der Steam-Release als Ein-Personen-Technikteam. Builds, Store-Einrichtung, Upload und die Early-Access-Phase liefen alle über mich. Early Access startete im Juli 2021, die Vollversion folgte im Februar 2022.",
"CHALLENGE_2": "Performance-Optimierung für eine flüssige Darstellung auf verschiedenen Hardware-Profilen.", "CHALLENGE_2": "Drei Sprachen. Jeden Text gibt es auf Englisch, Deutsch und Spanisch; für Spanisch habe ich mit einem Freund zusammengearbeitet, der Muttersprachler ist.",
"CHALLENGE_3": "Umgang mit den strengen Zertifizierungs-Anforderungen von Steam.", "CHALLENGE_3": "Die gegnerischen Einheiten. Dafür habe ich eine eigene KI und mein eigenes A*-Pathfinding auf dem Kartenraster geschrieben.",
"LEARNING_1": "Durchhaltevermögen und Fokus über einen mehrjährigen Entwicklungszyklus.", "LEARNING_1": "Fertigmachen dauert länger als Bauen. Allein die Strecke vom Early Access bis zur Vollversion hat etwa sieben Monate gedauert.",
"LEARNING_2": "Vermarktung und Community-Management für ein digitales Produkt." "LEARNING_2": "Eine klare Rollenverteilung hat ein Zwei-Personen-Team drei Jahre lang arbeitsfähig gehalten: Er war für das Spiel zuständig, ich für den Code und den Release."
}, },
"GAME_JAMS": { "GAME_JAMS": {
"TITLE": "Rapid Prototyping & Game Jams", "TITLE": "Game Jams",
"SHORT_DESCRIPTION": "Sammlung innovativer Spielkonzepte, entstanden in unter 48 Stunden.", "SHORT_DESCRIPTION": "Drei kleine Spiele, jedes in 48 Stunden von Freitag bis Sonntag entstanden.",
"INTRODUCTION": "Teilnahme an nationalen Wettbewerben (z.B. Beansjam). Hier geht es darum, unter extremem Zeitdruck funktionale und spaßige Prototypen zu erschaffen.", "INTRODUCTION": "Bei einer Game Jam bekommt man am Freitagabend ein Thema und soll bis Sonntag ein spielbares Spiel abliefern. Ich habe bisher drei gemacht, alle in der BeansJam-Reihe, in Teams aus zwei oder drei Leuten. Ich war immer Programmierer und Leiter; die anderen haben Ideen, Assets und Tests beigesteuert. Alle drei Spiele gibt es auf itch.io.",
"BULLET_1": "Fokus on 'Core Game Loop' und schnelles Feedback.", "BULLET_1": "Peter Blue and the carnival of death (2018, BeansJam): meine erste Jam.",
"BULLET_2": "Kollaborative Entwicklung in kleinen, agilen Teams.", "BULLET_2": "Arkonauts (2020, Mini Beans Jam 6): Space Invaders im Weltraum.",
"BULLET_3": "Effektives Zeitmanagement und Scope-Kontrolle.", "BULLET_3": "Sloth Bros (2022, BeansJam '22): Thema „wir müssen wach bleiben“, eines von 65 Spielen.",
"BULLET_4": "Veröffentlichung und Iteration basierend auf Community-Votings.", "BULLET_4": "Immer 48 Stunden, von Freitag bis Sonntag, in einem Team aus zwei oder drei Leuten.",
"CHALLENGE_1": "Reduzierung komplexer Ideen auf ein in 48h umsetzbares Minimum Viable Product (MVP).", "CHALLENGE_1": "Der Umfang. 48 Stunden sind nicht viel, und mit Schlaf sind es weniger. Am Freitag zu entscheiden, was wir nicht bauen, war in jeder Jam die wichtigste Entscheidung.",
"CHALLENGE_2": "Schnelle Fehlerdiagnose und Bugfixing unter massivem Zeitdruck.", "CHALLENGE_2": "Als einziger Programmierer. Alles, was sich das Team ausgedacht hat, ging durch meine Tastatur. Eine Idee musste also billig zu bauen sein, sonst wurde sie gestrichen.",
"LEARNING_1": "Radikale Priorisierung von Features ('Kill your darlings').", "LEARNING_1": "Eine spielbare Schleife am Samstag schlägt eine bessere Idee am Sonntag.",
"LEARNING_2": "Effektive Kommunikation und Entscheidungsfindung im Team-Stress." "LEARNING_2": "In einer Jam finde ich heraus, ob eine Spielidee funktioniert, bevor ich Jahre in sie stecke. El Mucho hat drei Jahre gedauert; ein Jam-Spiel dauert ein Wochenende.",
"LINK_COLLECTION": "Alle Spiele auf itch.io"
}, },
"DIPLOMA": { "DIPLOMA": {
"TITLE": "Wissenschaftliche Diplomarbeit", "TITLE": "Diplomarbeit: Selbstkollisionen bei Stoffen",
"SHORT_DESCRIPTION": "Echtzeit-Kollisionserkennung für komplexe, flexible 3D-Objekte.", "SHORT_DESCRIPTION": "Selbstdurchdringungen in simulierter Kleidung erkennen und auflösen, entstanden bei assyst für die Stoffsimulation Vidya (2010).",
"INTRODUCTION": "Forschungsarbeit im Bereich Computergraphik. Entwicklung eines Algorithmus zur physikalisch korrekten Simulation von Stoffen und Kleidung in Echtzeit.", "INTRODUCTION": "Simulierter Stoff faltet sich in sich selbst. Ein Plisseerock durchdringt zum Beispiel sein eigenes Gewebe, und sobald zwei Teile eines Kleidungsstücks durcheinander gerutscht sind, kann eine normale Kollisionsantwort sie nicht mehr trennen. Kollisionen zwischen verschiedenen Lagen konnte Vidya schon; Selbstdurchdringungen innerhalb eines Kleidungsteils nicht. In meiner Diplomarbeit bei assyst habe ich Intersection Contour Minimization (Volino et al. 2006) an Vidya angepasst und so erweitert, dass die Antwort Masse und Geschwindigkeit berücksichtigt. Geschrieben in Java, im März 2010 an der FH Bingen abgeschlossen und für den produktiven Einsatz vorgesehen.",
"BULLET_1": "Mathematische Modellierung von Mass-Spring-Systemen.", "BULLET_1": "Die Erkennung läuft auf Vidyas vorhandener Bounding-Volume-Hierarchie mit k-DOPs. Bei Selbsttests überspringt die Traversierung die Tests an inneren Knoten, die sie nicht braucht.",
"BULLET_2": "Low-Level Programmierung mit Java.", "BULLET_2": "Die Schnittkontur wird aus Segmenten aufgebaut. Ein räumlicher Hash verbindet sie auch dann, wenn Gleitkommafehler kleine Lücken lassen.",
"BULLET_3": "Optimierung durch räumliche Datenstrukturen (AABB Trees, Bounding Spheres).", "BULLET_3": "Die Antwort ist meine Erweiterung: Sie nutzt Masse und Geschwindigkeit, sodass das Ergebnis physikalisch plausibel ist und nicht nur ein geometrischer Schubs.",
"BULLET_4": "Wissenschaftliche Evaluation der Simulations-Präzision.", "BULLET_4": "Der Parameter h0 wird aus der mittleren Dreiecksfläche abgeleitet und muss daher nicht von Hand eingestellt werden.",
"CHALLENGE_1": "Behandlung von 'Self-Collisions' bei hochauflösenden Meshes ohne Performance-Einbruch.", "BULLET_5": "Die Methode teilt sich eine BVH mit Vidyas vorhandenem TriangleSelfCollisionDetector.",
"CHALLENGE_2": "Mathematische Stabilisierung der Integrationsverfahren bei hohen Krafteinwirkungen.", "CHALLENGE_1": "Der Aufwand. Die Testszene hatte etwa 16.000 Partikel bei etwa 430 ms pro Schritt auf einem 2-GHz-Core-2-Duo. Eine gemeinsame BVH für meinen Detektor und den vorhandenen hat die Zeit für Update und Traversierung halbiert und den Overhead um etwa 80 % gesenkt. Zusätzlich zur vorhandenen Erkennung kostet die Methode etwa 5 %.",
"LEARNING_1": "Transfer von theoretischen Forschungsarbeiten in produktiven, performanten Code.", "CHALLENGE_2": "Die Wahl der Methode. Ich habe Baraff (2003), Bridson (2002) und Volino (2000, 2006) verglichen und die globale Variante der Intersection Contour Minimization genommen, weil sie auch Durchdringungen auflöst, die schon bestehen.",
"LEARNING_2": "Präzises Arbeiten und Dokumentation nach wissenschaftlichen Standards." "CHALLENGE_3": "Bekannte Grenzen, dokumentiert: nahezu ebene Flächen und ein Vertex, der genau auf einer Dreiecksebene liegt, werden nicht behandelt.",
"LEARNING_1": "Paper richtig lesen. Das gleiche Problem hatte vier veröffentlichte Lösungen, und die nützliche Arbeit war zu verstehen, wo jede davon scheitert.",
"LEARNING_2": "Erst messen, dann behaupten. Die Methode allein war etwa 20 % schneller als der vorhandene Detektor plus Layering, und diese Zahl zählt nur, weil sie aus derselben Szene auf derselben Maschine stammt."
}, },
"CLAUDE_BOX": { "CLAUDE_BOX": {
"TITLE": "Claude-in-a-Box", "TITLE": "Claude-in-a-Box",
@@ -389,17 +395,19 @@
}, },
"TBP": { "TBP": {
"TITLE": "To Be Played", "TITLE": "To Be Played",
"SHORT_DESCRIPTION": "Ein Steam-Backlog-Tracker, der bei der Entscheidung hilft, was als Nächstes gespielt wird.", "SHORT_DESCRIPTION": "Ein Backlog-Tracker für Steam und PlayStation, der bei der Entscheidung hilft, was als Nächstes gespielt wird.",
"INTRODUCTION": "Meine Steam-Bibliothek hat weit mehr Spiele, als ich je durchspielen werde. To Be Played importiert die Bibliothek, verwaltet den Backlog mit Status, Bewertung und Notizen und schlägt passend zur verfügbaren Zeit vor, was ich als Nächstes spielen kann. Dahinter stehen drei Dienste: ein Auth-Service, der die Login-Tokens signiert, ein Core-Service für Bibliothek und Statistiken und ein React-Frontend. Alles läuft selbst gehostet auf demselben Server wie diese Seite.", "INTRODUCTION": "Meine Spielebibliothek hat weit mehr Spiele, als ich je durchspielen werde. To Be Played synchronisiert Steam und PlayStation, importiert Listen von GOG, Playnite, Grouvee und HowLongToBeat, verwaltet den Backlog mit Status, Bewertung und Notizen und schlägt passend zur verfügbaren Zeit vor, was ich als Nächstes spielen kann. Dahinter stehen drei Dienste: ein Auth-Service, der die Login-Tokens signiert, ein Core-Service für Bibliothek und Statistiken und ein React-Frontend. Alles läuft selbst gehostet auf demselben Server wie diese Seite.",
"BULLET_1": "Steam-Sync von Spielen, Spielzeit, Erfolgen und Wunschliste, von Hand und jede Nacht, dazu die Spieldauer (Time to Beat) aus IGDB.", "BULLET_1": "Steam-Sync von Spielen, Spielzeit, Erfolgen und Wunschliste, PlayStation-Sync von Titeln, Spielzeit und Trophäen, von Hand und jede Nacht, dazu die Spieldauer (Time to Beat) aus IGDB.",
"BULLET_2": "„Was spiele ich als Nächstes?“: sieben Reihen mit Vorschlägen, etwa der nächste Teil einer Reihe, dasselbe Studio oder Spiele, die Leute durchgespielt haben, denen man folgt, gefiltert nach Zeitbudget und Stimmung.", "BULLET_2": "„Was spiele ich als Nächstes?“: sieben Reihen mit je drei Vorschlägen, etwa der nächste Teil einer Reihe, dasselbe Studio oder Spiele, die Leute gespielt oder durchgespielt haben, denen man folgt. Ein Zeitbudget filtert jede Reihe; der Stimmungsfilter wirkt nur auf die Zufallsvorschläge.",
"BULLET_3": "Öffentliche Profile, anderen Spielern folgen mit einer Inbox zu ihrem Fortschritt und Pile Points für jedes Spiel, das vom Stapel kommt.", "BULLET_3": "Öffentliche Profile, anderen Spielern folgen mit einer Inbox zu ihrem Fortschritt und Pile Points für jedes Spiel, das vom Stapel kommt.",
"BULLET_4": "Deutsch und Englisch, Light- und Dark-Mode, auch auf dem Handy nutzbar. Auth, Core und Frontend werden jeweils als eigenes Docker-Image ausgeliefert.", "BULLET_4": "Deutsch und Englisch, Light- und Dark-Mode, auch auf dem Handy nutzbar. Auth, Core und Frontend werden jeweils als eigenes Docker-Image ausgeliefert.",
"CHALLENGE_1": "Login über zwei Dienste. Auth signiert die Tokens mit einem Ed25519-Schlüssel, Core kennt nur den öffentlichen Schlüssel und kann einen Login also prüfen, aber nie selbst ausstellen. Die Tokens liegen in HttpOnly-Cookies, kein Skript auf der Seite kann sie lesen.", "BULLET_5": "Über 3.400 Tests in den drei Diensten, ArchUnit-Regeln, die die Schichten getrennt halten, und Testcontainers für die echte Datenbank. Die CI führt den vollständigen Build aus und taggt jedes Image mit dem Commit.",
"CHALLENGE_2": "Die Limits von Steam. Jeder Steam-Aufruf ist rate-limitiert und wird bei Bedarf wiederholt, und ein Sync reserviert seinen Lauf zuerst in der Datenbank, damit nie zwei Syncs desselben Users gleichzeitig laufen.", "CHALLENGE_1": "Login über zwei Dienste. Auth signiert die Tokens mit einem Ed25519-Schlüssel, Core kennt nur den öffentlichen Schlüssel und kann einen Login also prüfen, aber nie selbst ausstellen. Core holt diesen Schlüssel zur Laufzeit von Auth, gesucht über die Key-ID, und die Tokens liegen in HttpOnly-Cookies, kein Skript auf der Seite kann sie lesen.",
"CHALLENGE_2": "Die Limits von Steam. Jeder Steam-Aufruf ist auf zwei pro Sekunde begrenzt und wird nur dort wiederholt, wo eine Wiederholung sicher ist (Spielebibliothek, Schema, Erfolge). Ein Sync reserviert seinen Lauf zuerst in der Datenbank, damit nie zwei Syncs desselben Users gleichzeitig laufen, und der nächtliche Sync berührt nur User, die in den letzten 14 Tagen aktiv waren.",
"CHALLENGE_3": "Ein Spiel vorzuschlagen ist schwerer, als es klingt. Jede Vorschlagsreihe braucht eigene Regeln, und die Listen hinter jeder Zahl müssen dieselben Regeln nutzen wie die Zahl selbst, damit ein Spiel überall gleich zählt.", "CHALLENGE_3": "Ein Spiel vorzuschlagen ist schwerer, als es klingt. Jede Vorschlagsreihe braucht eigene Regeln, und die Listen hinter jeder Zahl müssen dieselben Regeln nutzen wie die Zahl selbst, damit ein Spiel überall gleich zählt.",
"LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript prüft, dass beide Kopien gleich sind.", "CHALLENGE_4": "Missbrauch beim Login. Zu viele fehlgeschlagene Logins bekommen ein 429 mit Retry-After. Auch eine unbekannte E-Mail zahlt für eine volle BCrypt-Prüfung, sodass die Antwortzeit nichts verrät. Ein Header-Schutz blockt CSRF, CORS erlaubt nur exakte Origins, und das PlayStation-Token wird verschlüsselt gespeichert.",
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: Backups, Migrationen und eine feste Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature." "LEARNING_1": "Die Aufteilung in Dienste lohnt sich nur mit einem klaren Vertrag. Die Token-Claims stehen in einer Vertragsdatei, gegen die beide Dienste testen, und ein Skript, das ich von Hand starte, prüft, dass beide Kopien gleich sind.",
"LEARNING_2": "Auch ein Hobbyprojekt braucht die langweiligen Teile: tägliche Datenbank-Dumps, die sieben Tage, vier Wochen und sechs Monate aufbewahrt werden, Migrationen und eine schriftlich festgehaltene Deploy-Reihenfolge. Sie haben mehr darüber entschieden, wie stabil es läuft, als jedes Feature."
} }
}, },
"IMPRINT": { "IMPRINT": {
+95 -87
View File
@@ -1,7 +1,7 @@
{ {
"APP": { "APP": {
"TITLE": "Playground", "TITLE": "Playground",
"COPYRIGHT": "Images and code protected by copyright, no use without permission!", "COPYRIGHT": "All rights reserved.",
"AI_NOTICE": "Built with an AI-assisted engineering workflow.", "AI_NOTICE": "Built with an AI-assisted engineering workflow.",
"AI_NOTICE_LINK": "How I build with AI" "AI_NOTICE_LINK": "How I build with AI"
}, },
@@ -187,13 +187,13 @@
"PROJECT": { "PROJECT": {
"TBP": { "TBP": {
"TITLE": "To Be Played", "TITLE": "To Be Played",
"DESCRIPTION": "A Steam backlog tracker that imports my library, keeps status, rating and notes, and suggests what to play next for the time I have. Three self-hosted services: auth, core and a React frontend.", "DESCRIPTION": "A backlog tracker for Steam and PlayStation that syncs my libraries, keeps status, rating and notes, and suggests what to play next for the time I have. Three self-hosted services: auth, core and a React frontend.",
"LINK_EXTERNAL": "tbp.andreas-dahm.eu", "LINK_EXTERNAL": "tbp.andreas-dahm.eu",
"LINK_INTERNAL": "Project details", "LINK_INTERNAL": "Project details",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Steam sync of games, playtime, achievements and wishlist, plus time to beat from IGDB.", "P1": "Steam and PlayStation sync of games, playtime, achievements and trophies, plus time to beat from IGDB.",
"P2": "\"What to play next\" suggestions filtered by time budget and mood.", "P2": "\"What to play next\": seven rows of suggestions, filtered by a time budget; a mood filter for the random picks.",
"P3": "Auth service signs the tokens with Ed25519, Core only holds the public key." "P3": "Auth signs the tokens with Ed25519; Core fetches the public key by key id and only verifies."
} }
}, },
"CLAUDE_BOX": { "CLAUDE_BOX": {
@@ -208,55 +208,55 @@
}, },
"P2": { "P2": {
"TITLE": "Playground", "TITLE": "Playground",
"DESCRIPTION": "This project is, as the name suggests, a personal playground that will grow over time. It showcases various private projects I’m working on and documents their progress, key ideas, and milestones.", "DESCRIPTION": "This site. A portfolio and a place to try algorithms in the browser: pathfinding, sorting, fractals in GLSL, and a cloth and a double pendulum simulation as WebGPU compute shaders. Angular, German and English, built and shipped by my own CI.",
"LINK_EXTERNAL": "andreas-dahm.eu", "LINK_EXTERNAL": "andreas-dahm.eu",
"LINK_INTERNAL": "Project details", "LINK_INTERNAL": "Project details",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Using modern technologies and CI/CD pipelines (Angular 20+, Spring Boot 4, GitHub).", "P1": "Visualizations from Dijkstra to raymarched 3D fractals, all on the client.",
"P2": "Showcasing personal projects and improving algorithmic skills over time.", "P2": "Two WebGPU compute simulations (cloth with XPBD, double pendulum) with a CPU or WebGL fallback.",
"P3": "Deepening knowledge in JavaScript/TypeScript, Angular, Spring Boot and related technologies through hands-on practice." "P3": "Gitea Actions pipeline: lint, i18n check, tests, Lighthouse, Docker image to my own registry."
} }
}, },
"P1": { "P1": {
"TITLE": "El Mucho", "TITLE": "El Mucho",
"DESCRIPTION": "This is my first published game on Steam. I developed it together with my brother, fulfilling one of my personal goals: creating and releasing my own video game.", "DESCRIPTION": "My first published game, a turn-based tactical RPG, made with my brother over about three years and released on Steam in 2022. He designed it, I built it.",
"LINK_EXTERNAL": "Steam Store", "LINK_EXTERNAL": "Steam Store",
"LINK_INTERNAL": "Project details", "LINK_INTERNAL": "Project details",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Publishing a game on Steam and integrating the Steam API.", "P1": "All the code in Unity (C#), the builds, the Steam setup and the release.",
"P2": "Designing, planning and developing a complete game from scratch.", "P2": "16 missions, achievements, three languages (EN, DE, ES).",
"P3": "Implementing complex algorithms, including a custom A* pathfinding system and game AI logic." "P3": "A custom AI for the enemy units and my own A* pathfinding on the map grid."
} }
}, },
"P0": { "P0": {
"TITLE": "Game Jams", "TITLE": "Game Jams",
"DESCRIPTION": "This section showcases several of my past game jam creations. Game jams are a great way to explore new game ideas and quickly validate whether a game loop works. The tight time constraints force you to focus, and it’s always exciting to see what can be achieved within such a short timeframe.", "DESCRIPTION": "Three 48-hour jam games from the BeansJam series, in teams of two or three. I was the programmer and lead each time. All of them are playable on itch.io.",
"LINK_EXTERNAL": "Itch.io Collection", "LINK_EXTERNAL": "Itch.io Collection",
"LINK_INTERNAL": "Project details", "LINK_INTERNAL": "Project details",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Planning a realistic project scope with a team that can be built within 48 hours.", "P1": "Peter Blue and the carnival of death (2018), Arkonauts (2020), Sloth Bros (2022).",
"P2": "Learning to stay focused and work effectively under strict time constraints.", "P2": "Friday to Sunday: a playable game in 48 hours, every time.",
"P3": "Experiencing the joy of creating a playable game in a short timeframe and seeing others enjoy it." "P3": "A cheap way to find out whether a game idea works before committing years to it."
} }
}, },
"DIPLOMA": { "DIPLOMA": {
"TITLE": "Diploma thesis - Collision detection and handling of complex garments.", "TITLE": "Diploma thesis - Collision detection and handling of complex garments.",
"DESCRIPTION": "The thesis deals with the detection and handling of collisions between and within individual items of clothing in real time. This is particularly challenging due to the flexibility of fabrics and their varying properties.", "DESCRIPTION": "Self-intersections inside one garment part, such as a pleated skirt, in the cloth simulation Vidya at assyst. I adapted Intersection Contour Minimization to Vidya's k-DOP hierarchy and extended the response with mass and velocity. FH Bingen, March 2010.",
"LINK_INTERNAL": "Project details", "LINK_INTERNAL": "Project details",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Real-time handling of collision detection and response.", "P1": "Detection on the existing BVH with k-DOPs; one shared hierarchy cut update and traversal time in half.",
"P2": "Understanding and evaluating scientific papers.", "P2": "Compared four published methods and took the global Intersection Contour Minimization variant.",
"P3": "Adaptation and further development of previous research work." "P3": "About 20 % faster than the existing self-collision detector plus Layering, planned for productive use."
} }
}, },
"TRIBBLE": { "TRIBBLE": {
"TITLE": "Homeserver 'Tribble'", "TITLE": "Homeserver 'Tribble'",
"DESCRIPTION": "This project is about setting up and maintaining my own homeserver. It runs several Docker containers like Gitea, Jellyfin and more. It's a great learning experience in self-hosting and system administration.", "DESCRIPTION": "A mini PC at home runs Gitea with its Actions runner and registry, plus Jellyfin, Paperless-ngx and AdGuard Home. A VPS serves the public sites and pulls new images over Tailscale with Watchtower. Running since May 2024, backups to Google Drive.",
"LINK_INTERNAL": "Project details", "LINK_INTERNAL": "Project details",
"HIGHLIGHTS": { "HIGHLIGHTS": {
"P1": "Self-hosting of various services using Docker.", "P1": "Gitea CI builds and tests this site and To Be Played at home; Watchtower deploys the images on the VPS.",
"P2": "CI/CD pipeline for the personal website using Gitea.", "P2": "Traefik for TLS and routing, Tailscale between the two machines.",
"P3": "Secure remote access with Tailscale and Traefik." "P3": "The hard parts were DNS and making the setup repeatable, not the services themselves."
} }
} }
}, },
@@ -301,6 +301,7 @@
"PROJECTS": { "PROJECTS": {
"DOWNLOAD": "Download", "DOWNLOAD": "Download",
"READ_MORE": "Read More", "READ_MORE": "Read More",
"DIAGRAM_ALT": "Architecture diagram",
"LINK_TO_PROJECT": "To the project", "LINK_TO_PROJECT": "To the project",
"CLOSE": "Close", "CLOSE": "Close",
"SECTION": { "SECTION": {
@@ -308,70 +309,75 @@
"LEARNINGS": "Learnings & Soft Skills" "LEARNINGS": "Learnings & Soft Skills"
}, },
"PLAYGROUND": { "PLAYGROUND": {
"TITLE": "Playground Portfolio", "TITLE": "Playground",
"SHORT_DESCRIPTION": "Full-stack portfolio with interactive algorithm visualizations.", "SHORT_DESCRIPTION": "This site: a portfolio with algorithm visualizations that run in the browser, some of them on the GPU.",
"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.", "INTRODUCTION": "This site is where I try things out. I wanted to see how far a plain browser gets with the algorithms I like, so every visualization runs on the client: pathfinding, maze generation, sorting, Conway's Game of Life, fractals in GLSL shaders, and two physics simulations that run as compute shaders on WebGPU. The rest is a normal Angular app with German and English texts, a dark mode, and a CI pipeline that builds and tests it and pushes it as a Docker image to my own registry.",
"BULLET_1": "Development with Angular 19+ and Material Design.", "BULLET_1": "Pathfinding with Dijkstra and A*, maze generation with Prim and Kruskal, five sorting algorithms with sound, Conway's Game of Life and a four-color map.",
"BULLET_2": "Implementation of performant visualizations (WebGPU, Shader, Canvas).", "BULLET_2": "2D fractals (Mandelbrot, Julia, Burning Ship, Newton) in one GLSL shader, and 3D fractals (Mandelbulb, Mandelbox, Julia-Bulb) by raymarching with normals from the distance field.",
"BULLET_3": "Automated CI/CD pipelines and containerization with Docker.", "BULLET_3": "A double pendulum and a cloth simulation run as compute shaders on WebGPU. Without WebGPU they fall back to the CPU or WebGL and say so in a snackbar.",
"BULLET_4": "Internationalization (i18n) for global reach.", "BULLET_4": "Angular with Material and Tailwind, signals for state, German and English, and a service worker (PWA).",
"CHALLENGE_1": "Optimizing render performance for complex 3D fractals in real-time.", "BULLET_5": "CI on Gitea Actions: lint, i18n check, tests, production build and a Lighthouse report. On main it builds a Docker image and pushes it to my own registry.",
"CHALLENGE_2": "Architecting a scalable and maintainable frontend structure for diverse sub-projects.", "CHALLENGE_1": "The cloth solver on the GPU. XPBD constraints that share a particle cannot be solved in parallel without a race. I group the constraints by graph coloring into eight phases, so inside one phase no two constraints touch the same particle and each phase runs fully in parallel.",
"LEARNING_1": "Effective UI/UX design for complex data-driven visualizations.", "CHALLENGE_2": "Not every browser has WebGPU. Both simulations have a CPU or WebGL path and a snackbar that says the fallback is active, so the page works everywhere and nobody wonders why it is slower.",
"LEARNING_2": "Advanced state management and reactive programming in Angular." "LEARNING_1": "Compute shaders change how you think about an algorithm. The data layout and the order of writes matter more than the math.",
"LEARNING_2": "A small CI with a key-parity check and a Lighthouse run catches the boring mistakes, a missing translation or a slow page, before I see them on the live site."
}, },
"TRIBBLE": { "TRIBBLE": {
"TITLE": "Self-Hosted Infrastructure", "TITLE": "Self-Hosted Infrastructure",
"SHORT_DESCRIPTION": "Home infrastructure with Docker, Traefik, and secure VPN connectivity.", "SHORT_DESCRIPTION": "Home server for CI and private services, plus a VPS for the public sites.",
"INTRODUCTION": "Documentation and construction of a private cloud infrastructure. Focus is on data sovereignty, automation, and security.", "INTRODUCTION": "Two machines. At home a mini PC called \"Tribble\" runs Gitea with its Actions runner and container registry, plus the private services. A Hostinger VPS serves the public sites, this one included. A git push builds the image at home, the VPS pulls it over Tailscale with Watchtower, and Traefik does TLS and routing. It has been running since May 2024.",
"BULLET_1": "Central management via Docker-Compose and Portainer.", "BULLET_1": "Hardware: a KODLIX GD50 mini PC with an 8-core Intel Core i5, 32 GB RAM and a 512 GB SSD.",
"BULLET_2": "Automatic SSL management and reverse proxy with Traefik.", "BULLET_2": "Gitea for git, the Actions runner and the container registry. This site and To Be Played are built and tested there.",
"BULLET_3": "Private version control (Gitea) and media streaming (Jellyfin).", "BULLET_3": "Private services: Jellyfin for media, Paperless-ngx for documents, AdGuard Home for DNS and ad blocking, Portainer to look at the containers, Traefik in front.",
"BULLET_4": "Network-wide ad-blocking and DNS control via AdGuard Home.", "BULLET_4": "Tailscale links the two machines. The VPS fetches new images from the registry at home over it, and Watchtower swaps the running container.",
"CHALLENGE_1": "Configuring secure network layers and firewall rules for remote access.", "BULLET_5": "Backups go to Google Drive.",
"CHALLENGE_2": "Automating backups and recovery strategies for containerized data.", "CHALLENGE_1": "DNS. Names have to resolve to the right address from inside the house, over Tailscale and from the public internet, and AdGuard Home sits in the middle of it. Getting that right took longer than any single service.",
"LEARNING_1": "Deep understanding of modern network protocols and IT security.", "CHALLENGE_2": "Getting everything to run at the same time, and then making it repeatable. The first setup was a long list of manual steps. I automated it so the server can be rebuilt without me remembering each one.",
"LEARNING_2": "Efficient resource management on limited server systems." "LEARNING_1": "Self-hosting is cheap in money and expensive in time. Most of the time goes into the parts nobody sees: DNS, certificates and backups.",
"LEARNING_2": "A git push that ends in a running container on the VPS, with no manual step in between, is the one part of this setup I would not give up."
}, },
"EL_MUCHO": { "EL_MUCHO": {
"TITLE": "El Mucho (Steam Release)", "TITLE": "El Mucho (Steam Release)",
"SHORT_DESCRIPTION": "Turn-based tactical RPG, published on Steam.", "SHORT_DESCRIPTION": "A turn-based tactical RPG that my brother and I released 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.", "INTRODUCTION": "El Mucho is a turn-based tactical RPG in a fictional world called Liberika, inspired by Langrisser. My brother and I made it over about three years next to a full-time job and released it on Steam: Early Access in July 2021, full release in February 2022. The split was clear. He had the idea and did the assets, the level design, testing and balancing. I did everything technical: the code in Unity, the builds, the Steam setup and the release.",
"BULLET_1": "Complete engine development in Unity (C#).", "BULLET_1": "Unity and C#. One developer for all of the code.",
"BULLET_2": "Integration of Steamworks features (Achievements, Cloud Saves).", "BULLET_2": "16 missions, achievements and family sharing on Steam, published as Lobo Corp.",
"BULLET_3": "Development of a custom tactical AI and pathfinding logic.", "BULLET_3": "English, German and Spanish. The Spanish translation was done by a friend who is a native speaker.",
"BULLET_4": "Management of the entire asset pipeline and sound design.", "BULLET_4": "Art and music are partly open source, partly my brother's, partly mine.",
"CHALLENGE_1": "Implementing a robust turn-based system with complex dependencies.", "CHALLENGE_1": "Shipping on Steam as a one-person tech team. Builds, store setup, upload and the Early Access phase all went through me. Early Access started in July 2021, the full release followed in February 2022.",
"CHALLENGE_2": "Performance optimization for a smooth experience across various hardware profiles.", "CHALLENGE_2": "Three languages. Every text exists in English, German and Spanish; for Spanish I worked with a native-speaking friend.",
"CHALLENGE_3": "Handling Steam's strict certification requirements.", "CHALLENGE_3": "The enemy units. I wrote a custom AI for them and my own A* pathfinding on the map grid.",
"LEARNING_1": "Perseverance and focus over a multi-year development cycle.", "LEARNING_1": "Finishing takes longer than building. The stretch from Early Access to the release alone took about seven months.",
"LEARNING_2": "Marketing and community management for a digital product." "LEARNING_2": "A clear split of roles kept a two-person team working for three years: he owned the game, I owned the code and the release."
}, },
"GAME_JAMS": { "GAME_JAMS": {
"TITLE": "Rapid Prototyping & Game Jams", "TITLE": "Game Jams",
"SHORT_DESCRIPTION": "Collection of innovative game concepts, created in under 48 hours.", "SHORT_DESCRIPTION": "Three small games, each made in 48 hours from Friday to Sunday.",
"INTRODUCTION": "Participation in national competitions (e.g. BeansJam). The focus is on creating functional and fun prototypes under extreme time pressure.", "INTRODUCTION": "A game jam gives you a theme on Friday evening and wants a playable game by Sunday. I have done three so far, all in the BeansJam series, in teams of two or three. I was always the programmer and the lead; the others brought ideas, assets and testing. All three games are on itch.io.",
"BULLET_1": "Focus on 'Core Game Loop' and fast feedback.", "BULLET_1": "Peter Blue and the carnival of death (2018, BeansJam): my first jam.",
"BULLET_2": "Collaborative development in small, agile teams.", "BULLET_2": "Arkonauts (2020, Mini Beans Jam 6): space invaders in space.",
"BULLET_3": "Effective time management and scope control.", "BULLET_3": "Sloth Bros (2022, BeansJam '22): theme \"we have to stay awake\", one of 65 entries.",
"BULLET_4": "Publishing and iteration based on community voting.", "BULLET_4": "Always 48 hours, Friday to Sunday, in a team of two or three.",
"CHALLENGE_1": "Reducing complex ideas to a Minimum Viable Product (MVP) achievable in 48h.", "CHALLENGE_1": "Scope. 48 hours is not much, and with sleep it is less. Deciding on Friday what we will not build was the most important decision of each jam.",
"CHALLENGE_2": "Rapid bug diagnosis and fixing under massive time pressure.", "CHALLENGE_2": "Being the only programmer. Everything the team came up with went through my keyboard, so an idea had to be cheap to build or it was dropped.",
"LEARNING_1": "Radical prioritization of features ('Kill your darlings').", "LEARNING_1": "A playable loop on Saturday beats a better idea on Sunday.",
"LEARNING_2": "Effective communication and decision-making under team stress." "LEARNING_2": "A jam is where I find out whether a game idea works before spending years on it. El Mucho took three years; a jam game takes a weekend.",
"LINK_COLLECTION": "All games on itch.io"
}, },
"DIPLOMA": { "DIPLOMA": {
"TITLE": "Scientific Diploma Thesis", "TITLE": "Diploma Thesis: Cloth Self-Collisions",
"SHORT_DESCRIPTION": "Real-time collision detection for complex, flexible 3D objects.", "SHORT_DESCRIPTION": "Detecting and resolving self-intersections in simulated garments, done at assyst for the cloth simulation Vidya (2010).",
"INTRODUCTION": "Research work in the field of computer graphics. Development of an algorithm for physically correct simulation of fabrics and clothing in real-time.", "INTRODUCTION": "Simulated cloth folds into itself. A pleated skirt, for example, intersects with its own fabric, and once two parts of one garment have passed through each other a normal collision response cannot pull them apart again. Collisions between different layers were already handled in Vidya; self-intersections inside one garment part were not. In my diploma thesis at assyst I adapted Intersection Contour Minimization (Volino et al. 2006) to Vidya and extended it so the response uses mass and velocity. Written in Java, finished in March 2010 at FH Bingen, and planned for productive use.",
"BULLET_1": "Mathematical modeling of mass-spring systems.", "BULLET_1": "Detection runs on Vidya's existing bounding volume hierarchy with k-DOPs. For self-tests the traversal skips the inner-node tests it does not need.",
"BULLET_2": "Low-level programming with Java.", "BULLET_2": "The intersection contour is built from segments. A spatial hash joins them even when floating point errors leave small gaps.",
"BULLET_3": "Optimization through spatial data structures (AABB Trees, Bounding Spheres).", "BULLET_3": "The response is my extension: it uses mass and velocity, so the result is physically plausible instead of a plain geometric push.",
"BULLET_4": "Scientific evaluation of simulation precision.", "BULLET_4": "The parameter h0 is derived from the mean triangle area, so it needs no manual tuning.",
"CHALLENGE_1": "Handling 'self-collisions' in high-resolution meshes without performance loss.", "BULLET_5": "The method shares one BVH with Vidya's existing TriangleSelfCollisionDetector.",
"CHALLENGE_2": "Mathematical stabilization of integration methods under high force impacts.", "CHALLENGE_1": "Cost. The test scene had about 16,000 particles at about 430 ms per step on a 2 GHz Core 2 Duo. Sharing one BVH between my detector and the existing one cut the update and traversal time in half and the overhead by about 80 %. On top of the existing detection the method adds about 5 %.",
"LEARNING_1": "Transferring theoretical research into productive, high-performance code.", "CHALLENGE_2": "Choosing the method. I compared Baraff (2003), Bridson (2002) and Volino (2000, 2006) and took the global variant of Intersection Contour Minimization, because it also resolves intersections that already exist.",
"LEARNING_2": "Precise working and documentation according to scientific standards." "CHALLENGE_3": "Known limits, documented: nearly planar surfaces and a vertex that lies exactly on a triangle plane are not handled.",
"LEARNING_1": "Reading papers properly. The same problem had four published answers, and the useful work was understanding where each one fails.",
"LEARNING_2": "Measure before you claim. The method alone was about 20 % faster than the existing detector plus Layering, and that number only counts because it comes from the same scene on the same machine."
}, },
"CLAUDE_BOX": { "CLAUDE_BOX": {
"TITLE": "Claude-in-a-Box", "TITLE": "Claude-in-a-Box",
@@ -389,17 +395,19 @@
}, },
"TBP": { "TBP": {
"TITLE": "To Be Played", "TITLE": "To Be Played",
"SHORT_DESCRIPTION": "A Steam backlog tracker that helps decide what to play next.", "SHORT_DESCRIPTION": "A backlog tracker for Steam and PlayStation that helps decide what to play next.",
"INTRODUCTION": "My Steam library has far more games than I will ever finish. To Be Played imports the library, keeps the backlog with status, rating and notes, and suggests what to play next for the time I have. Behind it are three services: an auth service that signs the login tokens, a core service for the library and the statistics, and a React frontend. All of it is self-hosted on the same server as this site.", "INTRODUCTION": "My game library has far more games than I will ever finish. To Be Played syncs Steam and PlayStation, imports lists from GOG, Playnite, Grouvee and HowLongToBeat, keeps the backlog with status, rating and notes, and suggests what to play next for the time I have. Behind it are three services: an auth service that signs the login tokens, a core service for the library and the statistics, and a React frontend. All of it is self-hosted on the same server as this site.",
"BULLET_1": "Steam sync of owned games, playtime, achievements and wishlist, by hand and every night, plus time to beat from IGDB.", "BULLET_1": "Steam sync of owned games, playtime, achievements and wishlist, PlayStation sync of titles, playtime and trophies, by hand and every night, plus time to beat from IGDB.",
"BULLET_2": "\"What to play next\": seven rows of suggestions, such as the next part of a series, the same studio, or games that people you follow finished, filtered by a time budget and a mood.", "BULLET_2": "\"What to play next\": seven rows of three suggestions, such as the next part of a series, the same studio, or games that people you follow played or finished. A time budget filters every row; the mood filter only shapes the random picks.",
"BULLET_3": "Public profiles, following other players with an inbox of their progress, and Pile Points for every game played off the pile.", "BULLET_3": "Public profiles, following other players with an inbox of their progress, and Pile Points for every game played off the pile.",
"BULLET_4": "German and English, light and dark mode, and it works on the phone. Auth, Core and Frontend each ship as their own Docker image.", "BULLET_4": "German and English, light and dark mode, and it works on the phone. Auth, Core and Frontend each ship as their own Docker image.",
"CHALLENGE_1": "Login across two services. Auth signs the tokens with an Ed25519 key, and Core only holds the public key, so Core can check a login but never create one. The tokens live in HttpOnly cookies, so no script on the page can read them.", "BULLET_5": "Over 3,400 tests across the three services, ArchUnit rules that keep the layers apart, and Testcontainers for the real database. CI runs the full build and tags every image by commit.",
"CHALLENGE_2": "Steam's limits. Every Steam call is rate limited and retried, and a sync first claims its run in the database, so two syncs of the same user never run at the same time.", "CHALLENGE_1": "Login across two services. Auth signs the tokens with an Ed25519 key, and Core only holds the public key, so Core can check a login but never create one. Core fetches that key from Auth at runtime, looked up by key id, and the tokens live in HttpOnly cookies, so no script on the page can read them.",
"CHALLENGE_2": "Steam's limits. Every Steam call is rate limited to two per second and retried only where a retry is safe (owned games, schema, achievements). A sync first claims its run in the database, so two syncs of the same user never run at the same time, and the nightly sync only touches users seen in the last 14 days.",
"CHALLENGE_3": "Suggesting a game is harder than it sounds. Each row of suggestions needs its own rules, and the lists behind every number must use the same rules as the number, so a game counts the same way everywhere.", "CHALLENGE_3": "Suggesting a game is harder than it sounds. Each row of suggestions needs its own rules, and the lists behind every number must use the same rules as the number, so a game counts the same way everywhere.",
"LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script checks that both copies match.", "CHALLENGE_4": "Login abuse. Too many failed logins get a 429 with Retry-After. An unknown email still pays for a full BCrypt check, so the timing gives nothing away. A header guard blocks CSRF, CORS allows exact origins only, and the PlayStation token is encrypted at rest.",
"LEARNING_2": "A hobby project still needs the boring parts: backups, migrations and a fixed deploy order. They decided more about how stable it runs than any feature did." "LEARNING_1": "Splitting an app into services only pays off with a clear contract. The token claims are pinned in a contract file that both services test against, and a script I run by hand checks that both copies match.",
"LEARNING_2": "A hobby project still needs the boring parts: daily database dumps kept for seven days, four weeks and six months, migrations, and a written deploy order. They decided more about how stable it runs than any feature did."
} }
}, },
"IMPRINT": { "IMPRINT": {
@@ -0,0 +1,82 @@
<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, a backup container dumps both databases daily. Core also calls the Steam API, PlayStation and IGDB. Core fetches the Auth public key (JWKS) at runtime.</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="320" width="310" height="220" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="370" y="354" class="sans name">Core service</text>
<text x="370" y="380" class="sans sub">library, stats, suggestions</text>
<text x="370" y="402" 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="312" width="140" height="56" rx="10" fill="#243040" stroke="#33475c"/>
<text x="830" y="347" class="mono chip" text-anchor="middle">core db</text>
<text x="1000" y="357" class="sans cap" text-anchor="start">EXTERNAL</text>
<rect x="1000" y="380" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="412" class="sans chip" text-anchor="middle">Steam API</text>
<rect x="1000" y="442" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="474" class="sans chip" text-anchor="middle">PlayStation</text>
<rect x="1000" y="504" width="152" height="50" rx="10" fill="#212a35" stroke="#2f3a47" stroke-dasharray="4 4"/>
<text x="1076" y="536" 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 340 H 342" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="297" y="330" 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 340 H 752" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<path d="M 600 312 V 278" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="588" y="300" class="sans arr" text-anchor="end">public key (JWKS)</text>
<path d="M 668 405 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="395" class="sans arr" text-anchor="middle">sync</text>
<path d="M 668 467 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="457" class="sans arr" text-anchor="middle">sync</text>
<path d="M 668 529 H 992" stroke="#4a5563" stroke-width="2" fill="none" marker-end="url(#arrow)"/>
<text x="830" y="519" class="sans arr" text-anchor="middle">time to beat</text>
<rect x="1000" y="228" width="152" height="76" rx="10" fill="#212a35" stroke="#2f3a47"/>
<text x="1020" y="258" class="sans name">db backup</text>
<text x="1020" y="284" class="sans sub">daily dumps</text>
<path d="M 928 266 H 992" stroke="#26c6da" stroke-width="2" fill="none" marker-end="url(#arrowCyan)"/>
<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: 5.7 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