11 Commits

88 changed files with 3706 additions and 274 deletions

10
.prettierignore Normal file
View File

@@ -0,0 +1,10 @@
dist
.angular
node_modules
coverage
package-lock.json
public/**/*.svg
*.pdf
Dockerfile
docker-compose.yml
.cursor

149
AGENTS.md Normal file
View File

@@ -0,0 +1,149 @@
# AGENTS.md
Contributor and agent guide for this repository. Read this before changing routing, copy, styles, or SSR behaviour.
## Purpose
This is a bilingual recruiter and B2B portfolio for a software/DevOps engineer. German is the default language and is served at `/`. English is the full second version and is served under `/en`. Every public page exists in both locales.
## Architecture
- Angular 21 standalone components, no NgModules
- Zoneless change detection
- Signals for local state
- Lazy-loaded feature routes
- SSR with prerendering (`@angular/ssr`, `outputMode: "server"`)
- Express host in `src/server.ts`
## Directory map
| Path | Role |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/app/core/**` | Contracts, services and tokens. No UI. |
| `src/app/features/**` | Routed page components, one folder per route. |
| `src/app/shared/**` | Reusable UI primitives used by multiple features. |
| `src/app/components/**` | Existing decorative/feature widgets (skills grid, dot background). |
| `public/**` | Static SVG and public assets copied as-is. |
| `src/styles.scss` | Global design tokens and primitives. The only place raw brand hex values are defined. |
| `src/_breakpoints.scss` | Breakpoint map (`sm` 40rem, `md` 48rem, `lg` 64rem, `xl` 80rem) and the `respond-to` mixin. Components pull it in with `@use 'breakpoints'`. |
## Commands
| Script | Purpose |
| ----------------------------- | --------------------------------------------- |
| `npm start` | Dev server (`ng serve`). |
| `npm run build` | Production build (default configuration). |
| `npm run watch` | Development rebuild on change. |
| `npm test` | Unit tests in watch mode. |
| `npm run test:ci` | Unit tests once (`--watch=false`); must exit. |
| `npm run lint` | ESLint over the workspace. |
| `npm run lint:fix` | ESLint with autofix. |
| `npm run format` | Prettier write. |
| `npm run format:check` | Prettier check (CI). |
| `npm run serve:ssr` | Serve the production SSR bundle. |
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr` for existing tooling. |
| `npm run ci` | lint, format:check, test:ci, then build. |
## Code conventions
- Standalone components only
- `inject()` over constructor injection for new code
- Signals over RxJS for local state
- `ChangeDetectionStrategy.OnPush` on new components
- kebab-case file names
- No `any`
- No default exports
- Templates in separate files
## SCSS conventions
- Global design tokens live only in `src/styles.scss` as CSS custom properties
- Components consume `var(--...)` and never redefine raw hex values
- Use the documented spacing (`--space-1``--space-12`) and typography (`--text-xs``--text-3xl`) scale
- Hover-only affordances must be wrapped in `@media (hover: hover) and (pointer: fine)`
- All motion must respect `prefers-reduced-motion`
- Breakpoints and the `respond-to` mixin live in `src/_breakpoints.scss`; do not invent extra breakpoint names
- Shared glass, layout and focus treatments belong on the global primitives (`.glass-surface`, `.content-container`, `.stack`, `.cluster`), not copied into component stylesheets
- Keep component styles under the `anyComponentStyle` budget (4 kB warn, 8 kB error)
## Content rules
All user-facing copy comes from the typed bilingual content layer in `src/app/core/content`. Never inline marketing or page copy in templates.
Keep the four copy roles as separate fields:
- `headline` — the serious heading
- `proof` — only a verifiable statement
- `playfulLine` — optional wordplay; never a capability claim
- `cta` — action label, kept swappable
German and English are written idiomatically per language, never machine-translated word for word.
## Claims policy (non-negotiable)
- No unsupported superlatives
- No invented references, client names, metrics or certifications
- Unproven AI, local-LLM or multi-agent capabilities are phrased as an offering or way of working, never as a delivered reference
- The CV stations HUP, BitWiz and Cybertrading are deliberately excluded from all public pages, cases and timelines
- The legal pages (Impressum, Datenschutz) contain placeholders that MUST be reviewed by the site owner before publication and must visibly say so
## i18n rules
- German at `/`, English under `/en`
- Every route id exists in both locales
- New pages are added by extending the route-id table in `src/app/core/routing`, never by hard-coding paths
- Do not swap `LOCALE_ID`; the active locale is `AppLocale` from `LocaleService`
## SSR rules
- No `window`, `document`, `navigator`, `localStorage` or `matchMedia` access outside `afterNextRender` or `isPlatformBrowser` guards
- Use the injected `DOCUMENT` token when the document element is required (it works on server and browser)
- No user-agent sniffing; use CSS media queries for device capability
- Capability helpers live in `src/app/core/platform/browser.ts` and return conservative defaults on the server
- Helpers that use `inject()` must be called from a field initializer or constructor, never from a lifecycle hook or callback, and the resolved value must be stored on the instance
- Every addressable route must remain prerenderable
## Accessibility checklist
- Skip link as the first focusable element, targeting `#main-content`
- Single `h1` per page
- Landmark elements (`header`/`nav`, `main`, `footer`)
- Visible `:focus-visible` ring
- Keyboard reachability for every action
- `aria-current` on the active nav item
- At least 4.5:1 text contrast on the base surface
- Reduced-motion alternatives for animation and smooth scrolling
- Navigation remains usable in its server-rendered default state (no JS-only menus)
## SEO expectations
- Semantic headings
- Real locale-prefixed URLs
- Server-rendered core text
- Per-route titles from the content layer
Canonical, hreflang document tags and JSON-LD arrive in the integration phase. The language switch already exposes `hreflang` on the alternate-locale link.
## Test checklist
A change is not done until:
1. `npm run lint` exits 0
2. `npm run format:check` exits 0
3. `npm run test:ci` exits 0 with no watcher left running
4. `npm run build` exits 0
5. New or changed routes appear in `prerenderablePaths()` and are prerendered
6. Routing and locale contracts still have unit coverage (paths, locale helpers, navigation links)
7. No new `window` / `document` / `navigator` reads were added outside an SSR-safe guard
8. Placeholder or legal copy was not replaced with invented professional claims
## Domain boundaries
| Branch | Owns |
| ----------- | -------------------------------------------------------------------------------- |
| Foundation | Tooling, tokens, shell, routing and content contracts, SSR safety, test baseline |
| Content | Bilingual copy data and page composition |
| Signature | Systems Map, terminal, dot background and motion |
| Integration | SEO and cross-cutting a11y/performance hardening |
Each branch extends the shared contracts instead of duplicating paths or copy. The Content branch replaces `placeholder-content.ts` and keeps `SITE_CONTENT`. The Signature branch may replace the dot-background internals but must keep the SSR-safe init/teardown contract.

BIN
CV.pdf Normal file

Binary file not shown.

View File

@@ -1,59 +1,62 @@
# Portfolio
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5.
Bilingual recruiter and B2B portfolio for Antonio Ledebuhr, a software and DevOps engineer. German is the default language at `/`. English is the full second version under `/en`.
## Development server
This repository is the Angular 21 standalone, zoneless, SSR application that serves both locales from one build.
To start a local development server, run:
## Prerequisites
- Node.js 22 or newer
- npm 11 or newer (npm 12 prints an engine-compatibility warning on some Node 24 builds; that warning is not an error)
## Install
```bash
ng serve
npm install
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Scripts
## Code scaffolding
| Script | Description |
| ----------------------------- | ------------------------------------------------------------------------- |
| `npm start` | Start the Angular dev server. |
| `npm run build` | Production build (default configuration), including SSR and prerendering. |
| `npm run watch` | Development rebuild on change. |
| `npm test` | Unit tests in watch mode. |
| `npm run test:ci` | Unit tests once; exits when finished. |
| `npm run lint` | Run ESLint. |
| `npm run lint:fix` | Run ESLint with autofix. |
| `npm run format` | Format the workspace with Prettier. |
| `npm run format:check` | Check formatting without writing. |
| `npm run serve:ssr` | Serve the production SSR bundle from `dist/`. |
| `npm run serve:ssr:Portfolio` | Alias of `serve:ssr`. |
| `npm run ci` | lint, format check, tests, then production build. |
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
## Local SSR build
```bash
ng generate component component-name
npm run build
npm run serve:ssr
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
The server listens on `http://localhost:4000` unless `PORT` is set. The CV is copied into the browser output at `/cv/CV.pdf`.
```bash
ng generate --help
## Project structure
```
src/app/core/ Typed contracts: locale, routing, navigation, content, platform
src/app/features/ One standalone page per route id
src/app/shared/ Reusable UI primitives
src/app/components/ Existing decorative widgets (skills, dot background)
src/styles.scss Design tokens and global primitives
src/_breakpoints.scss Breakpoint map and respond-to mixin
public/ Static SVG assets
```
## Building
Design tokens live in `src/styles.scss` as CSS custom properties. Content contracts and the placeholder provider live in `src/app/core/content`. Route ids and locale path tables live in `src/app/core/routing`.
To build the project run:
Contributor and agent conventions are in [AGENTS.md](./AGENTS.md).
```bash
ng build
```
## Legal pages
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
The Impressum (`/impressum`, `/en/legal-notice`) and Datenschutz (`/datenschutz`, `/en/privacy`) pages contain unreviewed placeholders. They must be checked by the site owner before publication.

View File

@@ -23,15 +23,21 @@
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"stylePreprocessorOptions": {
"includePaths": ["src"]
},
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "CV.pdf",
"input": ".",
"output": "cv"
}
],
"styles": [
"src/styles.scss"
],
"styles": ["src/styles.scss"],
"server": "src/main.server.ts",
"outputMode": "server",
"ssr": {

43
eslint.config.js Normal file
View File

@@ -0,0 +1,43 @@
// @ts-check
const eslint = require('@eslint/js');
const tseslint = require('typescript-eslint');
const angular = require('angular-eslint');
const prettier = require('eslint-config-prettier');
module.exports = tseslint.config(
{
ignores: ['dist/**', '.angular/**', 'node_modules/**', 'coverage/**'],
},
{
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommended,
...angular.configs.tsRecommended,
],
processor: angular.processInlineTemplates,
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
},
prettier,
);

1535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,14 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs"
"test:ci": "ng test --watch=false",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"serve:ssr": "node dist/Portfolio/server/server.mjs",
"serve:ssr:Portfolio": "node dist/Portfolio/server/server.mjs",
"ci": "npm run lint && npm run format:check && npm run test:ci && npm run build"
},
"prettier": {
"printWidth": 100,
@@ -40,10 +47,16 @@
"@angular/build": "^21.0.5",
"@angular/cli": "^21.0.5",
"@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.5",
"@types/express": "^5.0.1",
"@types/node": "^20.17.19",
"angular-eslint": "^21.4.0",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"jsdom": "^27.1.0",
"prettier": "^3.9.6",
"typescript": "~5.9.2",
"typescript-eslint": "^8.68.0",
"vitest": "^4.0.8"
}
}

14
src/_breakpoints.scss Normal file
View File

@@ -0,0 +1,14 @@
@use 'sass:map';
$breakpoints: (
sm: 40rem,
md: 48rem,
lg: 64rem,
xl: 80rem,
);
@mixin respond-to($name) {
@media (min-width: map.get($breakpoints, $name)) {
@content;
}
}

View File

@@ -4,9 +4,7 @@ import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(withRoutes(serverRoutes))
]
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@@ -1,12 +1,22 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
import { SITE_CONTENT } from './core/content/content.token';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideClientHydration(withEventReplay())
]
provideRouter(
routes,
withComponentInputBinding(),
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
),
provideClientHydration(withEventReplay()),
{ provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT },
],
};

View File

@@ -1,12 +1,93 @@
<app-dot-background></app-dot-background>
<div>
<header>
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
<app-dot-background aria-hidden="true"></app-dot-background>
<div class="site" [class.nav-collapsed]="!navOpen()">
<header class="site-header">
<div class="content-container site-header-inner glass-surface">
<a class="site-identity" [routerLink]="navigation.link('home')">
{{ siteConfig.personName }}
</a>
<button
type="button"
class="nav-toggle"
[attr.aria-expanded]="navOpen()"
aria-controls="primary-nav"
[attr.aria-label]="menuLabel()"
(click)="toggleNav()"
>
<span aria-hidden="true"></span>
</button>
<nav class="site-nav" [attr.aria-label]="shell().primaryNavLabel">
<ul class="primary-nav" id="primary-nav">
@for (item of navigation.primaryNav(); track item.routeId) {
<li>
<a
[routerLink]="item.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
>{{ item.label }}</a
>
@if (item.children; as children) {
<ul>
@for (child of children; track child.routeId) {
<li>
<a
[routerLink]="child.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
>{{ child.label }}</a
>
</li>
}
</ul>
}
</li>
}
</ul>
</nav>
<div class="site-actions cluster">
<a
class="language-switch"
[routerLink]="navigation.alternateLocaleLink()"
[attr.hreflang]="otherHtmlLang()"
[attr.aria-label]="shell().languageSwitch"
>
{{ shell().otherLocaleName }}
</a>
<a
[href]="siteConfig.cvAssetPath"
[attr.download]="siteConfig.cvDownloadFileName"
type="application/pdf"
>
{{ shell().cvLabel }}
</a>
<a class="contact-cta" [routerLink]="navigation.contactLink()">{{ shell().contactCta }}</a>
</div>
</div>
</header>
<main class="router">
<router-outlet></router-outlet>
<main id="main-content" class="site-main" tabindex="-1">
<router-outlet />
</main>
<footer>
<footer class="site-footer">
<div class="content-container site-footer-inner cluster">
<p>{{ siteConfig.personName }}</p>
<a [href]="'mailto:' + siteConfig.contactEmail">{{ siteConfig.contactEmail }}</a>
<nav [attr.aria-label]="shell().footerNavLabel">
<ul class="cluster footer-nav">
@for (item of navigation.footerNav(); track item.routeId) {
<li>
<a
[routerLink]="item.link"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
>{{ item.label }}</a
>
</li>
}
</ul>
</nav>
</div>
</footer>
</div>

View File

@@ -1,8 +1,13 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
import { RenderMode, type ServerRoute } from '@angular/ssr';
import { prerenderableServerPaths } from './core/routing/route-paths';
export const serverRoutes: ServerRoute[] = [
...prerenderableServerPaths().map((path): ServerRoute => ({
path,
renderMode: RenderMode.Prerender,
})),
{
path: '**',
renderMode: RenderMode.Prerender
}
renderMode: RenderMode.Server,
},
];

View File

@@ -0,0 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, type Routes } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from './app.routes';
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
import { SITE_CONTENT } from './core/content/content.token';
import { LocaleService } from './core/i18n/locale.service';
import { type AppRouteData } from './core/routing/app-route-data';
function flattenRoutes(tree: Routes, prefix = ''): Array<{ path: string; data?: AppRouteData }> {
const entries: Array<{ path: string; data?: AppRouteData }> = [];
for (const route of tree) {
const segment = route.path ?? '';
const path = [prefix, segment].filter((part) => part.length > 0).join('/');
entries.push({ path, data: route.data as AppRouteData | undefined });
if (route.children) {
entries.push(...flattenRoutes(route.children, path));
}
}
return entries;
}
describe('app routes', () => {
it('places the English subtree first and attaches locale data', () => {
expect(routes[0]?.path).toBe('en');
const flattened = flattenRoutes(routes);
const englishProjects = flattened.find((entry) => entry.path === 'en/projects');
const germanProjects = flattened.find((entry) => entry.path === 'projekte');
const englishWildcard = flattened.find((entry) => entry.path === 'en/**');
const germanWildcard = flattened.find((entry) => entry.path === '**');
expect(englishProjects?.data).toEqual({ routeId: 'projects', locale: 'en' });
expect(germanProjects?.data).toEqual({ routeId: 'projects', locale: 'de' });
expect(englishWildcard?.data).toEqual({ routeId: 'notFound', locale: 'en' });
expect(germanWildcard?.data).toEqual({ routeId: 'notFound', locale: 'de' });
});
it('navigates to the English projects placeholder', async () => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
});
const harness = await RouterTestingHarness.create();
const locale = TestBed.inject(LocaleService);
await harness.navigateByUrl('/en/projects');
expect(locale.locale()).toBe('en');
expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toContain('Projects');
expect(harness.routeNativeElement?.textContent).toContain('This page is being built.');
});
});

View File

@@ -1,6 +1,69 @@
import { Routes } from '@angular/router';
import {Skills} from './components/pages/skills/skills';
import { type Type } from '@angular/core';
import { type Routes } from '@angular/router';
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
import { localeResolver } from './core/i18n/locale.resolver';
import { type AppLocale } from './core/i18n/locale';
import { type AppRouteData } from './core/routing/app-route-data';
import { ROUTE_IDS, type RouteId } from './core/routing/route-ids';
import { LOCALE_PREFIX, ROUTE_SEGMENTS } from './core/routing/route-paths';
type LazyPage = () => Promise<Type<unknown>>;
const PAGE_LOADERS: Record<RouteId, LazyPage> = {
home: () => import('./features/home/home').then((module) => module.HomePage),
services: () => import('./features/services/services').then((module) => module.ServicesPage),
servicesSoftware: () =>
import('./features/services/software/software').then((module) => module.ServicesSoftwarePage),
servicesHardwareNetwork: () =>
import('./features/services/hardware-network/hardware-network').then(
(module) => module.ServicesHardwareNetworkPage,
),
servicesClusters: () =>
import('./features/services/clusters/clusters').then((module) => module.ServicesClustersPage),
servicesAi: () =>
import('./features/services/ai-integration/ai-integration').then(
(module) => module.ServicesAiPage,
),
projects: () => import('./features/projects/projects').then((module) => module.ProjectsPage),
stack: () => import('./features/stack/stack').then((module) => module.StackPage),
about: () => import('./features/about/about').then((module) => module.AboutPage),
contact: () => import('./features/contact/contact').then((module) => module.ContactPage),
imprint: () => import('./features/imprint/imprint').then((module) => module.ImprintPage),
privacy: () => import('./features/privacy/privacy').then((module) => module.PrivacyPage),
notFound: () => import('./features/not-found/not-found').then((module) => module.NotFoundPage),
};
function routeData(routeId: RouteId, locale: AppLocale): AppRouteData {
return { routeId, locale };
}
function localeRoutes(locale: AppLocale): Routes {
const segments = ROUTE_SEGMENTS[locale];
const pages = PLACEHOLDER_CONTENT[locale].pages;
return ROUTE_IDS.filter((routeId) => routeId !== 'notFound')
.map((routeId) => ({
path: segments[routeId],
loadComponent: PAGE_LOADERS[routeId],
data: routeData(routeId, locale),
title: pages[routeId]?.title,
resolve: { locale: localeResolver },
}))
.concat([
{
path: '**',
loadComponent: PAGE_LOADERS.notFound,
data: routeData('notFound', locale),
title: pages.notFound?.title,
resolve: { locale: localeResolver },
},
]);
}
export const routes: Routes = [
{path: '', component: Skills}
{
path: LOCALE_PREFIX.en,
children: localeRoutes('en'),
},
...localeRoutes('de'),
];

View File

@@ -0,0 +1,163 @@
@use 'breakpoints' as bp;
:host {
display: block;
min-height: 100vh;
color: var(--color-text);
font-family: var(--font-sans);
}
.site {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
padding-block: var(--space-3);
}
.site-header-inner {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-3);
align-items: start;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
}
.site-identity,
.site-nav a,
.site-actions a,
.site-footer a {
color: var(--color-text);
text-decoration: none;
}
.site-identity {
font-weight: 600;
}
.nav-toggle {
justify-self: end;
width: 2.5rem;
height: 2.5rem;
border: 1px solid var(--surface-glass-border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
}
.nav-toggle span,
.nav-toggle span::before,
.nav-toggle span::after {
display: block;
width: 1rem;
height: 2px;
margin-inline: auto;
background: currentColor;
}
.nav-toggle span::before,
.nav-toggle span::after {
content: '';
position: relative;
}
.nav-toggle span::before {
top: -0.35rem;
}
.nav-toggle span::after {
top: 0.25rem;
}
.site-nav,
.site-actions {
grid-column: 1 / -1;
}
.primary-nav,
.primary-nav ul,
.footer-nav {
list-style: none;
margin: 0;
padding: 0;
}
.primary-nav {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.primary-nav ul {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-inline-start: var(--space-4);
}
.contact-cta {
color: var(--color-text);
}
.is-active {
color: var(--color-accent-cool);
}
.nav-collapsed .site-nav,
.nav-collapsed .site-actions {
display: none;
}
.site-main {
flex: 1;
outline: none;
}
.site-footer {
padding-block: var(--space-6);
color: var(--color-text-muted);
}
.site-footer-inner {
justify-content: space-between;
}
.site-footer p {
margin: 0;
}
@include bp.respond-to(md) {
.nav-toggle {
display: none;
}
.site-header-inner {
grid-template-columns: auto 1fr auto;
align-items: center;
}
.site-nav,
.site-actions,
.nav-collapsed .site-nav,
.nav-collapsed .site-actions {
display: flex;
grid-column: auto;
}
.primary-nav {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-4);
}
.primary-nav ul {
padding-inline-start: 0;
}
}

View File

@@ -1,23 +1,46 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
import { routes } from './app.routes';
import { PLACEHOLDER_CONTENT } from './core/content/placeholder-content';
import { SITE_CONTENT } from './core/content/content.token';
describe('App', () => {
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
afterEach(() => {
vi.restoreAllMocks();
});
it('should render title', async () => {
it('should create the shell', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
expect(fixture.componentInstance).toBeTruthy();
});
it('should render an accessible application shell', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, Portfolio');
const focusable = compiled.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const firstFocusable = focusable.item(0);
expect(firstFocusable).toBeTruthy();
expect(firstFocusable.getAttribute('href')).toBe('#main-content');
expect(firstFocusable.classList.contains('skip-link')).toBe(true);
expect(compiled.querySelector('main#main-content')).toBeTruthy();
expect(compiled.querySelector('nav[aria-label]')).toBeTruthy();
expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy();
expect(compiled.querySelector('footer')).toBeTruthy();
});
});

View File

@@ -1,13 +1,33 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import {DotBackground} from './components/dot-background/dot-background';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { DotBackground } from './components/dot-background/dot-background';
import { SHELL_COPY } from './core/content/shell-copy';
import { SITE_CONFIG } from './core/content/site-config';
import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
import { LocaleService } from './core/i18n/locale.service';
import { NavigationService } from './core/navigation/navigation.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, DotBackground],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrl: './app.scss',
})
export class App {
protected readonly title = signal('Portfolio');
protected readonly navigation = inject(NavigationService);
protected readonly localeService = inject(LocaleService);
protected readonly siteConfig = SITE_CONFIG;
protected readonly navOpen = signal(true);
protected readonly shell = computed(() => SHELL_COPY[this.localeService.locale()]);
protected readonly otherLocale = computed(() => otherLocale(this.localeService.locale()));
protected readonly otherHtmlLang = computed(() => LOCALE_HTML_LANG[this.otherLocale()]);
protected readonly menuLabel = computed(() =>
this.navOpen() ? this.shell().menuClose : this.shell().menuOpen,
);
protected toggleNav(): void {
this.navOpen.update((open) => !open);
}
}

View File

@@ -2,8 +2,10 @@ canvas {
position: fixed;
inset: 0;
z-index: -1;
background: #0a0a0f;
background: var(--color-surface);
width: 100vw;
height: 100vh;
width: 100lvw;
height: 100lvh;
}

View File

@@ -7,17 +7,47 @@ describe('DotBackground', () => {
let fixture: ComponentFixture<DotBackground>;
beforeEach(async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
await TestBed.configureTestingModule({
imports: [DotBackground]
})
.compileComponents();
imports: [DotBackground],
}).compileComponents();
fixture = TestBed.createComponent(DotBackground);
component = fixture.componentInstance;
await fixture.whenStable();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('does not throw when destroyed after browser initialization', async () => {
const gradient = { addColorStop: vi.fn() };
const context = {
clearRect: vi.fn(),
beginPath: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
createRadialGradient: vi.fn(() => gradient),
};
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
context as unknown as CanvasRenderingContext2D,
);
const animationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(0);
const initializedFixture = TestBed.createComponent(DotBackground);
initializedFixture.detectChanges();
await initializedFixture.whenStable();
expect(() => initializedFixture.destroy()).not.toThrow();
animationFrameSpy.mockRestore();
vi.restoreAllMocks();
});
});

View File

@@ -1,5 +1,14 @@
import {afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild} from '@angular/core';
import {Dot} from '../../models/dot';
import {
afterNextRender,
Component,
ElementRef,
inject,
NgZone,
OnDestroy,
ViewChild,
} from '@angular/core';
import { isBrowserPlatform, prefersCoarsePointer } from '../../core/platform/browser';
import { Dot } from '../../models/dot';
@Component({
selector: 'app-dot-background',
@@ -10,42 +19,66 @@ import {Dot} from '../../models/dot';
export class DotBackground implements OnDestroy {
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
private readonly ngZone = inject(NgZone);
private readonly coarsePointer = prefersCoarsePointer();
private readonly isBrowser = isBrowserPlatform();
private ctx: CanvasRenderingContext2D | undefined;
private dots: Dot[] = [];
private mouse = { x: -1000, y: -1000 };
private animationId = 0;
private initialized = false;
private readonly DOT_COUNT = 12;
private readonly INIT_DOT_COUNT = 12;
private readonly MAX_DOT_COUNT = 100;
private readonly MAX_DOT_COUNT_MOBILE = 40;
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
private readonly MOUSE_RADIUS = 150;
private lastWidth = 0;
private lastHeight = 0;
private readonly RESIZE_THRESHOLD = 100; // ignore small height changes (browser chrome)
private ballSpawnId = 0;
private ballSpawnNextColor = 0;
constructor(private ngZone: NgZone) {
constructor() {
afterNextRender(() => {
this.init();
});
}
ngOnDestroy() {
if (!this.initialized) return;
if (!this.initialized) {
return;
}
cancelAnimationFrame(this.animationId);
window.removeEventListener('resize', this.resize);
window.removeEventListener('mousemove', this.onMouseMove);
if (this.isBrowser) {
window.removeEventListener('resize', this.resize);
window.removeEventListener('mousemove', this.onMouseMove);
window.removeEventListener('click', this.onMouseClick);
}
}
private init() {
const canvas = this.canvasRef.nativeElement;
this.ctx = canvas.getContext('2d')!;
let ctx: CanvasRenderingContext2D | null = null;
try {
ctx = canvas.getContext('2d');
} catch {
return;
}
if (!ctx) {
return;
}
this.ctx = ctx;
this.resize();
this.initDots();
window.addEventListener('resize', this.resize);
window.addEventListener('mousemove', this.onMouseMove);
window.addEventListener('click', this.onMouseClick);
this.initialized = true;
this.ngZone.runOutsideAngular(() => this.animate());
@@ -56,57 +89,91 @@ export class DotBackground implements OnDestroy {
const width = window.innerWidth;
const height = window.innerHeight;
const widthChanged = width !== this.lastWidth;
const heightDiff = Math.abs(height - this.lastHeight);
const significantHeightChange = heightDiff > this.RESIZE_THRESHOLD;
const dx = Math.abs(width - canvas.width) / width;
const dy = Math.abs(height - canvas.height) / height;
// Only resize on width change or significant height change (orientation flip, not browser chrome)
if (!widthChanged && !significantHeightChange && this.lastWidth !== 0) {
return;
}
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
canvas.width = width;
canvas.height = height;
this.lastWidth = width;
this.lastHeight = height;
canvas.width = width;
canvas.height = height;
for (const dot of this.dots) {
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
for (const dot of this.dots) {
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
}
}
};
private onMouseMove = (e: MouseEvent) => {
this.mouse.x = e.clientX;
this.mouse.y = e.clientY;
const canvas = this.canvasRef.nativeElement;
this.mouse.x = (e.clientX / window.innerWidth) * canvas.width;
this.mouse.y = (e.clientY / window.innerHeight) * canvas.height;
};
private initDots() {
private onMouseClick = () => {
const dot = this.spawnDot();
dot.x = this.mouse.x;
dot.y = this.mouse.y;
};
private spawnDot(): Dot {
const dotId = this.ballSpawnId++;
const maxCount = this.coarsePointer ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
let dot: Dot;
if (dotId < maxCount) {
dot = {
x: 0,
y: 0,
vx: 0,
vy: 0,
radius: 1,
color: '#000000',
};
this.dots.push(dot);
} else {
dot = this.dots[dotId % this.dots.length];
}
this.populateDot(dot);
return dot;
}
private populateDot(dot: Dot) {
const { width, height } = this.canvasRef.nativeElement;
for (let i = 0; i < this.DOT_COUNT; i++) {
this.dots.push({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
radius: Math.random() * 60 + 40,
color: this.COLORS[i % this.COLORS.length],
});
dot.x = Math.random() * width;
dot.y = Math.random() * height;
dot.vx = (Math.random() - 0.5) * 0.5;
dot.vy = (Math.random() - 0.5) * 0.5;
dot.radius = Math.random() * 60 + 40;
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
}
private initDots() {
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
this.spawnDot();
}
}
private animate = () => {
const canvas = this.canvasRef.nativeElement;
const ctx = this.ctx;
if (!ctx) {
return;
}
const { width, height } = canvas;
this.ctx.clearRect(0, 0, width, height);
ctx.clearRect(0, 0, width, height);
for (const dot of this.dots) {
const dx = dot.x - this.mouse.x;
const dy = dot.y - this.mouse.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < this.MOUSE_RADIUS) {
if (dist != 0 && dist < this.MOUSE_RADIUS) {
const force = (this.MOUSE_RADIUS - dist) / this.MOUSE_RADIUS;
dot.vx += (dx / dist) * force * 0.5;
dot.vy += (dy / dist) * force * 0.5;
@@ -119,27 +186,32 @@ export class DotBackground implements OnDestroy {
if (speed > 0.3) {
dot.vx *= 0.98;
dot.vy *= 0.98;
} else if (speed <= 0.3) {
dot.vx *= 1.02;
dot.vy *= 1.02;
} else if (speed === 0) {
dot.vx = Math.random() * 0.2 - 0.1;
dot.vy = Math.random() * 0.2 - 0.1;
}
// Bounce off edges (accounting for radius)
if (dot.x < dot.radius || dot.x > width - dot.radius) dot.vx *= -1;
if (dot.y < dot.radius || dot.y > height - dot.radius) dot.vy *= -1;
if (dot.x < dot.radius || dot.x > width - dot.radius) {
dot.vx *= -1;
}
if (dot.y < dot.radius || dot.y > height - dot.radius) {
dot.vy *= -1;
}
// Clamp to bounds
dot.x = Math.max(dot.radius, Math.min(width - dot.radius, dot.x));
dot.y = Math.max(dot.radius, Math.min(height - dot.radius, dot.y));
const gradient = this.ctx.createRadialGradient(
dot.x, dot.y, 0,
dot.x, dot.y, dot.radius
);
const gradient = ctx.createRadialGradient(dot.x, dot.y, 0, dot.x, dot.y, dot.radius);
gradient.addColorStop(0, dot.color + '40');
gradient.addColorStop(1, 'transparent');
this.ctx.beginPath();
this.ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
this.ctx.fillStyle = gradient;
this.ctx.fill();
ctx.beginPath();
ctx.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
}
this.animationId = requestAnimationFrame(this.animate);

View File

@@ -1,10 +1,11 @@
<div class="card">
<div class="card glass-surface">
<h3>{{ title }}</h3>
<div class="icons">
@for (skill of skills; track skill.icon) {
<a
[href]="skill.url"
target="_blank"
rel="noopener noreferrer"
[attr.aria-label]="skill.name"
>
<img

View File

@@ -1,44 +1,27 @@
// skill-card.component.scss
.card {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 1.5rem;
border-radius: var(--radius-lg);
padding: var(--space-6);
height: 100%;
display: flex;
flex-direction: column;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow:
0 8px 32px rgba(99, 102, 241, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.25);
}
transition:
transform var(--duration-base) var(--ease-standard),
box-shadow var(--duration-base) var(--ease-standard),
border-color var(--duration-base) var(--ease-standard);
h3 {
margin: 0 0 1rem;
font-size: 0.875rem;
margin: 0 0 var(--space-4);
font-size: var(--text-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.6);
letter-spacing: var(--tracking-wide);
color: var(--color-text-muted);
}
.icons {
display: flex;
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
align-items: center;
flex: 1;
@@ -51,13 +34,23 @@
height: 40px;
object-fit: contain;
filter: grayscale(100%) brightness(0.8);
transition: filter 0.2s ease, transform 0.2s ease;
}
&:hover img {
filter: none;
transform: scale(1.1);
transition:
filter var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
}
}
}
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-raised);
border-color: var(--surface-glass-border-strong);
}
.card .icons a:hover img {
filter: none;
transform: scale(1.1);
}
}

View File

@@ -8,9 +8,8 @@ describe('SkillCard', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkillCard]
})
.compileComponents();
imports: [SkillCard],
}).compileComponents();
fixture = TestBed.createComponent(SkillCard);
component = fixture.componentInstance;

View File

@@ -1,5 +1,5 @@
import {Component, Input} from '@angular/core';
import {Skill} from '../../../../models/skill';
import { Component, Input } from '@angular/core';
import { Skill } from '../../../../models/skill';
@Component({
selector: 'app-skill-card',

View File

@@ -2,5 +2,5 @@
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 10px;
gap: var(--space-3);
}

View File

@@ -8,9 +8,8 @@ describe('SkillsGrid', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkillsGrid]
})
.compileComponents();
imports: [SkillsGrid],
}).compileComponents();
fixture = TestBed.createComponent(SkillsGrid);
component = fixture.componentInstance;

View File

@@ -1,12 +1,10 @@
import {Component} from '@angular/core';
import {SkillCategory} from '../../../../models/skill-category';
import {SkillCard} from '../skill-card/skill-card';
import { Component } from '@angular/core';
import { SkillCategory } from '../../../../models/skill-category';
import { SkillCard } from '../skill-card/skill-card';
@Component({
selector: 'app-skills-grid',
imports: [
SkillCard
],
imports: [SkillCard],
templateUrl: './skills-grid.html',
styleUrl: './skills-grid.scss',
})
@@ -17,15 +15,19 @@ export class SkillsGrid {
category: 'programming',
gridArea: 'prog',
skills: [
{name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/'},
{name: 'JavaScript', icon: 'javascript', url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript'},
{name: 'Angular', icon: 'angular', url: 'https://angular.dev/'},
{name: 'Java', icon: 'java', url: 'https://www.java.com/'},
{name: 'Spring', icon: 'java-spring', url: 'https://spring.io/'},
{name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/'},
{name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/'},
{name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/'},
{name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/'},
{ name: 'TypeScript', icon: 'typescript', url: 'https://www.typescriptlang.org/' },
{
name: 'JavaScript',
icon: 'javascript',
url: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript',
},
{ name: 'Angular', icon: 'angular', url: 'https://angular.dev/' },
{ name: 'Java', icon: 'java', url: 'https://www.java.com/' },
{ name: 'Spring', icon: 'java-spring', url: 'https://spring.io/' },
{ name: 'C#', icon: 'c-sharp', url: 'https://learn.microsoft.com/en-us/dotnet/csharp/' },
{ name: '.NET', icon: 'c-sharp-net', url: 'https://dotnet.microsoft.com/' },
{ name: 'Kafka', icon: 'kafka', url: 'https://kafka.apache.org/' },
{ name: 'Elasticsearch', icon: 'elasticseach', url: 'https://www.elastic.co/' },
],
},
{
@@ -33,10 +35,14 @@ export class SkillsGrid {
category: 'db',
gridArea: 'db',
skills: [
{name: 'SQL Server', icon: 'microsoftsqlserver', url: 'https://www.microsoft.com/en-us/sql-server'},
{name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/'},
{name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/'},
{name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/'},
{
name: 'SQL Server',
icon: 'microsoftsqlserver',
url: 'https://www.microsoft.com/en-us/sql-server',
},
{ name: 'PostgreSQL', icon: 'postgresql', url: 'https://www.postgresql.org/' },
{ name: 'MySQL', icon: 'mysql', url: 'https://www.mysql.com/' },
{ name: 'MariaDB', icon: 'mariadb', url: 'https://mariadb.org/' },
],
},
{
@@ -44,13 +50,17 @@ export class SkillsGrid {
category: 'devops',
gridArea: 'devops',
skills: [
{name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/'},
{name: 'Docker', icon: 'docker', url: 'https://www.docker.com/'},
{name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/'},
{name: 'Azure DevOps', icon: 'devops', url: 'https://azure.microsoft.com/en-us/products/devops'},
{name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/'},
{name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/'},
{name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/'},
{ name: 'Kubernetes', icon: 'kubernetes', url: 'https://kubernetes.io/' },
{ name: 'Docker', icon: 'docker', url: 'https://www.docker.com/' },
{ name: 'GitLab', icon: 'gitlab', url: 'https://gitlab.com/' },
{
name: 'Azure DevOps',
icon: 'devops',
url: 'https://azure.microsoft.com/en-us/products/devops',
},
{ name: 'Azure', icon: 'azure', url: 'https://azure.microsoft.com/' },
{ name: 'Hetzner', icon: 'hetzner', url: 'https://www.hetzner.com/' },
{ name: 'Netcup', icon: 'netcup', url: 'https://www.netcup.eu/' },
],
},
{
@@ -58,10 +68,10 @@ export class SkillsGrid {
category: 'os',
gridArea: 'os',
skills: [
{name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/'},
{name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/'},
{name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/'},
{name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows'},
{ name: 'Arch Linux', icon: 'arch', url: 'https://archlinux.org/' },
{ name: 'Ubuntu', icon: 'ubuntu', url: 'https://ubuntu.com/' },
{ name: 'macOS', icon: 'macos', url: 'https://www.apple.com/macos/' },
{ name: 'Windows', icon: 'windows', url: 'https://www.microsoft.com/windows' },
],
},
{
@@ -69,8 +79,8 @@ export class SkillsGrid {
category: 'iac',
gridArea: 'iac',
skills: [
{name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/'},
{name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/'},
{ name: 'Terraform', icon: 'terraform', url: 'https://www.terraform.io/' },
{ name: 'Ansible', icon: 'ansible', url: 'https://www.ansible.com/' },
],
},
{
@@ -78,8 +88,12 @@ export class SkillsGrid {
category: 'hyperviser',
gridArea: 'hyper',
skills: [
{name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/'},
{name: 'Hyper-V', icon: 'hyperv', url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/'},
{ name: 'Proxmox', icon: 'proxmox', url: 'https://www.proxmox.com/' },
{
name: 'Hyper-V',
icon: 'hyperv',
url: 'https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/',
},
],
},
{
@@ -87,9 +101,13 @@ export class SkillsGrid {
category: 'tools',
gridArea: 'tools',
skills: [
{name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/'},
{name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/'},
{name: 'Microsoft Office', icon: 'office', url: 'https://www.microsoft.com/microsoft-365'},
{ name: 'JetBrains', icon: 'jetbrains', url: 'https://www.jetbrains.com/' },
{ name: 'Visual Studio', icon: 'vs', url: 'https://visualstudio.microsoft.com/' },
{
name: 'Microsoft Office',
icon: 'office',
url: 'https://www.microsoft.com/microsoft-365',
},
],
},
];

View File

@@ -1,5 +1,7 @@
<div class="main">
<h1>Antonio Ledebuhr</h1>
<p>Software Engineering & DevOps</p>
@if (page(); as copy) {
<h1>{{ copy.title }}</h1>
<p>{{ copy.description }}</p>
}
<app-skills-grid></app-skills-grid>
</div>

View File

@@ -4,17 +4,19 @@
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
font-family: system-ui, sans-serif;
color: var(--color-text);
font-family: var(--font-sans);
padding: var(--space-8) var(--content-gutter);
}
h1 {
font-size: 3rem;
font-size: var(--text-3xl);
font-weight: 600;
line-height: var(--leading-tight);
margin: 0;
}
p {
color: rgba(255, 255, 255, 0.5);
margin: 0.5rem 0 3rem;
color: var(--color-text-muted);
margin: var(--space-2) 0 var(--space-9);
}

View File

@@ -1,5 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PLACEHOLDER_CONTENT } from '../../../core/content/placeholder-content';
import { SITE_CONTENT } from '../../../core/content/content.token';
import { SITE_CONFIG } from '../../../core/content/site-config';
import { Skills } from './skills';
describe('Skills', () => {
@@ -8,9 +10,9 @@ describe('Skills', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Skills]
})
.compileComponents();
imports: [Skills],
providers: [{ provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
}).compileComponents();
fixture = TestBed.createComponent(Skills);
component = fixture.componentInstance;
@@ -20,4 +22,10 @@ describe('Skills', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('renders the localized stack title instead of the person name', () => {
const heading = (fixture.nativeElement as HTMLElement).querySelector('h1');
expect(heading?.textContent?.trim()).toBe('Stack');
expect(heading?.textContent).not.toContain(SITE_CONFIG.personName);
});
});

View File

@@ -1,14 +1,14 @@
import { Component } from '@angular/core';
import {SkillsGrid} from './skills-grid/skills-grid';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../../core/content/content.service';
import { SkillsGrid } from './skills-grid/skills-grid';
@Component({
selector: 'app-skills',
imports: [
SkillsGrid
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkillsGrid],
templateUrl: './skills.html',
styleUrl: './skills.scss',
})
export class Skills {
protected readonly page = inject(ContentService).page('stack');
}

View File

@@ -0,0 +1,50 @@
import { type RouteId } from '../routing/route-ids';
/**
* Copy roles stay separate on purpose:
* - headline is the serious heading used for SEO and page structure
* - proof is only ever a verifiable statement
* - playfulLine is an optional wordplay hook that is never a capability claim
* - cta lives on CtaCopy so action labels stay swappable
*/
export interface CopyBlock {
readonly headline: string;
readonly proof?: string;
readonly playfulLine?: string;
readonly body?: readonly string[];
}
export interface CtaCopy {
readonly label: string;
readonly routeId?: RouteId;
readonly href?: string;
readonly external?: boolean;
}
export interface SectionCopy extends CopyBlock {
readonly id: string;
}
export interface PageCopy {
readonly routeId: RouteId;
readonly title: string;
readonly description: string;
readonly hero: CopyBlock;
readonly sections: readonly SectionCopy[];
readonly ctas: readonly CtaCopy[];
}
export interface CaseStudySummary {
readonly id: string;
readonly client: string;
readonly headline: string;
readonly proof?: string;
readonly tags: readonly string[];
readonly routeId?: RouteId;
}
export type LocalizedPages = Partial<Record<RouteId, PageCopy>>;
export interface SiteContent {
readonly pages: LocalizedPages;
}

View File

@@ -0,0 +1,15 @@
import { computed, inject, Injectable, type Signal } from '@angular/core';
import { LocaleService } from '../i18n/locale.service';
import { type RouteId } from '../routing/route-ids';
import { type PageCopy } from './content.contracts';
import { SITE_CONTENT } from './content.token';
@Injectable({ providedIn: 'root' })
export class ContentService {
private readonly localeService = inject(LocaleService);
private readonly content = inject(SITE_CONTENT);
page(routeId: RouteId): Signal<PageCopy | undefined> {
return computed(() => this.content[this.localeService.locale()].pages[routeId]);
}
}

View File

@@ -0,0 +1,5 @@
import { InjectionToken } from '@angular/core';
import { type AppLocale } from '../i18n/locale';
import { type SiteContent } from './content.contracts';
export const SITE_CONTENT = new InjectionToken<Record<AppLocale, SiteContent>>('SITE_CONTENT');

View File

@@ -0,0 +1,58 @@
import { type AppLocale } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from '../routing/route-ids';
import { type PageCopy, type SiteContent } from './content.contracts';
const PAGE_TITLES: Record<RouteId, Record<AppLocale, string>> = {
home: { de: 'Startseite', en: 'Home' },
services: { de: 'Leistungen', en: 'Services' },
servicesSoftware: { de: 'Software', en: 'Software' },
servicesHardwareNetwork: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
servicesClusters: { de: 'Cluster', en: 'Clusters' },
servicesAi: { de: 'KI-Integration', en: 'AI integration' },
projects: { de: 'Projekte', en: 'Projects' },
stack: { de: 'Stack', en: 'Stack' },
about: { de: 'Über mich', en: 'About' },
contact: { de: 'Kontakt', en: 'Contact' },
imprint: { de: 'Impressum', en: 'Legal notice' },
privacy: { de: 'Datenschutz', en: 'Privacy' },
notFound: { de: 'Seite nicht gefunden', en: 'Page not found' },
};
function scaffoldPage(routeId: RouteId, locale: AppLocale): PageCopy {
const title = PAGE_TITLES[routeId][locale];
const description =
locale === 'de' ? 'Diese Seite wird derzeit aufgebaut.' : 'This page is being built.';
return {
routeId,
title,
description,
hero: {
headline: title,
body: [description],
},
sections: [],
ctas:
routeId === 'notFound'
? [
{
label: locale === 'de' ? 'Zur Startseite' : 'Back to home',
routeId: 'home',
},
]
: [],
};
}
function pagesFor(locale: AppLocale): SiteContent {
const pages = Object.fromEntries(
ROUTE_IDS.map((routeId) => [routeId, scaffoldPage(routeId, locale)]),
) as Record<RouteId, PageCopy>;
return { pages };
}
export const PLACEHOLDER_CONTENT: Record<AppLocale, SiteContent> = {
de: pagesFor('de'),
en: pagesFor('en'),
};

View File

@@ -0,0 +1,49 @@
import { type AppLocale } from '../i18n/locale';
export interface ShellCopy {
readonly skipLink: string;
readonly primaryNavLabel: string;
readonly footerNavLabel: string;
readonly menuOpen: string;
readonly menuClose: string;
readonly languageSwitch: string;
readonly otherLocaleName: string;
readonly cvLabel: string;
readonly contactCta: string;
readonly scaffoldingNote: string;
readonly legalReviewNotice: string;
readonly notFoundHomeLabel: string;
}
export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
de: {
skipLink: 'Zum Inhalt springen',
primaryNavLabel: 'Hauptnavigation',
footerNavLabel: 'Fußzeilen-Navigation',
menuOpen: 'Menü öffnen',
menuClose: 'Menü schließen',
languageSwitch: 'Zur englischen Version wechseln',
otherLocaleName: 'English',
cvLabel: 'Lebenslauf als PDF',
contactCta: 'Kontakt',
scaffoldingNote: 'Hinweis: Der endgültige Inhalt folgt. Diese Seite ist ein Gerüst.',
legalReviewNotice:
'Platzhalter: Dieser Rechtstext ist ungeprüft und muss vor der Veröffentlichung vom Seitenbetreiber geprüft werden.',
notFoundHomeLabel: 'Zur Startseite',
},
en: {
skipLink: 'Skip to content',
primaryNavLabel: 'Primary navigation',
footerNavLabel: 'Footer navigation',
menuOpen: 'Open menu',
menuClose: 'Close menu',
languageSwitch: 'Switch to the German version',
otherLocaleName: 'Deutsch',
cvLabel: 'Curriculum vitae as PDF',
contactCta: 'Contact',
scaffoldingNote: 'Note: Final content follows. This page is scaffolding.',
legalReviewNotice:
'Placeholder: This legal text is unreviewed and must be checked by the site owner before publication.',
notFoundHomeLabel: 'Back to home',
},
};

View File

@@ -0,0 +1,7 @@
export const SITE_CONFIG = {
personName: 'Antonio Ledebuhr',
contactEmail: 'info@antoniolede.de',
cvAssetPath: '/cv/CV.pdf',
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
calendarUrl: null,
} as const;

View File

@@ -0,0 +1,11 @@
import { inject } from '@angular/core';
import { type ResolveFn } from '@angular/router';
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from './locale';
import { LocaleService } from './locale.service';
export const localeResolver: ResolveFn<AppLocale> = (route) => {
const localeValue: unknown = route.data['locale'];
const locale = isAppLocale(localeValue) ? localeValue : DEFAULT_LOCALE;
inject(LocaleService).setLocale(locale);
return locale;
};

View File

@@ -0,0 +1,25 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { DEFAULT_LOCALE, LOCALE_HTML_LANG, type AppLocale } from './locale';
@Injectable({ providedIn: 'root' })
export class LocaleService {
private readonly document = inject(DOCUMENT);
private readonly localeSignal = signal<AppLocale>(DEFAULT_LOCALE);
readonly locale = this.localeSignal.asReadonly();
readonly htmlLang = computed(() => LOCALE_HTML_LANG[this.localeSignal()]);
constructor() {
this.applyHtmlLang(DEFAULT_LOCALE);
}
setLocale(locale: AppLocale): void {
this.localeSignal.set(locale);
this.applyHtmlLang(locale);
}
private applyHtmlLang(locale: AppLocale): void {
this.document.documentElement.lang = LOCALE_HTML_LANG[locale];
}
}

View File

@@ -0,0 +1,44 @@
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { DEFAULT_LOCALE, isAppLocale, otherLocale } from './locale';
import { LocaleService } from './locale.service';
describe('locale helpers', () => {
it('defaults to German', () => {
expect(DEFAULT_LOCALE).toBe('de');
});
it('accepts only de and en', () => {
expect(isAppLocale('de')).toBe(true);
expect(isAppLocale('en')).toBe(true);
expect(isAppLocale('fr')).toBe(false);
expect(isAppLocale('')).toBe(false);
expect(isAppLocale(null)).toBe(false);
});
it('round-trips the other locale', () => {
expect(otherLocale('de')).toBe('en');
expect(otherLocale('en')).toBe('de');
expect(otherLocale(otherLocale('de'))).toBe('de');
});
});
describe('LocaleService', () => {
it('updates the locale signal and the document lang attribute', () => {
TestBed.configureTestingModule({});
const service = TestBed.inject(LocaleService);
const document = TestBed.inject(DOCUMENT);
expect(service.locale()).toBe('de');
expect(document.documentElement.lang).toBe('de-DE');
service.setLocale('en');
expect(service.locale()).toBe('en');
expect(service.htmlLang()).toBe('en');
expect(document.documentElement.lang).toBe('en');
service.setLocale('de');
expect(service.locale()).toBe('de');
expect(document.documentElement.lang).toBe('de-DE');
});
});

View File

@@ -0,0 +1,18 @@
export type AppLocale = 'de' | 'en';
export const APP_LOCALES: readonly AppLocale[] = ['de', 'en'];
export const DEFAULT_LOCALE: AppLocale = 'de';
export const LOCALE_HTML_LANG: Record<AppLocale, string> = {
de: 'de-DE',
en: 'en',
};
export function isAppLocale(value: unknown): value is AppLocale {
return value === 'de' || value === 'en';
}
export function otherLocale(locale: AppLocale): AppLocale {
return locale === 'de' ? 'en' : 'de';
}

View File

@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../app.routes';
import { PLACEHOLDER_CONTENT } from '../content/placeholder-content';
import { SITE_CONTENT } from '../content/content.token';
import { LocaleService } from '../i18n/locale.service';
import { NavigationService } from './navigation.service';
describe('NavigationService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
});
});
it('maps a German route to its English counterpart and back', async () => {
const harness = await RouterTestingHarness.create();
const navigation = TestBed.inject(NavigationService);
await harness.navigateByUrl('/projekte');
expect(navigation.activeRouteId()).toBe('projects');
expect(navigation.alternateLocaleLink()).toEqual(['/', 'en', 'projects']);
await harness.navigateByUrl('/en/projects');
expect(navigation.activeRouteId()).toBe('projects');
expect(navigation.alternateLocaleLink()).toEqual(['/', 'projekte']);
});
it('builds links for the active locale', () => {
const navigation = TestBed.inject(NavigationService);
const locale = TestBed.inject(LocaleService);
locale.setLocale('de');
expect(navigation.link('projects')).toEqual(['/', 'projekte']);
expect(navigation.link('home')).toEqual(['/']);
locale.setLocale('en');
expect(navigation.link('projects')).toEqual(['/', 'en', 'projects']);
expect(navigation.link('home')).toEqual(['/', 'en']);
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
});
});

View File

@@ -0,0 +1,77 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { SITE_CONFIG } from '../content/site-config';
import { otherLocale, type AppLocale } from '../i18n/locale';
import { LocaleService } from '../i18n/locale.service';
import { isAppRouteData } from '../routing/app-route-data';
import { type RouteId } from '../routing/route-ids';
import { routeCommands } from '../routing/route-paths';
import { FOOTER_NAV, PRIMARY_NAV, type NavItem } from './navigation';
export interface ResolvedNavItem {
readonly routeId: RouteId;
readonly label: string;
readonly link: unknown[];
readonly children?: readonly ResolvedNavItem[];
}
@Injectable({ providedIn: 'root' })
export class NavigationService {
private readonly localeService = inject(LocaleService);
private readonly router = inject(Router);
private readonly activeRouteIdSignal = signal<RouteId>('home');
readonly activeRouteId = this.activeRouteIdSignal.asReadonly();
readonly cvHref = SITE_CONFIG.cvAssetPath;
readonly contactLink = computed(() => this.link('contact'));
readonly primaryNav = computed(() => this.resolveItems(PRIMARY_NAV, this.localeService.locale()));
readonly footerNav = computed(() => this.resolveItems(FOOTER_NAV, this.localeService.locale()));
constructor() {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.syncFromRouter());
this.syncFromRouter();
}
link(routeId: RouteId, locale?: AppLocale): unknown[] {
return routeCommands(routeId, locale ?? this.localeService.locale());
}
alternateLocaleLink(): unknown[] {
const targetLocale = otherLocale(this.localeService.locale());
const current = this.activeRouteId();
const routeId = current === 'notFound' ? 'home' : current;
return routeCommands(routeId, targetLocale);
}
private resolveItems(items: readonly NavItem[], locale: AppLocale): ResolvedNavItem[] {
return items.map((item) => ({
routeId: item.routeId,
label: item.label[locale],
link: routeCommands(item.routeId, locale),
children: item.children ? this.resolveItems(item.children, locale) : undefined,
}));
}
private syncFromRouter(): void {
let snapshot = this.router.routerState.snapshot.root;
while (snapshot.firstChild) {
snapshot = snapshot.firstChild;
}
if (!isAppRouteData(snapshot.data)) {
return;
}
this.localeService.setLocale(snapshot.data.locale);
this.activeRouteIdSignal.set(snapshot.data.routeId);
}
}

View File

@@ -0,0 +1,68 @@
import { type AppLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
export interface NavItem {
readonly routeId: RouteId;
readonly label: Record<AppLocale, string>;
readonly children?: readonly NavItem[];
}
export const PRIMARY_NAV: readonly NavItem[] = [
{
routeId: 'home',
label: { de: 'Start', en: 'Home' },
},
{
routeId: 'services',
label: { de: 'Leistungen', en: 'Services' },
children: [
{
routeId: 'servicesSoftware',
label: { de: 'Software', en: 'Software' },
},
{
routeId: 'servicesHardwareNetwork',
label: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
},
{
routeId: 'servicesClusters',
label: { de: 'Cluster', en: 'Clusters' },
},
{
routeId: 'servicesAi',
label: { de: 'KI-Integration', en: 'AI integration' },
},
],
},
{
routeId: 'projects',
label: { de: 'Projekte', en: 'Projects' },
},
{
routeId: 'stack',
label: { de: 'Stack', en: 'Stack' },
},
{
routeId: 'about',
label: { de: 'Über mich', en: 'About' },
},
{
routeId: 'contact',
label: { de: 'Kontakt', en: 'Contact' },
},
];
export const FOOTER_NAV: readonly NavItem[] = [
{
routeId: 'imprint',
label: { de: 'Impressum', en: 'Legal notice' },
},
{
routeId: 'privacy',
label: { de: 'Datenschutz', en: 'Privacy' },
},
{
routeId: 'contact',
label: { de: 'Kontakt', en: 'Contact' },
},
];

View File

@@ -0,0 +1,23 @@
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
viewportMatches,
} from './browser';
describe('browser platform helpers', () => {
it('returns conservative defaults on the server without throwing', () => {
TestBed.configureTestingModule({
providers: [{ provide: PLATFORM_ID, useValue: 'server' }],
});
TestBed.runInInjectionContext(() => {
expect(isBrowserPlatform()).toBe(false);
expect(prefersReducedMotion()).toBe(false);
expect(prefersCoarsePointer()).toBe(false);
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
});
});
});

View File

@@ -0,0 +1,26 @@
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
export function isBrowserPlatform(): boolean {
return isPlatformBrowser(inject(PLATFORM_ID));
}
export function prefersReducedMotion(): boolean {
return mediaQueryMatches('(prefers-reduced-motion: reduce)');
}
export function prefersCoarsePointer(): boolean {
return mediaQueryMatches('(pointer: coarse)');
}
export function viewportMatches(query: string): boolean {
return mediaQueryMatches(query);
}
function mediaQueryMatches(query: string): boolean {
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia(query).matches;
}

View File

@@ -0,0 +1,16 @@
import { isAppLocale, type AppLocale } from '../i18n/locale';
import { type RouteId } from './route-ids';
export interface AppRouteData {
readonly routeId: RouteId;
readonly locale: AppLocale;
}
export function isAppRouteData(value: unknown): value is AppRouteData {
if (typeof value !== 'object' || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return typeof record['routeId'] === 'string' && isAppLocale(record['locale']);
}

View File

@@ -0,0 +1,30 @@
export type RouteId =
| 'home'
| 'services'
| 'servicesSoftware'
| 'servicesHardwareNetwork'
| 'servicesClusters'
| 'servicesAi'
| 'projects'
| 'stack'
| 'about'
| 'contact'
| 'imprint'
| 'privacy'
| 'notFound';
export const ROUTE_IDS: readonly RouteId[] = [
'home',
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
'projects',
'stack',
'about',
'contact',
'imprint',
'privacy',
'notFound',
];

View File

@@ -0,0 +1,46 @@
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from './route-ids';
import { prerenderablePaths, routePath, ROUTE_SEGMENTS } from './route-paths';
describe('route paths', () => {
const addressableIds = ROUTE_IDS.filter((routeId) => routeId !== 'notFound');
it('defines every route id in both locales', () => {
for (const locale of APP_LOCALES) {
for (const routeId of ROUTE_IDS) {
expect(ROUTE_SEGMENTS[locale][routeId]).toBeTypeOf('string');
}
}
});
it('has no duplicate addressable paths within a locale', () => {
for (const locale of APP_LOCALES) {
const paths = addressableIds.map((routeId) => routePath(routeId, locale));
expect(new Set(paths).size).toBe(paths.length);
}
});
it('builds the expected home and projects paths', () => {
expect(routePath('home', 'de')).toBe('/');
expect(routePath('home', 'en')).toBe('/en');
expect(routePath('projects', 'de')).toBe('/projekte');
expect(routePath('projects', 'en')).toBe('/en/projects');
});
it('includes both locales in prerenderable paths and excludes the wildcard', () => {
const paths = prerenderablePaths();
expect(paths).toContain('/');
expect(paths).toContain('/en');
expect(paths).toContain('/projekte');
expect(paths).toContain('/en/projects');
expect(paths.some((path) => path.includes('**'))).toBe(false);
expect(paths).not.toContain('');
for (const locale of APP_LOCALES) {
for (const routeId of addressableIds) {
expect(paths).toContain(routePath(routeId as RouteId, locale));
}
}
});
});

View File

@@ -0,0 +1,83 @@
import { APP_LOCALES, type AppLocale } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from './route-ids';
export const LOCALE_PREFIX: Record<AppLocale, string> = {
de: '',
en: 'en',
};
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
de: {
home: '',
services: 'leistungen',
servicesSoftware: 'leistungen/software',
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
servicesClusters: 'leistungen/cluster',
servicesAi: 'leistungen/ai-integration',
projects: 'projekte',
stack: 'stack',
about: 'ueber-mich',
contact: 'kontakt',
imprint: 'impressum',
privacy: 'datenschutz',
notFound: '**',
},
en: {
home: '',
services: 'services',
servicesSoftware: 'services/software',
servicesHardwareNetwork: 'services/hardware-network',
servicesClusters: 'services/clusters',
servicesAi: 'services/ai-integration',
projects: 'projects',
stack: 'stack',
about: 'about',
contact: 'contact',
imprint: 'legal-notice',
privacy: 'privacy',
notFound: '**',
},
};
export function routePath(routeId: RouteId, locale: AppLocale): string {
if (routeId === 'notFound') {
return '';
}
const prefix = LOCALE_PREFIX[locale];
const segment = ROUTE_SEGMENTS[locale][routeId];
const parts = [prefix, segment].filter((part) => part.length > 0);
return parts.length === 0 ? '/' : `/${parts.join('/')}`;
}
export function routeCommands(routeId: RouteId, locale: AppLocale): unknown[] {
if (routeId === 'notFound') {
return ['/'];
}
const commands: string[] = ['/'];
const prefix = LOCALE_PREFIX[locale];
const segment = ROUTE_SEGMENTS[locale][routeId];
if (prefix.length > 0) {
commands.push(prefix);
}
if (segment.length > 0) {
commands.push(...segment.split('/'));
}
return commands;
}
export function prerenderablePaths(): readonly string[] {
return APP_LOCALES.flatMap((locale) =>
ROUTE_IDS.filter((routeId) => routeId !== 'notFound').map((routeId) =>
routePath(routeId, locale),
),
);
}
export function prerenderableServerPaths(): readonly string[] {
return prerenderablePaths().map((path) => (path === '/' ? '' : path.slice(1)));
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-about-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './about.html',
})
export class AboutPage {
protected readonly page = inject(ContentService).page('about');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-contact-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './contact.html',
})
export class ContactPage {
protected readonly page = inject(ContentService).page('contact');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-home-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './home.html',
})
export class HomePage {
protected readonly page = inject(ContentService).page('home');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-imprint-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './imprint.html',
})
export class ImprintPage {
protected readonly page = inject(ContentService).page('imprint');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-not-found-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './not-found.html',
})
export class NotFoundPage {
protected readonly page = inject(ContentService).page('notFound');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-privacy-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './privacy.html',
})
export class PrivacyPage {
protected readonly page = inject(ContentService).page('privacy');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-projects-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './projects.html',
})
export class ProjectsPage {
protected readonly page = inject(ContentService).page('projects');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../../core/content/content.service';
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-services-ai-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './ai-integration.html',
})
export class ServicesAiPage {
protected readonly page = inject(ContentService).page('servicesAi');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../../core/content/content.service';
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-services-clusters-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './clusters.html',
})
export class ServicesClustersPage {
protected readonly page = inject(ContentService).page('servicesClusters');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../../core/content/content.service';
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-services-hardware-network-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './hardware-network.html',
})
export class ServicesHardwareNetworkPage {
protected readonly page = inject(ContentService).page('servicesHardwareNetwork');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../core/content/content.service';
import { PagePlaceholder } from '../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-services-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './services.html',
})
export class ServicesPage {
protected readonly page = inject(ContentService).page('services');
}

View File

@@ -0,0 +1,3 @@
@if (page(); as copy) {
<app-page-placeholder [page]="copy" />
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ContentService } from '../../../core/content/content.service';
import { PagePlaceholder } from '../../../shared/page-placeholder/page-placeholder';
@Component({
selector: 'app-services-software-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PagePlaceholder],
templateUrl: './software.html',
})
export class ServicesSoftwarePage {
protected readonly page = inject(ContentService).page('servicesSoftware');
}

View File

@@ -0,0 +1 @@
<app-skills />

View File

@@ -0,0 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Skills } from '../../components/pages/skills/skills';
@Component({
selector: 'app-stack-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Skills],
templateUrl: './stack.html',
})
export class StackPage {}

View File

@@ -1,4 +1,4 @@
import {Skill} from './skill';
import { Skill } from './skill';
export interface SkillCategory {
title: string;

View File

@@ -0,0 +1,13 @@
<article class="content-container stack page">
<h1>{{ page().title }}</h1>
<p class="lead">{{ page().description }}</p>
<aside class="scaffolding-note" role="note">{{ shell().scaffoldingNote }}</aside>
@if (showLegalNotice()) {
<p class="legal-notice" role="note">{{ shell().legalReviewNotice }}</p>
}
@if (isNotFound()) {
<p>
<a [routerLink]="homeLink()">{{ shell().notFoundHomeLabel }}</a>
</p>
}
</article>

View File

@@ -0,0 +1,31 @@
.page {
padding-block: var(--space-10);
}
.lead {
color: var(--color-text-muted);
font-size: var(--text-lg);
max-width: 40rem;
}
.scaffolding-note,
.legal-notice {
margin: 0;
padding: var(--space-4);
border-radius: var(--radius-md);
color: var(--color-text);
}
.scaffolding-note {
border-left: var(--focus-ring-width) solid var(--color-accent);
background: var(--color-surface-raised);
}
.legal-notice {
border: 1px solid var(--color-accent-strong);
background: var(--color-surface-overlay);
}
a {
color: var(--color-accent-cool);
}

View File

@@ -0,0 +1,28 @@
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { type PageCopy } from '../../core/content/content.contracts';
import { SHELL_COPY } from '../../core/content/shell-copy';
import { LocaleService } from '../../core/i18n/locale.service';
import { NavigationService } from '../../core/navigation/navigation.service';
@Component({
selector: 'app-page-placeholder',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink],
templateUrl: './page-placeholder.html',
styleUrl: './page-placeholder.scss',
})
export class PagePlaceholder {
readonly page = input.required<PageCopy>();
private readonly localeService = inject(LocaleService);
private readonly navigation = inject(NavigationService);
protected readonly shell = computed(() => SHELL_COPY[this.localeService.locale()]);
protected readonly homeLink = computed(() => this.navigation.link('home'));
protected readonly isNotFound = computed(() => this.page().routeId === 'notFound');
protected readonly showLegalNotice = computed(() => {
const routeId = this.page().routeId;
return routeId === 'imprint' || routeId === 'privacy';
});
}

View File

@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Portfolio</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>Portfolio</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -2,7 +2,6 @@ import { BootstrapContext, bootstrapApplication } from '@angular/platform-browse
import { App } from './app/app';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) =>
bootstrapApplication(App, config, context);
const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context);
export default bootstrap;

View File

@@ -2,5 +2,4 @@ import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

View File

@@ -41,9 +41,7 @@ app.use(
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) =>
response ? writeResponseToNodeResponse(response, res) : next(),
)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});

View File

@@ -1,15 +1,180 @@
/* You can add global styles to this file, and also import other style files */
/* Design tokens — the only place raw brand hex values are defined. */
:root {
/* Color — surface ramp built on #0a0a0f */
--color-surface: #0a0a0f;
--color-surface-raised: #12121a;
--color-surface-overlay: #1a1a24;
--color-surface-muted: #22222e;
/* Color — accent ramp */
--color-accent: #6366f1;
--color-accent-strong: #8b5cf6;
--color-accent-soft: #a855f7;
--color-accent-cool: #3b82f6;
/* Color — text ramp. muted and subtle stay at least 4.5:1 on --color-surface. */
--color-text: #f4f4f8;
--color-text-muted: #d2d2dc;
--color-text-subtle: #b8b8c8;
/* Color — glass and elevation */
--surface-glass: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
--surface-glass-border: rgba(255, 255, 255, 0.15);
--surface-glass-border-strong: rgba(255, 255, 255, 0.25);
--shadow-soft: 0 4px 24px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1);
--shadow-raised: 0 8px 32px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.15);
--blur-glass: 16px;
/* Typography */
--font-sans: system-ui, sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--text-xs: clamp(0.75rem, 0.7rem + 0.2vw, 0.8125rem);
--text-sm: clamp(0.875rem, 0.8rem + 0.25vw, 0.9375rem);
--text-md: clamp(1rem, 0.95rem + 0.3vw, 1.125rem);
--text-lg: clamp(1.125rem, 1rem + 0.5vw, 1.375rem);
--text-xl: clamp(1.375rem, 1.1rem + 0.9vw, 1.75rem);
--text-2xl: clamp(1.75rem, 1.3rem + 1.4vw, 2.25rem);
--text-3xl: clamp(2.25rem, 1.6rem + 2vw, 3rem);
--leading-tight: 1.2;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
--tracking-wide: 0.08em;
/* Spacing */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-7: 2rem;
--space-8: 2.5rem;
--space-9: 3rem;
--space-10: 4rem;
--space-11: 5rem;
--space-12: 6rem;
/* Radii and layout */
--radius-sm: 0.375rem;
--radius-md: 0.75rem;
--radius-lg: 1rem;
--radius-pill: 999px;
--content-max: 72rem;
--content-gutter: clamp(1rem, 4vw, 2rem);
/* Motion */
--duration-fast: 150ms;
--duration-base: 250ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
/* Focus */
--focus-ring-color: var(--color-accent);
--focus-ring-width: 2px;
--focus-ring-offset: 3px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
color-scheme: dark;
scroll-behavior: smooth;
}
body {
margin: 0;
background: #0a0a0f;
color: #fff;
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-sans);
font-size: var(--text-md);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
color-scheme: dark;
}
:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skip-link {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 1000;
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised);
color: var(--color-text);
border-radius: var(--radius-sm);
text-decoration: none;
transform: translateY(-200%);
transition: transform var(--duration-fast) var(--ease-standard);
}
.skip-link:focus {
transform: translateY(0);
}
.glass-surface {
background: var(--surface-glass);
backdrop-filter: blur(var(--blur-glass));
-webkit-backdrop-filter: blur(var(--blur-glass));
border: 1px solid var(--surface-glass-border);
box-shadow: var(--shadow-soft);
}
.content-container {
width: min(100% - 2 * var(--content-gutter), var(--content-max));
margin-inline: auto;
}
.stack {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
align-items: center;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -4,14 +4,8 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": [
"node"
]
"types": ["node"]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}

View File

@@ -4,12 +4,7 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
"types": ["vitest/globals"]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}