Lots of technical improvements
This commit is contained in:
1 parent
102cdfcb52
commit
a26443af8f
26 files changed
+1864
-430
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection} from '@angular/core';
|
||||
import {ApplicationConfig, inject, isDevMode, provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection} from '@angular/core';
|
||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import {provideAnimations} from '@angular/platform-browser/animations';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
import {provideServiceWorker} from '@angular/service-worker';
|
||||
import {provideTranslateService} from '@ngx-translate/core';
|
||||
import {provideTranslateHttpLoader} from '@ngx-translate/http-loader';
|
||||
import {LocalStoreConstants} from './constants/LocalStoreConstants';
|
||||
@@ -47,11 +48,15 @@ export const appConfig: ApplicationConfig = {
|
||||
sanitizer.bypassSecurityTrustResourceUrl('assets/logos/linkedIn.svg')
|
||||
);
|
||||
}),
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
}),
|
||||
]
|
||||
};
|
||||
|
||||
function getInitialLang(): string {
|
||||
const saved = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY);
|
||||
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) : null;
|
||||
if (saved) return saved;
|
||||
const nav = typeof navigator !== 'undefined' ? navigator.language?.toLowerCase() : 'en';
|
||||
return nav?.startsWith('de') ? 'de' : 'en';
|
||||
|
||||
+16
-16
@@ -2,20 +2,20 @@ import { Routes } from '@angular/router';
|
||||
import {RouterConstants} from './constants/RouterConstants';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent) },
|
||||
{ path: RouterConstants.ABOUT.PATH, loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent) },
|
||||
{ path: RouterConstants.PROJECTS.PATH, loadComponent: () => import('./pages/projects/projects.component').then(m => m.ProjectsComponent) },
|
||||
{ path: RouterConstants.ALGORITHMS.PATH, loadComponent: () => import('./pages/algorithms/algorithms.component').then(m => m.AlgorithmsComponent) },
|
||||
{ path: RouterConstants.PATHFINDING.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/pathfinding.component').then(m => m.PathfindingComponent) },
|
||||
{ path: RouterConstants.SORTING.PATH, loadComponent: () => import('./pages/algorithms/sorting/sorting.component').then(m => m.SortingComponent) },
|
||||
{ path: RouterConstants.IMPRINT.PATH, loadComponent: () => import('./pages/imprint/imprint.component').then(m => m.ImprintComponent) },
|
||||
{ path: RouterConstants.GOL.PATH, loadComponent: () => import('./pages/algorithms/conway-gol/conway-gol.component').then(m => m.ConwayGolComponent) },
|
||||
{ path: RouterConstants.LABYRINTH.PATH, loadComponent: () => import('./pages/algorithms/pathfinding/labyrinth/labyrinth.component').then(m => m.LabyrinthComponent) },
|
||||
{ path: RouterConstants.FRACTAL.PATH, loadComponent: () => import('./pages/algorithms/fractal/fractal.component').then(m => m.FractalComponent) },
|
||||
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent) },
|
||||
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default) },
|
||||
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent) },
|
||||
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent) },
|
||||
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent) },
|
||||
{ path: '', loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
|
||||
{ path: RouterConstants.ABOUT.PATH, loadComponent: () => import('./pages/about/about.component').then(m => m.AboutComponent), data: { seo: 'ABOUT' } },
|
||||
{ path: RouterConstants.PROJECTS.PATH, loadComponent: () => import('./pages/projects/projects.component').then(m => m.ProjectsComponent), data: { seo: 'PROJECTS' } },
|
||||
{ path: RouterConstants.ALGORITHMS.PATH, loadComponent: () => import('./pages/algorithms/algorithms.component').then(m => m.AlgorithmsComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ 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.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' } },
|
||||
{ path: RouterConstants.FRACTAL3d.PATH, loadComponent: () => import('./pages/algorithms/fractal3d/fractal3d.component').then(m => m.Fractal3dComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.PENDULUM.PATH, loadComponent: () => import('./pages/algorithms/pendulum/pendulum.component').then(m => m.default), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.CLOTH.PATH, loadComponent: () => import('./pages/algorithms/cloth/cloth.component').then(m => m.ClothComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.FOUR_COLOR.PATH, loadComponent: () => import('./pages/algorithms/four-color/four-color.component').then(m => m.FourColorComponent), data: { seo: 'ALGORITHMS' } },
|
||||
{ path: RouterConstants.STOPWATCH.PATH, loadComponent: () => import('./pages/stopwatch/stopwatch.component').then(m => m.StopwatchComponent), data: { seo: 'STOPWATCH' } },
|
||||
{ path: '**', loadComponent: () => import('./pages/not-found/not-found.component').then(m => m.NotFoundComponent), data: { seo: 'NOT_FOUND' } },
|
||||
];
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
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';
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -12,5 +13,10 @@ import {ParticleBackgroundComponent} from '../../shared/components/particles-bac
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly seo = inject(SeoService);
|
||||
currentYear = new Date().getFullYear();
|
||||
|
||||
constructor() {
|
||||
this.seo.init();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { PathfindingService } from './pathfinding.service';
|
||||
import { Node } from '../pathfinding.models';
|
||||
|
||||
function createNode(row: number, col: number): Node {
|
||||
return {
|
||||
row,
|
||||
col,
|
||||
isStart: false,
|
||||
isEnd: false,
|
||||
isWall: false,
|
||||
isVisited: false,
|
||||
isPath: false,
|
||||
nodeData: Infinity,
|
||||
linkedNode: null,
|
||||
fScore: Infinity,
|
||||
hScore: Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
function createGrid(rows: number, cols: number): Node[][] {
|
||||
const grid: Node[][] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
const currentRow: Node[] = [];
|
||||
for (let col = 0; col < cols; col++) {
|
||||
currentRow.push(createNode(row, col));
|
||||
}
|
||||
grid.push(currentRow);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
describe('PathfindingService', () => {
|
||||
let service: PathfindingService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PathfindingService();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getUnvisitedNeighbors', () => {
|
||||
it('returns the four orthogonal neighbors for an inner node', () => {
|
||||
const grid = createGrid(3, 3);
|
||||
const neighbors = service.getUnvisitedNeighbors(grid[1][1], grid);
|
||||
|
||||
expect(neighbors.length).toBe(4);
|
||||
});
|
||||
|
||||
it('excludes walls and already visited nodes', () => {
|
||||
const grid = createGrid(3, 3);
|
||||
grid[0][1].isWall = true;
|
||||
grid[1][0].isVisited = true;
|
||||
const neighbors = service.getUnvisitedNeighbors(grid[1][1], grid);
|
||||
|
||||
expect(neighbors.length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns only in-bounds neighbors for a corner node', () => {
|
||||
const grid = createGrid(3, 3);
|
||||
const neighbors = service.getUnvisitedNeighbors(grid[0][0], grid);
|
||||
|
||||
expect(neighbors.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodesInShortestPath', () => {
|
||||
it('reconstructs the path by following linkedNode references', () => {
|
||||
const grid = createGrid(1, 3);
|
||||
grid[0][1].linkedNode = grid[0][0];
|
||||
grid[0][2].linkedNode = grid[0][1];
|
||||
const path = service.getNodesInShortestPath(grid[0][2]);
|
||||
|
||||
expect(path).toEqual([grid[0][0], grid[0][1], grid[0][2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dijkstra', () => {
|
||||
it('finds the shortest path on an open grid', () => {
|
||||
const grid = createGrid(5, 5);
|
||||
const start = grid[0][0];
|
||||
const end = grid[4][4];
|
||||
|
||||
const { visitedNodesInOrder, nodesInShortestPathOrder } = service.dijkstra(grid, start, end);
|
||||
|
||||
expect(visitedNodesInOrder).toContain(end);
|
||||
// Manhattan distance 8 => 9 nodes including both endpoints.
|
||||
expect(nodesInShortestPathOrder.length).toBe(9);
|
||||
expect(nodesInShortestPathOrder[0]).toBe(start);
|
||||
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
||||
});
|
||||
|
||||
it('returns an empty path when the end node is walled off', () => {
|
||||
const grid = createGrid(3, 3);
|
||||
const start = grid[0][0];
|
||||
const end = grid[2][2];
|
||||
grid[1][2].isWall = true;
|
||||
grid[2][1].isWall = true;
|
||||
|
||||
const { nodesInShortestPathOrder } = service.dijkstra(grid, start, end);
|
||||
|
||||
expect(nodesInShortestPathOrder).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aStar', () => {
|
||||
it('reaches the end and returns a connected path on an open grid', () => {
|
||||
const grid = createGrid(5, 5);
|
||||
const start = grid[0][0];
|
||||
const end = grid[4][4];
|
||||
|
||||
const { visitedNodesInOrder, nodesInShortestPathOrder } = service.aStar(grid, start, end);
|
||||
|
||||
expect(visitedNodesInOrder).toContain(end);
|
||||
expect(nodesInShortestPathOrder.length).toBeGreaterThan(0);
|
||||
expect(nodesInShortestPathOrder[0]).toBe(start);
|
||||
expect(nodesInShortestPathOrder[nodesInShortestPathOrder.length - 1]).toBe(end);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SortingService } from './sorting.service';
|
||||
import { SortData, SortSnapshot } from '../sorting.models';
|
||||
|
||||
function toSortData(values: number[]): SortData[] {
|
||||
return values.map((value) => ({ value, state: 'unsorted' }));
|
||||
}
|
||||
|
||||
function finalValues(snapshots: SortSnapshot[]): number[] {
|
||||
const lastSnapshot = snapshots[snapshots.length - 1];
|
||||
return lastSnapshot.array.map((item) => item.value);
|
||||
}
|
||||
|
||||
function isAscending(values: number[]): boolean {
|
||||
for (let i = 1; i < values.length; i++) {
|
||||
if (values[i - 1] > values[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
describe('SortingService', () => {
|
||||
let service: SortingService;
|
||||
const unsorted = [5, 2, 9, 1, 5, 6, 3, 8, 7, 4];
|
||||
const expectedSorted = [...unsorted].sort((a, b) => a - b);
|
||||
|
||||
beforeEach(() => {
|
||||
service = new SortingService();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
for (const algorithm of [
|
||||
'bubbleSort',
|
||||
'cocktailSort',
|
||||
'quickSort',
|
||||
'heapSort',
|
||||
'timSort',
|
||||
] as const) {
|
||||
it(`${algorithm} should produce a fully sorted final snapshot`, () => {
|
||||
const snapshots = service[algorithm](toSortData(unsorted));
|
||||
const result = finalValues(snapshots);
|
||||
|
||||
expect(result).toEqual(expectedSorted);
|
||||
expect(isAscending(result)).toBeTrue();
|
||||
});
|
||||
|
||||
it(`${algorithm} should not mutate the input array`, () => {
|
||||
const input = toSortData(unsorted);
|
||||
service[algorithm](input);
|
||||
|
||||
expect(input.map((item) => item.value)).toEqual(unsorted);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<section class="grid gap-fluid-md max-w-app mx-4 mt-auto">
|
||||
<mat-card class="p-fluid-md text-center">
|
||||
<h1 class="m-0 mb-2 text-[clamp(3rem,12vw,6rem)] font-bold opacity-80">404</h1>
|
||||
<h2 class="m-0 mb-4 text-[clamp(1rem,3vw,1.2rem)] font-semibold">{{ 'NOT_FOUND.TITLE' | translate }}</h2>
|
||||
<p class="opacity-80 mb-6">{{ 'NOT_FOUND.MESSAGE' | translate }}</p>
|
||||
<a mat-flat-button color="primary" [routerLink]="RouterConstants.ABOUT.LINK">
|
||||
{{ 'NOT_FOUND.BACK_HOME' | translate }}
|
||||
</a>
|
||||
</mat-card>
|
||||
</section>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatCard } from '@angular/material/card';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { RouterConstants } from '../../constants/RouterConstants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-not-found',
|
||||
imports: [RouterLink, MatCard, MatButton, TranslatePipe],
|
||||
templateUrl: './not-found.component.html',
|
||||
})
|
||||
export class NotFoundComponent {
|
||||
protected readonly RouterConstants = RouterConstants;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { GpuCapabilityService } from './gpu-capability.service';
|
||||
|
||||
describe('GpuCapabilityService', () => {
|
||||
let service: GpuCapabilityService;
|
||||
const originalGpu = (navigator as unknown as { gpu: unknown }).gpu;
|
||||
|
||||
function setGpu(value: unknown): void {
|
||||
Object.defineProperty(navigator, 'gpu', { value, configurable: true });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
service = new GpuCapabilityService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setGpu(originalGpu);
|
||||
});
|
||||
|
||||
it('reports "webgpu" when an adapter is available', async () => {
|
||||
setGpu({ requestAdapter: () => Promise.resolve({}) });
|
||||
|
||||
const tier = await service.detect();
|
||||
|
||||
expect(tier).toBe('webgpu');
|
||||
expect(service.tier()).toBe('webgpu');
|
||||
});
|
||||
|
||||
it('falls back to "webgl" when WebGPU is missing but a WebGL2 context exists', async () => {
|
||||
setGpu(undefined);
|
||||
const fakeCanvas = { getContext: () => ({}) } as unknown as HTMLCanvasElement;
|
||||
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
|
||||
|
||||
const tier = await service.detect();
|
||||
|
||||
expect(tier).toBe('webgl');
|
||||
});
|
||||
|
||||
it('reports "none" when neither WebGPU nor WebGL is available', async () => {
|
||||
setGpu(undefined);
|
||||
const fakeCanvas = { getContext: () => null } as unknown as HTMLCanvasElement;
|
||||
spyOn(document, 'createElement').and.returnValue(fakeCanvas);
|
||||
|
||||
const tier = await service.detect();
|
||||
|
||||
expect(tier).toBe('none');
|
||||
});
|
||||
|
||||
it('caches the detected tier and probes only once', async () => {
|
||||
setGpu({ requestAdapter: () => Promise.resolve({}) });
|
||||
|
||||
const first = await service.detect();
|
||||
setGpu(undefined);
|
||||
const second = await service.detect();
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ export class GpuCapabilityService {
|
||||
}
|
||||
|
||||
private async isWebGpuAvailable(): Promise<boolean> {
|
||||
if (!navigator.gpu) {
|
||||
if (typeof navigator === 'undefined' || !navigator.gpu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ export class GpuCapabilityService {
|
||||
}
|
||||
|
||||
private isWebGlAvailable(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('webgl2');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { LanguageService } from './language.service';
|
||||
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
|
||||
|
||||
describe('LanguageService', () => {
|
||||
let translateSpy: jasmine.SpyObj<TranslateService>;
|
||||
|
||||
function createService(): LanguageService {
|
||||
translateSpy = jasmine.createSpyObj<TranslateService>('TranslateService', ['use']);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: TranslateService, useValue: translateSpy }],
|
||||
});
|
||||
return TestBed.inject(LanguageService);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('uses the stored language as the initial value', () => {
|
||||
localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, 'de');
|
||||
const service = createService();
|
||||
|
||||
expect(service.lang()).toBe('de');
|
||||
expect(translateSpy.use).toHaveBeenCalledWith('de');
|
||||
});
|
||||
|
||||
it('use() updates the signal, the translate service and localStorage', () => {
|
||||
localStorage.setItem(LocalStoreConstants.LANGUAGE_KEY, 'en');
|
||||
const service = createService();
|
||||
|
||||
service.use('de');
|
||||
|
||||
expect(service.lang()).toBe('de');
|
||||
expect(translateSpy.use).toHaveBeenCalledWith('de');
|
||||
expect(localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY)).toBe('de');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,8 @@ export class LanguageService {
|
||||
const stored = localStorage.getItem(LocalStoreConstants.LANGUAGE_KEY) as Lang | null;
|
||||
if (stored === 'de' || stored === 'en') return stored;
|
||||
} catch (e) { void e; }
|
||||
const browser = (navigator.language || 'en').toLowerCase();
|
||||
const browserLang = typeof navigator !== 'undefined' ? navigator.language : 'en';
|
||||
const browser = (browserLang || 'en').toLowerCase();
|
||||
return browser.startsWith('de') ? 'de' : 'en';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Title, Meta } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { filter } from 'rxjs';
|
||||
|
||||
const SITE_URL = 'https://andreas-dahm.eu';
|
||||
const DEFAULT_SEO_KEY = 'DEFAULT';
|
||||
|
||||
/**
|
||||
* Keeps the document title and SEO/social meta tags in sync with the active
|
||||
* route and the selected language. Each route declares its translation key via
|
||||
* `data: { seo: 'ABOUT' }`; the matching `SEO.<key>` i18n entry provides the
|
||||
* localized title and description.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SeoService {
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly title = inject(Title);
|
||||
private readonly meta = inject(Meta);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
private currentSeoKey = DEFAULT_SEO_KEY;
|
||||
|
||||
init(): void {
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
this.currentSeoKey = this.resolveSeoKey();
|
||||
this.applyTags();
|
||||
});
|
||||
|
||||
// Re-translate the tags when the user switches language.
|
||||
this.translate.onLangChange.subscribe(() => this.applyTags());
|
||||
}
|
||||
|
||||
private resolveSeoKey(): string {
|
||||
let active = this.route;
|
||||
while (active.firstChild) {
|
||||
active = active.firstChild;
|
||||
}
|
||||
const seoKey = active.snapshot.data['seo'];
|
||||
return typeof seoKey === 'string' ? seoKey : DEFAULT_SEO_KEY;
|
||||
}
|
||||
|
||||
private applyTags(): void {
|
||||
const titleKey = `SEO.${this.currentSeoKey}.TITLE`;
|
||||
const descriptionKey = `SEO.${this.currentSeoKey}.DESCRIPTION`;
|
||||
|
||||
this.translate.get([titleKey, descriptionKey]).subscribe((translations) => {
|
||||
const title = translations[titleKey];
|
||||
const description = translations[descriptionKey];
|
||||
const url = SITE_URL + this.router.url.split('?')[0];
|
||||
|
||||
this.title.setTitle(title);
|
||||
this.meta.updateTag({ name: 'description', content: description });
|
||||
this.meta.updateTag({ property: 'og:title', content: title });
|
||||
this.meta.updateTag({ property: 'og:description', content: description });
|
||||
this.meta.updateTag({ property: 'og:url', content: url });
|
||||
this.meta.updateTag({ name: 'twitter:title', content: title });
|
||||
this.meta.updateTag({ name: 'twitter:description', content: description });
|
||||
this.updateCanonical(url);
|
||||
});
|
||||
}
|
||||
|
||||
private updateCanonical(url: string): void {
|
||||
let link = this.document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||
if (!link) {
|
||||
link = this.document.createElement('link');
|
||||
link.setAttribute('rel', 'canonical');
|
||||
this.document.head.appendChild(link);
|
||||
}
|
||||
link.setAttribute('href', url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ThemeService } from './theme.service';
|
||||
import { LocalStoreConstants } from '../constants/LocalStoreConstants';
|
||||
|
||||
describe('ThemeService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function createService(): ThemeService {
|
||||
TestBed.configureTestingModule({});
|
||||
return TestBed.inject(ThemeService);
|
||||
}
|
||||
|
||||
it('uses the stored theme as the initial value', () => {
|
||||
localStorage.setItem(LocalStoreConstants.THEME_KEY, 'dark');
|
||||
const service = createService();
|
||||
|
||||
expect(service.theme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('toggle switches between light and dark', () => {
|
||||
localStorage.setItem(LocalStoreConstants.THEME_KEY, 'light');
|
||||
const service = createService();
|
||||
|
||||
service.toggle();
|
||||
expect(service.theme()).toBe('dark');
|
||||
|
||||
service.toggle();
|
||||
expect(service.theme()).toBe('light');
|
||||
});
|
||||
|
||||
it('setTheme persists the selection to localStorage', () => {
|
||||
localStorage.setItem(LocalStoreConstants.THEME_KEY, 'light');
|
||||
const service = createService();
|
||||
|
||||
service.setTheme('dark');
|
||||
TestBed.tick();
|
||||
|
||||
expect(localStorage.getItem(LocalStoreConstants.THEME_KEY)).toBe('dark');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, ViewChild} from '@angular/core';
|
||||
import {AfterViewInit, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, PLATFORM_ID, ViewChild} from '@angular/core';
|
||||
import {isPlatformBrowser} from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-particles-background',
|
||||
@@ -10,6 +11,7 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('canvas', { static: true }) canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private particles: any[] = [];
|
||||
@@ -21,6 +23,11 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly particleSpeed = 0.8;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Canvas animation relies on browser-only APIs; skip it during SSR/prerender.
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
|
||||
@@ -38,6 +45,10 @@ export class ParticleBackgroundComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
@HostListener('window:resize')
|
||||
resizeCanvas(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
|
||||
Reference in new issue
Block a user