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

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

No files matched your search

@@ -12,6 +12,14 @@
</ul>
</div>
@if (project.diagram) {
<div class="mb-4 rounded-xl overflow-hidden bg-[#1e2530]">
<a [href]="project.diagram" target="_blank" rel="noopener" [attr.aria-label]="'PROJECTS.DIAGRAM_ALT' | translate">
<img class="w-full h-auto block" [src]="project.diagram" [alt]="'PROJECTS.DIAGRAM_ALT' | translate">
</a>
</div>
}
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] sm-dialog:grid-cols-1 gap-4 mb-4">
<div class="p-5 rounded-xl bg-black/3 border border-black/5 dark:bg-white/5 dark:border-white/10">
<div class="flex items-center gap-3 mb-3 text-link">
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
@@ -27,27 +27,46 @@ describe('ProjectDialogComponent', () => {
expect(() => register()).not.toThrow();
});
it('renders an upgraded swiper-container for project images', async () => {
async function createDialog(data: object): Promise<ComponentFixture<ProjectDialogComponent>> {
await TestBed.configureTestingModule({
imports: [ProjectDialogComponent],
providers: [
provideZonelessChangeDetection(),
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
{
provide: MAT_DIALOG_DATA,
useValue: {
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
},
},
{ provide: MAT_DIALOG_DATA, useValue: data },
],
}).compileComponents();
const fixture = TestBed.createComponent(ProjectDialogComponent);
fixture.detectChanges();
return fixture;
}
it('renders an upgraded swiper-container for project images', async () => {
const fixture = await createDialog({
title: 'T',
technologies: [],
images: [{ url: 'a.png' }, { url: 'b.png' }],
});
const el: HTMLElement = fixture.nativeElement.querySelector('swiper-container');
expect(el).not.toBeNull();
expect(el instanceof customElements.get('swiper-container')!).toBe(true);
});
it('renders the diagram above the sections when the project has one', async () => {
const fixture = await createDialog({ title: 'T', technologies: [], images: [], diagram: 'd.svg' });
const img: HTMLImageElement | null = fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]');
expect(img?.getAttribute('src')).toBe('d.svg');
const link: HTMLAnchorElement | null = img?.closest('a') ?? null;
expect(link?.getAttribute('href')).toBe('d.svg');
expect(link?.getAttribute('target')).toBe('_blank');
expect(fixture.nativeElement.querySelector('swiper-container')).toBeNull();
});
it('renders no diagram without one', async () => {
const fixture = await createDialog({ title: 'T', technologies: [], images: [] });
expect(fixture.nativeElement.querySelector('img[alt="PROJECTS.DIAGRAM_ALT"]')).toBeNull();
});
});
@@ -5,8 +5,8 @@
<mat-card-title>{{ project.title | translate }}</mat-card-title>
<mat-card-subtitle>{{ project.shortDescription | translate }}</mat-card-subtitle>
</mat-card-header>
@if(project.images.length > 0) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
@if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
@@ -31,8 +31,8 @@
<mat-card-header class="pb-4">
<mat-card-title>{{ project.title | translate }}</mat-card-title>
</mat-card-header>
@if(project.images.length > 0) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="project.images[0].url" [alt]="project.title | translate">
@if (cardImage(project); as imageUrl) {
<img mat-card-image class="w-full h-[clamp(150px,25vw,250px)] object-cover" [src]="imageUrl" [alt]="project.title | translate">
} @else {
<div class="flex justify-center items-center h-[clamp(150px,20vw,200px)] bg-[#f0f0f0]">
<mat-icon class="text-[clamp(3rem,8vw,4rem)] w-[clamp(3rem,8vw,4rem)] h-[clamp(3rem,8vw,4rem)] text-[#666]">{{ project.icon }}</mat-icon>
@@ -36,6 +36,14 @@ describe('ProjectsComponent', () => {
expect(titles).toContain('PROJECTS.TBP.TITLE');
});
it('features To Be Played', () => {
expect(fixture.componentInstance.featuredProject()?.identifier).toBe('to-be-played');
});
it('card falls back to the diagram when a project has no images', () => {
expect(fixture.nativeElement.querySelector('img[src="/assets/projects/claude-in-a-box/1.svg"]')).not.toBeNull();
});
it('every project translation key exists in en and de', () => {
const keys = fixture.componentInstance.allProjects.flatMap(project => [
project.title,
+17 -10
View File
@@ -19,6 +19,7 @@ export interface Projects {
url: string,
source: string
}[],
diagram?: string,
icon: string,
assets: string,
links: {
@@ -59,6 +60,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TBP.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TBP.INTRODUCTION',
images: AssetsConstants.TBP_IMAGES.map(url => ({ url, source: '' })),
diagram: AssetsConstants.TBP_DIAGRAM,
icon: 'playlist_add_check',
assets: '',
links: [
@@ -82,7 +84,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TBP.LEARNING_1',
'PROJECTS.TBP.LEARNING_2',
],
isFeatured: false,
isFeatured: true,
technologies: ['Java', 'Spring Boot', 'PostgreSQL', 'React', 'Tailwind CSS', 'Docker']
},
{
@@ -90,7 +92,8 @@ export class ProjectsComponent implements OnInit, OnDestroy {
title: 'PROJECTS.CLAUDE_BOX.TITLE',
shortDescription: 'PROJECTS.CLAUDE_BOX.SHORT_DESCRIPTION',
introduction: 'PROJECTS.CLAUDE_BOX.INTRODUCTION',
images: AssetsConstants.CLAUDE_BOX_IMAGES.map(url => ({ url, source: '' })),
images: [],
diagram: AssetsConstants.CLAUDE_BOX_DIAGRAM,
icon: 'smart_toy',
assets: '',
links: [],
@@ -136,7 +139,7 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.PLAYGROUND.LEARNING_2',
],
isFeatured: false,
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'GitHub Actions', 'Docker']
technologies: ['Angular', 'TypeScript', 'SCSS', 'HTML', 'Gitea Actions', 'Docker']
},
{
identifier: "elmucho",
@@ -156,13 +159,12 @@ export class ProjectsComponent implements OnInit, OnDestroy {
challenges: [
'PROJECTS.EL_MUCHO.CHALLENGE_1',
'PROJECTS.EL_MUCHO.CHALLENGE_2',
'PROJECTS.EL_MUCHO.CHALLENGE_3',
],
learnings: [
'PROJECTS.EL_MUCHO.LEARNING_1',
'PROJECTS.EL_MUCHO.LEARNING_2',
],
isFeatured: true,
isFeatured: false,
technologies: ['Unity', 'C#', 'Steamworks', 'Git']
},
{
@@ -223,16 +225,17 @@ export class ProjectsComponent implements OnInit, OnDestroy {
shortDescription: 'PROJECTS.TRIBBLE.SHORT_DESCRIPTION',
introduction: 'PROJECTS.TRIBBLE.INTRODUCTION',
images: [
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://upload.wikimedia.org/wikipedia/commons/0/03/Hostinger_Logo.png' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/gitea' },
{ url: AssetsConstants.TRIBBLE_IMAGES[3], source: 'https://dashboardicons.com/icons/traefik' }
{ url: AssetsConstants.TRIBBLE_IMAGES[0], source: 'https://dashboardicons.com/icons/docker-engine' },
{ url: AssetsConstants.TRIBBLE_IMAGES[1], source: 'https://dashboardicons.com/icons/gitea' },
{ url: AssetsConstants.TRIBBLE_IMAGES[2], source: 'https://dashboardicons.com/icons/traefik' }
],
diagram: AssetsConstants.TRIBBLE_DIAGRAM,
icon: 'dns',
assets: '',
links: [
{ name: 'Ubuntu Server', url: 'https://ubuntu.com/server' },
{ name: 'Docker', url: 'https://www.docker.com/' },
{ name: 'Portainer', url: 'https://www.portainer.io/' },
{ name: 'Traefik', url: 'https://traefik.io/' },
{ name: 'Gitea', url: 'https://gitea.io/' },
{ name: 'Jellyfin', url: 'https://jellyfin.org/' },
@@ -255,13 +258,17 @@ export class ProjectsComponent implements OnInit, OnDestroy {
'PROJECTS.TRIBBLE.LEARNING_2',
],
isFeatured: false,
technologies: ['Ubuntu Server', 'Docker', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
technologies: ['Ubuntu Server', 'Docker', 'Portainer', 'Traefik', 'Gitea', 'Jellyfin', 'AdGuard Home', 'Paperless-ngx', 'Tailscale']
}
]
featuredProject = computed(() => this.allProjects.find(p => p.isFeatured));
otherProjects = computed(() => this.allProjects.filter(p => !p.isFeatured));
cardImage(project: Projects): string | undefined {
return project.images[0]?.url ?? project.diagram;
}
ngOnInit(): void {
window.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => { this.dialogOpenFunction(); }, 10);