feat: added languages

This commit is contained in:
2026-04-28 13:52:38 +02:00
parent d5d995c50d
commit 735b5fc206
19 changed files with 1300 additions and 592 deletions
+65 -1
View File
@@ -1 +1,65 @@
<router-outlet></router-outlet>
<div class="app-content" [attr.aria-hidden]="isLanguageSelectorOpen() ? 'true' : null" [attr.inert]="isLanguageSelectorOpen() ? '' : null">
<router-outlet></router-outlet>
</div>
@if (isLanguageConfirmed()) {
<button
type="button"
class="language-switcher"
[attr.aria-label]="content().app.changeLanguageAriaLabel"
[title]="content().app.changeLanguageAriaLabel"
(click)="reopenLanguageSelector()"
>
<span class="language-switcher-tag">{{ currentLanguageCode().toUpperCase() }}</span>
<span class="language-switcher-copy">
<strong>{{ content().app.changeLanguage }}</strong>
<span>{{ languageSwitcherLabel() }}</span>
</span>
</button>
}
@if (isLanguageSelectorOpen()) {
<div
class="language-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="language-title"
(click)="closeLanguageSelectorOnBackdrop($event)"
>
<section class="language-card panel">
<div class="language-card-header">
<div class="language-copy">
<h2 id="language-title">{{ dialogContent().app.selectorTitle }}</h2>
</div>
@if (isLanguageConfirmed()) {
<button
type="button"
class="language-close"
[attr.aria-label]="dialogContent().app.closeSelector"
(click)="closeLanguageSelector()"
>
x
</button>
}
</div>
<div class="language-options" role="radiogroup" [attr.aria-label]="dialogContent().app.selectorTitle">
@for (option of languageOptions; track option.code) {
<button
type="button"
class="language-option"
role="radio"
[attr.aria-checked]="selectedLanguage() === option.code"
[attr.autofocus]="selectedLanguage() === option.code ? '' : null"
[class.language-option-active]="selectedLanguage() === option.code"
(click)="chooseLanguage(option.code)"
>
<span class="language-option-accent">{{ option.accent }}</span>
<strong>{{ getLanguageLabel(option.code) }}</strong>
</button>
}
</div>
</section>
</div>
}
+54 -15
View File
@@ -9,6 +9,8 @@ describe('AppComponent', (): void => {
let component: AppComponent;
beforeEach(async (): Promise<void> => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter([])],
@@ -23,28 +25,65 @@ describe('AppComponent', (): void => {
expect(component).toBeTruthy();
});
it('should have title "Lechner Julian"', (): void => {
expect(component.title).toBe('Lechner Julian');
it('should render the language selector on first visit', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('.language-overlay')).not.toBeNull();
});
it('should render a router-outlet element', (): void => {
it('should preselect English by default', (): void => {
const comp = component as unknown as { selectedLanguage(): string };
expect(comp.selectedLanguage()).toBe('en');
});
it('should always render a router-outlet element', (): void => {
const el = fixture.nativeElement as HTMLElement;
expect(el.querySelector('router-outlet')).not.toBeNull();
});
it('should not render any additional root elements besides router-outlet', (): void => {
const el = fixture.nativeElement as HTMLElement;
const children = Array.from(el.children);
expect(children.length).toBe(1);
expect(children[0].tagName.toLowerCase()).toBe('router-outlet');
it('should confirm and persist the selected language', (): void => {
const comp = component as unknown as {
chooseLanguage(language: 'en' | 'de'): void;
currentLanguageCode(): string;
isLanguageConfirmed(): boolean;
isLanguageSelectorOpen(): boolean;
};
comp.chooseLanguage('de');
fixture.detectChanges();
expect(comp.currentLanguageCode()).toBe('de');
expect(comp.isLanguageConfirmed()).toBeTrue();
expect(comp.isLanguageSelectorOpen()).toBeFalse();
expect(localStorage.getItem('portfolio-language')).toBe('de');
});
describe('Performance', (): void => {
it('should create and run initial change detection within 100 ms', (): void => {
const start = performance.now();
const f = TestBed.createComponent(AppComponent);
f.detectChanges();
expect(performance.now() - start).toBeLessThan(100);
});
it('should restore a saved language from localStorage', async (): Promise<void> => {
localStorage.setItem('portfolio-language', 'de');
const restoredFixture = TestBed.createComponent(AppComponent);
restoredFixture.detectChanges();
const restoredComponent = restoredFixture.componentInstance as unknown as {
currentLanguageCode(): string;
isLanguageConfirmed(): boolean;
isLanguageSelectorOpen(): boolean;
};
expect(restoredComponent.currentLanguageCode()).toBe('de');
expect(restoredComponent.isLanguageConfirmed()).toBeTrue();
expect(restoredComponent.isLanguageSelectorOpen()).toBeFalse();
});
it('should reopen the selector after confirmation', (): void => {
const comp = component as unknown as {
chooseLanguage(language: 'en' | 'de'): void;
reopenLanguageSelector(): void;
isLanguageSelectorOpen(): boolean;
};
comp.chooseLanguage('en');
comp.reopenLanguageSelector();
fixture.detectChanges();
expect(comp.isLanguageSelectorOpen()).toBeTrue();
});
});
+88 -3
View File
@@ -1,11 +1,96 @@
import { Component } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
} from '@angular/core';
import type { OnDestroy, OnInit } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import type { LanguageCode } from '../languages/language.types';
import type { LanguageOption } from './app.types';
import { LanguageService } from './services/language.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
title: string = 'Lechner Julian';
export class AppComponent implements OnInit, OnDestroy {
private readonly document = inject(DOCUMENT);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly currentLanguageCode = this.languageService.languageCode;
protected readonly isLanguageConfirmed = this.languageService.isLanguageConfirmed;
protected readonly isLanguageSelectorOpen = signal(true);
protected readonly selectedLanguage = signal<LanguageCode>('en');
protected readonly languageOptions: readonly LanguageOption[] = [
{ code: 'en', accent: 'EN' },
{ code: 'de', accent: 'DE' },
];
protected readonly dialogContent = computed(() =>
this.languageService.getPack(this.selectedLanguage()),
);
protected readonly languageSwitcherLabel = computed(() =>
this.getLanguageLabel(this.currentLanguageCode(), this.content()),
);
constructor() {
effect((): void => {
const overflowValue = this.isLanguageSelectorOpen() ? 'hidden' : '';
this.document.body.style.overflow = overflowValue;
this.document.documentElement.style.overflow = overflowValue;
});
}
ngOnInit(): void {
const initialLanguage = this.languageService.initializeFromStorage();
this.selectedLanguage.set(initialLanguage);
this.isLanguageSelectorOpen.set(!this.isLanguageConfirmed());
}
protected chooseLanguage(language: LanguageCode): void {
this.selectedLanguage.set(language);
this.confirmLanguage();
}
protected confirmLanguage(): void {
this.languageService.confirmLanguage(this.selectedLanguage());
this.isLanguageSelectorOpen.set(false);
}
protected reopenLanguageSelector(): void {
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(true);
}
protected closeLanguageSelector(): void {
if (!this.isLanguageConfirmed()) {
return;
}
this.selectedLanguage.set(this.currentLanguageCode());
this.isLanguageSelectorOpen.set(false);
}
protected closeLanguageSelectorOnBackdrop(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.closeLanguageSelector();
}
}
protected getLanguageLabel(language: LanguageCode, content = this.dialogContent()): string {
return language === 'de' ? content.app.languageGerman : content.app.languageEnglish;
}
ngOnDestroy(): void {
this.document.body.style.overflow = '';
this.document.documentElement.style.overflow = '';
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
import type { ApplicationConfig } from '@angular/core';
import { importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import { provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { BrowserModule, provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { routes } from './app.routes';
@@ -16,6 +16,5 @@ export const appConfig: ApplicationConfig = {
}),
),
provideClientHydration(withEventReplay()),
importProvidersFrom(BrowserModule),
],
};
+6
View File
@@ -0,0 +1,6 @@
import type { LanguageCode } from '../languages/language.types';
export interface LanguageOption {
code: LanguageCode;
accent: string;
}
+2 -2
View File
@@ -1,7 +1,7 @@
<noscript>
<div class="page-shell footer-shell">
<div class="panel noscript-panel">
<h2 id="loading-text" class="warning-text">Please enable JavaScript for the full experience.</h2>
<h2 id="loading-text" class="warning-text">{{ content().footer.noscriptMessage }}</h2>
</div>
</div>
</noscript>
@@ -9,6 +9,6 @@
<footer class="site-footer">
<div class="footer-inner">
<p>&copy; 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
<a routerLink="/imprint">Imprint</a>
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
</div>
</footer>
+9 -4
View File
@@ -1,14 +1,19 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import type { Global } from '../../global';
import { global } from '../../global';
import { LanguageService } from '../services/language.service';
@Component({
selector: 'app-footer',
standalone: true,
imports: [RouterLink],
templateUrl: './footer.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FooterComponent {
protected readonly global: Global = global;
protected readonly currentYear: number = new Date().getFullYear();
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly global = global;
protected readonly currentYear = new Date().getFullYear();
}
+39 -71
View File
@@ -2,53 +2,44 @@
<section class="hero-section panel">
<div class="hero-copy">
<div class="hero-copy-main">
<p id="portfolio-eyebrow" class="eyebrow">Portfolio</p>
<p id="portfolio-eyebrow" class="eyebrow">{{ content().home.portfolioEyebrow }}</p>
<h1>
<span class="hero-name-part" [attr.data-name]="global.firstname">{{ global.firstname }}</span>
<span class="hero-name-part" [attr.data-name]="global.lastname">{{ global.lastname }}</span>
</h1>
<p class="hero-intro">
Building reliable systems, clean interfaces, and practical digital solutions that actually hold up in production.
</p>
<p class="hero-intro">{{ content().home.heroIntro }}</p>
</div>
<div class="hero-actions">
<div class="hero-contact-block">
<p class="hero-contact-title">Contact me</p>
<p class="hero-contact-title">{{ content().home.contactTitle }}</p>
<div id="contact-links" class="contact-area hero-contact-area">
<a [href]="contactSafeMail" aria-label="Send mail">
<fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon>
</a>
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number">
<fa-icon class="fab" [icon]="faPhone" size="lg"></fa-icon>
</a>
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile">
<fa-icon class="fab" [icon]="faDiscord" size="lg"></fa-icon>
</a>
<a href="https://github.com/fraujulian" aria-label="GitHub profile">
<fa-icon class="fab" [icon]="faGithub" size="lg"></fa-icon>
</a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile">
<fa-icon class="fab" [icon]="faLinkedin" size="lg"></fa-icon>
</a>
<a [href]="contactMailHref" aria-label="Send mail"><fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon></a>
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number"
><fa-icon class="fab" [icon]="faPhone" size="lg"></fa-icon
></a>
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
><fa-icon class="fab" [icon]="faDiscord" size="lg"></fa-icon
></a>
<a href="https://github.com/fraujulian" aria-label="GitHub profile"
><fa-icon class="fab" [icon]="faGithub" size="lg"></fa-icon
></a>
<a href="https://www.linkedin.com/in/julian-lechner-98b377356/" aria-label="LinkedIn profile"
><fa-icon class="fab" [icon]="faLinkedin" size="lg"></fa-icon
></a>
</div>
</div>
<div class="hero-action-links">
<button class="action-link" type="button" (click)="scrollToProjectsSection()">View projects</button>
<button class="action-link" type="button" (click)="scrollToAboutSection()">About me</button>
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
</div>
</div>
<div class="hero-meta">
<div>
<span class="meta-label">{{ currentBioEntry.label }}</span>
<strong>{{ currentBioEntry.value }}</strong>
<span class="meta-label">{{ currentBioEntry.label }}</span
><strong>{{ currentBioEntry.value }}</strong>
</div>
</div>
</div>
@@ -60,8 +51,8 @@
type="button"
class="portrait-highlight-button"
(click)="showNextPortraitHighlight()"
[attr.aria-label]="'Show next highlight image'"
[title]="portraitHighlights.length > 1 ? 'Show next image' : 'Single image'"
[attr.aria-label]="content().home.nextHighlightAriaLabel"
[title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
>
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
<img
@@ -74,10 +65,10 @@
disableOptimizedSrcset
priority
decoding="async"
[alt]="currentPortraitHighlight.text + ' Image'"
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix"
/>
@if (currentPortraitHighlightIndex === 0) {
<span class="portrait-click-hint">Click me!</span>
<span class="portrait-click-hint">{{ content().home.clickHint }}</span>
}
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span>
</div>
@@ -89,47 +80,27 @@
<section id="about" class="story-section panel about-scroll-anim-enabled">
<div class="section-heading">
<p class="eyebrow">About me</p>
<p class="eyebrow">{{ content().home.aboutEyebrow }}</p>
</div>
<div class="story-grid">
<div class="story-copy">
<p>
My name is {{ global.firstname }}, though most people call me Julie. Im {{ age }} and based in Austria. A large part of my day
happens on the move — usually on trains, going between meetings, offices, and time with my family.
</p>
<p>
I care deeply about digital security and privacy. I design systems with minimal exposure, sensible defaults, and a strong focus on
data protection. Anonymity, secure communication, and reducing unnecessary data collection arent extras for me — theyre baseline
requirements for my personal infrastructure.
</p>
<p>
I work with modern web technologies and on business-critical systems. I dont care much for unnecessary complexity — I focus on
clear, stable solutions that actually hold up in day-to-day use and deliver real value.
</p>
<p>{{ interpolate(content().home.aboutParagraphs[0]) }}</p>
<p>{{ content().home.aboutParagraphs[1] }}</p>
<p>{{ content().home.aboutParagraphs[2] }}</p>
</div>
<div class="story-detail">
<button class="detail-toggle" type="button" (click)="toggleBio()">
<span>{{ isLongBioShown ? 'Hide extended profile' : 'Read extended profile' }}</span>
<fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
<span>{{ isLongBioShown ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
><fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
</button>
@if (isLongBioMounted) {
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
<p>
What really interests me is modern code — well-structured, maintainable, and built with performance in mind. No unnecessary
bloat, just clean, readable solutions that reliably work under real-world conditions… in theory 😁
</p>
<p>
Outside of software, my main hobby is scuba diving. The silence underwater and the focus on the present moment keep pulling me
back into Austrias dark, cold lakes.
</p>
<p>
Right now, I mainly work for SobIT GmbH in Vienna, alongside freelance projects for companies, organizations, and private
clients. No gimmicks — just software that does what its supposed to do, built properly.
</p>
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">Get in touch</button>
<p>{{ content().home.detailParagraphs[0] }}</p>
<p>{{ content().home.detailParagraphs[1] }}</p>
<p>{{ content().home.detailParagraphs[2] }}</p>
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">
{{ content().home.getInTouch }}
</button>
</div>
}
</div>
@@ -138,9 +109,8 @@
<section id="projects" class="projects-section">
<div class="section-heading section-heading-centered">
<p class="eyebrow">current Jobs & Projects</p>
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
</div>
<div class="project-list">
@for (project of projects; track project.link + '-' + $index; let i = $index) {
<a
@@ -149,7 +119,7 @@
[class.project-entry-from-right]="i % 2 === 0"
[class.project-entry-from-left]="i % 2 === 1"
[href]="project.link"
[attr.aria-label]="'Open project ' + project.title"
[attr.aria-label]="content().home.openProjectPrefix + ' ' + project.title"
[attr.data-project-index]="i"
>
@if (project.icon) {
@@ -165,19 +135,17 @@
[alt]="project.title + ' icon'"
/>
}
<div class="project-copy">
<div class="project-header">
<div class="project-title-wrap">
<span class="project-link">{{ project.title }}</span>
</div>
<div class="project-languages" aria-label="Project languages">
<div class="project-languages" [attr.aria-label]="content().home.projectLanguagesAriaLabel">
@for (language of project.skills; track language + '-' + $index) {
<span class="project-language-chip">{{ language }}</span>
}
</div>
</div>
<p class="project-description">{{ project.description }}</p>
</div>
</a>
+19 -15
View File
@@ -8,7 +8,11 @@ import type { Subscription } from 'rxjs';
import { HomeComponent } from './home.component';
import { global } from '../../global';
import type { BioTextEntry, PortraitHighlight } from '../../global';
import { enLanguage } from '../../languages/en';
import type {
LanguageBioTextEntry,
LanguagePortraitHighlight,
} from '../../languages/language.types';
describe('HomeComponent', (): void => {
let fixture: ComponentFixture<HomeComponent>;
@@ -34,8 +38,8 @@ describe('HomeComponent', (): void => {
scrollToAboutSection(): void;
scrollToProjectsSection(): void;
scrollToContactLinks(): void;
currentBioEntry: BioTextEntry;
currentPortraitHighlight: PortraitHighlight | null;
currentBioEntry: LanguageBioTextEntry;
currentPortraitHighlight: LanguagePortraitHighlight | null;
}
let comp: ComponentAccess;
@@ -93,12 +97,12 @@ describe('HomeComponent', (): void => {
describe('currentBioEntry getter', (): void => {
it('should return the first bioTextsList entry at index 0', (): void => {
comp.currentIndex = 0;
expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
it('should return the correct entry when index changes', (): void => {
comp.currentIndex = 3;
expect(comp.currentBioEntry).toEqual(global.bioTextsList[3]);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[3]);
});
it('should return the fallback entry when index is out of range', (): void => {
@@ -119,12 +123,12 @@ describe('HomeComponent', (): void => {
});
it('should correctly wrap from the last entry back to 0 using modulo', (): void => {
const last = global.bioTextsList.length - 1;
const last = enLanguage.bioTextsList.length - 1;
comp.currentIndex = last;
expect(comp.currentBioEntry).toEqual(global.bioTextsList[last]);
comp.currentIndex = (last + 1) % global.bioTextsList.length;
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]);
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length;
expect(comp.currentIndex).toBe(0);
expect(comp.currentBioEntry).toEqual(global.bioTextsList[0]);
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[0]);
});
});
@@ -133,7 +137,7 @@ describe('HomeComponent', (): void => {
describe('currentPortraitHighlight getter', (): void => {
it('should return the highlight at the current index', (): void => {
comp.currentPortraitHighlightIndex = 0;
expect(comp.currentPortraitHighlight).toEqual(global.portraitHighlights[0]);
expect(comp.currentPortraitHighlight).toEqual(enLanguage.portraitHighlights[0]);
});
it('should return null when index is out of range', (): void => {
@@ -159,7 +163,7 @@ describe('HomeComponent', (): void => {
});
it('should not call setTimeout when only one portrait exists', (): void => {
const highlights = (comp as unknown as { portraitHighlights: PortraitHighlight[] })
const highlights = (comp as unknown as { portraitHighlights: LanguagePortraitHighlight[] })
.portraitHighlights;
const saved = [...highlights]; // snapshot before mutation
highlights.splice(1, saved.length - 1); // reduce to a single entry
@@ -193,7 +197,7 @@ describe('HomeComponent', (): void => {
}));
it('should wrap portrait index from the last position back to 0', fakeAsync((): void => {
comp.currentPortraitHighlightIndex = global.portraitHighlights.length - 1;
comp.currentPortraitHighlightIndex = enLanguage.portraitHighlights.length - 1;
const zone = TestBed.inject(NgZone);
zone.runOutsideAngular((): void => {
comp.showNextPortraitHighlight();
@@ -400,15 +404,15 @@ describe('HomeComponent', (): void => {
expect(el.querySelector('#projects')).not.toBeNull();
});
it('should render one project entry per global.projects entry', (): void => {
it('should render one project entry per localized project entry', (): void => {
const el = fixture.nativeElement as HTMLElement;
const entries = el.querySelectorAll('.project-entry');
expect(entries.length).toBe(global.projects.length);
expect(entries.length).toBe(enLanguage.projects.length);
});
it('should render all project titles', (): void => {
const el = fixture.nativeElement as HTMLElement;
global.projects.forEach((project): void => {
enLanguage.projects.forEach((project): void => {
expect(el.textContent).toContain(project.title);
});
});
+65 -144
View File
@@ -1,67 +1,64 @@
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
import { Component, inject, NgZone } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, NgZone } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons';
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DomSanitizer } from '@angular/platform-browser';
import type { SafeUrl } from '@angular/platform-browser';
import type { Subscription } from 'rxjs';
import { interval } from 'rxjs';
import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global';
import { global } from '../../global';
import type {
LanguageBioTextEntry,
LanguagePortraitHighlight,
LanguageProject,
} from '../../languages/language.types';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
@Component({
selector: 'app-home',
standalone: true,
imports: [FooterComponent, FaIconComponent, NgOptimizedImage],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly sanitizer = inject(DomSanitizer);
private readonly zone = inject(NgZone);
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
private scrollAnimationFrameId: number | null = null;
private projectEntryAnimationFrameId: number | null = null;
private aboutSectionAnimationFrameId: number | null = null;
private readonly scheduleNameGradientUpdate: () => void = (): void => {
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return;
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
this.scrollAnimationFrameId = null;
this.updateNameGradientProgress();
});
};
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') return;
this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => {
this.projectEntryAnimationFrameId = null;
this.updateProjectEntryRevealProgress();
});
};
private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => {
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') {
return;
}
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return;
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
this.aboutSectionAnimationFrameId = null;
this.updateAboutSectionScrollProgress();
});
};
protected readonly global: Global = global;
protected readonly age: number = this.calculateAge(global.birthdate);
protected isLongBioShown: boolean = false;
protected isLongBioMounted: boolean = false;
protected readonly global = global;
protected readonly age = this.calculateAge(global.birthdate);
protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected isLongBioShown = false;
protected isLongBioMounted = false;
protected readonly faArrowRight: IconDefinition = faArrowRight;
protected readonly faArrowDown: IconDefinition = faArrowDown;
protected readonly faEnvelope: IconDefinition = faEnvelope;
@@ -70,28 +67,44 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
readonly faGithub: IconDefinition = faGithub;
readonly faLinkedin: IconDefinition = faLinkedin;
protected readonly contactSafeMail: SafeUrl;
protected readonly portraitHighlights: PortraitHighlight[] = global.portraitHighlights;
protected currentPortraitHighlightIndex: number = 0;
protected isPortraitSwitching: boolean = false;
protected currentPortraitHighlightIndex = 0;
protected isPortraitSwitching = false;
protected currentIndex = 0;
protected readonly projects: PortfolioProject[] = global.projects;
protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' };
protected readonly fallbackBioEntry: LanguageBioTextEntry = { label: 'my stack', value: '' };
private sub!: Subscription;
private bioHideTimeoutId: number | null = null;
constructor() {
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
protected get portraitHighlights(): LanguagePortraitHighlight[] {
return this.content().portraitHighlights;
}
protected get projects(): LanguageProject[] {
return this.content().projects;
}
protected get currentPortraitHighlight(): LanguagePortraitHighlight | null {
return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null;
}
protected get currentBioEntry(): LanguageBioTextEntry {
return (
this.content().bioTextsList[this.currentIndex] ?? {
label: this.content().home.fallbackBioLabel,
value: '',
}
);
}
protected interpolate(text: string): string {
return text
.replaceAll('{{firstname}}', this.global.firstname)
.replaceAll('{{age}}', String(this.age));
}
ngOnInit(): void {
this.initNameGradientScrollAnimation();
this.zone.runOutsideAngular((): void => {
this.sub = interval(1000).subscribe((): void => {
this.zone.run((): void => {
this.currentIndex = (this.currentIndex + 1) % this.global.bioTextsList.length;
const len = this.content().bioTextsList.length;
this.currentIndex = len > 0 ? (this.currentIndex + 1) % len : 0;
});
});
});
@@ -101,7 +114,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
this.initProjectEntryRevealObserver();
this.initAboutSectionScrollAnimation();
}
ngOnDestroy(): void {
this.sub.unsubscribe();
if (this.bioHideTimeoutId !== null) {
@@ -117,9 +129,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
if (this.isLongBioShown) {
this.isLongBioShown = false;
if (typeof window !== 'undefined') {
if (this.bioHideTimeoutId !== null) {
window.clearTimeout(this.bioHideTimeoutId);
}
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = window.setTimeout((): void => {
this.isLongBioMounted = false;
this.bioHideTimeoutId = null;
@@ -129,12 +139,10 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
}
return;
}
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
window.clearTimeout(this.bioHideTimeoutId);
this.bioHideTimeoutId = null;
}
this.isLongBioMounted = true;
if (typeof window !== 'undefined') {
window.requestAnimationFrame((): void => {
@@ -146,88 +154,52 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
this.isLongBioShown = true;
}
}
protected get currentPortraitHighlight(): PortraitHighlight | null {
return this.portraitHighlights[this.currentPortraitHighlightIndex] ?? null;
}
protected get currentBioEntry(): BioTextEntry {
return this.global.bioTextsList[this.currentIndex] ?? this.fallbackBioEntry;
}
protected showNextPortraitHighlight(): void {
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) {
return;
}
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
this.isPortraitSwitching = true;
setTimeout((): void => {
this.currentPortraitHighlightIndex =
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
}, 125);
setTimeout((): void => {
this.isPortraitSwitching = false;
}, 250);
}
protected scrollToAboutSection(): void {
this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035));
}
protected scrollToProjectsSection(): void {
this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035));
}
protected scrollToContactLinks(): void {
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
}
private initNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
this.scheduleNameGradientUpdate();
}
private initProjectEntryRevealObserver(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
this.scheduleProjectEntryRevealUpdate();
}
private destroyProjectEntryRevealObserver(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
if (typeof window === 'undefined' || typeof document === 'undefined') return;
window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate);
window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate);
if (this.projectEntryAnimationFrameId !== null) {
window.cancelAnimationFrame(this.projectEntryAnimationFrameId);
this.projectEntryAnimationFrameId = null;
}
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
projectEntries.forEach((entry: HTMLElement): void => {
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
entry.style.removeProperty('--project-entry-progress');
});
}
private initAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
passive: true,
});
@@ -236,45 +208,28 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
});
this.scheduleAboutSectionScrollAnimationUpdate();
}
private destroyAboutSectionScrollAnimation(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
if (typeof window === 'undefined' || typeof document === 'undefined') return;
window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate);
window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate);
if (this.aboutSectionAnimationFrameId !== null) {
window.cancelAnimationFrame(this.aboutSectionAnimationFrameId);
this.aboutSectionAnimationFrameId = null;
}
const aboutSection: HTMLElement | null = document.getElementById('about');
aboutSection?.style.removeProperty('--about-scroll-progress');
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
}
private destroyNameGradientScrollAnimation(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
if (this.scrollAnimationFrameId !== null) {
window.cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
document.documentElement.style.removeProperty('--name-pride-progress');
}
private updateNameGradientProgress(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
const root: HTMLElement = document.documentElement;
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
const portfolioTriggerY: number =
@@ -292,20 +247,12 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
window.scrollY > 0
? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05)
: acceleratedProgress;
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
}
private updateAboutSectionScrollProgress(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
const aboutSection: HTMLElement | null = document.getElementById('about');
if (aboutSection === null) {
return;
}
if (aboutSection === null) return;
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.96;
@@ -313,21 +260,15 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
const totalDistance: number = Math.max(startY - endY, 1);
const rawProgress: number = (startY - sectionRect.top) / totalDistance;
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
}
private updateProjectEntryRevealProgress(): void {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
const viewportHeight: number = window.innerHeight || 1;
const startY: number = viewportHeight * 0.98;
const endY: number = viewportHeight * 0.56;
const totalDistance: number = Math.max(startY - endY, 1);
projectEntries.forEach((entry: HTMLElement): void => {
const entryRect: DOMRect = entry.getBoundingClientRect();
const rawProgress: number = (startY - entryRect.top) / totalDistance;
@@ -335,7 +276,6 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4));
});
}
private calculateAge(birthDateString: string): number {
const birthDate: Date = new Date(birthDateString);
const now: Date = new Date();
@@ -343,40 +283,21 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
const hasBirthdayPassedThisYear: boolean =
now.getMonth() > birthDate.getMonth() ||
(now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate());
if (!hasBirthdayPassedThisYear) {
age -= 1;
}
if (!hasBirthdayPassedThisYear) age -= 1;
return age;
}
private scrollToElementById(elementId: string, offsetPx: number = 0): void {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return;
}
if (typeof document === 'undefined' || typeof window === 'undefined') return;
const element: HTMLElement | null = document.getElementById(elementId);
if (element === null) {
return;
}
if (element === null) return;
const targetTop: number = Math.max(
element.getBoundingClientRect().top + window.scrollY - offsetPx,
0,
);
window.scrollTo({
top: targetTop,
behavior: 'smooth',
});
window.scrollTo({ top: targetTop, behavior: 'smooth' });
}
private getResponsiveScrollOffset(viewportRatio: number = 0.05): number {
if (typeof window === 'undefined') {
return 0;
}
if (typeof window === 'undefined') return 0;
const minOffsetPx: number = 20;
const maxOffsetPx: number = 48;
const responsiveOffsetPx: number = window.innerHeight * viewportRatio;
+35 -94
View File
@@ -2,43 +2,34 @@
<section class="hero-section panel legal-hero">
<div class="hero-copy">
<div class="hero-copy-main">
<p class="eyebrow">Legal</p>
<h1>Imprint & Privacy</h1>
<p class="hero-intro legal-intro">
Legal information for this website under Austrian law, including privacy information for websites operated by
{{ global.firstname }} {{ global.lastname }}
(Lechner Systems).
</p>
<p class="eyebrow">{{ content().imprint.legalEyebrow }}</p>
<h1>{{ content().imprint.title }}</h1>
<p class="hero-intro legal-intro">{{ bind(content().imprint.intro) }}</p>
</div>
<div class="hero-actions">
<a class="back-link" routerLink="">
<fa-icon [icon]="faArrowLeft"></fa-icon>
<span>Back to portfolio</span>
</a>
<a class="back-link" routerLink=""
><fa-icon [icon]="faArrowLeft"></fa-icon><span>{{ content().imprint.backToPortfolio }}</span></a
>
</div>
</div>
<div class="hero-visual">
<div class="portrait-highlight legal-contact-box">
<p class="hero-contact-title">Provider</p>
<p class="hero-contact-title">{{ content().imprint.provider }}</p>
<p class="legal-provider-name">{{ global.firstname }} {{ global.lastname }} / Lechner Systems</p>
<p>{{ displayStreet }} {{ global.address.houseNumber }}</p>
<p>{{ global.address.zip }} {{ displayCity }}</p>
<p>{{ global.address.street }} {{ global.address.houseNumber }}</p>
<p>{{ global.address.zip }} {{ global.address.city }}</p>
<p>{{ global.address.country }}</p>
<hr />
<p class="hero-contact-title">Direct Contact</p>
<p class="hero-contact-title">{{ content().imprint.directContact }}</p>
<p>
Phone:
<a class="inline-link" href="tel:{{ global.hrefContactPhone }}">{{ global.contactPhone }}</a>
{{ content().imprint.phone }}: <a class="inline-link" href="tel:{{ global.hrefContactPhone }}">{{ global.contactPhone }}</a>
</p>
<p>
Contact:
<a class="inline-link" [href]="contactSafeMail">{{ global.contactMail }}</a>
{{ content().imprint.contact }}: <a class="inline-link" [href]="contactMailHref">{{ global.contactMail }}</a>
</p>
<p>
Abuse:
<a class="inline-link" [href]="abuseSafeMail">{{ global.abuseMail }}</a>
{{ content().imprint.abuse }}: <a class="inline-link" [href]="abuseMailHref">{{ global.abuseMail }}</a>
</p>
</div>
</div>
@@ -46,113 +37,63 @@
<section class="story-section panel legal-section">
<div class="section-heading">
<p class="eyebrow">Scope</p>
<h2>Scope Of These Notices</h2>
<p class="eyebrow">{{ content().imprint.scopeEyebrow }}</p>
<h2>{{ content().imprint.scopeTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>
These notices apply to this website and, unless a more specific notice is published there, to other websites operated by Julian
Lechner under the name Lechner Systems.
</p>
<p>{{ content().imprint.scopeLeft }}</p>
</div>
<div class="story-detail">
<p>
This website provides information about projects, technical services, contact options, and professional work in software
engineering, IT, and related digital services.
</p>
<p>{{ content().imprint.scopeRight }}</p>
</div>
</div>
</section>
<section class="story-section panel legal-section">
<div class="section-heading compact">
<p class="eyebrow">Privacy</p>
<h2>Privacy And Data Processing</h2>
<p class="eyebrow">{{ content().imprint.privacyEyebrow }}</p>
<h2>{{ content().imprint.privacyTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>
The controller for data processing within the meaning of the GDPR is {{ global.firstname }} {{ global.lastname }}, reachable at
<a class="inline-link" [href]="contactSafeMail">{{ global.contactMail }}</a> and by phone at
<a class="inline-link" href="tel:{{ global.hrefContactPhone }}">{{ global.contactPhone }}</a
>.
</p>
<p>
As currently implemented in this web project, this website does not use its own tracking, analytics cookies, marketing cookies, or
long-term personal usage profiling.
</p>
<p>
For informational website access, technically necessary connection and server data may be processed for operation, IT security,
abuse prevention, and troubleshooting.
</p>
<p>{{ bind(content().imprint.privacyLeftParagraphs[0]) }}</p>
<p>{{ content().imprint.privacyLeftParagraphs[1] }}</p>
<p>{{ content().imprint.privacyLeftParagraphs[2] }}</p>
</div>
<div class="story-detail">
<p>
If you contact me by email or phone, the data you provide is processed to handle your request and maintain communication.
Applicable legal bases may include Article 6(1)(b), 6(1)(c), and 6(1)(f) GDPR, depending on context.
</p>
<p>
Personal data is disclosed only where legally required, needed for the establishment, exercise, or defence of legal claims, or
explicitly initiated by you.
</p>
<p>Data is stored only as long as required for communication, request handling, and legal retention duties.</p>
<p>{{ content().imprint.privacyRightParagraphs[0] }}</p>
<p>{{ content().imprint.privacyRightParagraphs[1] }}</p>
<p>{{ content().imprint.privacyRightParagraphs[2] }}</p>
</div>
</div>
</section>
<section class="story-section panel legal-section">
<div class="section-heading compact">
<p class="eyebrow">Rights</p>
<h2>Data Subject Rights And Complaints</h2>
<p class="eyebrow">{{ content().imprint.rightsEyebrow }}</p>
<h2>{{ content().imprint.rightsTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>
Subject to statutory requirements, data subjects have the right of access, rectification, erasure, restriction of processing, data
portability, and objection.
</p>
<p>{{ content().imprint.rightsLeft }}</p>
</div>
<div class="story-detail">
<p>
Complaints can be submitted to the Austrian Data Protection Authority: Barichgasse 40-42, 1030 Vienna, email
<a class="inline-link" href="mailto:dsb@dsb.gv.at">dsb&#64;dsb.gv.at</a>, website
<a class="inline-link" href="https://dsb.gv.at/">dsb.gv.at</a>.
</p>
<p>{{ content().imprint.rightsRight }}</p>
</div>
</div>
</section>
<section class="story-section panel legal-section">
<div class="section-heading compact">
<p class="eyebrow">Copyright</p>
<h2>Copyright, External Links, And Applicable Law</h2>
<p class="eyebrow">{{ content().imprint.copyrightEyebrow }}</p>
<h2>{{ content().imprint.copyrightTitle }}</h2>
</div>
<div class="story-grid">
<div class="story-copy">
<p>
All content on this website, including texts, images, graphics, photographs, layouts, source code, and other works, is protected
by copyright unless stated otherwise. All rights remain reserved.
</p>
<p>Any use beyond statutory exceptions requires prior written consent of the respective rights holder.</p>
<p>{{ content().imprint.copyrightLeftParagraphs[0] }}</p>
<p>{{ content().imprint.copyrightLeftParagraphs[1] }}</p>
</div>
<div class="story-detail">
<p>
This website may contain links to third-party websites. Their operators are solely responsible for those contents, and no
liability is accepted for external links to the extent permitted by law.
</p>
<p>
Austrian law applies exclusively, excluding conflict-of-law rules that would refer to another legal system, unless mandatory
consumer protection provisions require otherwise.
</p>
<p>{{ content().imprint.copyrightRightParagraphs[0] }}</p>
<p>{{ content().imprint.copyrightRightParagraphs[1] }}</p>
</div>
</div>
</section>
+16 -27
View File
@@ -1,45 +1,34 @@
import type { OnInit } from '@angular/core';
import { Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DomSanitizer } from '@angular/platform-browser';
import type { SafeUrl } from '@angular/platform-browser';
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
import type { Global } from '../../global';
import { global } from '../../global';
import { FooterComponent } from '../footer/footer.component';
import { LanguageService } from '../services/language.service';
@Component({
selector: 'app-imprint',
standalone: true,
imports: [FooterComponent, FaIconComponent, RouterLink],
templateUrl: './imprint.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImprintComponent implements OnInit {
private readonly sanitizer = inject(DomSanitizer);
protected readonly global: Global = global;
protected readonly displayStreet: string = this.normalizeText(global.address.street);
protected readonly displayCity: string = this.normalizeText(global.address.city);
protected contactSafeMail!: SafeUrl;
protected abuseSafeMail!: SafeUrl;
export class ImprintComponent {
private readonly languageService = inject(LanguageService);
protected readonly content = this.languageService.content;
protected readonly global = global;
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
protected readonly contactMailHref = `mailto:${global.contactMail}`;
protected readonly abuseMailHref = `mailto:${global.abuseMail}`;
ngOnInit(): void {
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
this.abuseSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${this.global.abuseMail}`);
}
private normalizeText(value: string): string {
return value
.replaceAll('ß', 'ß')
.replaceAll('ö', 'ö')
.replaceAll('Ä', 'Ä')
.replaceAll('ä', 'ä')
.replaceAll('Ü', 'Ü')
.replaceAll('ü', 'ü');
protected bind(text: string): string {
return text
.replaceAll('{{firstname}}', this.global.firstname)
.replaceAll('{{lastname}}', this.global.lastname)
.replaceAll('{{contactMail}}', this.global.contactMail)
.replaceAll('{{contactPhone}}', this.global.contactPhone);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Injectable, computed, signal } from '@angular/core';
import { deLanguage } from '../../languages/de';
import { enLanguage } from '../../languages/en';
import type { LanguageCode, LanguagePack } from '../../languages/language.types';
@Injectable({ providedIn: 'root' })
export class LanguageService {
private static readonly storageKey = 'portfolio-language';
private readonly currentLanguageCode = signal<LanguageCode>('en');
private readonly languageConfirmed = signal(false);
readonly languageCode = this.currentLanguageCode.asReadonly();
readonly isLanguageConfirmed = this.languageConfirmed.asReadonly();
readonly content = computed<LanguagePack>(() => this.getPack(this.currentLanguageCode()));
initializeFromStorage(): LanguageCode {
if (typeof window === 'undefined') {
return this.currentLanguageCode();
}
const storedLanguage = window.localStorage.getItem(LanguageService.storageKey);
if (storedLanguage === 'de' || storedLanguage === 'en') {
this.currentLanguageCode.set(storedLanguage);
this.languageConfirmed.set(true);
}
return this.currentLanguageCode();
}
confirmLanguage(code: LanguageCode): void {
this.currentLanguageCode.set(code);
this.languageConfirmed.set(true);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LanguageService.storageKey, code);
}
}
getPack(code: LanguageCode): LanguagePack {
return code === 'de' ? deLanguage : enLanguage;
}
}