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;
}
}
+1 -213
View File
@@ -1,51 +1,4 @@
export interface PortfolioProject {
title: string;
link: string;
description: string;
skills: string[];
icon?: string;
iconSrcset?: string;
iconSizes?: string;
iconWidth?: number;
iconHeight?: number;
CircleIcon?: boolean;
}
export interface PortraitHighlight {
image: string;
imageSrcset: string;
imageSizes: string;
imageWidth: number;
imageHeight: number;
text: string;
}
export interface ImprintData {
street: string;
houseNumber: number;
zip: number;
city: string;
country: string;
}
export interface Global {
firstname: string;
lastname: string;
contactMail: string;
abuseMail: string;
contactPhone: string;
hrefContactPhone: string;
birthdate: string;
bioTextsList: BioTextEntry[];
portraitHighlights: PortraitHighlight[];
projects: PortfolioProject[];
address: ImprintData;
}
export interface BioTextEntry {
label: string;
value: string;
}
import type { Global } from './global.types';
export const global: Global = {
firstname: 'Julian',
@@ -55,171 +8,6 @@ export const global: Global = {
contactPhone: '+43 (0) 660 9254001',
hrefContactPhone: '+436609254001',
birthdate: '2009-03-03',
bioTextsList: [
{ label: 'Languages', value: 'C#' },
{ label: 'Languages', value: 'TypeScript' },
{ label: 'Languages', value: 'Bash' },
{ label: 'Frontend', value: 'Angular' },
{ label: 'Frontend', value: 'WPF' },
{ label: 'Frontend', value: 'Avalonia' },
{ label: 'Backend', value: '.NET' },
{ label: 'Backend', value: 'ASP.NET Core' },
{ label: 'Backend', value: 'Entity Framework Core' },
{ label: 'Backend', value: 'Node.js' },
{ label: 'Backend', value: 'REST' },
{ label: 'Backend', value: 'Swagger' },
{ label: 'Database', value: 'Microsoft SQL' },
{ label: 'Database', value: 'MariaDB' },
{ label: 'DevOps', value: 'Git' },
{ label: 'DevOps', value: 'GitHub & GitLab' },
{ label: 'DevOps', value: 'Azure DevOps' },
{ label: 'DevOps', value: 'Docker' },
{ label: 'DevOps', value: 'Ubuntu' },
{ label: 'DevOps', value: 'Arch' },
{ label: 'Declarative Languages', value: 'XAML' },
{ label: 'Declarative Languages', value: 'YAML' },
{ label: 'Declarative Languages', value: 'Markdown' },
],
portraitHighlights: [
{
image: 'assets/optimized/portrait/me.webp',
imageSrcset:
'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 769,
text: 'ME',
},
{
image: 'assets/optimized/portrait/love.webp',
imageSrcset:
'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 710,
text: 'My Love',
},
{
image: 'assets/optimized/portrait/scuba.webp',
imageSrcset:
'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 480,
text: 'Scuba Diving',
},
{
image: 'assets/optimized/portrait/trains.webp',
imageSrcset:
'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 850,
text: 'Trains',
},
{
image: 'assets/optimized/portrait/traveling.webp',
imageSrcset:
'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 480,
text: 'Traveling',
},
{
image: 'assets/optimized/portrait/culture.webp',
imageSrcset:
'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 482,
text: 'Culture',
},
],
projects: [
{
title: 'SobIT GmbH',
link: 'https://sobit.at/',
description:
'I currently work primarily for the viennese company SobIT GmbH. This company develops software for the healthcare industry. - Development of modern desktop, mobile, and web applications to support healthcare delivery.',
skills: ['TypeScript', 'C#', 'Microsoft SQL', 'WPF', 'YML/XML'],
icon: 'assets/optimized/logos/sobit.webp',
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: false,
},
{
title: 'SynHost',
link: 'https://www.synhost.de/',
description:
'A hosting provider operated by GERLACH SYSTEMS, where I develop integrations and handle technical support.',
skills: ['TypeScript', 'MariaDB', 'YML'],
icon: 'assets/optimized/logos/synhost.webp',
iconSrcset:
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
CircleIcon: false,
},
{
title: 'SynRadio',
link: 'https://www.synradio.de/',
description:
'An online radio project, where I build and maintain the technical side of its digital presence — including the website, bots, and plugins for voice chats and games.',
skills: ['TypeScript', 'Angular', 'FFmpeg', 'YML'],
icon: 'assets/optimized/logos/synradio.webp',
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
{
title: 'Discord Audio Stream Library',
link: 'https://github.com/FrauJulian/Discord-Audio-Stream',
description:
'A TypeScript library for Discord that simplifies audio playback, with a focus on stable 24/7 streaming without interruptions.',
skills: ['TypeScript', 'YML'],
icon: 'assets/optimized/logos/discord.webp',
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
{
title: 'Portfolio',
link: '#',
description:
'This portfolio website youre currently viewing is one of my actively maintained projects.',
skills: ['TypeScript', 'Angular', 'YML'],
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
CircleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
link: 'https://tauchertreff-mostviertel.at/',
description:
'I also support my local diving club by building and running its digital infrastructure — email, website, cloud services, and everything around it.',
skills: ['TypeScript', 'MariaDB', 'Angular', 'YML'],
icon: 'assets/optimized/logos/tauchertreff.webp',
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
],
address: {
street: 'Ulmenstraße',
houseNumber: 9,
+18
View File
@@ -0,0 +1,18 @@
export interface ImprintData {
street: string;
houseNumber: number;
zip: number;
city: string;
country: string;
}
export interface Global {
firstname: string;
lastname: string;
contactMail: string;
abuseMail: string;
contactPhone: string;
hrefContactPhone: string;
birthdate: string;
address: ImprintData;
}
+258
View File
@@ -0,0 +1,258 @@
import type { LanguagePack } from './language.types';
export const deLanguage: LanguagePack = {
app: {
selectorTitle: 'Sprache auswählen',
changeLanguage: 'Sprache',
changeLanguageAriaLabel: 'Sprache ändern',
closeSelector: 'Sprachauswahl schließen',
languageEnglish: 'Englisch',
languageGerman: 'Deutsch',
},
footer: {
noscriptMessage: 'Bitte aktiviere JavaScript für die vollständige Nutzung.',
imprintLink: 'Impressum',
},
home: {
portfolioEyebrow: 'Portfolio',
heroIntro:
'Ich entwickle zuverlässige Systeme, saubere Interfaces und praktische digitale Lösungen, die auch produktiv wirklich halten.',
contactTitle: 'Kontakt',
viewProjects: 'Projekte ansehen',
aboutMe: 'Über mich',
nextHighlightAriaLabel: 'Nächstes Highlight-Bild anzeigen',
nextHighlightTitle: 'Nächstes Bild anzeigen',
singleHighlightTitle: 'Einzelnes Bild',
portraitAltSuffix: 'Bild',
clickHint: 'Klick mich!',
aboutEyebrow: 'Über mich',
aboutParagraphs: [
'Ich heiße {{firstname}}, die meisten nennen mich Julie. Ich bin {{age}} Jahre alt und komme aus Österreich. Ein großer Teil meines Alltags passiert unterwegs meistens im Zug, zwischen Terminen, Büros und Zeit mit meiner Familie.',
'Digitale Sicherheit und Privatsphäre sind mir sehr wichtig. Ich baue Systeme mit möglichst kleiner Angriffsfläche, sinnvollen Standards und starkem Fokus auf Datenschutz.',
'Ich arbeite mit modernen Web-Technologien und an geschäftskritischen Systemen. Mein Fokus liegt auf klaren, stabilen Lösungen, die im Alltag funktionieren und echten Nutzen liefern.',
],
detailToggleOpen: 'Erweitertes Profil lesen',
detailToggleClose: 'Erweitertes Profil ausblenden',
detailParagraphs: [
'Was mich wirklich interessiert, ist moderner Code: sauber strukturiert, wartbar und mit Blick auf Performance gebaut.',
'Abseits von Software ist Tauchen mein größtes Hobby. Die Stille unter Wasser zieht mich immer wieder zurück in Österreichs Seen.',
'Hauptsächlich arbeite ich für die SobIT GmbH in Wien, zusätzlich zu Freelance-Projekten für Unternehmen, Organisationen und private Kunden.',
],
getInTouch: 'Kontakt aufnehmen',
projectsEyebrow: 'Aktuelle Jobs & Projekte',
openProjectPrefix: 'Projekt öffnen',
projectLanguagesAriaLabel: 'Projekt-Technologien',
fallbackBioLabel: 'mein Stack',
},
imprint: {
legalEyebrow: 'Rechtliches',
title: 'Impressum & Datenschutz',
intro:
'Rechtliche Informationen zu dieser Website nach österreichischem Recht, inklusive Datenschutzinformationen für Websites von {{firstname}} {{lastname}} (Lechner Systems).',
backToPortfolio: 'Zurück zum Portfolio',
provider: 'Anbieter',
directContact: 'Direkter Kontakt',
phone: 'Telefon',
contact: 'Kontakt',
abuse: 'Missbrauch',
scopeEyebrow: 'Geltungsbereich',
scopeTitle: 'Geltungsbereich dieser Hinweise',
scopeLeft:
'Diese Hinweise gelten für diese Website und, sofern dort keine spezielleren Hinweise veröffentlicht sind, auch für andere Websites von Julian Lechner unter dem Namen Lechner Systems.',
scopeRight:
'Diese Website informiert über Projekte, technische Dienstleistungen, Kontaktmöglichkeiten und berufliche Arbeit in Softwareentwicklung, IT und verwandten digitalen Dienstleistungen.',
privacyEyebrow: 'Datenschutz',
privacyTitle: 'Datenschutz & Datenverarbeitung',
privacyLeftParagraphs: [
'Verantwortlicher für die Datenverarbeitung im Sinne der DSGVO ist {{firstname}} {{lastname}}, erreichbar unter {{contactMail}} sowie telefonisch unter {{contactPhone}}.',
'In der aktuellen Umsetzung dieses Webprojekts verwendet diese Website kein eigenes Tracking, keine Analyse-Cookies, keine Marketing-Cookies und keine langfristigen persönlichen Nutzungsprofile.',
'Beim informatorischen Zugriff auf die Website können technisch notwendige Verbindungs- und Serverdaten für Betrieb, IT-Sicherheit, Missbrauchsprävention und Fehleranalyse verarbeitet werden.',
],
privacyRightParagraphs: [
'Wenn du mich per E-Mail oder Telefon kontaktierst, werden die von dir angegebenen Daten zur Bearbeitung deiner Anfrage und zur Kommunikation verarbeitet.',
'Personenbezogene Daten werden nur weitergegeben, wenn es gesetzlich erforderlich ist, zur Durchsetzung rechtlicher Ansprüche benötigt wird oder ausdrücklich von dir veranlasst wurde.',
'Daten werden nur so lange gespeichert, wie es für Kommunikation, Bearbeitung von Anfragen und gesetzliche Aufbewahrungspflichten erforderlich ist.',
],
rightsEyebrow: 'Rechte',
rightsTitle: 'Betroffenenrechte & Beschwerden',
rightsLeft:
'Betroffene Personen haben im Rahmen der gesetzlichen Vorgaben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch.',
rightsRight:
'Beschwerden können bei der österreichischen Datenschutzbehörde eingereicht werden: Barichgasse 40-42, 1030 Wien, E-Mail dsb@dsb.gv.at, Website dsb.gv.at.',
copyrightEyebrow: 'Urheberrecht',
copyrightTitle: 'Urheberrecht, externe Links & anwendbares Recht',
copyrightLeftParagraphs: [
'Alle Inhalte dieser Website, einschließlich Texte, Bilder, Grafiken, Fotos, Layouts, Quellcode und sonstige Werke, sind urheberrechtlich geschützt, sofern nicht anders angegeben.',
'Jede Nutzung außerhalb der gesetzlichen Ausnahmen benötigt eine vorherige schriftliche Zustimmung des jeweiligen Rechteinhabers.',
],
copyrightRightParagraphs: [
'Diese Website kann Links zu externen Websites enthalten. Für deren Inhalte sind ausschließlich die jeweiligen Betreiber verantwortlich.',
'Es gilt ausschließlich österreichisches Recht, sofern keine zwingenden Verbraucherschutzbestimmungen etwas anderes vorgeben.',
],
},
bioTextsList: [
{ label: 'Sprachen', value: 'C#' },
{ label: 'Sprachen', value: 'TypeScript' },
{ label: 'Sprachen', value: 'Bash' },
{ label: 'Frontend', value: 'Angular' },
{ label: 'Frontend', value: 'WPF' },
{ label: 'Frontend', value: 'Avalonia' },
{ label: 'Backend', value: '.NET' },
{ label: 'Backend', value: 'ASP.NET Core' },
{ label: 'Backend', value: 'Entity Framework Core' },
{ label: 'Backend', value: 'Node.js' },
{ label: 'Backend', value: 'REST' },
{ label: 'Backend', value: 'Swagger' },
{ label: 'Datenbank', value: 'Microsoft SQL' },
{ label: 'Datenbank', value: 'MariaDB' },
{ label: 'DevOps', value: 'Git' },
{ label: 'DevOps', value: 'GitHub & GitLab' },
{ label: 'DevOps', value: 'Azure DevOps' },
{ label: 'DevOps', value: 'Docker' },
{ label: 'DevOps', value: 'Ubuntu' },
{ label: 'DevOps', value: 'Arch' },
{ label: 'Deklarativ', value: 'XAML' },
{ label: 'Deklarativ', value: 'YAML' },
{ label: 'Deklarativ', value: 'Markdown' },
],
portraitHighlights: [
{
image: 'assets/optimized/portrait/me.webp',
imageSrcset:
'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 769,
text: 'Ich',
},
{
image: 'assets/optimized/portrait/love.webp',
imageSrcset:
'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 710,
text: 'Meine Liebe',
},
{
image: 'assets/optimized/portrait/scuba.webp',
imageSrcset:
'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 480,
text: 'Tauchen',
},
{
image: 'assets/optimized/portrait/trains.webp',
imageSrcset:
'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 850,
text: 'Züge',
},
{
image: 'assets/optimized/portrait/traveling.webp',
imageSrcset:
'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 480,
text: 'Reisen',
},
{
image: 'assets/optimized/portrait/culture.webp',
imageSrcset:
'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 482,
text: 'Kultur',
},
],
projects: [
{
title: 'SobIT GmbH',
link: 'https://sobit.at/',
description:
'Ich arbeite aktuell hauptsächlich für die Wiener Firma SobIT GmbH. Das Unternehmen entwickelt Software für den Gesundheitsbereich. - Entwicklung moderner Desktop-, Mobile- und Webanwendungen zur Unterstützung der Gesundheitsversorgung.',
skills: ['TypeScript', 'C#', 'Microsoft SQL', 'WPF', 'YML/XML'],
icon: 'assets/optimized/logos/sobit.webp',
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: false,
},
{
title: 'SynHost',
link: 'https://www.synhost.de/',
description:
'Ein Hosting-Dienstleister unter der Verwaltung von GERLACH SYSTEMS, für den ich Schnittstellen entwickle und technischen Support mache.',
skills: ['TypeScript', 'MariaDB', 'YML'],
icon: 'assets/optimized/logos/synhost.webp',
iconSrcset:
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
CircleIcon: false,
},
{
title: 'SynRadio',
link: 'https://www.synradio.de/',
description:
'Ein Internetradio-Projekt, bei dem ich die technische Seite der digitalen Präsenz entwickle und betreue — inklusive Website, Bots und Plugins für Sprachchats und Spiele.',
skills: ['TypeScript', 'Angular', 'FFmpeg', 'YML'],
icon: 'assets/optimized/logos/synradio.webp',
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
{
title: 'Discord Audio Stream Library',
link: 'https://github.com/FrauJulian/Discord-Audio-Stream',
description:
'Eine TypeScript-Library für Discord, die Audiowiedergabe einfacher macht, mit Fokus auf stabile 24/7-Streams ohne Unterbrechungen.',
skills: ['TypeScript', 'YML'],
icon: 'assets/optimized/logos/discord.webp',
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
{
title: 'Portfolio',
link: '#',
description:
'Diese Portfolio-Website, die du gerade anschaust, ist eines meiner aktiv gepflegten Projekte.',
skills: ['TypeScript', 'Angular', 'YML'],
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
CircleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
link: 'https://tauchertreff-mostviertel.at/',
description:
'Ich unterstütze außerdem meinen lokalen Tauchverein, indem ich die digitale Infrastruktur aufbaue und betreibe — E-Mail, Website, Cloud-Dienste und alles, was dazugehört.',
skills: ['TypeScript', 'MariaDB', 'Angular', 'YML'],
icon: 'assets/optimized/logos/tauchertreff.webp',
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
],
};
+258
View File
@@ -0,0 +1,258 @@
import type { LanguagePack } from './language.types';
export const enLanguage: LanguagePack = {
app: {
selectorTitle: 'Select language',
changeLanguage: 'Language',
changeLanguageAriaLabel: 'Change language',
closeSelector: 'Close language selection',
languageEnglish: 'English',
languageGerman: 'German',
},
footer: {
noscriptMessage: 'Please enable JavaScript for the full experience.',
imprintLink: 'Imprint',
},
home: {
portfolioEyebrow: 'Portfolio',
heroIntro:
'Building reliable systems, clean interfaces, and practical digital solutions that actually hold up in production.',
contactTitle: 'Contact me',
viewProjects: 'View projects',
aboutMe: 'About me',
nextHighlightAriaLabel: 'Show next highlight image',
nextHighlightTitle: 'Show next image',
singleHighlightTitle: 'Single image',
portraitAltSuffix: 'Image',
clickHint: 'Click me!',
aboutEyebrow: 'About me',
aboutParagraphs: [
'My name is {{firstname}}, though most people call me Julie. I am {{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.',
'I care deeply about digital security and privacy. I design systems with minimal exposure, sensible defaults, and a strong focus on data protection.',
'I work with modern web technologies and on business-critical systems. I focus on clear, stable solutions that hold up in day-to-day use and deliver value.',
],
detailToggleOpen: 'Read extended profile',
detailToggleClose: 'Hide extended profile',
detailParagraphs: [
'What really interests me is modern code: well-structured, maintainable, and built with performance in mind.',
"Outside of software, my main hobby is scuba diving. The silence underwater keeps pulling me back into Austria's lakes.",
'I mainly work for SobIT GmbH in Vienna, alongside freelance projects for companies, organizations, and private clients.',
],
getInTouch: 'Get in touch',
projectsEyebrow: 'Current Jobs & Projects',
openProjectPrefix: 'Open project',
projectLanguagesAriaLabel: 'Project languages',
fallbackBioLabel: 'my stack',
},
imprint: {
legalEyebrow: 'Legal',
title: 'Imprint & Privacy',
intro:
'Legal information for this website under Austrian law, including privacy information for websites operated by {{firstname}} {{lastname}} (Lechner Systems).',
backToPortfolio: 'Back to portfolio',
provider: 'Provider',
directContact: 'Direct Contact',
phone: 'Phone',
contact: 'Contact',
abuse: 'Abuse',
scopeEyebrow: 'Scope',
scopeTitle: 'Scope Of These Notices',
scopeLeft:
'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.',
scopeRight:
'This website provides information about projects, technical services, contact options, and professional work in software engineering, IT, and related digital services.',
privacyEyebrow: 'Privacy',
privacyTitle: 'Privacy And Data Processing',
privacyLeftParagraphs: [
'The controller for data processing within the meaning of the GDPR is {{firstname}} {{lastname}}, reachable at {{contactMail}} and by phone at {{contactPhone}}.',
'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.',
'For informational website access, technically necessary connection and server data may be processed for operation, IT security, abuse prevention, and troubleshooting.',
],
privacyRightParagraphs: [
'If you contact me by email or phone, the data you provide is processed to handle your request and maintain communication.',
'Personal data is disclosed only where legally required, needed for legal claims, or explicitly initiated by you.',
'Data is stored only as long as required for communication, request handling, and legal retention duties.',
],
rightsEyebrow: 'Rights',
rightsTitle: 'Data Subject Rights And Complaints',
rightsLeft:
'Subject to statutory requirements, data subjects have the right of access, rectification, erasure, restriction of processing, data portability, and objection.',
rightsRight:
'Complaints can be submitted to the Austrian Data Protection Authority: Barichgasse 40-42, 1030 Vienna, email dsb@dsb.gv.at, website dsb.gv.at.',
copyrightEyebrow: 'Copyright',
copyrightTitle: 'Copyright, External Links, And Applicable Law',
copyrightLeftParagraphs: [
'All content on this website, including texts, images, graphics, photographs, layouts, source code, and other works, is protected by copyright unless stated otherwise.',
'Any use beyond statutory exceptions requires prior written consent of the respective rights holder.',
],
copyrightRightParagraphs: [
'This website may contain links to third-party websites. Their operators are solely responsible for those contents.',
'Austrian law applies exclusively, unless mandatory consumer protection provisions require otherwise.',
],
},
bioTextsList: [
{ label: 'Languages', value: 'C#' },
{ label: 'Languages', value: 'TypeScript' },
{ label: 'Languages', value: 'Bash' },
{ label: 'Frontend', value: 'Angular' },
{ label: 'Frontend', value: 'WPF' },
{ label: 'Frontend', value: 'Avalonia' },
{ label: 'Backend', value: '.NET' },
{ label: 'Backend', value: 'ASP.NET Core' },
{ label: 'Backend', value: 'Entity Framework Core' },
{ label: 'Backend', value: 'Node.js' },
{ label: 'Backend', value: 'REST' },
{ label: 'Backend', value: 'Swagger' },
{ label: 'Database', value: 'Microsoft SQL' },
{ label: 'Database', value: 'MariaDB' },
{ label: 'DevOps', value: 'Git' },
{ label: 'DevOps', value: 'GitHub & GitLab' },
{ label: 'DevOps', value: 'Azure DevOps' },
{ label: 'DevOps', value: 'Docker' },
{ label: 'DevOps', value: 'Ubuntu' },
{ label: 'DevOps', value: 'Arch' },
{ label: 'Declarative Languages', value: 'XAML' },
{ label: 'Declarative Languages', value: 'YAML' },
{ label: 'Declarative Languages', value: 'Markdown' },
],
portraitHighlights: [
{
image: 'assets/optimized/portrait/me.webp',
imageSrcset:
'assets/optimized/portrait/me-320.webp 320w, assets/optimized/portrait/me-480.webp 480w, assets/optimized/portrait/me.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 769,
text: 'ME',
},
{
image: 'assets/optimized/portrait/love.webp',
imageSrcset:
'assets/optimized/portrait/love-320.webp 320w, assets/optimized/portrait/love-480.webp 480w, assets/optimized/portrait/love.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 710,
text: 'My Love',
},
{
image: 'assets/optimized/portrait/scuba.webp',
imageSrcset:
'assets/optimized/portrait/scuba-320.webp 320w, assets/optimized/portrait/scuba-480.webp 480w, assets/optimized/portrait/scuba.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 480,
text: 'Scuba Diving',
},
{
image: 'assets/optimized/portrait/trains.webp',
imageSrcset:
'assets/optimized/portrait/trains-320.webp 320w, assets/optimized/portrait/trains-480.webp 480w, assets/optimized/portrait/trains.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 850,
text: 'Trains',
},
{
image: 'assets/optimized/portrait/traveling.webp',
imageSrcset:
'assets/optimized/portrait/traveling-320.webp 320w, assets/optimized/portrait/traveling-480.webp 480w, assets/optimized/portrait/traveling.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 480,
text: 'Traveling',
},
{
image: 'assets/optimized/portrait/culture.webp',
imageSrcset:
'assets/optimized/portrait/culture-320.webp 320w, assets/optimized/portrait/culture-480.webp 480w, assets/optimized/portrait/culture.webp 640w',
imageSizes:
'(max-width: 700px) calc(100vw - 2rem), (max-width: 900px) min(100vw - 6rem, 560px), 560px',
imageWidth: 640,
imageHeight: 482,
text: 'Culture',
},
],
projects: [
{
title: 'SobIT GmbH',
link: 'https://sobit.at/',
description:
'I currently work primarily for the viennese company SobIT GmbH. This company develops software for the healthcare industry. - Development of modern desktop, mobile, and web applications to support healthcare delivery.',
skills: ['TypeScript', 'C#', 'Microsoft SQL', 'WPF', 'YML/XML'],
icon: 'assets/optimized/logos/sobit.webp',
iconSrcset:
'assets/optimized/logos/sobit-64.webp 64w, assets/optimized/logos/sobit-128.webp 128w, assets/optimized/logos/sobit.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: false,
},
{
title: 'SynHost',
link: 'https://www.synhost.de/',
description:
'A hosting provider operated by GERLACH SYSTEMS, where I develop integrations and handle technical support.',
skills: ['TypeScript', 'MariaDB', 'YML'],
icon: 'assets/optimized/logos/synhost.webp',
iconSrcset:
'assets/optimized/logos/synhost-64.webp 64w, assets/optimized/logos/synhost-128.webp 128w, assets/optimized/logos/synhost.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 108,
iconHeight: 64,
CircleIcon: false,
},
{
title: 'SynRadio',
link: 'https://www.synradio.de/',
description:
'An online radio project, where I build and maintain the technical side of its digital presence — including the website, bots, and plugins for voice chats and games.',
skills: ['TypeScript', 'Angular', 'FFmpeg', 'YML'],
icon: 'assets/optimized/logos/synradio.webp',
iconSrcset:
'assets/optimized/logos/synradio-64.webp 64w, assets/optimized/logos/synradio-128.webp 128w, assets/optimized/logos/synradio.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
{
title: 'Discord Audio Stream Library',
link: 'https://github.com/FrauJulian/Discord-Audio-Stream',
description:
'A TypeScript library for Discord that simplifies audio playback, with a focus on stable 24/7 streaming without interruptions.',
skills: ['TypeScript', 'YML'],
icon: 'assets/optimized/logos/discord.webp',
iconSrcset:
'assets/optimized/logos/discord-64.webp 64w, assets/optimized/logos/discord-128.webp 128w, assets/optimized/logos/discord.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
{
title: 'Portfolio',
link: '#',
description:
'This portfolio website youre currently viewing is one of my actively maintained projects.',
skills: ['TypeScript', 'Angular', 'YML'],
icon: 'assets/optimized/portrait/me.webp',
iconSrcset:
'assets/optimized/portrait/me-64.webp 64w, assets/optimized/portrait/me-128.webp 128w, assets/optimized/portrait/me.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
iconWidth: 320,
iconHeight: 385,
CircleIcon: true,
},
{
title: 'Tauchertreff-Mostviertel',
link: 'https://tauchertreff-mostviertel.at/',
description:
'I also support my local diving club by building and running its digital infrastructure — email, website, cloud services, and everything around it.',
skills: ['TypeScript', 'MariaDB', 'Angular', 'YML'],
icon: 'assets/optimized/logos/tauchertreff.webp',
iconSrcset:
'assets/optimized/logos/tauchertreff-64.webp 64w, assets/optimized/logos/tauchertreff-128.webp 128w, assets/optimized/logos/tauchertreff.webp 216w',
iconSizes: '(max-width: 700px) 64px, 108px',
CircleIcon: true,
},
],
};
+95
View File
@@ -0,0 +1,95 @@
export type LanguageCode = 'en' | 'de';
export interface LanguageProject {
title: string;
link: string;
description: string;
skills: string[];
icon?: string;
iconSrcset?: string;
iconSizes?: string;
iconWidth?: number;
iconHeight?: number;
CircleIcon?: boolean;
}
export interface LanguageBioTextEntry {
label: string;
value: string;
}
export interface LanguagePortraitHighlight {
image: string;
imageSrcset: string;
imageSizes: string;
imageWidth: number;
imageHeight: number;
text: string;
}
export interface LanguagePack {
app: {
selectorTitle: string;
changeLanguage: string;
changeLanguageAriaLabel: string;
closeSelector: string;
languageEnglish: string;
languageGerman: string;
};
footer: {
noscriptMessage: string;
imprintLink: string;
};
home: {
portfolioEyebrow: string;
heroIntro: string;
contactTitle: string;
viewProjects: string;
aboutMe: string;
nextHighlightAriaLabel: string;
nextHighlightTitle: string;
singleHighlightTitle: string;
portraitAltSuffix: string;
clickHint: string;
aboutEyebrow: string;
aboutParagraphs: string[];
detailToggleOpen: string;
detailToggleClose: string;
detailParagraphs: string[];
getInTouch: string;
projectsEyebrow: string;
openProjectPrefix: string;
projectLanguagesAriaLabel: string;
fallbackBioLabel: string;
};
imprint: {
legalEyebrow: string;
title: string;
intro: string;
backToPortfolio: string;
provider: string;
directContact: string;
phone: string;
contact: string;
abuse: string;
scopeEyebrow: string;
scopeTitle: string;
scopeLeft: string;
scopeRight: string;
privacyEyebrow: string;
privacyTitle: string;
privacyLeftParagraphs: string[];
privacyRightParagraphs: string[];
rightsEyebrow: string;
rightsTitle: string;
rightsLeft: string;
rightsRight: string;
copyrightEyebrow: string;
copyrightTitle: string;
copyrightLeftParagraphs: string[];
copyrightRightParagraphs: string[];
};
bioTextsList: LanguageBioTextEntry[];
portraitHighlights: LanguagePortraitHighlight[];
projects: LanguageProject[];
}
+227
View File
@@ -43,6 +43,11 @@ html.no-js {
.hero-meta {
display: none;
}
.language-overlay,
.language-switcher {
display: none !important;
}
}
body,
@@ -1065,3 +1070,225 @@ hr {
justify-items: center;
}
}
.language-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
padding: 1.25rem;
background:
radial-gradient(circle at top, rgba(239, 191, 157, 0.16), transparent 28%),
linear-gradient(180deg, rgba(4, 4, 6, 0.7), rgba(7, 7, 10, 0.9));
backdrop-filter: blur(14px);
}
.language-card {
display: grid;
gap: 1rem;
width: min(100%, 520px);
padding: clamp(1rem, 4vw, 1.35rem);
background:
radial-gradient(circle at top left, rgba(239, 191, 157, 0.16), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)), rgba(10, 10, 14, 0.62);
}
.language-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.language-copy {
display: grid;
gap: 0;
}
.language-card h2,
.language-card p {
margin: 0;
}
.language-copy h2 {
font-size: clamp(1.85rem, 4vw, 2.5rem);
line-height: 1;
letter-spacing: -0.04em;
}
.language-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.language-option {
position: relative;
display: grid;
gap: 0.45rem;
justify-items: center;
align-content: center;
min-height: 8.5rem;
padding: 1rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.03)), rgba(10, 10, 14, 0.34);
color: inherit;
text-align: center;
cursor: pointer;
transition:
transform var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
}
.language-option:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.28);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.1),
0 14px 28px rgba(0, 0, 0, 0.16);
}
.language-option-active {
border-color: rgba(239, 191, 157, 0.64);
background: linear-gradient(180deg, rgba(239, 191, 157, 0.14), rgba(255, 255, 255, 0.05)), rgba(10, 10, 14, 0.48);
box-shadow:
0 0 0 1px rgba(239, 191, 157, 0.22),
0 18px 34px rgba(0, 0, 0, 0.2);
}
.language-option strong {
font-size: 1.32rem;
line-height: 1.08;
letter-spacing: -0.03em;
}
.language-option-accent,
.language-switcher-tag {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 1.9rem;
padding: 0.18rem 0.7rem;
border-radius: 999px;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.language-option-accent {
color: var(--accent-strong);
background: rgba(239, 191, 157, 0.12);
border: 1px solid rgba(239, 191, 157, 0.24);
}
.language-close {
width: 2.8rem;
height: 2.8rem;
border: 1px solid var(--border-soft);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
font: inherit;
font-size: 1.4rem;
cursor: pointer;
transition:
transform var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.language-close:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.42);
background: rgba(255, 255, 255, 0.08);
}
.language-switcher {
position: fixed;
right: 1rem;
top: 1rem;
z-index: 2000;
display: inline-flex;
align-items: center;
gap: 0.7rem;
min-height: 3.5rem;
padding: 0.55rem 0.8rem 0.55rem 0.55rem;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.04)), rgba(12, 12, 16, 0.82);
color: var(--text-primary);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(18px);
cursor: pointer;
transition:
transform var(--transition-fast),
border-color var(--transition-fast),
background var(--transition-fast);
}
.language-switcher:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.52);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.05)), rgba(18, 18, 24, 0.9);
}
.language-switcher-tag {
min-width: 2.3rem;
color: #111214;
background: #f7f1ea;
}
.language-switcher-copy {
display: grid;
justify-items: start;
line-height: 1.1;
}
.language-switcher-copy strong {
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.language-switcher-copy span {
color: var(--text-muted);
font-size: 0.9rem;
}
.app-content[aria-hidden='true'] {
user-select: none;
pointer-events: none;
}
@media (max-width: 700px) {
.language-card {
width: min(100%, 460px);
gap: 0.9rem;
padding: 0.95rem;
}
.language-card-header {
display: grid;
}
.language-options {
grid-template-columns: 1fr;
}
.language-copy h2 {
font-size: clamp(1.9rem, 9vw, 2.5rem);
}
.language-option {
min-height: auto;
}
.language-switcher {
right: 0.75rem;
top: 0.75rem;
}
}