feat(app): add prerender, self-hosted fonts and privacy page
Fonts come from npm, not Google, so no third-party font requests. Pages are prerendered at build time; browser-only code is guarded. nginx serves per-route HTML, a real 404 and gzip.
This commit is contained in:
1 parent
57f861510e
commit
54b93055ba
42 files changed
+544
-88
No files matched your search
@@ -24,10 +24,7 @@
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">email</mat-icon>
|
||||
<button type="button" class="link-button" data-testid="contact-mail"
|
||||
(click)="SharedFunctions.openMail($event)">
|
||||
{{ 'ABOUT.CONTACT_ME' | translate }}
|
||||
</button>
|
||||
<a href="mailto:info@andreas-dahm.eu" data-testid="contact-mail">info@andreas-dahm.eu</a>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-[0.4rem]">
|
||||
<mat-icon aria-hidden="true">data_object</mat-icon>
|
||||
|
||||
@@ -204,13 +204,16 @@ describe('AboutComponent', () => {
|
||||
expect(fixture.debugElement.queryAll(By.css('a[download]')).length).toBe(0);
|
||||
});
|
||||
|
||||
it('exposes the mail contact as a button rather than an empty link', () => {
|
||||
it('renders the mail contact as a mailto link that shows the address', () => {
|
||||
const emptyLinks = fixture.debugElement
|
||||
.queryAll(By.css('a'))
|
||||
.filter(anchor => anchor.nativeElement.getAttribute('href') === '');
|
||||
|
||||
expect(emptyLinks.length).toBe(0);
|
||||
expect(fixture.debugElement.query(By.css('[data-testid="contact-mail"]'))).toBeTruthy();
|
||||
|
||||
const mail = fixture.debugElement.query(By.css('a[data-testid="contact-mail"]')).nativeElement;
|
||||
expect(mail.getAttribute('href')).toBe('mailto:info@andreas-dahm.eu');
|
||||
expect(mail.textContent.trim()).toBe('info@andreas-dahm.eu');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {UrlConstants} from '../../constants/UrlConstants';
|
||||
import {AssetsConstants} from '../../constants/AssetsConstants';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
|
||||
/** One role. Consecutive entries sharing a `company` render as a single employer block. */
|
||||
interface ExperienceEntry {
|
||||
@@ -267,5 +266,4 @@ export class AboutComponent {
|
||||
|
||||
protected readonly UrlConstants = UrlConstants;
|
||||
protected readonly AssetsConstants = AssetsConstants;
|
||||
protected readonly SharedFunctions = SharedFunctions;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AfterViewInit, Component, ElementRef, inject, signal, ViewChild} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AfterViewInit, Component, ElementRef, inject, PLATFORM_ID, signal, ViewChild} from '@angular/core';
|
||||
import {CommonModule, isPlatformBrowser} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButtonModule} from '@angular/material/button';
|
||||
import {MatCardModule} from '@angular/material/card';
|
||||
@@ -33,6 +33,7 @@ import {UrlConstants} from '../../../constants/UrlConstants';
|
||||
})
|
||||
export class FourColorComponent implements AfterViewInit {
|
||||
private readonly fourColorService = inject(FourColorService);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
@@ -70,6 +71,9 @@ export class FourColorComponent implements AfterViewInit {
|
||||
private nodeSize = 0;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||
this.initializeGrid();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, ViewChild} from '@angular/core';
|
||||
import {AfterViewInit, Component, computed, ElementRef, HostListener, inject, NgZone, OnDestroy, signal, PLATFORM_ID, ViewChild} from '@angular/core';
|
||||
import {isPlatformBrowser} from '@angular/common';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {MatButton} from '@angular/material/button';
|
||||
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
})
|
||||
export class FourierComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly fourierService = inject(FourierService);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@@ -75,6 +77,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
|
||||
protected readonly MAX_SPEED = MAX_SPEED;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
this.ctx = this.canvasRef.nativeElement.getContext('2d')!;
|
||||
this.resizeCanvas();
|
||||
this.applyPreset(Preset.HEART);
|
||||
@@ -84,6 +89,9 @@ export class FourierComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {MatCard} from '@angular/material/card';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-imprint',
|
||||
@@ -12,7 +11,4 @@ import {SharedFunctions} from '../../shared/SharedFunctions';
|
||||
templateUrl: './imprint.component.html',
|
||||
styleUrl: './imprint.component.scss',
|
||||
})
|
||||
export class ImprintComponent {
|
||||
|
||||
protected readonly SharedFunctions = SharedFunctions;
|
||||
}
|
||||
export class ImprintComponent {}
|
||||
@@ -0,0 +1,28 @@
|
||||
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||
<mat-card class="p-fluid-md">
|
||||
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'PRIVACY.TITLE' | translate }}</h2>
|
||||
|
||||
<div class="grid gap-1 mb-4">
|
||||
<h3 class="text-xs tracking-[0.04em] uppercase opacity-70 m-0">{{ 'PRIVACY.CONTROLLER' | translate }}</h3>
|
||||
<p>
|
||||
Andreas Dahm<br />
|
||||
Hauptstraße 37A<br />
|
||||
85614 Kirchseeon<br />
|
||||
{{ 'IMPRINT.COUNTRY' | translate }}
|
||||
</p>
|
||||
<p>
|
||||
E-Mail:
|
||||
<a href="mailto:info@andreas-dahm.eu">
|
||||
info@andreas-dahm.eu
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@for (key of sectionKeys; track key) {
|
||||
<div class="grid gap-1 mb-4">
|
||||
<h3 class="text-xs tracking-[0.04em] uppercase opacity-70 m-0" data-testid="privacy-section">{{ key + '.TITLE' | translate }}</h3>
|
||||
<p>{{ key + '.TEXT' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</mat-card>
|
||||
</section>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { PrivacyComponent } from './privacy.component';
|
||||
import { routes } from '../../app.routes';
|
||||
import en from '../../../assets/i18n/en.json';
|
||||
import de from '../../../assets/i18n/de.json';
|
||||
|
||||
describe('PrivacyComponent', () => {
|
||||
let fixture: ComponentFixture<PrivacyComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PrivacyComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PrivacyComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders the seven sections in the documented order', () => {
|
||||
const headings = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('[data-testid="privacy-section"]'))
|
||||
.map(heading => heading.textContent?.trim());
|
||||
|
||||
expect(headings).toEqual([
|
||||
'PRIVACY.SECTIONS.HOSTING.TITLE',
|
||||
'PRIVACY.SECTIONS.STORAGE.TITLE',
|
||||
'PRIVACY.SECTIONS.SERVICE_WORKER.TITLE',
|
||||
'PRIVACY.SECTIONS.FONTS.TITLE',
|
||||
'PRIVACY.SECTIONS.EXTERNAL_LINKS.TITLE',
|
||||
'PRIVACY.SECTIONS.RIGHTS.TITLE',
|
||||
'PRIVACY.SECTIONS.AUTHORITY.TITLE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('has every key it uses, plus the SEO keys, in en and de', () => {
|
||||
const keys = [
|
||||
'PRIVACY.TITLE',
|
||||
'PRIVACY.CONTROLLER',
|
||||
'SEO.PRIVACY.TITLE',
|
||||
'SEO.PRIVACY.DESCRIPTION',
|
||||
...fixture.componentInstance.sectionKeys.flatMap(key => [`${key}.TITLE`, `${key}.TEXT`]),
|
||||
];
|
||||
|
||||
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('is routed at /privacy with its own SEO entry', () => {
|
||||
const route = routes.find(entry => entry.path === 'privacy');
|
||||
|
||||
expect(route?.data?.['seo']).toBe('PRIVACY');
|
||||
});
|
||||
|
||||
function resolve(translations: unknown, key: string): unknown {
|
||||
return key
|
||||
.split('.')
|
||||
.reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], translations);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MatCard } from '@angular/material/card';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy',
|
||||
imports: [MatCard, TranslatePipe],
|
||||
templateUrl: './privacy.component.html',
|
||||
})
|
||||
export class PrivacyComponent {
|
||||
/** Each key has TITLE and TEXT in i18n. */
|
||||
readonly sectionKeys = ['HOSTING', 'STORAGE', 'SERVICE_WORKER', 'FONTS', 'EXTERNAL_LINKS', 'RIGHTS', 'AUTHORITY']
|
||||
.map(section => `PRIVACY.SECTIONS.${section}`);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, computed, inject, CUSTOM_ELEMENTS_SCHEMA, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Subscription } from "rxjs";
|
||||
import { MatCardModule } from "@angular/material/card";
|
||||
@@ -49,6 +50,7 @@ export interface Projects {
|
||||
export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly router = inject(Router);
|
||||
private queryParamSub: Subscription | undefined;
|
||||
@@ -283,7 +285,9 @@ export class ProjectsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
if (this.isBrowser) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
setTimeout(() => { this.dialogOpenFunction(); }, 10);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user