Compare commits
4 Commits
1020ac4c68
...
orchestrat
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c1572bf3c | |||
| 7813499ee3 | |||
| 9ea2885c7b | |||
| 42e9f01253 |
10
.prettierignore
Normal file
10
.prettierignore
Normal 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
149
AGENTS.md
Normal 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.
|
||||
81
README.md
81
README.md
@@ -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.
|
||||
|
||||
12
angular.json
12
angular.json
@@ -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
43
eslint.config.js
Normal 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
1535
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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
14
src/_breakpoints.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,12 +1,94 @@
|
||||
<app-dot-background></app-dot-background>
|
||||
<div [class.mobile]="isMobile()" [class.desktop]="isDesktop()">
|
||||
<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">
|
||||
<app-command-palette></app-command-palette>
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
55
src/app/app.routes.spec.ts
Normal file
55
src/app/app.routes.spec.ts
Normal 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.');
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
167
src/app/app.scss
167
src/app/app.scss
@@ -0,0 +1,167 @@
|
||||
@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-actions app-command-palette {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import {Component, computed, HostListener, OnInit, signal} from '@angular/core';
|
||||
import {RouterOutlet} from '@angular/router';
|
||||
import {DotBackground} from './components/dot-background/dot-background';
|
||||
import {DeviceDetectionService} from './service/device-detection-service';
|
||||
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';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, DotBackground],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, CommandPalette],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App implements OnInit {
|
||||
protected readonly title = signal('Portfolio');
|
||||
protected readonly isMobile = signal(false);
|
||||
protected readonly isDesktop = computed(() => !this.isMobile());
|
||||
export class App {
|
||||
protected readonly navigation = inject(NavigationService);
|
||||
protected readonly localeService = inject(LocaleService);
|
||||
protected readonly siteConfig = SITE_CONFIG;
|
||||
protected readonly navOpen = signal(true);
|
||||
|
||||
constructor(private deviceDetectionService: DeviceDetectionService) {
|
||||
}
|
||||
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,
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.isMobile.set(this.deviceDetectionService.mobileCheck());
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize() {
|
||||
this.isMobile.set(this.deviceDetectionService.mobileCheck());
|
||||
protected toggleNav(): void {
|
||||
this.navOpen.update((open) => !open);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: #0a0a0f;
|
||||
background: var(--color-surface);
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
@@ -1,23 +1,281 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DotBackground } from './dot-background';
|
||||
|
||||
function mockContext(): CanvasRenderingContext2D {
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
|
||||
return {
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: matchesQuery(query),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
describe('DotBackground', () => {
|
||||
let component: DotBackground;
|
||||
let fixture: ComponentFixture<DotBackground>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DotBackground);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Reflect.deleteProperty(window, 'matchMedia');
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
async function createFixture(): Promise<ComponentFixture<DotBackground>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('should create', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
|
||||
const fixture = await createFixture();
|
||||
expect(fixture.componentInstance).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not throw when destroyed after browser initialization', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||
|
||||
const fixture = await createFixture();
|
||||
expect(() => fixture.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it('adds no listener and schedules no animation frame when the 2D context is null', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
expect(scheduled).toEqual([]);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||
expect(documentAdd.mock.calls.some((call) => call[0] === 'visibilitychange')).toBe(false);
|
||||
expect(() => fixture.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it('cancels the scheduled frame and removes every listener on destroy', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(17);
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
const windowRemove = vi.spyOn(window, 'removeEventListener');
|
||||
const documentAdd = vi.spyOn(document, 'addEventListener');
|
||||
const documentRemove = vi.spyOn(document, 'removeEventListener');
|
||||
|
||||
const fixture = await createFixture();
|
||||
|
||||
expect(raf).toHaveBeenCalled();
|
||||
|
||||
const windowAdds = windowAdd.mock.calls.filter((call) =>
|
||||
['resize', 'mousemove', 'click'].includes(String(call[0])),
|
||||
);
|
||||
const documentAdds = documentAdd.mock.calls.filter((call) => call[0] === 'visibilitychange');
|
||||
|
||||
expect(windowAdds.length).toBeGreaterThan(0);
|
||||
expect(documentAdds.length).toBeGreaterThan(0);
|
||||
|
||||
fixture.destroy();
|
||||
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
for (const [type, handler] of windowAdds) {
|
||||
expect(windowRemove).toHaveBeenCalledWith(type, handler);
|
||||
}
|
||||
|
||||
for (const [type, handler] of documentAdds) {
|
||||
expect(documentRemove).toHaveBeenCalledWith(type, handler);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws a single static frame under reduced motion and skips pointer listeners', async () => {
|
||||
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||
const context = mockContext();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
expect(context.clearRect).toHaveBeenCalled();
|
||||
expect(scheduled).toEqual([]);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('registers no pointer listeners for a coarse pointer', async () => {
|
||||
mockMatchMedia((query) => query.includes('pointer: coarse'));
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(1);
|
||||
const windowAdd = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
await createFixture();
|
||||
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'mousemove')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'click')).toBe(false);
|
||||
expect(windowAdd.mock.calls.some((call) => call[0] === 'resize')).toBe(true);
|
||||
});
|
||||
|
||||
it('pauses the loop when the document is hidden and resumes when it is visible', async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(mockContext());
|
||||
const raf = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(21);
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame');
|
||||
|
||||
await createFixture();
|
||||
expect(raf).toHaveBeenCalled();
|
||||
raf.mockClear();
|
||||
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
expect(raf).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts with a bounded initial dot count on a large desktop viewport', async () => {
|
||||
const context = mockContext();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
const widthStub = vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2560);
|
||||
const heightStub = vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1440);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
|
||||
try {
|
||||
const fixture = await createFixture();
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
const arcCalls = vi.mocked(context.arc).mock.calls.length;
|
||||
expect(arcCalls).toBeGreaterThanOrEqual(6);
|
||||
expect(arcCalls).toBeLessThanOrEqual(24);
|
||||
fixture.destroy();
|
||||
} finally {
|
||||
widthStub.mockRestore();
|
||||
heightStub.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('redraws the static frame after resize under reduced motion without starting a loop', async () => {
|
||||
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||
const context = mockContext();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context);
|
||||
const scheduled: FrameRequestCallback[] = [];
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
});
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DotBackground],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(DotBackground);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
|
||||
const pending = scheduled.splice(0);
|
||||
for (const callback of pending) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
vi.mocked(context.clearRect).mockClear();
|
||||
vi.mocked(context.arc).mockClear();
|
||||
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
|
||||
const resizeFrames = scheduled.splice(0);
|
||||
for (const callback of resizeFrames) {
|
||||
callback(0);
|
||||
}
|
||||
|
||||
expect(context.clearRect).toHaveBeenCalled();
|
||||
expect(context.arc).toHaveBeenCalled();
|
||||
expect(scheduled).toEqual([]);
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,52 @@
|
||||
import {afterNextRender, Component, ElementRef, NgZone, OnDestroy, ViewChild} from '@angular/core';
|
||||
import {Dot} from '../../models/dot';
|
||||
import {DeviceDetectionService} from '../../service/device-detection-service';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
isBrowserPlatform,
|
||||
prefersCoarsePointer,
|
||||
prefersReducedMotion,
|
||||
} from '../../core/platform/browser';
|
||||
import { Dot } from '../../models/dot';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dot-background',
|
||||
imports: [],
|
||||
templateUrl: './dot-background.html',
|
||||
styleUrl: './dot-background.scss',
|
||||
host: {
|
||||
'aria-hidden': 'true',
|
||||
},
|
||||
})
|
||||
export class DotBackground implements OnDestroy {
|
||||
export class DotBackground {
|
||||
@ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||
|
||||
private ctx!: CanvasRenderingContext2D;
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly coarsePointer = prefersCoarsePointer();
|
||||
private readonly reducedMotion = prefersReducedMotion();
|
||||
|
||||
private ctx: CanvasRenderingContext2D | undefined;
|
||||
private dots: Dot[] = [];
|
||||
private mouse = { x: -1000, y: -1000 };
|
||||
private animationId = 0;
|
||||
private initialized = false;
|
||||
private resizeFrameId = 0;
|
||||
private loopActive = false;
|
||||
private tornDown = false;
|
||||
private readonly teardowns: Array<() => void> = [];
|
||||
|
||||
private readonly INIT_DOT_COUNT = 12;
|
||||
private readonly INITIAL_DOT_COUNT_MIN = 6;
|
||||
private readonly INITIAL_DOT_COUNT_MAX = 24;
|
||||
private readonly INITIAL_DOT_COUNT_MAX_COARSE = 12;
|
||||
private readonly INITIAL_DOT_AREA_DIVISOR = 130_000;
|
||||
private readonly MAX_DOT_COUNT = 100;
|
||||
private readonly MAX_DOT_COUNT_MOBILE = 40;
|
||||
private readonly COLORS = ['#6366f1', '#8b5cf6', '#a855f7', '#3b82f6'];
|
||||
@@ -26,44 +55,160 @@ export class DotBackground implements OnDestroy {
|
||||
private ballSpawnId = 0;
|
||||
private ballSpawnNextColor = 0;
|
||||
|
||||
constructor(private ngZone: NgZone, private mobileService: DeviceDetectionService) {
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
|
||||
afterNextRender(() => {
|
||||
this.init();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (!this.initialized) return;
|
||||
|
||||
cancelAnimationFrame(this.animationId);
|
||||
window.removeEventListener('resize', this.resize);
|
||||
window.removeEventListener('mousemove', this.onMouseMove);
|
||||
private view(): Window | null {
|
||||
return this.document.defaultView;
|
||||
}
|
||||
|
||||
private init() {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
this.ctx = canvas.getContext('2d')!;
|
||||
private init(): void {
|
||||
if (this.tornDown || !this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.view();
|
||||
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
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.listen(view, 'resize', this.onResize);
|
||||
this.listen(this.document, 'visibilitychange', this.onVisibilityChange);
|
||||
|
||||
this.initialized = true;
|
||||
this.ngZone.runOutsideAngular(() => this.animate());
|
||||
if (!this.reducedMotion && !this.coarsePointer) {
|
||||
this.listen(view, 'mousemove', this.onMouseMove);
|
||||
this.listen(view, 'click', this.onMouseClick);
|
||||
}
|
||||
|
||||
if (this.reducedMotion) {
|
||||
this.drawFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||
}
|
||||
|
||||
private resize = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
private listen(target: EventTarget, type: string, handler: EventListener): void {
|
||||
target.addEventListener(type, handler);
|
||||
this.teardowns.push(() => target.removeEventListener(type, handler));
|
||||
}
|
||||
|
||||
const dx = Math.abs(width - canvas.width) / width;
|
||||
const dy = Math.abs(height - canvas.height) / height;
|
||||
private requestFrame(callback: FrameRequestCallback): number {
|
||||
const view = this.view();
|
||||
return view ? view.requestAnimationFrame(callback) : 0;
|
||||
}
|
||||
|
||||
if (!this.mobileService.mobileCheck() || dy > 0.2 || dx > 0.05) {
|
||||
//sync canvas size to screen size
|
||||
private cancelFrame(id: number): void {
|
||||
const view = this.view();
|
||||
|
||||
if (!view || id === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
view.cancelAnimationFrame(id);
|
||||
}
|
||||
|
||||
private startLoop(): void {
|
||||
if (this.loopActive || this.reducedMotion || this.tornDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loopActive = true;
|
||||
this.scheduleAnimate();
|
||||
}
|
||||
|
||||
private stopLoop(): void {
|
||||
this.loopActive = false;
|
||||
this.cancelFrame(this.animationId);
|
||||
this.animationId = 0;
|
||||
}
|
||||
|
||||
private scheduleAnimate(): void {
|
||||
this.animationId = this.requestFrame(this.animate);
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
if (this.tornDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tornDown = true;
|
||||
this.stopLoop();
|
||||
this.cancelFrame(this.resizeFrameId);
|
||||
this.resizeFrameId = 0;
|
||||
|
||||
for (const dispose of this.teardowns) {
|
||||
dispose();
|
||||
}
|
||||
|
||||
this.teardowns.length = 0;
|
||||
}
|
||||
|
||||
private onResize = (): void => {
|
||||
if (this.resizeFrameId !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizeFrameId = this.requestFrame(() => {
|
||||
this.resizeFrameId = 0;
|
||||
this.resize();
|
||||
});
|
||||
};
|
||||
|
||||
private onVisibilityChange = (): void => {
|
||||
if (this.document.hidden) {
|
||||
this.stopLoop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.reducedMotion) {
|
||||
this.ngZone.runOutsideAngular(() => this.startLoop());
|
||||
}
|
||||
};
|
||||
|
||||
private resize = (): void => {
|
||||
const view = this.view();
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
if (!view || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = view.innerWidth;
|
||||
const height = view.innerHeight;
|
||||
|
||||
const dx = width === 0 ? 0 : Math.abs(width - canvas.width) / width;
|
||||
const dy = height === 0 ? 0 : Math.abs(height - canvas.height) / height;
|
||||
|
||||
if (!this.coarsePointer || dy > 0.2 || dx > 0.05) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
@@ -71,36 +216,57 @@ export class DotBackground implements OnDestroy {
|
||||
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));
|
||||
}
|
||||
|
||||
if (this.reducedMotion && this.ctx) {
|
||||
this.drawFrame();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseMove = (event: Event): void => {
|
||||
if (!(event instanceof MouseEvent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = this.view();
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
|
||||
private onMouseMove = (e: MouseEvent) => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
// map real res to canvas res
|
||||
this.mouse.x = e.clientX / window.innerWidth * canvas.width;
|
||||
this.mouse.y = e.clientY / window.innerHeight * canvas.height;
|
||||
if (!view || !canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mouse.x = (event.clientX / view.innerWidth) * canvas.width;
|
||||
this.mouse.y = (event.clientY / view.innerHeight) * canvas.height;
|
||||
};
|
||||
|
||||
private onMouseClick = () => {
|
||||
private onMouseClick = (): void => {
|
||||
const dot = this.spawnDot();
|
||||
dot.x = this.mouse.x;
|
||||
dot.y = this.mouse.y;
|
||||
};
|
||||
|
||||
private targetDotCount(width: number, height: number): number {
|
||||
const maxInitial = this.coarsePointer
|
||||
? this.INITIAL_DOT_COUNT_MAX_COARSE
|
||||
: this.INITIAL_DOT_COUNT_MAX;
|
||||
const area = Math.max(0, width) * Math.max(0, height);
|
||||
const fromArea = Math.round(area / this.INITIAL_DOT_AREA_DIVISOR);
|
||||
return Math.min(maxInitial, Math.max(this.INITIAL_DOT_COUNT_MIN, fromArea));
|
||||
}
|
||||
|
||||
private spawnDot(): Dot {
|
||||
const dotId = this.ballSpawnId++;
|
||||
const max_count = this.mobileService.mobileCheck() ? this.MAX_DOT_COUNT_MOBILE : this.MAX_DOT_COUNT;
|
||||
let dot;
|
||||
if (dotId < max_count) {
|
||||
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",
|
||||
color: '#000000',
|
||||
};
|
||||
|
||||
this.dots.push(dot);
|
||||
@@ -113,8 +279,8 @@ export class DotBackground implements OnDestroy {
|
||||
return dot;
|
||||
}
|
||||
|
||||
private populateDot(dot: Dot) {
|
||||
const {width, height} = this.canvasRef.nativeElement;
|
||||
private populateDot(dot: Dot): void {
|
||||
const { width, height } = this.canvasRef.nativeElement;
|
||||
|
||||
dot.x = Math.random() * width;
|
||||
dot.y = Math.random() * height;
|
||||
@@ -124,16 +290,34 @@ export class DotBackground implements OnDestroy {
|
||||
dot.color = this.COLORS[this.ballSpawnNextColor++ % this.COLORS.length];
|
||||
}
|
||||
|
||||
private initDots() {
|
||||
for (let i = 0; i < this.INIT_DOT_COUNT; i++) {
|
||||
private initDots(): void {
|
||||
const { width, height } = this.canvasRef.nativeElement;
|
||||
const count = this.targetDotCount(width, height);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
this.spawnDot();
|
||||
}
|
||||
}
|
||||
|
||||
private animate = () => {
|
||||
const canvas = this.canvasRef.nativeElement;
|
||||
private animate = (): void => {
|
||||
this.animationId = 0;
|
||||
this.drawFrame();
|
||||
|
||||
if (this.loopActive && !this.tornDown) {
|
||||
this.scheduleAnimate();
|
||||
}
|
||||
};
|
||||
|
||||
private drawFrame(): void {
|
||||
const canvas = this.canvasRef?.nativeElement;
|
||||
const ctx = this.ctx;
|
||||
|
||||
if (!canvas || !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;
|
||||
@@ -161,27 +345,24 @@ export class DotBackground implements OnDestroy {
|
||||
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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,43 +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;
|
||||
|
||||
:host-context(.desktop) &: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;
|
||||
|
||||
@@ -50,13 +34,23 @@
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
filter: grayscale(100%) brightness(0.8);
|
||||
transition: filter 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
:host-context(.desktop) &: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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ describe('SkillCard', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SkillCard]
|
||||
})
|
||||
.compileComponents();
|
||||
imports: [SkillCard],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SkillCard);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ describe('SkillsGrid', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SkillsGrid]
|
||||
})
|
||||
.compileComponents();
|
||||
imports: [SkillsGrid],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SkillsGrid);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
50
src/app/core/content/content.contracts.ts
Normal file
50
src/app/core/content/content.contracts.ts
Normal 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;
|
||||
}
|
||||
15
src/app/core/content/content.service.ts
Normal file
15
src/app/core/content/content.service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
5
src/app/core/content/content.token.ts
Normal file
5
src/app/core/content/content.token.ts
Normal 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');
|
||||
58
src/app/core/content/placeholder-content.ts
Normal file
58
src/app/core/content/placeholder-content.ts
Normal 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'),
|
||||
};
|
||||
49
src/app/core/content/shell-copy.ts
Normal file
49
src/app/core/content/shell-copy.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
60
src/app/core/content/signature-copy.spec.ts
Normal file
60
src/app/core/content/signature-copy.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { APP_LOCALES } from '../i18n/locale';
|
||||
import { COMMAND_IDS } from '../../shared/command-palette/commands';
|
||||
import { SIGNATURE_COPY } from './signature-copy';
|
||||
|
||||
function leafPaths(value: unknown, prefix = ''): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.keys(value).flatMap((key) => {
|
||||
const next = prefix.length > 0 ? `${prefix}.${key}` : key;
|
||||
return leafPaths((value as Record<string, unknown>)[key], next);
|
||||
});
|
||||
}
|
||||
|
||||
return [prefix];
|
||||
}
|
||||
|
||||
function collectStrings(value: unknown): string[] {
|
||||
if (typeof value === 'string') {
|
||||
return [value];
|
||||
}
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.values(value).flatMap((entry) => collectStrings(entry));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
describe('SIGNATURE_COPY', () => {
|
||||
it('exposes the same key structure in both locales', () => {
|
||||
const [first, ...rest] = APP_LOCALES.map((locale) => leafPaths(SIGNATURE_COPY[locale]));
|
||||
|
||||
for (const keys of rest) {
|
||||
expect(keys).toEqual(first);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps every string non-empty', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const value of collectStrings(SIGNATURE_COPY[locale])) {
|
||||
expect(value.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('describes every CommandId in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
|
||||
|
||||
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||
|
||||
for (const id of COMMAND_IDS) {
|
||||
expect(descriptions[id].trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
150
src/app/core/content/signature-copy.ts
Normal file
150
src/app/core/content/signature-copy.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
import { type CommandId } from '../../shared/command-palette/commands';
|
||||
|
||||
export interface SignatureCopy {
|
||||
readonly palette: {
|
||||
readonly triggerLabel: string;
|
||||
readonly shortcutHint: string;
|
||||
readonly dialogTitle: string;
|
||||
readonly dialogDescription: string;
|
||||
readonly inputLabel: string;
|
||||
readonly inputPlaceholder: string;
|
||||
readonly closeLabel: string;
|
||||
readonly suggestionsLabel: string;
|
||||
readonly outputLabel: string;
|
||||
readonly emptySuggestions: string;
|
||||
readonly unknownCommand: string;
|
||||
readonly helpIntro: string;
|
||||
readonly clearedMessage: string;
|
||||
readonly cvOpened: string;
|
||||
readonly navigating: string;
|
||||
readonly commandDescriptions: Record<CommandId, string>;
|
||||
readonly responses: {
|
||||
readonly brew: string;
|
||||
readonly ignite: string;
|
||||
readonly rev: string;
|
||||
};
|
||||
};
|
||||
readonly systemsMap: {
|
||||
readonly heading: string;
|
||||
readonly intro: string;
|
||||
readonly diagramDescription: string;
|
||||
readonly listHeading: string;
|
||||
readonly legendHeading: string;
|
||||
readonly relationshipLabel: string;
|
||||
readonly legend: {
|
||||
readonly ai: string;
|
||||
readonly cluster: string;
|
||||
readonly hardware: string;
|
||||
readonly software: string;
|
||||
readonly project: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
de: {
|
||||
palette: {
|
||||
triggerLabel: 'Befehle öffnen',
|
||||
shortcutHint: 'Strg+K',
|
||||
dialogTitle: 'Befehle',
|
||||
dialogDescription:
|
||||
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
||||
inputLabel: 'Befehl',
|
||||
inputPlaceholder: 'Befehl eingeben',
|
||||
closeLabel: 'Schließen',
|
||||
suggestionsLabel: 'Vorschläge',
|
||||
outputLabel: 'Ausgabe',
|
||||
emptySuggestions: 'Keine passenden Befehle.',
|
||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||
helpIntro: 'Verfügbare Befehle:',
|
||||
clearedMessage: 'Ausgabe geleert.',
|
||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||
navigating: 'Wechsel zu {target}.',
|
||||
commandDescriptions: {
|
||||
help: 'Listet die verfügbaren Befehle.',
|
||||
projects: 'Öffnet die Projektübersicht.',
|
||||
servicesAi: 'Öffnet die Seite zur KI-Integration.',
|
||||
cv: 'Öffnet den Lebenslauf als PDF.',
|
||||
contact: 'Öffnet die Kontaktseite.',
|
||||
brew: 'Eine kurze, spielerische Rückmeldung.',
|
||||
ignite: 'Eine kurze, spielerische Rückmeldung.',
|
||||
rev: 'Eine kurze, spielerische Rückmeldung.',
|
||||
clear: 'Leert die Ausgabe.',
|
||||
close: 'Schließt die Befehlsübersicht.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
||||
ignite: 'Zündung frei. Die Systeme laufen warm.',
|
||||
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||
},
|
||||
},
|
||||
systemsMap: {
|
||||
heading: 'Systemkarte',
|
||||
intro:
|
||||
'Wie die technischen Schichten zusammenhängen — von Hardware bis zu den Projektfeldern.',
|
||||
diagramDescription: 'Diagramm der technischen Schichten und ihrer Verbindungen.',
|
||||
listHeading: 'Knoten als Liste',
|
||||
legendHeading: 'Legende',
|
||||
relationshipLabel: 'Verbunden mit {targets}.',
|
||||
legend: {
|
||||
ai: 'KI-Integration',
|
||||
cluster: 'Cluster',
|
||||
hardware: 'Hardware und Netz',
|
||||
software: 'Software',
|
||||
project: 'Projektfeld',
|
||||
},
|
||||
},
|
||||
},
|
||||
en: {
|
||||
palette: {
|
||||
triggerLabel: 'Open commands',
|
||||
shortcutHint: 'Ctrl+K',
|
||||
dialogTitle: 'Commands',
|
||||
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
||||
inputLabel: 'Command',
|
||||
inputPlaceholder: 'Type a command',
|
||||
closeLabel: 'Close',
|
||||
suggestionsLabel: 'Suggestions',
|
||||
outputLabel: 'Output',
|
||||
emptySuggestions: 'No matching commands.',
|
||||
unknownCommand: 'Unknown command: {command}',
|
||||
helpIntro: 'Available commands:',
|
||||
clearedMessage: 'Output cleared.',
|
||||
cvOpened: 'Opened the CV in a new tab.',
|
||||
navigating: 'Going to {target}.',
|
||||
commandDescriptions: {
|
||||
help: 'Lists the available commands.',
|
||||
projects: 'Opens the projects overview.',
|
||||
servicesAi: 'Opens the AI integration page.',
|
||||
cv: 'Opens the CV as a PDF.',
|
||||
contact: 'Opens the contact page.',
|
||||
brew: 'A short playful acknowledgement.',
|
||||
ignite: 'A short playful acknowledgement.',
|
||||
rev: 'A short playful acknowledgement.',
|
||||
clear: 'Clears the output.',
|
||||
close: 'Closes the command palette.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||
ignite: 'Ignition on. Systems are warming up.',
|
||||
rev: 'Revs climbing, the content stays calm.',
|
||||
},
|
||||
},
|
||||
systemsMap: {
|
||||
heading: 'Systems map',
|
||||
intro: 'How the technical layers connect — from hardware through to the project fields.',
|
||||
diagramDescription: 'Diagram of the technical layers and their connections.',
|
||||
listHeading: 'Nodes as a list',
|
||||
legendHeading: 'Legend',
|
||||
relationshipLabel: 'Connected to {targets}.',
|
||||
legend: {
|
||||
ai: 'AI integration',
|
||||
cluster: 'Cluster',
|
||||
hardware: 'Hardware and network',
|
||||
software: 'Software',
|
||||
project: 'Project field',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
7
src/app/core/content/site-config.ts
Normal file
7
src/app/core/content/site-config.ts
Normal 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;
|
||||
11
src/app/core/i18n/locale.resolver.ts
Normal file
11
src/app/core/i18n/locale.resolver.ts
Normal 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;
|
||||
};
|
||||
25
src/app/core/i18n/locale.service.ts
Normal file
25
src/app/core/i18n/locale.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
44
src/app/core/i18n/locale.spec.ts
Normal file
44
src/app/core/i18n/locale.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
18
src/app/core/i18n/locale.ts
Normal file
18
src/app/core/i18n/locale.ts
Normal 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';
|
||||
}
|
||||
43
src/app/core/navigation/navigation.service.spec.ts
Normal file
43
src/app/core/navigation/navigation.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
77
src/app/core/navigation/navigation.service.ts
Normal file
77
src/app/core/navigation/navigation.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
68
src/app/core/navigation/navigation.ts
Normal file
68
src/app/core/navigation/navigation.ts
Normal 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' },
|
||||
},
|
||||
];
|
||||
23
src/app/core/platform/browser.spec.ts
Normal file
23
src/app/core/platform/browser.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/app/core/platform/browser.ts
Normal file
26
src/app/core/platform/browser.ts
Normal 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;
|
||||
}
|
||||
16
src/app/core/routing/app-route-data.ts
Normal file
16
src/app/core/routing/app-route-data.ts
Normal 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']);
|
||||
}
|
||||
30
src/app/core/routing/route-ids.ts
Normal file
30
src/app/core/routing/route-ids.ts
Normal 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',
|
||||
];
|
||||
46
src/app/core/routing/route-paths.spec.ts
Normal file
46
src/app/core/routing/route-paths.spec.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
83
src/app/core/routing/route-paths.ts
Normal file
83
src/app/core/routing/route-paths.ts
Normal 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)));
|
||||
}
|
||||
3
src/app/features/about/about.html
Normal file
3
src/app/features/about/about.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/about/about.ts
Normal file
13
src/app/features/about/about.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/contact/contact.html
Normal file
3
src/app/features/contact/contact.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/contact/contact.ts
Normal file
13
src/app/features/contact/contact.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/home/home.html
Normal file
3
src/app/features/home/home.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/home/home.ts
Normal file
13
src/app/features/home/home.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/imprint/imprint.html
Normal file
3
src/app/features/imprint/imprint.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/imprint/imprint.ts
Normal file
13
src/app/features/imprint/imprint.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/not-found/not-found.html
Normal file
3
src/app/features/not-found/not-found.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/not-found/not-found.ts
Normal file
13
src/app/features/not-found/not-found.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/privacy/privacy.html
Normal file
3
src/app/features/privacy/privacy.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/privacy/privacy.ts
Normal file
13
src/app/features/privacy/privacy.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/projects/projects.html
Normal file
3
src/app/features/projects/projects.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/projects/projects.ts
Normal file
13
src/app/features/projects/projects.ts
Normal 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');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/services/ai-integration/ai-integration.ts
Normal file
13
src/app/features/services/ai-integration/ai-integration.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/services/clusters/clusters.html
Normal file
3
src/app/features/services/clusters/clusters.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/services/clusters/clusters.ts
Normal file
13
src/app/features/services/clusters/clusters.ts
Normal 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');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
3
src/app/features/services/services.html
Normal file
3
src/app/features/services/services.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/services/services.ts
Normal file
13
src/app/features/services/services.ts
Normal 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');
|
||||
}
|
||||
3
src/app/features/services/software/software.html
Normal file
3
src/app/features/services/software/software.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (page(); as copy) {
|
||||
<app-page-placeholder [page]="copy" />
|
||||
}
|
||||
13
src/app/features/services/software/software.ts
Normal file
13
src/app/features/services/software/software.ts
Normal 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');
|
||||
}
|
||||
1
src/app/features/stack/stack.html
Normal file
1
src/app/features/stack/stack.html
Normal file
@@ -0,0 +1 @@
|
||||
<app-skills />
|
||||
10
src/app/features/stack/stack.ts
Normal file
10
src/app/features/stack/stack.ts
Normal 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 {}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Skill} from './skill';
|
||||
import { Skill } from './skill';
|
||||
|
||||
export interface SkillCategory {
|
||||
title: string;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DeviceDetectionService } from './device-detection-service';
|
||||
|
||||
describe('DeviceDetectionService', () => {
|
||||
let service: DeviceDetectionService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(DeviceDetectionService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DeviceDetectionService {
|
||||
public mobileCheck() {
|
||||
let check = false;
|
||||
(function (a) {
|
||||
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4)))
|
||||
check = true
|
||||
})(navigator.userAgent || navigator.vendor || (window as any).opera);
|
||||
return check;
|
||||
};
|
||||
}
|
||||
86
src/app/shared/command-palette/command-palette.html
Normal file
86
src/app/shared/command-palette/command-palette.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="command-palette-trigger"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="dialogId"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ copy().shortcutHint }}</kbd>
|
||||
</button>
|
||||
|
||||
@if (open()) {
|
||||
<div class="command-palette-overlay">
|
||||
<div class="command-palette-scrim" aria-hidden="true"></div>
|
||||
<div
|
||||
#dialog
|
||||
class="command-palette-dialog glass-surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[id]="dialogId"
|
||||
[attr.aria-labelledby]="titleId"
|
||||
[attr.aria-describedby]="descriptionId"
|
||||
(keydown)="onDialogKeydown($event, dialog)"
|
||||
>
|
||||
<div class="command-palette-header">
|
||||
<div>
|
||||
<h2 [id]="titleId" class="command-palette-title">{{ copy().dialogTitle }}</h2>
|
||||
<p [id]="descriptionId" class="command-palette-description">
|
||||
{{ copy().dialogDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="command-palette-close" (click)="closePalette()">
|
||||
{{ copy().closeLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="command-palette-form" (submit)="onSubmit($event)">
|
||||
<label [attr.for]="inputId">{{ copy().inputLabel }}</label>
|
||||
<input
|
||||
#commandInput
|
||||
[id]="inputId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
[attr.placeholder]="copy().inputPlaceholder"
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
/>
|
||||
|
||||
<p class="command-palette-kicker" [id]="suggestionsId">{{ copy().suggestionsLabel }}</p>
|
||||
@if (suggestions().length === 0) {
|
||||
<p class="command-palette-empty">{{ copy().emptySuggestions }}</p>
|
||||
} @else {
|
||||
<ul class="command-palette-suggestions" [attr.aria-labelledby]="suggestionsId">
|
||||
@for (command of suggestions(); track command.id) {
|
||||
<li>
|
||||
<button type="button" (click)="runDefinition(command)">
|
||||
<span>{{ command.input }}</span>
|
||||
<span>{{ copy().commandDescriptions[command.id] }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</form>
|
||||
|
||||
<div
|
||||
class="command-palette-output"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
[attr.aria-label]="copy().outputLabel"
|
||||
>
|
||||
<ul>
|
||||
@for (line of output(); track line.id) {
|
||||
<li>{{ line.text }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
168
src/app/shared/command-palette/command-palette.scss
Normal file
168
src/app/shared/command-palette/command-palette.scss
Normal file
@@ -0,0 +1,168 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-palette-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-trigger kbd {
|
||||
padding: 0.1rem var(--space-2);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.command-palette-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-surface);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.command-palette-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
width: min(100%, 40rem);
|
||||
max-height: min(36rem, 90vh);
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.command-palette-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.command-palette-title,
|
||||
.command-palette-description,
|
||||
.command-palette-kicker,
|
||||
.command-palette-empty,
|
||||
.command-palette-output ul,
|
||||
.command-palette-suggestions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.command-palette-title {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.command-palette-description,
|
||||
.command-palette-empty,
|
||||
.command-palette-output {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.command-palette-close {
|
||||
min-height: 2.75rem;
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.command-palette-form input {
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-kicker {
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.command-palette-suggestions,
|
||||
.command-palette-output ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.command-palette-suggestions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.command-palette-suggestions button {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.command-palette-suggestions button span:first-child {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.command-palette-suggestions button span:last-child,
|
||||
.command-palette-output {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.command-palette-output {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-trigger:hover,
|
||||
.command-palette-close:hover,
|
||||
.command-palette-suggestions button:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.command-palette-overlay,
|
||||
.command-palette-dialog {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
277
src/app/shared/command-palette/command-palette.spec.ts
Normal file
277
src/app/shared/command-palette/command-palette.spec.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { CommandPalette } from './command-palette';
|
||||
import { COMMAND_IDS, COMMANDS } from './commands';
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createFixture(
|
||||
locale: AppLocale = 'de',
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<CommandPalette>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommandPalette],
|
||||
providers: [provideRouter([]), ...extraProviders],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(LocaleService).setLocale(locale);
|
||||
const fixture = TestBed.createComponent(CommandPalette);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function trigger(fixture: ComponentFixture<CommandPalette>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
||||
}
|
||||
|
||||
function dialog(fixture: ComponentFixture<CommandPalette>): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('[role="dialog"]');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||
trigger(fixture).click();
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
async function submitQuery(
|
||||
fixture: ComponentFixture<CommandPalette>,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.nativeElement
|
||||
.querySelector('form')
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
function outputText(fixture: ComponentFixture<CommandPalette>): string {
|
||||
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
|
||||
}
|
||||
|
||||
function focusable(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((element) => element.tabIndex >= 0);
|
||||
}
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'renders the trigger and not the dialog on the server (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(trigger(fixture)).toBeTruthy();
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(dialog(fixture)).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'opens from Ctrl+K and Meta+K and prevents the default (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await flush(fixture);
|
||||
|
||||
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
||||
const openDialog = dialog(fixture);
|
||||
if (openDialog) {
|
||||
openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifier,
|
||||
});
|
||||
const prevent = vi.spyOn(event, 'preventDefault');
|
||||
document.dispatchEvent(event);
|
||||
await flush(fixture);
|
||||
|
||||
expect(prevent).toHaveBeenCalled();
|
||||
expect(dialog(fixture)).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const openDialog = dialog(fixture);
|
||||
expect(openDialog).toBeTruthy();
|
||||
|
||||
openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const closeButton = fixture.nativeElement.querySelector('.command-palette-close');
|
||||
closeButton.click();
|
||||
await flush(fixture);
|
||||
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const openDialog = dialog(fixture);
|
||||
expect(openDialog).toBeTruthy();
|
||||
|
||||
const items = focusable(openDialog as HTMLElement);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
|
||||
last.focus();
|
||||
openDialog?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
first.focus();
|
||||
openDialog?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const copy = SIGNATURE_COPY[locale].palette;
|
||||
|
||||
await submitQuery(fixture, 'help');
|
||||
const helpText = outputText(fixture);
|
||||
expect(helpText).toContain(copy.helpIntro);
|
||||
for (const command of COMMANDS) {
|
||||
expect(helpText).toContain(command.input);
|
||||
expect(helpText).toContain(copy.commandDescriptions[command.id]);
|
||||
}
|
||||
expect(COMMAND_IDS.every((id) => helpText.includes(copy.commandDescriptions[id]))).toBe(true);
|
||||
|
||||
await submitQuery(fixture, 'brew');
|
||||
expect(outputText(fixture)).toContain(copy.responses.brew);
|
||||
await submitQuery(fixture, 'ignite');
|
||||
expect(outputText(fixture)).toContain(copy.responses.ignite);
|
||||
await submitQuery(fixture, 'rev');
|
||||
expect(outputText(fixture)).toContain(copy.responses.rev);
|
||||
|
||||
await submitQuery(fixture, 'clear');
|
||||
expect(outputText(fixture).trim()).toBe(copy.clearedMessage);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'navigates projects, services ai and contact through NavigationService (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigation = TestBed.inject(NavigationService);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'projects');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('projects'));
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'services ai');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('servicesAi'));
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'contact');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('contact'));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('opens the CV in a new tab and never navigates (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'cv');
|
||||
|
||||
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(outputText(fixture)).toContain(SIGNATURE_COPY[locale].palette.cvOpened);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'reports unknown input without navigating or opening a window (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
const raw = 'rm -rf /';
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, raw);
|
||||
|
||||
expect(outputText(fixture)).toContain(
|
||||
SIGNATURE_COPY[locale].palette.unknownCommand.replace('{command}', raw),
|
||||
);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('removes the document keydown listener on destroy', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
const fixture = await createFixture();
|
||||
await flush(fixture);
|
||||
|
||||
const added = add.mock.calls.find((call) => call[0] === 'keydown');
|
||||
expect(added).toBeTruthy();
|
||||
|
||||
const remove = vi.spyOn(document, 'removeEventListener');
|
||||
fixture.destroy();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
||||
});
|
||||
|
||||
it('registers no document listener on the server', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
||||
});
|
||||
});
|
||||
272
src/app/shared/command-palette/command-palette.ts
Normal file
272
src/app/shared/command-palette/command-palette.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { isBrowserPlatform } from '../../core/platform/browser';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
parseCommand,
|
||||
suggestCommands,
|
||||
type CommandDefinition,
|
||||
} from './commands';
|
||||
|
||||
let commandPaletteInstanceId = 0;
|
||||
let commandPaletteOutputId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-command-palette',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './command-palette.html',
|
||||
styleUrl: './command-palette.scss',
|
||||
})
|
||||
export class CommandPalette {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly instanceId = commandPaletteInstanceId++;
|
||||
|
||||
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||
|
||||
protected readonly dialogId = `command-palette-dialog-${this.instanceId}`;
|
||||
protected readonly titleId = `command-palette-title-${this.instanceId}`;
|
||||
protected readonly descriptionId = `command-palette-description-${this.instanceId}`;
|
||||
protected readonly inputId = `command-palette-input-${this.instanceId}`;
|
||||
protected readonly suggestionsId = `command-palette-suggestions-${this.instanceId}`;
|
||||
|
||||
protected readonly open = signal(false);
|
||||
protected readonly query = signal('');
|
||||
protected readonly output = signal<readonly { id: number; text: string }[]>([]);
|
||||
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
|
||||
protected readonly suggestions = computed(() =>
|
||||
suggestCommands(this.query(), this.localeService.locale()),
|
||||
);
|
||||
|
||||
private opener: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
});
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
this.triggerRef()?.nativeElement.focus();
|
||||
this.openPalette();
|
||||
}
|
||||
|
||||
protected onQueryInput(event: Event): void {
|
||||
const target = event.target;
|
||||
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.query.set(target.value);
|
||||
}
|
||||
|
||||
protected onSubmit(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.runRaw(this.query());
|
||||
}
|
||||
|
||||
protected onDialogKeydown(event: KeyboardEvent, dialog: HTMLElement): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = this.focusableElements(dialog);
|
||||
|
||||
if (focusable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = this.document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected runDefinition(definition: CommandDefinition): void {
|
||||
const copy = this.copy();
|
||||
|
||||
switch (definition.action.kind) {
|
||||
case 'navigate': {
|
||||
void this.router.navigate(this.navigation.link(definition.action.routeId));
|
||||
this.append(copy.navigating.replace('{target}', definition.input));
|
||||
this.closePalette();
|
||||
return;
|
||||
}
|
||||
case 'openCv': {
|
||||
if (this.isBrowser) {
|
||||
this.document.defaultView?.open(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
this.append(copy.cvOpened);
|
||||
return;
|
||||
}
|
||||
case 'message': {
|
||||
this.appendMessage(definition.action.messageKey);
|
||||
return;
|
||||
}
|
||||
case 'clear': {
|
||||
this.output.set([]);
|
||||
this.append(copy.clearedMessage);
|
||||
return;
|
||||
}
|
||||
case 'close': {
|
||||
this.closePalette();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runRaw(raw: string): void {
|
||||
const match = parseCommand(raw);
|
||||
|
||||
if (match.kind === 'empty') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.kind === 'unknown') {
|
||||
this.append(this.copy().unknownCommand.replace('{command}', match.input));
|
||||
return;
|
||||
}
|
||||
|
||||
this.runDefinition(match.definition);
|
||||
}
|
||||
|
||||
private appendMessage(key: 'help' | 'brew' | 'ignite' | 'rev'): void {
|
||||
const copy = this.copy();
|
||||
|
||||
if (key === 'help') {
|
||||
const lines = [
|
||||
copy.helpIntro,
|
||||
...COMMAND_IDS.map((id) => {
|
||||
const command = COMMANDS.find((entry) => entry.id === id);
|
||||
const input = command?.input ?? id;
|
||||
return `${input} — ${copy.commandDescriptions[id]}`;
|
||||
}),
|
||||
];
|
||||
this.append(lines.join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.append(copy.responses[key]);
|
||||
}
|
||||
|
||||
private append(text: string): void {
|
||||
this.output.update((lines) => [...lines, { id: commandPaletteOutputId++, text }]);
|
||||
}
|
||||
|
||||
private openPalette(): void {
|
||||
if (this.open()) {
|
||||
this.focusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.document.activeElement;
|
||||
this.opener = active instanceof HTMLElement ? active : null;
|
||||
this.open.set(true);
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
protected closePalette(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open.set(false);
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
private focusInput(): void {
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.inputRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||
opener.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private focusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
79
src/app/shared/command-palette/commands.spec.ts
Normal file
79
src/app/shared/command-palette/commands.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { APP_LOCALES } from '../../core/i18n/locale';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
normalizeCommandInput,
|
||||
parseCommand,
|
||||
suggestCommands,
|
||||
type CommandId,
|
||||
} from './commands';
|
||||
|
||||
describe('command parsing', () => {
|
||||
it('collapses whitespace and lowercases input', () => {
|
||||
expect(normalizeCommandInput(' Services AI ')).toBe('services ai');
|
||||
expect(normalizeCommandInput('HELP')).toBe('help');
|
||||
});
|
||||
|
||||
it('resolves every CommandId from its canonical input and aliases in both locales', () => {
|
||||
for (const command of COMMANDS) {
|
||||
const canonical = parseCommand(command.input);
|
||||
expect(canonical).toEqual({ kind: 'command', definition: command });
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const alias of command.aliases[locale]) {
|
||||
expect(parseCommand(alias)).toEqual({ kind: 'command', definition: command });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(COMMANDS.map((command) => command.id)).toEqual([...COMMAND_IDS]);
|
||||
});
|
||||
|
||||
it('treats hostile and prototype inputs as unknown without side effects', () => {
|
||||
const prototypeNames = Object.getOwnPropertyNames(Object.prototype);
|
||||
const hostile = [
|
||||
'rm -rf /',
|
||||
'eval(1+1)',
|
||||
'new Function()',
|
||||
'<script>alert(1)</script>',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'toString',
|
||||
'hasOwnProperty',
|
||||
];
|
||||
|
||||
for (const input of hostile) {
|
||||
expect(parseCommand(input)).toEqual({ kind: 'unknown', input });
|
||||
}
|
||||
|
||||
expect(parseCommand('')).toEqual({ kind: 'empty' });
|
||||
expect(parseCommand(' ')).toEqual({ kind: 'empty' });
|
||||
expect(Object.getOwnPropertyNames(Object.prototype)).toEqual(prototypeNames);
|
||||
expect(Object.prototype).not.toHaveProperty('polluted');
|
||||
});
|
||||
|
||||
it('returns an equal result for the same input across repeated calls', () => {
|
||||
const samples = ['help', 'services ai', ' ', 'unknown-token', '__proto__'];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(parseCommand(sample)).toEqual(parseCommand(sample));
|
||||
}
|
||||
});
|
||||
|
||||
it('suggests commands in a stable COMMAND_IDS order and filters by prefix', () => {
|
||||
const emptyDe = suggestCommands('', 'de').map((command) => command.id);
|
||||
const emptyEn = suggestCommands('', 'en').map((command) => command.id);
|
||||
const expectedIds: CommandId[] = [...COMMAND_IDS];
|
||||
|
||||
expect(emptyDe).toEqual(expectedIds);
|
||||
expect(emptyEn).toEqual(expectedIds);
|
||||
expect(suggestCommands('c', 'en').map((command) => command.id)).toEqual([
|
||||
'cv',
|
||||
'contact',
|
||||
'clear',
|
||||
'close',
|
||||
]);
|
||||
expect(suggestCommands('lei', 'de').map((command) => command.id)).toEqual(['servicesAi']);
|
||||
expect(suggestCommands('c', 'de')).toEqual(suggestCommands('c', 'de'));
|
||||
});
|
||||
});
|
||||
167
src/app/shared/command-palette/commands.ts
Normal file
167
src/app/shared/command-palette/commands.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
export type CommandId =
|
||||
| 'help'
|
||||
| 'projects'
|
||||
| 'servicesAi'
|
||||
| 'cv'
|
||||
| 'contact'
|
||||
| 'brew'
|
||||
| 'ignite'
|
||||
| 'rev'
|
||||
| 'clear'
|
||||
| 'close';
|
||||
|
||||
export const COMMAND_IDS: readonly CommandId[] = [
|
||||
'help',
|
||||
'projects',
|
||||
'servicesAi',
|
||||
'cv',
|
||||
'contact',
|
||||
'brew',
|
||||
'ignite',
|
||||
'rev',
|
||||
'clear',
|
||||
'close',
|
||||
];
|
||||
|
||||
export type CommandAction =
|
||||
| { readonly kind: 'navigate'; readonly routeId: RouteId }
|
||||
| { readonly kind: 'openCv' }
|
||||
| { readonly kind: 'message'; readonly messageKey: 'help' | 'brew' | 'ignite' | 'rev' }
|
||||
| { readonly kind: 'clear' }
|
||||
| { readonly kind: 'close' };
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly id: CommandId;
|
||||
readonly input: string;
|
||||
readonly aliases: Record<AppLocale, readonly string[]>;
|
||||
readonly action: CommandAction;
|
||||
}
|
||||
|
||||
export type CommandMatch =
|
||||
| { readonly kind: 'command'; readonly definition: CommandDefinition }
|
||||
| { readonly kind: 'empty' }
|
||||
| { readonly kind: 'unknown'; readonly input: string };
|
||||
|
||||
export const COMMANDS: readonly CommandDefinition[] = [
|
||||
{
|
||||
id: 'help',
|
||||
input: 'help',
|
||||
aliases: { de: ['hilfe'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'help' },
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
input: 'projects',
|
||||
aliases: { de: ['projekte'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'projects' },
|
||||
},
|
||||
{
|
||||
id: 'servicesAi',
|
||||
input: 'services ai',
|
||||
aliases: { de: ['leistungen ai'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'servicesAi' },
|
||||
},
|
||||
{
|
||||
id: 'cv',
|
||||
input: 'cv',
|
||||
aliases: { de: ['lebenslauf'], en: ['resume'] },
|
||||
action: { kind: 'openCv' },
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
input: 'contact',
|
||||
aliases: { de: ['kontakt'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'contact' },
|
||||
},
|
||||
{
|
||||
id: 'brew',
|
||||
input: 'brew',
|
||||
aliases: { de: ['brauen'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'brew' },
|
||||
},
|
||||
{
|
||||
id: 'ignite',
|
||||
input: 'ignite',
|
||||
aliases: { de: ['zuenden', 'zünden'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'ignite' },
|
||||
},
|
||||
{
|
||||
id: 'rev',
|
||||
input: 'rev',
|
||||
aliases: { de: ['drehzahl'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'rev' },
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
input: 'clear',
|
||||
aliases: { de: ['leeren'], en: [] },
|
||||
action: { kind: 'clear' },
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
input: 'close',
|
||||
aliases: { de: ['schliessen', 'schließen'], en: [] },
|
||||
action: { kind: 'close' },
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_LOOKUP = buildCommandLookup(COMMANDS);
|
||||
|
||||
function buildCommandLookup(
|
||||
commands: readonly CommandDefinition[],
|
||||
): ReadonlyMap<string, CommandDefinition> {
|
||||
const lookup = new Map<string, CommandDefinition>();
|
||||
|
||||
for (const command of commands) {
|
||||
lookup.set(normalizeCommandInput(command.input), command);
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const alias of command.aliases[locale]) {
|
||||
lookup.set(normalizeCommandInput(alias), command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export function normalizeCommandInput(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function parseCommand(raw: string): CommandMatch {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
const definition = COMMAND_LOOKUP.get(normalized);
|
||||
|
||||
if (!definition) {
|
||||
return { kind: 'unknown', input: raw };
|
||||
}
|
||||
|
||||
return { kind: 'command', definition };
|
||||
}
|
||||
|
||||
export function suggestCommands(raw: string, locale: AppLocale): readonly CommandDefinition[] {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
return COMMANDS.filter((command) => {
|
||||
if (normalized.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command.input.startsWith(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return command.aliases[locale].some((alias) =>
|
||||
normalizeCommandInput(alias).startsWith(normalized),
|
||||
);
|
||||
});
|
||||
}
|
||||
23
src/app/shared/motion/_reveal.scss
Normal file
23
src/app/shared/motion/_reveal.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
@mixin reveal-target {
|
||||
&.reveal-pending {
|
||||
opacity: 0;
|
||||
transform: translateY(0.5rem);
|
||||
transition:
|
||||
opacity var(--duration-base) var(--ease-standard),
|
||||
transform var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
&.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&.reveal-pending,
|
||||
&.is-revealed {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/app/shared/motion/metric-bar/metric-bar.html
Normal file
20
src/app/shared/motion/metric-bar/metric-bar.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<div class="metric-bar">
|
||||
<div class="metric-bar-header">
|
||||
<span [id]="labelId">{{ label() }}</span>
|
||||
<span>{{ displayValue() }}</span>
|
||||
</div>
|
||||
@if (description(); as descriptionText) {
|
||||
<p class="metric-bar-description">{{ descriptionText }}</p>
|
||||
}
|
||||
<div
|
||||
class="metric-bar-track"
|
||||
role="progressbar"
|
||||
[attr.aria-valuenow]="clampedValue()"
|
||||
aria-valuemin="0"
|
||||
[attr.aria-valuemax]="max()"
|
||||
[attr.aria-valuetext]="displayValue()"
|
||||
[attr.aria-labelledby]="labelId"
|
||||
>
|
||||
<div class="metric-bar-fill" [style.width.%]="percent()"></div>
|
||||
</div>
|
||||
</div>
|
||||
44
src/app/shared/motion/metric-bar/metric-bar.scss
Normal file
44
src/app/shared/motion/metric-bar/metric-bar.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
@use 'app/shared/motion/reveal' as reveal;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metric-bar {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
@include reveal.reveal-target;
|
||||
}
|
||||
|
||||
.metric-bar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.metric-bar-description {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.metric-bar-track {
|
||||
height: 0.5rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.metric-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--color-accent);
|
||||
transition: width var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metric-bar-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
95
src/app/shared/motion/metric-bar/metric-bar.spec.ts
Normal file
95
src/app/shared/motion/metric-bar/metric-bar.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MetricBar } from './metric-bar';
|
||||
|
||||
describe('MetricBar', () => {
|
||||
async function createFixture(inputs: {
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
valueText?: string;
|
||||
description?: string;
|
||||
}): Promise<ComponentFixture<MetricBar>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MetricBar],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(MetricBar);
|
||||
fixture.componentRef.setInput('label', inputs.label);
|
||||
fixture.componentRef.setInput('value', inputs.value);
|
||||
|
||||
if (inputs.max !== undefined) {
|
||||
fixture.componentRef.setInput('max', inputs.max);
|
||||
}
|
||||
|
||||
if (inputs.valueText !== undefined) {
|
||||
fixture.componentRef.setInput('valueText', inputs.valueText);
|
||||
}
|
||||
|
||||
if (inputs.description !== undefined) {
|
||||
fixture.componentRef.setInput('description', inputs.description);
|
||||
}
|
||||
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it('renders the label and value as visible text', async () => {
|
||||
const fixture = await createFixture({
|
||||
label: 'Coverage',
|
||||
value: 40,
|
||||
valueText: '40 of 80',
|
||||
});
|
||||
const text = fixture.nativeElement.textContent ?? '';
|
||||
|
||||
expect(text).toContain('Coverage');
|
||||
expect(text).toContain('40 of 80');
|
||||
});
|
||||
|
||||
it('exposes progressbar semantics and an accessible name through aria-labelledby', async () => {
|
||||
const fixture = await createFixture({
|
||||
label: 'Latency',
|
||||
value: 25,
|
||||
max: 50,
|
||||
valueText: '25 ms',
|
||||
});
|
||||
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||
const labelId = bar.getAttribute('aria-labelledby');
|
||||
const label = fixture.nativeElement.querySelector(`#${labelId}`);
|
||||
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('25');
|
||||
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('50');
|
||||
expect(bar.getAttribute('aria-valuetext')).toBe('25 ms');
|
||||
expect(label?.textContent?.trim()).toBe('Latency');
|
||||
});
|
||||
|
||||
it('clamps values below 0 and above max', async () => {
|
||||
const low = await createFixture({ label: 'Low', value: -12, max: 10 });
|
||||
const lowBar = low.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||
const lowFill = low.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
|
||||
|
||||
expect(lowBar.getAttribute('aria-valuenow')).toBe('0');
|
||||
expect(lowFill.style.width).toBe('0%');
|
||||
|
||||
const high = await createFixture({ label: 'High', value: 140, max: 50 });
|
||||
const highBar = high.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||
const highFill = high.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
|
||||
|
||||
expect(highBar.getAttribute('aria-valuenow')).toBe('50');
|
||||
expect(highFill.style.width).toBe('100%');
|
||||
});
|
||||
|
||||
it('does not produce NaN when max is 0', async () => {
|
||||
const fixture = await createFixture({ label: 'Empty', value: 8, max: 0 });
|
||||
const bar = fixture.nativeElement.querySelector('[role="progressbar"]') as HTMLElement;
|
||||
const fill = fixture.nativeElement.querySelector('.metric-bar-fill') as HTMLElement;
|
||||
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('0');
|
||||
expect(fill.style.width).toBe('0%');
|
||||
expect(fill.style.width).not.toContain('NaN');
|
||||
expect(fixture.nativeElement.textContent).toContain('Empty');
|
||||
expect(fixture.nativeElement.textContent).toContain('0');
|
||||
});
|
||||
});
|
||||
47
src/app/shared/motion/metric-bar/metric-bar.ts
Normal file
47
src/app/shared/motion/metric-bar/metric-bar.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
|
||||
let metricBarInstanceId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-metric-bar',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './metric-bar.html',
|
||||
styleUrl: './metric-bar.scss',
|
||||
})
|
||||
export class MetricBar {
|
||||
readonly label = input.required<string>();
|
||||
readonly value = input.required<number>();
|
||||
readonly max = input(100);
|
||||
readonly valueText = input<string | undefined>(undefined);
|
||||
readonly description = input<string | undefined>(undefined);
|
||||
|
||||
private readonly instanceId = metricBarInstanceId++;
|
||||
protected readonly labelId = `metric-bar-label-${this.instanceId}`;
|
||||
|
||||
protected readonly clampedValue = computed(() => {
|
||||
const max = this.max();
|
||||
const value = this.value();
|
||||
|
||||
if (!Number.isFinite(max) || max <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(max, Math.max(0, value));
|
||||
});
|
||||
|
||||
protected readonly percent = computed(() => {
|
||||
const max = this.max();
|
||||
|
||||
if (!Number.isFinite(max) || max <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (this.clampedValue() / max) * 100;
|
||||
});
|
||||
|
||||
protected readonly displayValue = computed(() => this.valueText() ?? String(this.clampedValue()));
|
||||
}
|
||||
181
src/app/shared/motion/reveal.directive.spec.ts
Normal file
181
src/app/shared/motion/reveal.directive.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ApplicationRef, Component, PLATFORM_ID } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RevealDirective } from './reveal.directive';
|
||||
|
||||
@Component({
|
||||
imports: [RevealDirective],
|
||||
template: `<div appReveal [appRevealThreshold]="0.2">Reveal host</div>`,
|
||||
})
|
||||
class RevealHost {}
|
||||
|
||||
class MockIntersectionObserver {
|
||||
static instances: MockIntersectionObserver[] = [];
|
||||
|
||||
readonly observe = vi.fn();
|
||||
readonly unobserve = vi.fn();
|
||||
readonly disconnect = vi.fn();
|
||||
|
||||
constructor(
|
||||
private readonly callback: IntersectionObserverCallback,
|
||||
readonly options?: IntersectionObserverInit,
|
||||
) {
|
||||
MockIntersectionObserver.instances.push(this);
|
||||
}
|
||||
|
||||
trigger(isIntersecting: boolean): void {
|
||||
this.callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
this as unknown as IntersectionObserver,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mockMatchMedia(matchesQuery: (query: string) => boolean): void {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: matchesQuery(query),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}) as MediaQueryList,
|
||||
});
|
||||
}
|
||||
|
||||
describe('RevealDirective', () => {
|
||||
afterEach(() => {
|
||||
MockIntersectionObserver.instances = [];
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
Reflect.deleteProperty(window, 'matchMedia');
|
||||
});
|
||||
|
||||
async function createHost(
|
||||
providers: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<RevealHost>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RevealHost],
|
||||
providers,
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(RevealHost);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function hostElement(fixture: ComponentFixture<RevealHost>): HTMLElement {
|
||||
return fixture.nativeElement.querySelector('[appReveal]');
|
||||
}
|
||||
|
||||
it('reveals immediately on the server without constructing an observer', async () => {
|
||||
const Observer = vi.fn();
|
||||
vi.stubGlobal('IntersectionObserver', Observer);
|
||||
|
||||
const fixture = await createHost([{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
const element = hostElement(fixture);
|
||||
|
||||
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
expect(Observer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals immediately when IntersectionObserver is missing', async () => {
|
||||
const original = window.IntersectionObserver;
|
||||
Reflect.deleteProperty(window, 'IntersectionObserver');
|
||||
|
||||
try {
|
||||
const fixture = await createHost();
|
||||
const element = hostElement(fixture);
|
||||
|
||||
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
} finally {
|
||||
window.IntersectionObserver = original;
|
||||
}
|
||||
});
|
||||
|
||||
it('reveals immediately when reduced motion is requested', async () => {
|
||||
mockMatchMedia((query) => query.includes('prefers-reduced-motion'));
|
||||
const Observer = vi.fn();
|
||||
vi.stubGlobal('IntersectionObserver', Observer);
|
||||
|
||||
const fixture = await createHost();
|
||||
const element = hostElement(fixture);
|
||||
|
||||
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
expect(Observer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the element pending, then revealed, and disconnects on intersection', async () => {
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
|
||||
const fixture = await createHost();
|
||||
const element = hostElement(fixture);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
|
||||
expect(observer).toBeTruthy();
|
||||
expect(observer.observe).toHaveBeenCalled();
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
expect(element.classList.contains('is-revealed')).toBe(false);
|
||||
|
||||
observer.trigger(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(element.classList.contains('reveal-pending')).toBe(true);
|
||||
expect(element.classList.contains('is-revealed')).toBe(false);
|
||||
|
||||
observer.trigger(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reveals on a first intersecting callback without ever adding reveal-pending', async () => {
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
|
||||
const fixture = await createHost();
|
||||
const element = hostElement(fixture);
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
const addedTokens: string[] = [];
|
||||
const add = element.classList.add.bind(element.classList);
|
||||
vi.spyOn(element.classList, 'add').mockImplementation((...tokens: string[]) => {
|
||||
addedTokens.push(...tokens);
|
||||
add(...tokens);
|
||||
});
|
||||
|
||||
expect(observer).toBeTruthy();
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
|
||||
observer.trigger(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(addedTokens).not.toContain('reveal-pending');
|
||||
expect(element.classList.contains('reveal-pending')).toBe(false);
|
||||
expect(element.classList.contains('is-revealed')).toBe(true);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disconnects when destroyed before intersection', async () => {
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
|
||||
const fixture = await createHost();
|
||||
const observer = MockIntersectionObserver.instances[0];
|
||||
expect(observer).toBeTruthy();
|
||||
|
||||
fixture.destroy();
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
77
src/app/shared/motion/reveal.directive.ts
Normal file
77
src/app/shared/motion/reveal.directive.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { isBrowserPlatform, prefersReducedMotion } from '../../core/platform/browser';
|
||||
|
||||
@Directive({
|
||||
selector: '[appReveal]',
|
||||
})
|
||||
export class RevealDirective {
|
||||
readonly appRevealThreshold = input(0.12);
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly reducedMotion = prefersReducedMotion();
|
||||
private observer: IntersectionObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
if (!this.isBrowser || this.reducedMotion || !this.canObserve()) {
|
||||
this.revealNow();
|
||||
return;
|
||||
}
|
||||
|
||||
afterNextRender(() => this.observe(), { injector: this.injector });
|
||||
this.destroyRef.onDestroy(() => this.disconnect());
|
||||
}
|
||||
|
||||
private canObserve(): boolean {
|
||||
const view = this.document.defaultView;
|
||||
return !!view && typeof view.IntersectionObserver === 'function';
|
||||
}
|
||||
|
||||
private observe(): void {
|
||||
const view = this.document.defaultView;
|
||||
|
||||
if (!view || typeof view.IntersectionObserver !== 'function') {
|
||||
this.revealNow();
|
||||
return;
|
||||
}
|
||||
|
||||
this.observer = new view.IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
this.host.nativeElement.classList.remove('reveal-pending');
|
||||
this.host.nativeElement.classList.add('is-revealed');
|
||||
this.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.host.nativeElement.classList.add('reveal-pending');
|
||||
},
|
||||
{
|
||||
rootMargin: '0px 0px -8% 0px',
|
||||
threshold: this.appRevealThreshold(),
|
||||
},
|
||||
);
|
||||
this.observer.observe(this.host.nativeElement);
|
||||
}
|
||||
|
||||
private revealNow(): void {
|
||||
this.host.nativeElement.classList.add('is-revealed');
|
||||
}
|
||||
|
||||
private disconnect(): void {
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
}
|
||||
13
src/app/shared/page-placeholder/page-placeholder.html
Normal file
13
src/app/shared/page-placeholder/page-placeholder.html
Normal 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>
|
||||
31
src/app/shared/page-placeholder/page-placeholder.scss
Normal file
31
src/app/shared/page-placeholder/page-placeholder.scss
Normal 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);
|
||||
}
|
||||
28
src/app/shared/page-placeholder/page-placeholder.ts
Normal file
28
src/app/shared/page-placeholder/page-placeholder.ts
Normal 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';
|
||||
});
|
||||
}
|
||||
87
src/app/shared/systems-map/systems-map.html
Normal file
87
src/app/shared/systems-map/systems-map.html
Normal file
@@ -0,0 +1,87 @@
|
||||
<section class="systems-map" [attr.aria-labelledby]="headingId">
|
||||
@if (headingLevel() === 3) {
|
||||
<h3 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h3>
|
||||
} @else {
|
||||
<h2 [id]="headingId" class="systems-map-heading">{{ headingText() }}</h2>
|
||||
}
|
||||
<p class="systems-map-intro">{{ introText() }}</p>
|
||||
|
||||
<div class="systems-map-figure">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1000 560"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="group"
|
||||
[attr.aria-labelledby]="svgTitleId"
|
||||
[attr.aria-describedby]="svgDescId"
|
||||
>
|
||||
<title [id]="svgTitleId">{{ headingText() }}</title>
|
||||
<desc [id]="svgDescId">{{ copy().diagramDescription }}</desc>
|
||||
@for (edge of edges(); track edge.from + '-' + edge.to) {
|
||||
<line
|
||||
[attr.x1]="edge.x1"
|
||||
[attr.y1]="edge.y1"
|
||||
[attr.x2]="edge.x2"
|
||||
[attr.y2]="edge.y2"
|
||||
class="systems-map-edge"
|
||||
[class.is-connected]="isConnectedEdge(edge)"
|
||||
[class.is-dimmed]="isDimmedEdge(edge)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
@for (node of nodes(); track node.id) {
|
||||
<a
|
||||
[attr.href]="node.href"
|
||||
[attr.aria-label]="node.accessibleName"
|
||||
class="systems-map-node"
|
||||
[attr.data-kind]="node.kind"
|
||||
[class.is-connected]="isConnectedNode(node.id)"
|
||||
[class.is-dimmed]="isDimmedNode(node.id)"
|
||||
(click)="onNodeActivate($event, node)"
|
||||
(focusin)="onNodeEnter(node.id)"
|
||||
(focusout)="onNodeLeave(node.id)"
|
||||
(mouseenter)="onNodeEnter(node.id)"
|
||||
(mouseleave)="onNodeLeave(node.id)"
|
||||
>
|
||||
@if (node.kind === 'ai' || node.kind === 'cluster') {
|
||||
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
|
||||
} @else {
|
||||
<rect
|
||||
[attr.x]="node.x - 70"
|
||||
[attr.y]="node.y - 20"
|
||||
width="140"
|
||||
height="40"
|
||||
rx="8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
<text [attr.x]="node.x" [attr.y]="node.y + 4" aria-hidden="true">{{ node.label }}</text>
|
||||
</a>
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="systems-map-list">
|
||||
<p class="systems-map-kicker" [id]="listHeadingId">{{ copy().listHeading }}</p>
|
||||
<ul class="systems-map-cards" [attr.aria-labelledby]="listHeadingId">
|
||||
@for (node of nodes(); track node.id) {
|
||||
<li>
|
||||
<a [routerLink]="node.link" [attr.aria-label]="node.accessibleName">
|
||||
<span class="systems-map-card-label">{{ node.label }}</span>
|
||||
<span class="systems-map-card-summary">{{ node.summary }}</span>
|
||||
<span class="systems-map-card-relation">{{ node.relationship }}</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="systems-map-kicker" [id]="legendHeadingId">{{ copy().legendHeading }}</p>
|
||||
<ul class="systems-map-legend" [attr.aria-labelledby]="legendHeadingId">
|
||||
@for (item of legendItems(); track item.kind) {
|
||||
<li [attr.data-kind]="item.kind">{{ item.label }}</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<p class="systems-map-readout" aria-hidden="true">{{ activeReadout() }}</p>
|
||||
</section>
|
||||
162
src/app/shared/systems-map/systems-map.model.ts
Normal file
162
src/app/shared/systems-map/systems-map.model.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
export type SystemsMapNodeKind = 'ai' | 'cluster' | 'hardware' | 'software' | 'project';
|
||||
|
||||
export type SystemsMapNodeId =
|
||||
| 'ai'
|
||||
| 'clusters'
|
||||
| 'hardware'
|
||||
| 'software'
|
||||
| 'stack'
|
||||
| 'caseMigration'
|
||||
| 'casePlatform'
|
||||
| 'caseAutomation';
|
||||
|
||||
export const SYSTEMS_MAP_NODE_KINDS: readonly SystemsMapNodeKind[] = [
|
||||
'ai',
|
||||
'cluster',
|
||||
'hardware',
|
||||
'software',
|
||||
'project',
|
||||
];
|
||||
|
||||
export interface SystemsMapNode {
|
||||
readonly id: SystemsMapNodeId;
|
||||
readonly kind: SystemsMapNodeKind;
|
||||
readonly routeId: RouteId;
|
||||
readonly label: Record<AppLocale, string>;
|
||||
readonly summary: Record<AppLocale, string>;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
|
||||
export interface SystemsMapEdge {
|
||||
readonly from: SystemsMapNodeId;
|
||||
readonly to: SystemsMapNodeId;
|
||||
}
|
||||
|
||||
export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
{
|
||||
id: 'hardware',
|
||||
kind: 'hardware',
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
label: { de: 'Hardware', en: 'Hardware' },
|
||||
summary: {
|
||||
de: 'Geräte, Netz und physische Schicht',
|
||||
en: 'Devices, network and the physical layer',
|
||||
},
|
||||
x: 140,
|
||||
y: 300,
|
||||
},
|
||||
{
|
||||
id: 'clusters',
|
||||
kind: 'cluster',
|
||||
routeId: 'servicesClusters',
|
||||
label: { de: 'Cluster', en: 'Clusters' },
|
||||
summary: {
|
||||
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
|
||||
en: 'Orchestration and cluster operations',
|
||||
},
|
||||
x: 340,
|
||||
y: 140,
|
||||
},
|
||||
{
|
||||
id: 'software',
|
||||
kind: 'software',
|
||||
routeId: 'servicesSoftware',
|
||||
label: { de: 'Software', en: 'Software' },
|
||||
summary: {
|
||||
de: 'Anwendungen und Schnittstellen',
|
||||
en: 'Applications and interfaces',
|
||||
},
|
||||
x: 520,
|
||||
y: 300,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
kind: 'ai',
|
||||
routeId: 'servicesAi',
|
||||
label: { de: 'KI', en: 'AI' },
|
||||
summary: {
|
||||
de: 'Lokale Modelle und Integrationsarbeit',
|
||||
en: 'Local models and integration work',
|
||||
},
|
||||
x: 720,
|
||||
y: 140,
|
||||
},
|
||||
{
|
||||
id: 'stack',
|
||||
kind: 'software',
|
||||
routeId: 'stack',
|
||||
label: { de: 'Stack', en: 'Stack' },
|
||||
summary: {
|
||||
de: 'Werkzeuge und Laufzeitumgebung',
|
||||
en: 'Tools and runtime environment',
|
||||
},
|
||||
x: 340,
|
||||
y: 460,
|
||||
},
|
||||
{
|
||||
id: 'caseMigration',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Datenmigration', en: 'Data migration' },
|
||||
summary: {
|
||||
de: 'Datenbestände strukturiert überführen',
|
||||
en: 'Moving data stores in a structured way',
|
||||
},
|
||||
x: 860,
|
||||
y: 460,
|
||||
},
|
||||
{
|
||||
id: 'casePlatform',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
|
||||
summary: {
|
||||
de: 'Plattformen betreiben und weiterentwickeln',
|
||||
en: 'Operating and evolving platforms',
|
||||
},
|
||||
x: 860,
|
||||
y: 300,
|
||||
},
|
||||
{
|
||||
id: 'caseAutomation',
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
|
||||
summary: {
|
||||
de: 'Abläufe automatisieren und Modelle anbinden',
|
||||
en: 'Automating workflows and connecting models',
|
||||
},
|
||||
x: 900,
|
||||
y: 140,
|
||||
},
|
||||
];
|
||||
|
||||
export const SYSTEMS_MAP_EDGES: readonly SystemsMapEdge[] = [
|
||||
{ from: 'hardware', to: 'clusters' },
|
||||
{ from: 'clusters', to: 'software' },
|
||||
{ from: 'software', to: 'ai' },
|
||||
{ from: 'ai', to: 'clusters' },
|
||||
{ from: 'hardware', to: 'software' },
|
||||
{ from: 'stack', to: 'software' },
|
||||
{ from: 'ai', to: 'caseAutomation' },
|
||||
{ from: 'software', to: 'casePlatform' },
|
||||
{ from: 'software', to: 'caseMigration' },
|
||||
];
|
||||
|
||||
export function connectedNodeIds(id: SystemsMapNodeId): readonly SystemsMapNodeId[] {
|
||||
const connected: SystemsMapNodeId[] = [];
|
||||
|
||||
for (const edge of SYSTEMS_MAP_EDGES) {
|
||||
if (edge.from === id) {
|
||||
connected.push(edge.to);
|
||||
} else if (edge.to === id) {
|
||||
connected.push(edge.from);
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
188
src/app/shared/systems-map/systems-map.scss
Normal file
188
src/app/shared/systems-map/systems-map.scss
Normal file
@@ -0,0 +1,188 @@
|
||||
@use 'breakpoints' as bp;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.systems-map {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.systems-map-heading,
|
||||
.systems-map-intro,
|
||||
.systems-map-kicker,
|
||||
.systems-map-readout {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.systems-map-heading {
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.systems-map-intro,
|
||||
.systems-map-card-summary,
|
||||
.systems-map-card-relation,
|
||||
.systems-map-readout {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.systems-map-kicker {
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.systems-map-figure {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.systems-map-figure svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-surface-raised);
|
||||
background-image:
|
||||
linear-gradient(var(--color-surface-muted) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--color-surface-muted) 1px, transparent 1px);
|
||||
background-size: 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.systems-map-edge {
|
||||
fill: none;
|
||||
stroke: var(--color-accent-cool);
|
||||
stroke-width: 1.25;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.systems-map-node {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.systems-map-node circle,
|
||||
.systems-map-node rect {
|
||||
fill: var(--color-surface-overlay);
|
||||
stroke: var(--color-accent);
|
||||
stroke-width: 1.25;
|
||||
transition:
|
||||
opacity var(--duration-base) var(--ease-standard),
|
||||
stroke var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.systems-map-node[data-kind='project'] rect,
|
||||
.systems-map-legend [data-kind='project'] {
|
||||
stroke: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.systems-map-node[data-kind='ai'] circle,
|
||||
.systems-map-legend [data-kind='ai'] {
|
||||
stroke: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.systems-map-node text {
|
||||
fill: currentColor;
|
||||
font-size: 0.75rem;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.systems-map-node.is-connected circle,
|
||||
.systems-map-node.is-connected rect,
|
||||
.systems-map-edge.is-connected {
|
||||
stroke: var(--color-accent-strong);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.systems-map-node.is-dimmed,
|
||||
.systems-map-edge.is-dimmed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.systems-map-cards,
|
||||
.systems-map-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.systems-map-cards {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.systems-map-cards a {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.systems-map-node:focus-visible,
|
||||
.systems-map-cards a:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.systems-map-node:focus-visible circle,
|
||||
.systems-map-node:focus-visible rect {
|
||||
stroke: var(--focus-ring-color);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.systems-map-card-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.systems-map-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.systems-map-legend li {
|
||||
padding-inline-start: var(--space-3);
|
||||
border-inline-start: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.systems-map-readout {
|
||||
min-height: var(--text-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.systems-map-node:hover circle,
|
||||
.systems-map-node:hover rect {
|
||||
stroke: var(--color-accent-strong);
|
||||
}
|
||||
|
||||
.systems-map-cards a:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.systems-map-edge,
|
||||
.systems-map-node circle,
|
||||
.systems-map-node rect {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include bp.respond-to(lg) {
|
||||
.systems-map-figure {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.systems-map-list {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user