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
@@ -0,0 +1,8 @@
|
||||
import { mergeApplicationConfig } from '@angular/core';
|
||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||
import { appConfig } from './app.config';
|
||||
import { serverRoutes } from './app.routes.server';
|
||||
|
||||
export const serverConfig = mergeApplicationConfig(appConfig, {
|
||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import {provideAnimations} from '@angular/platform-browser/animations';
|
||||
import {provideHttpClient, withXhr} from '@angular/common/http';
|
||||
import {provideHttpClient, withFetch} from '@angular/common/http';
|
||||
import {provideServiceWorker} from '@angular/service-worker';
|
||||
import {provideTranslateService} from '@ngx-translate/core';
|
||||
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
|
||||
@@ -25,7 +25,7 @@ export const appConfig: ApplicationConfig = {
|
||||
anchorScrolling: 'enabled'
|
||||
})),
|
||||
provideAnimations(),
|
||||
provideHttpClient(withXhr()),
|
||||
provideHttpClient(withFetch()),
|
||||
provideTranslateService({
|
||||
lang: INITIAL_LANG,
|
||||
fallbackLang: 'en',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RenderMode } from '@angular/ssr';
|
||||
import { routes } from './app.routes';
|
||||
import { serverRoutes } from './app.routes.server';
|
||||
|
||||
describe('serverRoutes', () => {
|
||||
it('prerenders every static route', () => {
|
||||
const staticPaths = routes.map(route => route.path).filter(path => path !== '**');
|
||||
|
||||
const prerendered = serverRoutes
|
||||
.filter(route => route.renderMode === RenderMode.Prerender)
|
||||
.map(route => route.path);
|
||||
|
||||
expect(prerendered).toEqual(staticPaths);
|
||||
});
|
||||
|
||||
it('renders the catch-all on the client', () => {
|
||||
const catchAll = serverRoutes.find(route => route.path === '**');
|
||||
|
||||
expect(catchAll?.renderMode).toBe(RenderMode.Client);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
const WILDCARD = '**';
|
||||
|
||||
/** Every static route gets its own HTML at build time; the catch-all stays client-rendered (it cannot be prerendered without params). */
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
...routes
|
||||
.filter(route => route.path !== WILDCARD)
|
||||
.map((route): ServerRoute => ({ path: route.path ?? '', renderMode: RenderMode.Prerender })),
|
||||
{ path: WILDCARD, renderMode: RenderMode.Client },
|
||||
];
|
||||
@@ -9,6 +9,7 @@ export const routes: Routes = [
|
||||
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent), data: { seo: 'IMPRINT' } },
|
||||
{ path: RouterConstants.PRIVACY.PATH, loadComponent: () => import('./pages/privacy/privacy.component').then(m => m.PrivacyComponent), data: { seo: 'PRIVACY' } },
|
||||
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent), data: { seo: 'ALGORITHMS' } },
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
LINK: '/imprint',
|
||||
};
|
||||
|
||||
static readonly PRIVACY = {
|
||||
PATH: 'privacy',
|
||||
LINK: '/privacy',
|
||||
};
|
||||
|
||||
static readonly STOPWATCH = {
|
||||
PATH: 'stopwatch',
|
||||
LINK: '/stopwatch',
|
||||
|
||||
@@ -11,4 +11,11 @@
|
||||
{{ 'APP.AI_NOTICE' | translate }}
|
||||
<a [routerLink]="['/projects']" [queryParams]="{ project: 'claude-in-a-box' }">{{ 'APP.AI_NOTICE_LINK' | translate }}</a>
|
||||
</small>
|
||||
<nav class="mt-2 flex justify-center gap-2" aria-label="Legal">
|
||||
<a [routerLink]="RouterConstants.IMPRINT.LINK">{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a [routerLink]="RouterConstants.PRIVACY.LINK">{{ 'PRIVACY.TITLE' | translate }}</a>
|
||||
<span aria-hidden="true">·</span>
|
||||
<a [routerLink]="RouterConstants.STOPWATCH.LINK">{{ 'TOPBAR.STOPWATCH' | translate }}</a>
|
||||
</nav>
|
||||
</footer>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
@@ -14,7 +14,9 @@ class TopbarStub {}
|
||||
class ParticlesStub {}
|
||||
|
||||
describe('AppComponent', () => {
|
||||
it('renders the copyright line with year, name and a closing dot', async () => {
|
||||
let fixture: ComponentFixture<AppComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.overrideComponent(AppComponent, {
|
||||
remove: { imports: [TopbarComponent, ParticleBackgroundComponent] },
|
||||
add: { imports: [TopbarStub, ParticlesStub] },
|
||||
@@ -29,10 +31,19 @@ describe('AppComponent', () => {
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
fixture = TestBed.createComponent(AppComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders the copyright line with year, name and a closing dot', () => {
|
||||
const text = fixture.nativeElement.querySelector('footer small').textContent.trim();
|
||||
expect(text).toBe(`© ${new Date().getFullYear()} Andreas Dahm. APP.COPYRIGHT`);
|
||||
});
|
||||
|
||||
it('links imprint, privacy and stopwatch from the footer', () => {
|
||||
const hrefs = Array.from<HTMLAnchorElement>(fixture.nativeElement.querySelectorAll('footer nav a'))
|
||||
.map(anchor => anchor.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toEqual(['/imprint', '/privacy', '/stopwatch']);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {TopbarComponent} from '../topbar/topbar.component';
|
||||
import {TranslatePipe} from '@ngx-translate/core';
|
||||
import {ParticleBackgroundComponent} from '../../shared/components/particles-background/particles-background.component';
|
||||
import {SeoService} from '../../service/seo.service';
|
||||
import {RouterConstants} from '../../constants/RouterConstants';
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -14,6 +15,7 @@ import {SeoService} from '../../service/seo.service';
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly seo = inject(SeoService);
|
||||
protected readonly RouterConstants = RouterConstants;
|
||||
currentYear = new Date().getFullYear();
|
||||
|
||||
constructor() {
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
[routerLink]="RouterConstants.PROJECTS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.PROJECTS' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.ALGORITHMS.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.ALGORITHMS' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.STOPWATCH.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.STOPWATCH' | translate }}</a>
|
||||
<a class="opacity-70 transition-opacity duration-150 hover:opacity-100 relative after:content-[''] after:absolute after:bottom-1 after:left-2.5 after:right-2.5 after:h-0.5 after:bg-current after:rounded-xs after:scale-x-0 after:transition-transform [&.active]:opacity-100 [&.active]:after:scale-x-100"
|
||||
[routerLink]="RouterConstants.IMPRINT.LINK" routerLinkActive="active" mat-button>{{ 'TOPBAR.IMPRINT' | translate }}</a>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile nav menu button -->
|
||||
@@ -36,12 +32,6 @@
|
||||
<button mat-menu-item [routerLink]="RouterConstants.ALGORITHMS.LINK">
|
||||
{{ 'TOPBAR.ALGORITHMS' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="RouterConstants.STOPWATCH.LINK">
|
||||
{{ 'TOPBAR.STOPWATCH' | translate }}
|
||||
</button>
|
||||
<button mat-menu-item [routerLink]="RouterConstants.IMPRINT.LINK">
|
||||
{{ 'TOPBAR.IMPRINT' | translate }}
|
||||
</button>
|
||||
</mat-menu>
|
||||
|
||||
<span class="flex-1"></span>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
import { TopbarComponent } from './topbar.component';
|
||||
|
||||
describe('TopbarComponent', () => {
|
||||
let fixture: ComponentFixture<TopbarComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TopbarComponent],
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
provideRouter([]),
|
||||
provideTranslateService({ lang: 'en', fallbackLang: 'en' }),
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TopbarComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('links only the main sections; imprint and stopwatch live in the footer', () => {
|
||||
const hrefs = Array.from<HTMLAnchorElement>(fixture.nativeElement.querySelectorAll('nav a'))
|
||||
.map(anchor => anchor.getAttribute('href'));
|
||||
|
||||
expect(hrefs).toEqual(['/about', '/projects', '/algorithms']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
|
||||
export class SharedFunctions {
|
||||
static openMail(event?: Event) {
|
||||
event?.preventDefault();
|
||||
const user = 'info';
|
||||
const domain = 'andreas-dahm.eu';
|
||||
globalThis.location.href = `mailto:${user}@${domain}`;
|
||||
}
|
||||
|
||||
static randomIntFromInterval(min: number, max: number): number {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, Output, PLATFORM_ID, ViewChild} from '@angular/core';
|
||||
import {CommonModule, isPlatformBrowser} from '@angular/common';
|
||||
|
||||
export interface GridPos { row: number; col: number }
|
||||
|
||||
@@ -31,6 +31,7 @@ export class GenericGridComponent implements AfterViewInit {
|
||||
@Output() gridChange = new EventEmitter<any[][]>();
|
||||
@Output() nodeClick = new EventEmitter<GridPos>();
|
||||
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
grid: any[][] = [];
|
||||
|
||||
@@ -38,6 +39,9 @@ export class GenericGridComponent implements AfterViewInit {
|
||||
protected lastCell: GridPos | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
this.ctx = this.getContextOrThrow();
|
||||
this.setupCanvasListeners();
|
||||
this.applyGridSize();
|
||||
@@ -156,6 +160,9 @@ export class GenericGridComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
drawNode(node: any): void {
|
||||
if (!this.ctx) {
|
||||
return;
|
||||
}
|
||||
this.ctx.fillStyle = this.getNodeColorFn(node);
|
||||
this.ctx.fillRect(node.col * this.nodeSize, node.row * this.nodeSize, this.nodeSize, this.nodeSize);
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, input, NgZone, OnDestroy, Output, ViewChild} from '@angular/core';
|
||||
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, input, NgZone, OnDestroy, Output, PLATFORM_ID, ViewChild} from '@angular/core';
|
||||
import {isPlatformBrowser} from '@angular/common';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
import {TranslateService} from '@ngx-translate/core';
|
||||
import {ArcRotateCamera, Camera, Engine, MeshBuilder, Scene, ShaderLanguage, ShaderMaterial, Vector2, Vector3, WebGPUEngine} from '@babylonjs/core';
|
||||
@@ -33,6 +34,7 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly gpuCapability = inject(GpuCapabilityService);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
@ViewChild('renderCanvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
@@ -54,10 +56,16 @@ export class BabylonCanvas implements AfterViewInit, OnDestroy {
|
||||
private readonly wheelHandler = (evt: WheelEvent) => evt.preventDefault();
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
this.initBabylon().then(() => { console.log("Engine initialized"); });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
+38
-1
@@ -33,7 +33,6 @@
|
||||
"ROLE": "Senior Software Entwickler / Full-Stack Entwickler / Softwarearchitekt",
|
||||
"LOCATION": "München · Remote",
|
||||
"VIEW_PROJECTS": "Projekte ansehen",
|
||||
"CONTACT_ME": "Kontaktiere mich",
|
||||
"SECTION": {
|
||||
"SKILLS": "Fähigkeiten & Stack",
|
||||
"LANGUAGES": "Sprachen & Frameworks",
|
||||
@@ -416,6 +415,40 @@
|
||||
"COUNTRY": "Deutschland",
|
||||
"CONTACT": "Kontakt"
|
||||
},
|
||||
"PRIVACY": {
|
||||
"TITLE": "Datenschutzerklärung",
|
||||
"CONTROLLER": "Verantwortlicher",
|
||||
"SECTIONS": {
|
||||
"HOSTING": {
|
||||
"TITLE": "Hosting und Server-Logs",
|
||||
"TEXT": "Diese Seite wird bei Hostinger gehostet [TODO: Rechtsträger, Adresse und Aufbewahrungsdauer der Logs bestätigen; Auftragsverarbeitungsvertrag (Art. 28 DSGVO) bestätigen]. Der Webserver schreibt Server-Logs: IP-Adresse, Zeitpunkt, aufgerufene URL und User-Agent. Zweck: Betrieb und Sicherheit der Seite. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO. Die Logs werden nach kurzer Zeit gelöscht [TODO: genaue Dauer]."
|
||||
},
|
||||
"STORAGE": {
|
||||
"TITLE": "Lokaler Speicher",
|
||||
"TEXT": "Die Seite speichert nur Sprache und Design (hell oder dunkel) im localStorage des Browsers. Es gibt keine Cookies, kein Tracking und keine Analyse-Tools."
|
||||
},
|
||||
"SERVICE_WORKER": {
|
||||
"TITLE": "Service Worker",
|
||||
"TEXT": "Ein Service Worker legt Dateien der Seite im Browser-Cache ab, damit die Seite schneller und offline lädt. Es werden keine Daten an Dritte gesendet."
|
||||
},
|
||||
"FONTS": {
|
||||
"TITLE": "Schriften",
|
||||
"TEXT": "Schriften (Roboto, Material Icons) werden vom eigenen Server geladen. Es gibt keine Verbindung zu Google."
|
||||
},
|
||||
"EXTERNAL_LINKS": {
|
||||
"TITLE": "Externe Links",
|
||||
"TEXT": "Links zu Gitea, LinkedIn und Codeberg werden erst beim Klick aufgerufen. Ab dann gilt die Datenschutzerklärung des jeweiligen Anbieters."
|
||||
},
|
||||
"RIGHTS": {
|
||||
"TITLE": "Ihre Rechte",
|
||||
"TEXT": "Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch (Art. 15 bis 21 DSGVO). Kontakt: die E-Mail-Adresse oben."
|
||||
},
|
||||
"AUTHORITY": {
|
||||
"TITLE": "Beschwerderecht",
|
||||
"TEXT": "Sie können sich bei einer Aufsichtsbehörde beschweren. Zuständig: Bayerisches Landesamt für Datenschutzaufsicht (BayLDA), Promenade 18, 91522 Ansbach."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PATHFINDING": {
|
||||
"TITLE": "Pfadfindungsalgorithmen",
|
||||
"START_NODE": "Startknoten",
|
||||
@@ -738,6 +771,10 @@
|
||||
"TITLE": "Impressum – Andreas Dahm",
|
||||
"DESCRIPTION": "Rechtliche Informationen und Kontaktdaten zur Website Andreas Dahm Playground."
|
||||
},
|
||||
"PRIVACY": {
|
||||
"TITLE": "Datenschutzerklärung – Andreas Dahm",
|
||||
"DESCRIPTION": "Datenschutzerklärung zur Website Andreas Dahm Playground."
|
||||
},
|
||||
"STOPWATCH": {
|
||||
"TITLE": "Stoppuhr – Andreas Dahm",
|
||||
"DESCRIPTION": "Eine einfache Online-Stoppuhr mit konfigurierbaren Intervall-Signaltönen."
|
||||
|
||||
+38
-1
@@ -33,7 +33,6 @@
|
||||
"ROLE": "Senior Software Developer / Full-Stack Developer / Software Architect",
|
||||
"LOCATION": "Munich · Remote",
|
||||
"VIEW_PROJECTS": "View projects",
|
||||
"CONTACT_ME": "Contact me",
|
||||
"SECTION": {
|
||||
"SKILLS": "Skills & Stack",
|
||||
"LANGUAGES": "Languages & Frameworks",
|
||||
@@ -416,6 +415,40 @@
|
||||
"COUNTRY": "Germany",
|
||||
"CONTACT": "Contact"
|
||||
},
|
||||
"PRIVACY": {
|
||||
"TITLE": "Privacy policy",
|
||||
"CONTROLLER": "Controller",
|
||||
"SECTIONS": {
|
||||
"HOSTING": {
|
||||
"TITLE": "Hosting and server logs",
|
||||
"TEXT": "This site is hosted by Hostinger [TODO: legal entity, address and log retention period to be confirmed; data processing agreement (Art. 28 GDPR) to be confirmed]. The web server writes server logs: IP address, time, requested URL and user agent. Purpose: operation and security of the site. Legal basis: Art. 6 (1) (f) GDPR. The logs are deleted after a short time [TODO: exact period]."
|
||||
},
|
||||
"STORAGE": {
|
||||
"TITLE": "Local storage",
|
||||
"TEXT": "The site stores only your language and theme (light or dark) in the local storage of your browser. There are no cookies, no tracking and no analytics tools."
|
||||
},
|
||||
"SERVICE_WORKER": {
|
||||
"TITLE": "Service worker",
|
||||
"TEXT": "A service worker keeps files of this site in the browser cache, so the site loads faster and works offline. No data is sent to third parties."
|
||||
},
|
||||
"FONTS": {
|
||||
"TITLE": "Fonts",
|
||||
"TEXT": "Fonts (Roboto, Material Icons) are loaded from this server. There is no connection to Google."
|
||||
},
|
||||
"EXTERNAL_LINKS": {
|
||||
"TITLE": "External links",
|
||||
"TEXT": "Links to Gitea, LinkedIn and Codeberg are only opened when you click them. From then on the privacy policy of the respective provider applies."
|
||||
},
|
||||
"RIGHTS": {
|
||||
"TITLE": "Your rights",
|
||||
"TEXT": "You have the right to access, rectification, erasure, restriction of processing, data portability and objection (Art. 15 to 21 GDPR). Contact: the e-mail address above."
|
||||
},
|
||||
"AUTHORITY": {
|
||||
"TITLE": "Right to lodge a complaint",
|
||||
"TEXT": "You can complain to a supervisory authority. Responsible: Bayerisches Landesamt für Datenschutzaufsicht (BayLDA), Promenade 18, 91522 Ansbach."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PATHFINDING": {
|
||||
"TITLE": "Pathfinding Algorithms",
|
||||
"START_NODE": "Start Node",
|
||||
@@ -738,6 +771,10 @@
|
||||
"TITLE": "Imprint – Andreas Dahm",
|
||||
"DESCRIPTION": "Legal information and contact details for the Andreas Dahm Playground website."
|
||||
},
|
||||
"PRIVACY": {
|
||||
"TITLE": "Privacy policy – Andreas Dahm",
|
||||
"DESCRIPTION": "Privacy policy for the Andreas Dahm Playground website."
|
||||
},
|
||||
"STOPWATCH": {
|
||||
"TITLE": "Stopwatch – Andreas Dahm",
|
||||
"DESCRIPTION": "A simple online stopwatch with configurable interval beeps."
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+1
-6
@@ -12,7 +12,7 @@
|
||||
<meta name="theme-color" content="#313131">
|
||||
<link rel="canonical" href="https://andreas-dahm.eu/">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="assets/icons/app-icon.svg">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Andreas Dahm – Playground">
|
||||
@@ -34,11 +34,6 @@
|
||||
fetch in parallel with the bundle instead of after it.
|
||||
-->
|
||||
<link rel="preload" as="image" href="assets/me.webp" fetchpriority="high">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<app-root class="flex flex-col min-h-screen"></app-root>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
||||
import { AppComponent } from './app/layout/app/app.component';
|
||||
import { serverConfig } from './app/app.config.server';
|
||||
|
||||
const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, serverConfig, context);
|
||||
|
||||
export default bootstrap;
|
||||
@@ -14,11 +14,11 @@
|
||||
"src": "assets/icons/app-icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "16x16 29x32",
|
||||
"sizes": "16x16 32x32",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -44,16 +44,6 @@
|
||||
@apply inline-block w-[15px] h-[15px] border border-gray-300 align-middle mr-[5px];
|
||||
}
|
||||
|
||||
/*
|
||||
Preflight is off, so a bare <button> keeps the UA font (13.33px Arial) instead of
|
||||
the page font. Anything that should read as a link but has to stay a button for
|
||||
accessibility uses this.
|
||||
*/
|
||||
@utility link-button {
|
||||
font: inherit;
|
||||
@apply text-link font-medium bg-transparent p-0 cursor-pointer hover:text-link-hover hover:underline;
|
||||
}
|
||||
|
||||
@utility card-gradient-bar {
|
||||
@apply relative;
|
||||
|
||||
|
||||
Reference in new issue
Block a user