feat: added languages
This commit is contained in:
@@ -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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ describe('AppComponent', (): void => {
|
|||||||
let component: AppComponent;
|
let component: AppComponent;
|
||||||
|
|
||||||
beforeEach(async (): Promise<void> => {
|
beforeEach(async (): Promise<void> => {
|
||||||
|
localStorage.clear();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [AppComponent],
|
imports: [AppComponent],
|
||||||
providers: [provideRouter([])],
|
providers: [provideRouter([])],
|
||||||
@@ -23,28 +25,65 @@ describe('AppComponent', (): void => {
|
|||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have title "Lechner Julian"', (): void => {
|
it('should render the language selector on first visit', (): void => {
|
||||||
expect(component.title).toBe('Lechner Julian');
|
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;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
expect(el.querySelector('router-outlet')).not.toBeNull();
|
expect(el.querySelector('router-outlet')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render any additional root elements besides router-outlet', (): void => {
|
it('should confirm and persist the selected language', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const comp = component as unknown as {
|
||||||
const children = Array.from(el.children);
|
chooseLanguage(language: 'en' | 'de'): void;
|
||||||
expect(children.length).toBe(1);
|
currentLanguageCode(): string;
|
||||||
expect(children[0].tagName.toLowerCase()).toBe('router-outlet');
|
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 restore a saved language from localStorage', async (): Promise<void> => {
|
||||||
it('should create and run initial change detection within 100 ms', (): void => {
|
localStorage.setItem('portfolio-language', 'de');
|
||||||
const start = performance.now();
|
|
||||||
const f = TestBed.createComponent(AppComponent);
|
const restoredFixture = TestBed.createComponent(AppComponent);
|
||||||
f.detectChanges();
|
restoredFixture.detectChanges();
|
||||||
expect(performance.now() - start).toBeLessThan(100);
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
import type { LanguageCode } from '../languages/language.types';
|
||||||
|
import type { LanguageOption } from './app.types';
|
||||||
|
import { LanguageService } from './services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
title: string = 'Lechner Julian';
|
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 = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ApplicationConfig } from '@angular/core';
|
import type { ApplicationConfig } from '@angular/core';
|
||||||
import { importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
|
import { provideZoneChangeDetection } from '@angular/core';
|
||||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
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';
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
@@ -16,6 +16,5 @@ export const appConfig: ApplicationConfig = {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideClientHydration(withEventReplay()),
|
provideClientHydration(withEventReplay()),
|
||||||
importProvidersFrom(BrowserModule),
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { LanguageCode } from '../languages/language.types';
|
||||||
|
|
||||||
|
export interface LanguageOption {
|
||||||
|
code: LanguageCode;
|
||||||
|
accent: string;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<noscript>
|
<noscript>
|
||||||
<div class="page-shell footer-shell">
|
<div class="page-shell footer-shell">
|
||||||
<div class="panel noscript-panel">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-inner">
|
<div class="footer-inner">
|
||||||
<p>© 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
|
<p>© 2023 - {{ currentYear }} {{ global.firstname }} {{ global.lastname }}</p>
|
||||||
<a routerLink="/imprint">Imprint</a>
|
<a routerLink="/imprint">{{ content().footer.imprintLink }}</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import type { Global } from '../../global';
|
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
|
standalone: true,
|
||||||
imports: [RouterLink],
|
imports: [RouterLink],
|
||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class FooterComponent {
|
export class FooterComponent {
|
||||||
protected readonly global: Global = global;
|
private readonly languageService = inject(LanguageService);
|
||||||
protected readonly currentYear: number = new Date().getFullYear();
|
|
||||||
|
protected readonly content = this.languageService.content;
|
||||||
|
protected readonly global = global;
|
||||||
|
protected readonly currentYear = new Date().getFullYear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,53 +2,44 @@
|
|||||||
<section class="hero-section panel">
|
<section class="hero-section panel">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<div class="hero-copy-main">
|
<div class="hero-copy-main">
|
||||||
<p id="portfolio-eyebrow" class="eyebrow">Portfolio</p>
|
<p id="portfolio-eyebrow" class="eyebrow">{{ content().home.portfolioEyebrow }}</p>
|
||||||
<h1>
|
<h1>
|
||||||
<span class="hero-name-part" [attr.data-name]="global.firstname">{{ global.firstname }}</span>
|
<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>
|
<span class="hero-name-part" [attr.data-name]="global.lastname">{{ global.lastname }}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero-intro">
|
<p class="hero-intro">{{ content().home.heroIntro }}</p>
|
||||||
Building reliable systems, clean interfaces, and practical digital solutions that actually hold up in production.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<div class="hero-contact-block">
|
<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">
|
<div id="contact-links" class="contact-area hero-contact-area">
|
||||||
<a [href]="contactSafeMail" aria-label="Send mail">
|
<a [href]="contactMailHref" aria-label="Send mail"><fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon></a>
|
||||||
<fa-icon class="fab" [icon]="faEnvelope" size="lg"></fa-icon>
|
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number"
|
||||||
</a>
|
><fa-icon class="fab" [icon]="faPhone" size="lg"></fa-icon
|
||||||
|
></a>
|
||||||
<a href="tel:{{ global.hrefContactPhone }}" aria-label="Call phone number">
|
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile"
|
||||||
<fa-icon class="fab" [icon]="faPhone" size="lg"></fa-icon>
|
><fa-icon class="fab" [icon]="faDiscord" size="lg"></fa-icon
|
||||||
</a>
|
></a>
|
||||||
|
<a href="https://github.com/fraujulian" aria-label="GitHub profile"
|
||||||
<a href="https://discord.com/users/860206216893693973" aria-label="Discord profile">
|
><fa-icon class="fab" [icon]="faGithub" size="lg"></fa-icon
|
||||||
<fa-icon class="fab" [icon]="faDiscord" size="lg"></fa-icon>
|
></a>
|
||||||
</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 href="https://github.com/fraujulian" aria-label="GitHub profile">
|
></a>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="hero-action-links">
|
<div class="hero-action-links">
|
||||||
<button class="action-link" type="button" (click)="scrollToProjectsSection()">View projects</button>
|
<button class="action-link" type="button" (click)="scrollToProjectsSection()">{{ content().home.viewProjects }}</button>
|
||||||
<button class="action-link" type="button" (click)="scrollToAboutSection()">About me</button>
|
<button class="action-link" type="button" (click)="scrollToAboutSection()">{{ content().home.aboutMe }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<div>
|
<div>
|
||||||
<span class="meta-label">{{ currentBioEntry.label }}</span>
|
<span class="meta-label">{{ currentBioEntry.label }}</span
|
||||||
<strong>{{ currentBioEntry.value }}</strong>
|
><strong>{{ currentBioEntry.value }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,8 +51,8 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="portrait-highlight-button"
|
class="portrait-highlight-button"
|
||||||
(click)="showNextPortraitHighlight()"
|
(click)="showNextPortraitHighlight()"
|
||||||
[attr.aria-label]="'Show next highlight image'"
|
[attr.aria-label]="content().home.nextHighlightAriaLabel"
|
||||||
[title]="portraitHighlights.length > 1 ? 'Show next image' : 'Single image'"
|
[title]="portraitHighlights.length > 1 ? content().home.nextHighlightTitle : content().home.singleHighlightTitle"
|
||||||
>
|
>
|
||||||
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
|
<div class="portrait-media" [class.portrait-media-switching]="isPortraitSwitching">
|
||||||
<img
|
<img
|
||||||
@@ -74,10 +65,10 @@
|
|||||||
disableOptimizedSrcset
|
disableOptimizedSrcset
|
||||||
priority
|
priority
|
||||||
decoding="async"
|
decoding="async"
|
||||||
[alt]="currentPortraitHighlight.text + ' Image'"
|
[alt]="currentPortraitHighlight.text + ' ' + content().home.portraitAltSuffix"
|
||||||
/>
|
/>
|
||||||
@if (currentPortraitHighlightIndex === 0) {
|
@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>
|
<span class="portrait-caption">{{ currentPortraitHighlight.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,47 +80,27 @@
|
|||||||
|
|
||||||
<section id="about" class="story-section panel about-scroll-anim-enabled">
|
<section id="about" class="story-section panel about-scroll-anim-enabled">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<p class="eyebrow">About me</p>
|
<p class="eyebrow">{{ content().home.aboutEyebrow }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>
|
<p>{{ interpolate(content().home.aboutParagraphs[0]) }}</p>
|
||||||
My name is {{ global.firstname }}, though most people call me Julie. I’m {{ age }} and based in Austria. A large part of my day
|
<p>{{ content().home.aboutParagraphs[1] }}</p>
|
||||||
happens on the move — usually on trains, going between meetings, offices, and time with my family.
|
<p>{{ content().home.aboutParagraphs[2] }}</p>
|
||||||
</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 aren’t extras for me — they’re baseline
|
|
||||||
requirements for my personal infrastructure.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
I work with modern web technologies and on business-critical systems. I don’t 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<button class="detail-toggle" type="button" (click)="toggleBio()">
|
<button class="detail-toggle" type="button" (click)="toggleBio()">
|
||||||
<span>{{ isLongBioShown ? 'Hide extended profile' : 'Read extended profile' }}</span>
|
<span>{{ isLongBioShown ? content().home.detailToggleClose : content().home.detailToggleOpen }}</span
|
||||||
<fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
|
><fa-icon [icon]="isLongBioShown ? faArrowDown : faArrowRight"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (isLongBioMounted) {
|
@if (isLongBioMounted) {
|
||||||
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
|
<div class="detail-panel" [class.detail-panel-enter]="isLongBioShown" [class.detail-panel-leave]="!isLongBioShown">
|
||||||
<p>
|
<p>{{ content().home.detailParagraphs[0] }}</p>
|
||||||
What really interests me is modern code — well-structured, maintainable, and built with performance in mind. No unnecessary
|
<p>{{ content().home.detailParagraphs[1] }}</p>
|
||||||
bloat, just clean, readable solutions that reliably work under real-world conditions… in theory 😁
|
<p>{{ content().home.detailParagraphs[2] }}</p>
|
||||||
</p>
|
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">
|
||||||
<p>
|
{{ content().home.getInTouch }}
|
||||||
Outside of software, my main hobby is scuba diving. The silence underwater and the focus on the present moment keep pulling me
|
</button>
|
||||||
back into Austria’s 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 it’s supposed to do, built properly.
|
|
||||||
</p>
|
|
||||||
<button class="inline-link inline-link-button" type="button" (click)="scrollToContactLinks()">Get in touch</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -138,9 +109,8 @@
|
|||||||
|
|
||||||
<section id="projects" class="projects-section">
|
<section id="projects" class="projects-section">
|
||||||
<div class="section-heading section-heading-centered">
|
<div class="section-heading section-heading-centered">
|
||||||
<p class="eyebrow">current Jobs & Projects</p>
|
<p class="eyebrow">{{ content().home.projectsEyebrow }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="project-list">
|
<div class="project-list">
|
||||||
@for (project of projects; track project.link + '-' + $index; let i = $index) {
|
@for (project of projects; track project.link + '-' + $index; let i = $index) {
|
||||||
<a
|
<a
|
||||||
@@ -149,7 +119,7 @@
|
|||||||
[class.project-entry-from-right]="i % 2 === 0"
|
[class.project-entry-from-right]="i % 2 === 0"
|
||||||
[class.project-entry-from-left]="i % 2 === 1"
|
[class.project-entry-from-left]="i % 2 === 1"
|
||||||
[href]="project.link"
|
[href]="project.link"
|
||||||
[attr.aria-label]="'Open project ' + project.title"
|
[attr.aria-label]="content().home.openProjectPrefix + ' ' + project.title"
|
||||||
[attr.data-project-index]="i"
|
[attr.data-project-index]="i"
|
||||||
>
|
>
|
||||||
@if (project.icon) {
|
@if (project.icon) {
|
||||||
@@ -165,19 +135,17 @@
|
|||||||
[alt]="project.title + ' icon'"
|
[alt]="project.title + ' icon'"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="project-copy">
|
<div class="project-copy">
|
||||||
<div class="project-header">
|
<div class="project-header">
|
||||||
<div class="project-title-wrap">
|
<div class="project-title-wrap">
|
||||||
<span class="project-link">{{ project.title }}</span>
|
<span class="project-link">{{ project.title }}</span>
|
||||||
</div>
|
</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) {
|
@for (language of project.skills; track language + '-' + $index) {
|
||||||
<span class="project-language-chip">{{ language }}</span>
|
<span class="project-language-chip">{{ language }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="project-description">{{ project.description }}</p>
|
<p class="project-description">{{ project.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import type { Subscription } from 'rxjs';
|
|||||||
|
|
||||||
import { HomeComponent } from './home.component';
|
import { HomeComponent } from './home.component';
|
||||||
import { global } from '../../global';
|
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 => {
|
describe('HomeComponent', (): void => {
|
||||||
let fixture: ComponentFixture<HomeComponent>;
|
let fixture: ComponentFixture<HomeComponent>;
|
||||||
@@ -34,8 +38,8 @@ describe('HomeComponent', (): void => {
|
|||||||
scrollToAboutSection(): void;
|
scrollToAboutSection(): void;
|
||||||
scrollToProjectsSection(): void;
|
scrollToProjectsSection(): void;
|
||||||
scrollToContactLinks(): void;
|
scrollToContactLinks(): void;
|
||||||
currentBioEntry: BioTextEntry;
|
currentBioEntry: LanguageBioTextEntry;
|
||||||
currentPortraitHighlight: PortraitHighlight | null;
|
currentPortraitHighlight: LanguagePortraitHighlight | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let comp: ComponentAccess;
|
let comp: ComponentAccess;
|
||||||
@@ -93,12 +97,12 @@ describe('HomeComponent', (): void => {
|
|||||||
describe('currentBioEntry getter', (): void => {
|
describe('currentBioEntry getter', (): void => {
|
||||||
it('should return the first bioTextsList entry at index 0', (): void => {
|
it('should return the first bioTextsList entry at index 0', (): void => {
|
||||||
comp.currentIndex = 0;
|
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 => {
|
it('should return the correct entry when index changes', (): void => {
|
||||||
comp.currentIndex = 3;
|
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 => {
|
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 => {
|
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;
|
comp.currentIndex = last;
|
||||||
expect(comp.currentBioEntry).toEqual(global.bioTextsList[last]);
|
expect(comp.currentBioEntry).toEqual(enLanguage.bioTextsList[last]);
|
||||||
comp.currentIndex = (last + 1) % global.bioTextsList.length;
|
comp.currentIndex = (last + 1) % enLanguage.bioTextsList.length;
|
||||||
expect(comp.currentIndex).toBe(0);
|
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 => {
|
describe('currentPortraitHighlight getter', (): void => {
|
||||||
it('should return the highlight at the current index', (): void => {
|
it('should return the highlight at the current index', (): void => {
|
||||||
comp.currentPortraitHighlightIndex = 0;
|
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 => {
|
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 => {
|
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;
|
.portraitHighlights;
|
||||||
const saved = [...highlights]; // snapshot before mutation
|
const saved = [...highlights]; // snapshot before mutation
|
||||||
highlights.splice(1, saved.length - 1); // reduce to a single entry
|
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 => {
|
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);
|
const zone = TestBed.inject(NgZone);
|
||||||
zone.runOutsideAngular((): void => {
|
zone.runOutsideAngular((): void => {
|
||||||
comp.showNextPortraitHighlight();
|
comp.showNextPortraitHighlight();
|
||||||
@@ -400,15 +404,15 @@ describe('HomeComponent', (): void => {
|
|||||||
expect(el.querySelector('#projects')).not.toBeNull();
|
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 el = fixture.nativeElement as HTMLElement;
|
||||||
const entries = el.querySelectorAll('.project-entry');
|
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 => {
|
it('should render all project titles', (): void => {
|
||||||
const el = fixture.nativeElement as HTMLElement;
|
const el = fixture.nativeElement as HTMLElement;
|
||||||
global.projects.forEach((project): void => {
|
enLanguage.projects.forEach((project): void => {
|
||||||
expect(el.textContent).toContain(project.title);
|
expect(el.textContent).toContain(project.title);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-144
@@ -1,67 +1,64 @@
|
|||||||
import type { AfterViewInit, OnDestroy, OnInit } from '@angular/core';
|
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 { NgOptimizedImage } from '@angular/common';
|
||||||
import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowRight, faArrowDown, faEnvelope, faPhone } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
import { faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||||
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
|
import type { IconDefinition } from '@fortawesome/angular-fontawesome';
|
||||||
import { FaIconComponent } 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 type { Subscription } from 'rxjs';
|
||||||
import { interval } from 'rxjs';
|
import { interval } from 'rxjs';
|
||||||
|
|
||||||
import type { BioTextEntry, Global, PortraitHighlight, PortfolioProject } from '../../global';
|
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
|
import type {
|
||||||
|
LanguageBioTextEntry,
|
||||||
|
LanguagePortraitHighlight,
|
||||||
|
LanguageProject,
|
||||||
|
} from '../../languages/language.types';
|
||||||
import { FooterComponent } from '../footer/footer.component';
|
import { FooterComponent } from '../footer/footer.component';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [FooterComponent, FaIconComponent, NgOptimizedImage],
|
imports: [FooterComponent, FaIconComponent, NgOptimizedImage],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
|
||||||
private readonly zone = inject(NgZone);
|
private readonly zone = inject(NgZone);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
protected readonly content = this.languageService.content;
|
||||||
|
|
||||||
private scrollAnimationFrameId: number | null = null;
|
private scrollAnimationFrameId: number | null = null;
|
||||||
private projectEntryAnimationFrameId: number | null = null;
|
private projectEntryAnimationFrameId: number | null = null;
|
||||||
private aboutSectionAnimationFrameId: number | null = null;
|
private aboutSectionAnimationFrameId: number | null = null;
|
||||||
private readonly scheduleNameGradientUpdate: () => void = (): void => {
|
private readonly scheduleNameGradientUpdate: () => void = (): void => {
|
||||||
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') {
|
if (this.scrollAnimationFrameId !== null || typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
|
this.scrollAnimationFrameId = window.requestAnimationFrame((): void => {
|
||||||
this.scrollAnimationFrameId = null;
|
this.scrollAnimationFrameId = null;
|
||||||
this.updateNameGradientProgress();
|
this.updateNameGradientProgress();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
|
private readonly scheduleProjectEntryRevealUpdate: () => void = (): void => {
|
||||||
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') {
|
if (this.projectEntryAnimationFrameId !== null || typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => {
|
this.projectEntryAnimationFrameId = window.requestAnimationFrame((): void => {
|
||||||
this.projectEntryAnimationFrameId = null;
|
this.projectEntryAnimationFrameId = null;
|
||||||
this.updateProjectEntryRevealProgress();
|
this.updateProjectEntryRevealProgress();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => {
|
private readonly scheduleAboutSectionScrollAnimationUpdate: () => void = (): void => {
|
||||||
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') {
|
if (this.aboutSectionAnimationFrameId !== null || typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
|
this.aboutSectionAnimationFrameId = window.requestAnimationFrame((): void => {
|
||||||
this.aboutSectionAnimationFrameId = null;
|
this.aboutSectionAnimationFrameId = null;
|
||||||
this.updateAboutSectionScrollProgress();
|
this.updateAboutSectionScrollProgress();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly global: Global = global;
|
protected readonly global = global;
|
||||||
protected readonly age: number = this.calculateAge(global.birthdate);
|
protected readonly age = this.calculateAge(global.birthdate);
|
||||||
|
protected readonly contactMailHref = `mailto:${global.contactMail}`;
|
||||||
protected isLongBioShown: boolean = false;
|
protected isLongBioShown = false;
|
||||||
protected isLongBioMounted: boolean = false;
|
protected isLongBioMounted = false;
|
||||||
protected readonly faArrowRight: IconDefinition = faArrowRight;
|
protected readonly faArrowRight: IconDefinition = faArrowRight;
|
||||||
protected readonly faArrowDown: IconDefinition = faArrowDown;
|
protected readonly faArrowDown: IconDefinition = faArrowDown;
|
||||||
protected readonly faEnvelope: IconDefinition = faEnvelope;
|
protected readonly faEnvelope: IconDefinition = faEnvelope;
|
||||||
@@ -70,28 +67,44 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
readonly faGithub: IconDefinition = faGithub;
|
readonly faGithub: IconDefinition = faGithub;
|
||||||
readonly faLinkedin: IconDefinition = faLinkedin;
|
readonly faLinkedin: IconDefinition = faLinkedin;
|
||||||
|
|
||||||
protected readonly contactSafeMail: SafeUrl;
|
protected currentPortraitHighlightIndex = 0;
|
||||||
protected readonly portraitHighlights: PortraitHighlight[] = global.portraitHighlights;
|
protected isPortraitSwitching = false;
|
||||||
protected currentPortraitHighlightIndex: number = 0;
|
|
||||||
protected isPortraitSwitching: boolean = false;
|
|
||||||
|
|
||||||
protected currentIndex = 0;
|
protected currentIndex = 0;
|
||||||
protected readonly projects: PortfolioProject[] = global.projects;
|
protected readonly fallbackBioEntry: LanguageBioTextEntry = { label: 'my stack', value: '' };
|
||||||
protected readonly fallbackBioEntry: BioTextEntry = { label: 'my stack', value: '' };
|
|
||||||
private sub!: Subscription;
|
private sub!: Subscription;
|
||||||
private bioHideTimeoutId: number | null = null;
|
private bioHideTimeoutId: number | null = null;
|
||||||
|
|
||||||
constructor() {
|
protected get portraitHighlights(): LanguagePortraitHighlight[] {
|
||||||
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
|
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 {
|
ngOnInit(): void {
|
||||||
this.initNameGradientScrollAnimation();
|
this.initNameGradientScrollAnimation();
|
||||||
|
|
||||||
this.zone.runOutsideAngular((): void => {
|
this.zone.runOutsideAngular((): void => {
|
||||||
this.sub = interval(1000).subscribe((): void => {
|
this.sub = interval(1000).subscribe((): void => {
|
||||||
this.zone.run((): 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.initProjectEntryRevealObserver();
|
||||||
this.initAboutSectionScrollAnimation();
|
this.initAboutSectionScrollAnimation();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.sub.unsubscribe();
|
this.sub.unsubscribe();
|
||||||
if (this.bioHideTimeoutId !== null) {
|
if (this.bioHideTimeoutId !== null) {
|
||||||
@@ -117,9 +129,7 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
if (this.isLongBioShown) {
|
if (this.isLongBioShown) {
|
||||||
this.isLongBioShown = false;
|
this.isLongBioShown = false;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
if (this.bioHideTimeoutId !== null) {
|
if (this.bioHideTimeoutId !== null) window.clearTimeout(this.bioHideTimeoutId);
|
||||||
window.clearTimeout(this.bioHideTimeoutId);
|
|
||||||
}
|
|
||||||
this.bioHideTimeoutId = window.setTimeout((): void => {
|
this.bioHideTimeoutId = window.setTimeout((): void => {
|
||||||
this.isLongBioMounted = false;
|
this.isLongBioMounted = false;
|
||||||
this.bioHideTimeoutId = null;
|
this.bioHideTimeoutId = null;
|
||||||
@@ -129,12 +139,10 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
|
if (this.bioHideTimeoutId !== null && typeof window !== 'undefined') {
|
||||||
window.clearTimeout(this.bioHideTimeoutId);
|
window.clearTimeout(this.bioHideTimeoutId);
|
||||||
this.bioHideTimeoutId = null;
|
this.bioHideTimeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isLongBioMounted = true;
|
this.isLongBioMounted = true;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.requestAnimationFrame((): void => {
|
window.requestAnimationFrame((): void => {
|
||||||
@@ -146,88 +154,52 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.isLongBioShown = true;
|
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 {
|
protected showNextPortraitHighlight(): void {
|
||||||
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) {
|
if (this.portraitHighlights.length <= 1 || this.isPortraitSwitching) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isPortraitSwitching = true;
|
this.isPortraitSwitching = true;
|
||||||
|
|
||||||
setTimeout((): void => {
|
setTimeout((): void => {
|
||||||
this.currentPortraitHighlightIndex =
|
this.currentPortraitHighlightIndex =
|
||||||
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
|
(this.currentPortraitHighlightIndex + 1) % this.portraitHighlights.length;
|
||||||
}, 125);
|
}, 125);
|
||||||
|
|
||||||
setTimeout((): void => {
|
setTimeout((): void => {
|
||||||
this.isPortraitSwitching = false;
|
this.isPortraitSwitching = false;
|
||||||
}, 250);
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected scrollToAboutSection(): void {
|
protected scrollToAboutSection(): void {
|
||||||
this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035));
|
this.scrollToElementById('about', this.getResponsiveScrollOffset(0.035));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected scrollToProjectsSection(): void {
|
protected scrollToProjectsSection(): void {
|
||||||
this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035));
|
this.scrollToElementById('projects', this.getResponsiveScrollOffset(0.035));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected scrollToContactLinks(): void {
|
protected scrollToContactLinks(): void {
|
||||||
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
|
this.scrollToElementById('contact-links', this.getResponsiveScrollOffset(0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
private initNameGradientScrollAnimation(): void {
|
private initNameGradientScrollAnimation(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
|
window.addEventListener('scroll', this.scheduleNameGradientUpdate, { passive: true });
|
||||||
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
|
window.addEventListener('resize', this.scheduleNameGradientUpdate, { passive: true });
|
||||||
this.scheduleNameGradientUpdate();
|
this.scheduleNameGradientUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initProjectEntryRevealObserver(): void {
|
private initProjectEntryRevealObserver(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
window.addEventListener('scroll', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
||||||
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
window.addEventListener('resize', this.scheduleProjectEntryRevealUpdate, { passive: true });
|
||||||
this.scheduleProjectEntryRevealUpdate();
|
this.scheduleProjectEntryRevealUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyProjectEntryRevealObserver(): void {
|
private destroyProjectEntryRevealObserver(): void {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate);
|
window.removeEventListener('scroll', this.scheduleProjectEntryRevealUpdate);
|
||||||
window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate);
|
window.removeEventListener('resize', this.scheduleProjectEntryRevealUpdate);
|
||||||
|
|
||||||
if (this.projectEntryAnimationFrameId !== null) {
|
if (this.projectEntryAnimationFrameId !== null) {
|
||||||
window.cancelAnimationFrame(this.projectEntryAnimationFrameId);
|
window.cancelAnimationFrame(this.projectEntryAnimationFrameId);
|
||||||
this.projectEntryAnimationFrameId = null;
|
this.projectEntryAnimationFrameId = null;
|
||||||
}
|
}
|
||||||
|
document.querySelectorAll<HTMLElement>('.project-entry').forEach((entry: HTMLElement): void => {
|
||||||
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
|
||||||
projectEntries.forEach((entry: HTMLElement): void => {
|
|
||||||
entry.style.removeProperty('--project-entry-progress');
|
entry.style.removeProperty('--project-entry-progress');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private initAboutSectionScrollAnimation(): void {
|
private initAboutSectionScrollAnimation(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
|
window.addEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate, {
|
||||||
passive: true,
|
passive: true,
|
||||||
});
|
});
|
||||||
@@ -236,45 +208,28 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
this.scheduleAboutSectionScrollAnimationUpdate();
|
this.scheduleAboutSectionScrollAnimationUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyAboutSectionScrollAnimation(): void {
|
private destroyAboutSectionScrollAnimation(): void {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate);
|
window.removeEventListener('scroll', this.scheduleAboutSectionScrollAnimationUpdate);
|
||||||
window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate);
|
window.removeEventListener('resize', this.scheduleAboutSectionScrollAnimationUpdate);
|
||||||
|
|
||||||
if (this.aboutSectionAnimationFrameId !== null) {
|
if (this.aboutSectionAnimationFrameId !== null) {
|
||||||
window.cancelAnimationFrame(this.aboutSectionAnimationFrameId);
|
window.cancelAnimationFrame(this.aboutSectionAnimationFrameId);
|
||||||
this.aboutSectionAnimationFrameId = null;
|
this.aboutSectionAnimationFrameId = null;
|
||||||
}
|
}
|
||||||
|
document.getElementById('about')?.style.removeProperty('--about-scroll-progress');
|
||||||
const aboutSection: HTMLElement | null = document.getElementById('about');
|
|
||||||
aboutSection?.style.removeProperty('--about-scroll-progress');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyNameGradientScrollAnimation(): void {
|
private destroyNameGradientScrollAnimation(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
|
window.removeEventListener('scroll', this.scheduleNameGradientUpdate);
|
||||||
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
|
window.removeEventListener('resize', this.scheduleNameGradientUpdate);
|
||||||
|
|
||||||
if (this.scrollAnimationFrameId !== null) {
|
if (this.scrollAnimationFrameId !== null) {
|
||||||
window.cancelAnimationFrame(this.scrollAnimationFrameId);
|
window.cancelAnimationFrame(this.scrollAnimationFrameId);
|
||||||
this.scrollAnimationFrameId = null;
|
this.scrollAnimationFrameId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.documentElement.style.removeProperty('--name-pride-progress');
|
document.documentElement.style.removeProperty('--name-pride-progress');
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateNameGradientProgress(): void {
|
private updateNameGradientProgress(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const root: HTMLElement = document.documentElement;
|
const root: HTMLElement = document.documentElement;
|
||||||
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
|
const portfolioEyebrow: HTMLElement | null = document.getElementById('portfolio-eyebrow');
|
||||||
const portfolioTriggerY: number =
|
const portfolioTriggerY: number =
|
||||||
@@ -292,20 +247,12 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
window.scrollY > 0
|
window.scrollY > 0
|
||||||
? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05)
|
? Math.max(acceleratedProgress, isMobileViewport ? 0.18 : 0.05)
|
||||||
: acceleratedProgress;
|
: acceleratedProgress;
|
||||||
|
|
||||||
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
|
root.style.setProperty('--name-pride-progress', progress.toFixed(4));
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateAboutSectionScrollProgress(): void {
|
private updateAboutSectionScrollProgress(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aboutSection: HTMLElement | null = document.getElementById('about');
|
const aboutSection: HTMLElement | null = document.getElementById('about');
|
||||||
if (aboutSection === null) {
|
if (aboutSection === null) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
|
const sectionRect: DOMRect = aboutSection.getBoundingClientRect();
|
||||||
const viewportHeight: number = window.innerHeight || 1;
|
const viewportHeight: number = window.innerHeight || 1;
|
||||||
const startY: number = viewportHeight * 0.96;
|
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 totalDistance: number = Math.max(startY - endY, 1);
|
||||||
const rawProgress: number = (startY - sectionRect.top) / totalDistance;
|
const rawProgress: number = (startY - sectionRect.top) / totalDistance;
|
||||||
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
|
const clampedProgress: number = Math.min(Math.max(rawProgress, 0), 1);
|
||||||
|
|
||||||
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
|
aboutSection.style.setProperty('--about-scroll-progress', clampedProgress.toFixed(4));
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateProjectEntryRevealProgress(): void {
|
private updateProjectEntryRevealProgress(): void {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
const projectEntries: NodeListOf<HTMLElement> = document.querySelectorAll('.project-entry');
|
||||||
const viewportHeight: number = window.innerHeight || 1;
|
const viewportHeight: number = window.innerHeight || 1;
|
||||||
const startY: number = viewportHeight * 0.98;
|
const startY: number = viewportHeight * 0.98;
|
||||||
const endY: number = viewportHeight * 0.56;
|
const endY: number = viewportHeight * 0.56;
|
||||||
const totalDistance: number = Math.max(startY - endY, 1);
|
const totalDistance: number = Math.max(startY - endY, 1);
|
||||||
|
|
||||||
projectEntries.forEach((entry: HTMLElement): void => {
|
projectEntries.forEach((entry: HTMLElement): void => {
|
||||||
const entryRect: DOMRect = entry.getBoundingClientRect();
|
const entryRect: DOMRect = entry.getBoundingClientRect();
|
||||||
const rawProgress: number = (startY - entryRect.top) / totalDistance;
|
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));
|
entry.style.setProperty('--project-entry-progress', clampedProgress.toFixed(4));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateAge(birthDateString: string): number {
|
private calculateAge(birthDateString: string): number {
|
||||||
const birthDate: Date = new Date(birthDateString);
|
const birthDate: Date = new Date(birthDateString);
|
||||||
const now: Date = new Date();
|
const now: Date = new Date();
|
||||||
@@ -343,40 +283,21 @@ export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
const hasBirthdayPassedThisYear: boolean =
|
const hasBirthdayPassedThisYear: boolean =
|
||||||
now.getMonth() > birthDate.getMonth() ||
|
now.getMonth() > birthDate.getMonth() ||
|
||||||
(now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate());
|
(now.getMonth() === birthDate.getMonth() && now.getDate() >= birthDate.getDate());
|
||||||
|
if (!hasBirthdayPassedThisYear) age -= 1;
|
||||||
if (!hasBirthdayPassedThisYear) {
|
|
||||||
age -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return age;
|
return age;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scrollToElementById(elementId: string, offsetPx: number = 0): void {
|
private scrollToElementById(elementId: string, offsetPx: number = 0): void {
|
||||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
if (typeof document === 'undefined' || typeof window === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const element: HTMLElement | null = document.getElementById(elementId);
|
const element: HTMLElement | null = document.getElementById(elementId);
|
||||||
if (element === null) {
|
if (element === null) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetTop: number = Math.max(
|
const targetTop: number = Math.max(
|
||||||
element.getBoundingClientRect().top + window.scrollY - offsetPx,
|
element.getBoundingClientRect().top + window.scrollY - offsetPx,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
window.scrollTo({ top: targetTop, behavior: 'smooth' });
|
||||||
window.scrollTo({
|
|
||||||
top: targetTop,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getResponsiveScrollOffset(viewportRatio: number = 0.05): number {
|
private getResponsiveScrollOffset(viewportRatio: number = 0.05): number {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return 0;
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minOffsetPx: number = 20;
|
const minOffsetPx: number = 20;
|
||||||
const maxOffsetPx: number = 48;
|
const maxOffsetPx: number = 48;
|
||||||
const responsiveOffsetPx: number = window.innerHeight * viewportRatio;
|
const responsiveOffsetPx: number = window.innerHeight * viewportRatio;
|
||||||
|
|||||||
@@ -2,43 +2,34 @@
|
|||||||
<section class="hero-section panel legal-hero">
|
<section class="hero-section panel legal-hero">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<div class="hero-copy-main">
|
<div class="hero-copy-main">
|
||||||
<p class="eyebrow">Legal</p>
|
<p class="eyebrow">{{ content().imprint.legalEyebrow }}</p>
|
||||||
<h1>Imprint & Privacy</h1>
|
<h1>{{ content().imprint.title }}</h1>
|
||||||
<p class="hero-intro legal-intro">
|
<p class="hero-intro legal-intro">{{ bind(content().imprint.intro) }}</p>
|
||||||
Legal information for this website under Austrian law, including privacy information for websites operated by
|
|
||||||
{{ global.firstname }} {{ global.lastname }}
|
|
||||||
(Lechner Systems).
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="back-link" routerLink="">
|
<a class="back-link" routerLink=""
|
||||||
<fa-icon [icon]="faArrowLeft"></fa-icon>
|
><fa-icon [icon]="faArrowLeft"></fa-icon><span>{{ content().imprint.backToPortfolio }}</span></a
|
||||||
<span>Back to portfolio</span>
|
>
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-visual">
|
<div class="hero-visual">
|
||||||
<div class="portrait-highlight legal-contact-box">
|
<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 class="legal-provider-name">{{ global.firstname }} {{ global.lastname }} / Lechner Systems</p>
|
||||||
<p>{{ displayStreet }} {{ global.address.houseNumber }}</p>
|
<p>{{ global.address.street }} {{ global.address.houseNumber }}</p>
|
||||||
<p>{{ global.address.zip }} {{ displayCity }}</p>
|
<p>{{ global.address.zip }} {{ global.address.city }}</p>
|
||||||
<p>{{ global.address.country }}</p>
|
<p>{{ global.address.country }}</p>
|
||||||
<hr />
|
<hr />
|
||||||
<p class="hero-contact-title">Direct Contact</p>
|
<p class="hero-contact-title">{{ content().imprint.directContact }}</p>
|
||||||
<p>
|
<p>
|
||||||
Phone:
|
{{ content().imprint.phone }}: <a class="inline-link" href="tel:{{ global.hrefContactPhone }}">{{ global.contactPhone }}</a>
|
||||||
<a class="inline-link" href="tel:{{ global.hrefContactPhone }}">{{ global.contactPhone }}</a>
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Contact:
|
{{ content().imprint.contact }}: <a class="inline-link" [href]="contactMailHref">{{ global.contactMail }}</a>
|
||||||
<a class="inline-link" [href]="contactSafeMail">{{ global.contactMail }}</a>
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Abuse:
|
{{ content().imprint.abuse }}: <a class="inline-link" [href]="abuseMailHref">{{ global.abuseMail }}</a>
|
||||||
<a class="inline-link" [href]="abuseSafeMail">{{ global.abuseMail }}</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,113 +37,63 @@
|
|||||||
|
|
||||||
<section class="story-section panel legal-section">
|
<section class="story-section panel legal-section">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<p class="eyebrow">Scope</p>
|
<p class="eyebrow">{{ content().imprint.scopeEyebrow }}</p>
|
||||||
<h2>Scope Of These Notices</h2>
|
<h2>{{ content().imprint.scopeTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>
|
<p>{{ content().imprint.scopeLeft }}</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<p>
|
<p>{{ content().imprint.scopeRight }}</p>
|
||||||
This website provides information about projects, technical services, contact options, and professional work in software
|
|
||||||
engineering, IT, and related digital services.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="story-section panel legal-section">
|
<section class="story-section panel legal-section">
|
||||||
<div class="section-heading compact">
|
<div class="section-heading compact">
|
||||||
<p class="eyebrow">Privacy</p>
|
<p class="eyebrow">{{ content().imprint.privacyEyebrow }}</p>
|
||||||
<h2>Privacy And Data Processing</h2>
|
<h2>{{ content().imprint.privacyTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>
|
<p>{{ bind(content().imprint.privacyLeftParagraphs[0]) }}</p>
|
||||||
The controller for data processing within the meaning of the GDPR is {{ global.firstname }} {{ global.lastname }}, reachable at
|
<p>{{ content().imprint.privacyLeftParagraphs[1] }}</p>
|
||||||
<a class="inline-link" [href]="contactSafeMail">{{ global.contactMail }}</a> and by phone at
|
<p>{{ content().imprint.privacyLeftParagraphs[2] }}</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<p>
|
<p>{{ content().imprint.privacyRightParagraphs[0] }}</p>
|
||||||
If you contact me by email or phone, the data you provide is processed to handle your request and maintain communication.
|
<p>{{ content().imprint.privacyRightParagraphs[1] }}</p>
|
||||||
Applicable legal bases may include Article 6(1)(b), 6(1)(c), and 6(1)(f) GDPR, depending on context.
|
<p>{{ content().imprint.privacyRightParagraphs[2] }}</p>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="story-section panel legal-section">
|
<section class="story-section panel legal-section">
|
||||||
<div class="section-heading compact">
|
<div class="section-heading compact">
|
||||||
<p class="eyebrow">Rights</p>
|
<p class="eyebrow">{{ content().imprint.rightsEyebrow }}</p>
|
||||||
<h2>Data Subject Rights And Complaints</h2>
|
<h2>{{ content().imprint.rightsTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>
|
<p>{{ content().imprint.rightsLeft }}</p>
|
||||||
Subject to statutory requirements, data subjects have the right of access, rectification, erasure, restriction of processing, data
|
|
||||||
portability, and objection.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<p>
|
<p>{{ content().imprint.rightsRight }}</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@dsb.gv.at</a>, website
|
|
||||||
<a class="inline-link" href="https://dsb.gv.at/">dsb.gv.at</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="story-section panel legal-section">
|
<section class="story-section panel legal-section">
|
||||||
<div class="section-heading compact">
|
<div class="section-heading compact">
|
||||||
<p class="eyebrow">Copyright</p>
|
<p class="eyebrow">{{ content().imprint.copyrightEyebrow }}</p>
|
||||||
<h2>Copyright, External Links, And Applicable Law</h2>
|
<h2>{{ content().imprint.copyrightTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-grid">
|
<div class="story-grid">
|
||||||
<div class="story-copy">
|
<div class="story-copy">
|
||||||
<p>
|
<p>{{ content().imprint.copyrightLeftParagraphs[0] }}</p>
|
||||||
All content on this website, including texts, images, graphics, photographs, layouts, source code, and other works, is protected
|
<p>{{ content().imprint.copyrightLeftParagraphs[1] }}</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="story-detail">
|
<div class="story-detail">
|
||||||
<p>
|
<p>{{ content().imprint.copyrightRightParagraphs[0] }}</p>
|
||||||
This website may contain links to third-party websites. Their operators are solely responsible for those contents, and no
|
<p>{{ content().imprint.copyrightRightParagraphs[1] }}</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,45 +1,34 @@
|
|||||||
import type { OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { RouterLink } from '@angular/router';
|
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 type { IconDefinition } from '@fortawesome/angular-fontawesome';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||||
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
import type { Global } from '../../global';
|
|
||||||
import { global } from '../../global';
|
import { global } from '../../global';
|
||||||
import { FooterComponent } from '../footer/footer.component';
|
import { FooterComponent } from '../footer/footer.component';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-imprint',
|
selector: 'app-imprint',
|
||||||
|
standalone: true,
|
||||||
imports: [FooterComponent, FaIconComponent, RouterLink],
|
imports: [FooterComponent, FaIconComponent, RouterLink],
|
||||||
templateUrl: './imprint.component.html',
|
templateUrl: './imprint.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ImprintComponent implements OnInit {
|
export class ImprintComponent {
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
|
protected readonly content = this.languageService.content;
|
||||||
|
protected readonly global = global;
|
||||||
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
|
protected readonly faArrowLeft: IconDefinition = faArrowLeft;
|
||||||
|
protected readonly contactMailHref = `mailto:${global.contactMail}`;
|
||||||
|
protected readonly abuseMailHref = `mailto:${global.abuseMail}`;
|
||||||
|
|
||||||
ngOnInit(): void {
|
protected bind(text: string): string {
|
||||||
this.contactSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${global.contactMail}`);
|
return text
|
||||||
this.abuseSafeMail = this.sanitizer.bypassSecurityTrustUrl(`mailto:${this.global.abuseMail}`);
|
.replaceAll('{{firstname}}', this.global.firstname)
|
||||||
}
|
.replaceAll('{{lastname}}', this.global.lastname)
|
||||||
|
.replaceAll('{{contactMail}}', this.global.contactMail)
|
||||||
private normalizeText(value: string): string {
|
.replaceAll('{{contactPhone}}', this.global.contactPhone);
|
||||||
return value
|
|
||||||
.replaceAll('ß', 'ß')
|
|
||||||
.replaceAll('ö', 'ö')
|
|
||||||
.replaceAll('Ä', 'Ä')
|
|
||||||
.replaceAll('ä', 'ä')
|
|
||||||
.replaceAll('Ü', 'Ü')
|
|
||||||
.replaceAll('ü', 'ü');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,51 +1,4 @@
|
|||||||
export interface PortfolioProject {
|
import type { Global } from './global.types';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const global: Global = {
|
export const global: Global = {
|
||||||
firstname: 'Julian',
|
firstname: 'Julian',
|
||||||
@@ -55,171 +8,6 @@ export const global: Global = {
|
|||||||
contactPhone: '+43 (0) 660 9254001',
|
contactPhone: '+43 (0) 660 9254001',
|
||||||
hrefContactPhone: '+436609254001',
|
hrefContactPhone: '+436609254001',
|
||||||
birthdate: '2009-03-03',
|
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 you’re 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: {
|
address: {
|
||||||
street: 'Ulmenstraße',
|
street: 'Ulmenstraße',
|
||||||
houseNumber: 9,
|
houseNumber: 9,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 you’re 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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
@@ -43,6 +43,11 @@ html.no-js {
|
|||||||
.hero-meta {
|
.hero-meta {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-overlay,
|
||||||
|
.language-switcher {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body,
|
body,
|
||||||
@@ -1065,3 +1070,225 @@ hr {
|
|||||||
justify-items: center;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user