Compare commits

22 Commits

Author SHA1 Message Date
2fc5e65f85 remove unnecessary newlines
All checks were successful
publish.yml / publish (push) Successful in 46s
2026-04-24 00:42:23 +02:00
38f1db1321 Update .gitea/workflows/publish.yml
All checks were successful
publish.yml / publish (push) Successful in 48s
2026-04-24 00:38:11 +02:00
fb06dca81d mobile center entry
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-24 00:14:19 +02:00
7108f22e8b simplified carousel
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-24 00:08:08 +02:00
f7eceda442 improve hover performance
All checks were successful
publish.yml / publish (push) Successful in 1m6s
2026-04-23 23:50:27 +02:00
1a00b77511 add hover delay to carousel and remove dynamic background
All checks were successful
publish.yml / publish (push) Successful in 1m10s
2026-04-23 23:45:30 +02:00
9531e5aad4 remove unnecessary newline
All checks were successful
publish.yml / publish (push) Successful in 22s
2026-04-19 00:13:48 +02:00
2de74a9882 commit signing from second device
All checks were successful
publish.yml / publish (push) Successful in 22s
2026-04-19 00:12:56 +02:00
211a018321 test again
All checks were successful
publish.yml / publish (push) Successful in 23s
2026-04-18 23:31:24 +02:00
861fdb59ec test commit verification
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-18 23:27:26 +02:00
1470b0723b make navbar dockable when scrolling
All checks were successful
publish.yml / publish (push) Successful in 1m6s
2026-04-17 13:05:52 +02:00
67676721d6 interactive-background fixes
All checks were successful
publish.yml / publish (push) Successful in 1m3s
2026-04-16 23:13:51 +02:00
15294f95a1 improve touchscreen scrolling on carousel and make mobile page wider
All checks were successful
publish.yml / publish (push) Successful in 1m3s
2026-04-16 23:08:42 +02:00
7288edd019 improve touchscreen scrolling on carousel
All checks were successful
publish.yml / publish (push) Successful in 1m5s
2026-04-16 23:01:27 +02:00
e5474716ec improve auto-scrolling and add pause when hovering/clicking
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-16 22:56:18 +02:00
834d884e07 make background less distracting on mobile
All checks were successful
publish.yml / publish (push) Successful in 1m10s
2026-04-16 22:26:56 +02:00
9864a9c6cb add fade to carousel
All checks were successful
publish.yml / publish (push) Successful in 1m3s
2026-04-16 20:12:19 +02:00
17fbfc5022 constant auto-scrolling
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-16 20:01:54 +02:00
6f30fa2260 extract carousel to component
All checks were successful
publish.yml / publish (push) Successful in 1m3s
2026-04-16 19:50:40 +02:00
3072668e05 carousel is now as big as the other elements
All checks were successful
publish.yml / publish (push) Successful in 1m3s
2026-04-16 19:44:48 +02:00
7032968fe2 add carousel auto-scroll and removed inactive cursor
All checks were successful
publish.yml / publish (push) Successful in 1m3s
2026-04-16 19:30:24 +02:00
eb1dd36195 add icons to technologies
All checks were successful
publish.yml / publish (push) Successful in 1m45s
2026-04-16 17:02:27 +02:00
36 changed files with 602 additions and 4781 deletions

View File

@@ -10,15 +10,16 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} registry: git.kaintim.duckdns.org
password: ${{ secrets.DOCKERHUB_TOKEN }} username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
push: true push: true
tags: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKERHUB_PROJECT_NAME }}:latest tags: git.kaintim.duckdns.org/${{ secrets.REGISTRY_USERNAME }}/${{ secrets.REGISTRY_PROJECT_NAME }}:latest
cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKERHUB_PROJECT_NAME }}:cache cache-from: type=registry,ref=git.kaintim.duckdns.org/${{ secrets.REGISTRY_USERNAME }}/${{ secrets.REGISTRY_PROJECT_NAME }}:cache
cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKERHUB_PROJECT_NAME }}:cache,mode=max cache-to: type=registry,ref=git.kaintim.duckdns.org/${{ secrets.REGISTRY_USERNAME }}/${{ secrets.REGISTRY_PROJECT_NAME }}:cache,mode=max

View File

@@ -36,40 +36,6 @@ ng build
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. 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.
## Electron desktop app
This project can also run as an Electron desktop app using the same Angular frontend.
### Run Electron with a production build
```bash
npm run electron
```
This command runs `npm run build:electron` internally to emit a file-based build for Electron.
### Run Electron against the Angular dev server
Start Angular in one terminal:
```bash
npm start
```
Then start Electron in another terminal:
```bash
npm run electron:dev
```
### Package a Linux desktop build
```bash
npm run electron:pack
```
The packaged app is created in the `release/` directory.
## Running unit tests ## Running unit tests
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:

View File

@@ -38,8 +38,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "5kB",
"maximumError": "8kB" "maximumError": "5kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"

View File

@@ -1,50 +0,0 @@
const { app, BrowserWindow, shell } = require('electron');
const path = require('path');
function createMainWindow() {
const mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
autoHideMenuBar: true,
backgroundColor: '#060910',
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
const devUrl = process.env.ELECTRON_RENDERER_URL;
if (devUrl) {
mainWindow.loadURL(devUrl);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'website', 'browser', 'index.html'));
}
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
app.whenReady().then(() => {
createMainWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});

View File

@@ -1,6 +0,0 @@
const { contextBridge } = require('electron');
contextBridge.exposeInMainWorld('desktop', {
isElectron: true,
});

3981
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,12 @@
{ {
"name": "website", "name": "website",
"version": "0.0.0", "version": "0.0.0",
"main": "electron/main.cjs",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"build:electron": "ng build --base-href ./",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test"
"electron": "npm run build:electron && electron .",
"electron:dev": "ELECTRON_RENDERER_URL=http://localhost:4200 electron .",
"electron:pack": "npm run build:electron && electron-builder --linux"
}, },
"private": true, "private": true,
"packageManager": "npm@11.12.1", "packageManager": "npm@11.12.1",
@@ -29,29 +24,9 @@
"@angular/build": "^21.2.6", "@angular/build": "^21.2.6",
"@angular/cli": "^21.2.6", "@angular/cli": "^21.2.6",
"@angular/compiler-cli": "^21.2.0", "@angular/compiler-cli": "^21.2.0",
"electron": "^36.4.0",
"electron-builder": "^24.13.3",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"vitest": "^4.0.8" "vitest": "^4.0.8"
},
"build": {
"appId": "com.kaintim.website",
"productName": "Website",
"directories": {
"output": "release"
},
"files": [
"dist/website/browser/**/*",
"electron/**/*",
"package.json"
],
"linux": {
"target": [
"AppImage"
],
"category": "Utility"
}
} }
} }

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Angular</title><path d="M16.712 17.711H7.288l-1.204 2.916L12 24l5.916-3.373-1.204-2.916ZM14.692 0l7.832 16.855.814-12.856L14.692 0ZM9.308 0 .662 3.999l.814 12.856L9.308 0Zm-.405 13.93h6.198L12 6.396 8.903 13.93Z"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>sharp</title><path d="M14.2209.0875v5.9613l-3.7433.5012v3.5233l3.7433-.5012v3.5735l3.492-.4672V9.1047L24 8.2634l-.4631-3.4613-5.824.7794V.0875zM6.287 1.145v5.9618L0 7.9483l.4634 3.4613 5.8514-.7834 3.4644-.4637V1.145zm3.5198 9.7185l-3.492.4675v3.578l-6.183.8276.4633 3.4613 5.8239-.7796v5.4942h3.492v-5.962l3.6114-.4834V13.944l-3.7156.4973zm13.73 1.7405l-5.824.779-3.492.4673v9.0179h3.492v-5.9618L24 16.0652Z"/></svg>

After

Width:  |  Height:  |  Size: 510 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Docker</title><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>.NET</title><path d="M24 8.77h-2.468v7.565h-1.425V8.77h-2.462V7.53H24zm-6.852 7.565h-4.821V7.53h4.63v1.24h-3.205v2.494h2.953v1.234h-2.953v2.604h3.396zm-6.708 0H8.882L4.78 9.863a2.896 2.896 0 0 1-.258-.51h-.036c.032.189.048.592.048 1.21v5.772H3.157V7.53h1.659l3.965 6.32c.167.261.275.442.323.54h.024c-.04-.233-.06-.629-.06-1.185V7.529h1.372zm-8.703-.693a.868.829 0 0 1-.869.829.868.829 0 0 1-.868-.83.868.829 0 0 1 .868-.828.868.829 0 0 1 .869.829Z"/></svg>

After

Width:  |  Height:  |  Size: 549 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Flutter</title><path d="M14.314 0L2.3 12 6 15.7 21.684.013h-7.357zm.014 11.072L7.857 17.53l6.47 6.47H21.7l-6.46-6.468 6.46-6.46h-7.37z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ionic</title><path d="M22.922 7.027l-.103-.23-.169.188c-.408.464-.928.82-1.505 1.036l-.159.061.066.155a9.745 9.745 0 0 1 .75 3.759c0 5.405-4.397 9.806-9.806 9.806-5.409 0-9.802-4.397-9.802-9.802 0-5.405 4.402-9.806 9.806-9.806 1.467 0 2.883.319 4.2.947l.155.075.066-.155a3.767 3.767 0 0 1 1.106-1.453l.197-.159-.225-.117A11.905 11.905 0 0 0 12.001.001c-6.619 0-12 5.381-12 12s5.381 12 12 12 12-5.381 12-12c0-1.73-.361-3.403-1.078-4.973zM12 6.53A5.476 5.476 0 0 0 6.53 12 5.476 5.476 0 0 0 12 17.47 5.476 5.476 0 0 0 17.47 12 5.479 5.479 0 0 0 12 6.53zm10.345-2.007a2.494 2.494 0 1 1-4.988 0 2.494 2.494 0 0 1 4.988 0z"/></svg>

After

Width:  |  Height:  |  Size: 719 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenJDK</title><path d="M11.915 0 11.7.215C9.515 2.4 7.47 6.39 6.046 10.483c-1.064 1.024-3.633 2.81-3.711 3.551-.093.87 1.746 2.611 1.55 3.235-.198.625-1.304 1.408-1.014 1.939.1.188.823.011 1.277-.491a13.389 13.389 0 0 0-.017 2.14c.076.906.27 1.668.643 2.232.372.563.956.911 1.667.911.397 0 .727-.114 1.024-.264.298-.149.571-.33.91-.5.68-.34 1.634-.666 3.53-.604 1.903.062 2.872.39 3.559.704.687.314 1.15.664 1.925.664.767 0 1.395-.336 1.807-.9.412-.563.631-1.33.72-2.24.06-.623.055-1.32 0-2.066.454.45 1.117.604 1.213.424.29-.53-.816-1.314-1.013-1.937-.198-.624 1.642-2.366 1.549-3.236-.08-.748-2.707-2.568-3.748-3.586C16.428 6.374 14.308 2.394 12.13.215zm.175 6.038a2.95 2.95 0 0 1 2.943 2.942 2.95 2.95 0 0 1-2.943 2.943A2.95 2.95 0 0 1 9.148 8.98a2.95 2.95 0 0 1 2.942-2.942zM8.685 7.983a3.515 3.515 0 0 0-.145.997c0 1.951 1.6 3.55 3.55 3.55 1.95 0 3.55-1.598 3.55-3.55 0-.329-.046-.648-.132-.951.334.095.64.208.915.336a42.699 42.699 0 0 1 2.042 5.829c.678 2.545 1.01 4.92.846 6.607-.082.844-.29 1.51-.606 1.94-.315.431-.713.651-1.315.651-.593 0-.932-.27-1.673-.61-.741-.338-1.825-.694-3.792-.758-1.974-.064-3.073.293-3.821.669-.375.188-.659.373-.911.5s-.466.2-.752.2c-.53 0-.876-.209-1.16-.64-.285-.43-.474-1.101-.545-1.948-.141-1.693.176-4.069.823-6.614a43.155 43.155 0 0 1 1.934-5.783c.348-.167.749-.31 1.192-.425zm-3.382 4.362a.216.216 0 0 1 .13.031c-.166.56-.323 1.116-.463 1.665a33.849 33.849 0 0 0-.547 2.555 3.9 3.9 0 0 0-.2-.39c-.58-1.012-.914-1.642-1.16-2.08.315-.24 1.679-1.755 2.24-1.781zm13.394.01c.562.027 1.926 1.543 2.24 1.783-.246.438-.58 1.068-1.16 2.08a4.428 4.428 0 0 0-.163.309 32.354 32.354 0 0 0-.562-2.49 40.579 40.579 0 0 0-.482-1.652.216.216 0 0 1 .127-.03z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>JavaScript</title><path d="M0 0h24v24H0V0zm22.034 18.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84 1.515-.66.39.12.75.42.976.9 1.034-.676 1.034-.676 1.755-1.125-.27-.42-.404-.601-.586-.78-.63-.705-1.469-1.065-2.834-1.034l-.705.089c-.676.165-1.32.525-1.71 1.005-1.14 1.291-.811 3.541.569 4.471 1.365 1.02 3.361 1.244 3.616 2.205.24 1.17-.87 1.545-1.966 1.41-.811-.18-1.26-.586-1.755-1.336l-1.83 1.051c.21.48.45.689.81 1.109 1.74 1.756 6.09 1.666 6.871-1.004.029-.09.24-.705.074-1.65l.046.067zm-8.983-7.245h-2.248c0 1.938-.009 3.864-.009 5.805 0 1.232.063 2.363-.138 2.711-.33.689-1.18.601-1.566.48-.396-.196-.597-.466-.83-.855-.063-.105-.11-.196-.127-.196l-1.825 1.125c.305.63.75 1.172 1.324 1.517.855.51 2.004.675 3.207.405.783-.226 1.458-.691 1.811-1.411.51-.93.402-2.07.397-3.346.012-2.054 0-4.109 0-6.179l.004-.056z"/></svg>

After

Width:  |  Height:  |  Size: 989 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>React</title><path d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spring</title><path d="M21.8537 1.4158a10.4504 10.4504 0 0 1-1.284 2.2471A11.9666 11.9666 0 1 0 3.8518 20.7757l.4445.3951a11.9543 11.9543 0 0 0 19.6316-8.2971c.3457-3.0126-.568-6.8649-2.0743-11.458zM5.5805 20.8745a1.0174 1.0174 0 1 1-.1482-1.4323 1.0396 1.0396 0 0 1 .1482 1.4323zm16.1991-3.5806c-2.9385 3.9263-9.2601 2.5928-13.2852 2.7904 0 0-.7161.0494-1.4323.1481 0 0 .2717-.1234.6174-.2469 2.8398-.9877 4.1732-1.1853 5.9018-2.0743 3.2349-1.6545 6.4698-5.2844 7.1118-9.0379-1.2347 3.6053-4.9881 6.7167-8.3959 7.9761-2.3459.8643-6.5685 1.7039-6.5685 1.7039l-.1729-.0988c-2.8645-1.4076-2.9632-7.6304 2.2718-9.6306 2.2966-.889 4.4696-.395 6.9637-.9877 2.6422-.6174 5.7043-2.5929 6.939-5.1857 1.3828 4.1732 3.062 10.643.0493 14.6434z"/></svg>

After

Width:  |  Height:  |  Size: 834 B

View File

@@ -0,0 +1 @@
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TypeScript</title><path d="M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#FFFFFF" d="M126 1.637l-67 9.834v49.831l67-.534zM1.647 66.709l.003 42.404 50.791 6.983-.04-49.057zm56.82.68l.094 49.465 67.376 9.509.016-58.863zM1.61 19.297l.047 42.383 50.791-.289-.023-49.016z"/></svg>

After

Width:  |  Height:  |  Size: 276 B

2
release/.gitignore vendored
View File

@@ -1,2 +0,0 @@
*
!.gitignore

View File

@@ -1,5 +1,5 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withHashLocation, withInMemoryScrolling } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -8,7 +8,6 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter( provideRouter(
routes, routes,
withHashLocation(),
withInMemoryScrolling({ withInMemoryScrolling({
anchorScrolling: 'enabled', anchorScrolling: 'enabled',
scrollPositionRestoration: 'enabled', scrollPositionRestoration: 'enabled',

View File

@@ -25,13 +25,40 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1.25rem; gap: 1.5rem;
padding: 0.9rem 1.2rem; padding: 1.1rem 1.45rem;
border: 1px solid #30363d; border: 1px solid #30363d;
border-radius: 16px; border-radius: 18px;
background: rgba(22, 27, 34, 0.75); background: rgba(22, 27, 34, 0.75);
backdrop-filter: blur(9px);
z-index: 3; z-index: 3;
transition:
top 260ms ease,
left 260ms ease,
width 260ms ease,
transform 260ms ease,
border-radius 260ms ease,
padding 260ms ease,
background-color 260ms ease;
}
.sidebar.is-partially-docked {
top: 0.2rem;
left: 50%;
transform: translateX(-50%);
width: min(1200px, calc(100% - 1rem));
border-radius: 8px;
padding: 1rem 1.35rem;
background: rgba(22, 27, 34, 0.84);
}
.sidebar.is-docked {
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
border-radius: 0;
padding: 1rem 1.5rem;
background: rgba(22, 27, 34, 0.9);
} }
.sidebar-header { .sidebar-header {
@@ -43,7 +70,7 @@
.brand { .brand {
color: #e6edf3; color: #e6edf3;
font-size: 1.05rem; font-size: 1.15rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-decoration: none; text-decoration: none;
@@ -64,10 +91,11 @@
} }
.nav a { .nav a {
padding: 0.55rem 0.8rem; padding: 0.62rem 0.95rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 12px; border-radius: 12px;
color: #c9d1d9; color: #c9d1d9;
font-size: 1rem;
text-decoration: none; text-decoration: none;
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease; transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
} }
@@ -151,14 +179,16 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.content { .content {
padding-top: calc(6.5rem + env(safe-area-inset-top)); padding: calc(6.5rem + env(safe-area-inset-top)) 0.5rem 3rem;
} }
.sidebar { .sidebar,
.sidebar.is-partially-docked,
.sidebar.is-docked {
position: fixed; position: fixed;
top: 0.75rem; top: 0.75rem;
right: 0.75rem; right: 0.5rem;
left: 0.75rem; left: 0.5rem;
transform: none; transform: none;
width: auto; width: auto;
margin: 0; margin: 0;
@@ -167,6 +197,28 @@
align-items: stretch; align-items: stretch;
justify-content: flex-start; justify-content: flex-start;
border-radius: 16px; border-radius: 16px;
transition:
top 300ms ease,
border-radius 300ms ease,
padding 300ms ease,
background-color 300ms ease;
}
.sidebar.is-partially-docked {
top: 0.35rem;
border-radius: 12px;
padding: 0.98rem 1.1rem;
background: rgba(22, 27, 34, 0.88);
}
.sidebar.is-docked {
top: 0;
left: 0;
right: 0;
width: auto;
border-radius: 0;
padding: 0.92rem 1rem;
background: rgba(22, 27, 34, 0.92);
} }
.subtitle { .subtitle {

View File

@@ -1,7 +1,11 @@
<div class="shell"> <div class="shell">
<app-interactive-background></app-interactive-background> <app-interactive-background></app-interactive-background>
<aside class="sidebar"> <aside
class="sidebar"
[class.is-partially-docked]="isNavPartiallyDocked"
[class.is-docked]="isNavDocked"
>
<div class="sidebar-header"> <div class="sidebar-header">
<a class="brand" routerLink="/home" (click)="closeMenu()">Tim Kainz</a> <a class="brand" routerLink="/home" (click)="closeMenu()">Tim Kainz</a>
<button <button

View File

@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { App } from './app'; import { App } from './app';
describe('App', () => { describe('App', () => {
@@ -27,4 +28,70 @@ describe('App', () => {
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4); expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
expect(compiled.querySelector('.site-footer')).toBeTruthy(); expect(compiled.querySelector('.site-footer')).toBeTruthy();
}); });
it('should transition navbar from partial to full dock based on scroll depth', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const scrollSpy = vi
.spyOn(window, 'scrollY', 'get')
.mockReturnValueOnce(24)
.mockReturnValueOnce(140)
.mockReturnValueOnce(0);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(true);
expect(app.isNavDocked).toBe(false);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(false);
expect(app.isNavDocked).toBe(true);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(false);
expect(app.isNavDocked).toBe(false);
scrollSpy.mockRestore();
});
it('should keep full dock until scroll drops below the full-dock exit threshold', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const scrollSpy = vi
.spyOn(window, 'scrollY', 'get')
.mockReturnValueOnce(110)
.mockReturnValueOnce(80)
.mockReturnValueOnce(60);
app.onWindowScroll();
expect(app.isNavDocked).toBe(true);
app.onWindowScroll();
expect(app.isNavDocked).toBe(true);
app.onWindowScroll();
expect(app.isNavPartiallyDocked).toBe(true);
expect(app.isNavDocked).toBe(false);
scrollSpy.mockRestore();
});
it('should reflect partial dock state on the sidebar class', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.isNavPartiallyDocked = true;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.sidebar.is-partially-docked')).toBeTruthy();
});
it('should reflect fully docked state on the sidebar class', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
app.isNavDocked = true;
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.sidebar.is-docked')).toBeTruthy();
});
}); });

View File

@@ -1,4 +1,4 @@
import { Component } from '@angular/core'; import { Component, HostListener } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { InteractiveBackground } from '../components/interactive-background/interactive-background'; import { InteractiveBackground } from '../components/interactive-background/interactive-background';
@@ -10,7 +10,44 @@ import { InteractiveBackground } from '../components/interactive-background/inte
}) })
export class App { export class App {
isMenuOpen = false; isMenuOpen = false;
isNavPartiallyDocked = false;
isNavDocked = false;
readonly currentYear = new Date().getFullYear(); readonly currentYear = new Date().getFullYear();
private readonly partialDockOffset = 8;
private readonly fullDockEnterOffset = 96;
private readonly fullDockExitOffset = 64;
@HostListener('window:scroll')
onWindowScroll(): void {
const currentScrollY = window.scrollY || document.documentElement.scrollTop || 0;
if (currentScrollY <= this.partialDockOffset) {
this.isNavPartiallyDocked = false;
this.isNavDocked = false;
return;
}
if (this.isNavDocked) {
if (currentScrollY <= this.fullDockExitOffset) {
this.isNavPartiallyDocked = true;
this.isNavDocked = false;
return;
}
this.isNavPartiallyDocked = false;
this.isNavDocked = true;
return;
}
if (currentScrollY >= this.fullDockEnterOffset) {
this.isNavPartiallyDocked = false;
this.isNavDocked = true;
return;
}
this.isNavPartiallyDocked = true;
this.isNavDocked = false;
}
toggleMenu(): void { toggleMenu(): void {
this.isMenuOpen = !this.isMenuOpen; this.isMenuOpen = !this.isMenuOpen;

View File

@@ -16,34 +16,3 @@
height: 100%; height: 100%;
display: block; display: block;
} }
.custom-cursor {
position: fixed;
top: 0;
left: 0;
width: 26px;
height: 26px;
border: 1px solid rgba(147, 197, 253, 0.9);
border-radius: 50%;
transform: translate3d(-9999px, -9999px, 0);
margin-left: -13px;
margin-top: -13px;
box-shadow: 0 0 24px rgba(56, 189, 248, 0.28);
background: radial-gradient(circle, rgba(96, 165, 250, 0.25) 0%, rgba(96, 165, 250, 0) 70%);
transition: width 130ms ease, height 130ms ease, margin 130ms ease, border-color 130ms ease;
}
.custom-cursor.pointer-down {
width: 38px;
height: 38px;
margin-left: -19px;
margin-top: -19px;
border-color: rgba(196, 181, 253, 0.95);
}
@media (prefers-reduced-motion: reduce) {
.custom-cursor {
transition: none;
}
}

View File

@@ -1,11 +1,3 @@
<div class="background-layer" aria-hidden="true"> <div class="background-layer" aria-hidden="true">
<canvas #canvas class="particle-canvas"></canvas> <canvas #canvas class="particle-canvas"></canvas>
@if (isCursorVisible) {
<div
class="custom-cursor"
[class.pointer-down]="isPointerDown"
[style.transform]="cursorTransform"
></div>
}
</div> </div>

View File

@@ -1,273 +1,4 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } from '@angular/core'; import { AfterViewInit, Component, ElementRef, HostListener, ViewChild } from '@angular/core';
import { NavigationStart, Router } from '@angular/router';
type PointerState = {
x: number;
y: number;
active: boolean;
pressed: boolean;
};
type Particle = {
x: number;
y: number;
vx: number;
vy: number;
size: number;
alpha: number;
ageMs: number;
lifetimeMs: number;
respawnDelayMs: number;
};
class ParticleEngine {
private readonly connectionDistanceSq = 19600;
private readonly densityRadiusSq = 4900;
private readonly densityFadeStart = 6;
private readonly densityFadeRange = 10;
private readonly maxDensityFade = 0.75;
private readonly minLifetimeMs = 9000;
private readonly maxLifetimeMs = 24000;
private readonly minRespawnDelayMs = 140;
private readonly maxRespawnDelayMs = 900;
private readonly particles: Particle[] = [];
private readonly localDensity: number[] = [];
private readonly lifeAlpha: number[] = [];
private width = 0;
private height = 0;
private dpr = 1;
private pointer: PointerState = { x: 0, y: 0, active: false, pressed: false };
constructor(private readonly ctx: CanvasRenderingContext2D, particleCount: number) {
for (let i = 0; i < particleCount; i += 1) {
const particle: Particle = {
x: Math.random(),
y: Math.random(),
vx: (Math.random() - 0.5) * 0.24,
vy: (Math.random() - 0.5) * 0.24,
size: 1.2 + Math.random() * 2.8,
alpha: 0.5 + Math.random() * 0.45,
ageMs: 0,
lifetimeMs: this.randomLifetimeMs(),
respawnDelayMs: 0,
};
this.resetParticle(particle, false);
this.particles.push(particle);
}
}
resize(width: number, height: number, dpr: number): void {
this.width = width;
this.height = height;
this.dpr = dpr;
}
setPointer(pointer: PointerState): void {
this.pointer = pointer;
}
renderFrame(deltaMs: number): void {
const clampedDeltaMs = Math.min(deltaMs, 40);
const step = clampedDeltaMs / 16.6667;
this.update(step, clampedDeltaMs);
this.draw();
}
renderStatic(): void {
this.draw();
}
private update(step: number, deltaMs: number): void {
if (this.width === 0 || this.height === 0) {
return;
}
const influenceRadius = Math.min(this.width, this.height) * 0.3;
const influenceRadiusSq = influenceRadius * influenceRadius;
for (const particle of this.particles) {
if (particle.respawnDelayMs > 0) {
particle.respawnDelayMs = Math.max(0, particle.respawnDelayMs - deltaMs);
if (particle.respawnDelayMs === 0) {
this.resetParticle(particle, false);
}
continue;
}
particle.ageMs += deltaMs;
if (particle.ageMs >= particle.lifetimeMs) {
particle.respawnDelayMs = this.randomRespawnDelayMs();
continue;
}
const px = particle.x * this.width;
const py = particle.y * this.height;
if (this.pointer.active) {
const dx = px - this.pointer.x;
const dy = py - this.pointer.y;
const distanceSq = dx * dx + dy * dy;
if (distanceSq > 1 && distanceSq < influenceRadiusSq) {
const distance = Math.sqrt(distanceSq);
const normalized = 1 - distance / influenceRadius;
const directionBoost = this.pointer.pressed ? -2.2 : 1.35;
const force = normalized * 0.033 * directionBoost;
particle.vx += (dx / distance) * force * step;
particle.vy += (dy / distance) * force * step;
}
}
particle.vx *= 0.978;
particle.vy *= 0.978;
particle.x += (particle.vx * step) / this.width;
particle.y += (particle.vy * step) / this.height;
if (particle.x < 0 || particle.x > 1) {
particle.vx *= -0.95;
particle.x = Math.min(1, Math.max(0, particle.x));
}
if (particle.y < 0 || particle.y > 1) {
particle.vy *= -0.95;
particle.y = Math.min(1, Math.max(0, particle.y));
}
}
}
private draw(): void {
if (this.width === 0 || this.height === 0) {
return;
}
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
this.ctx.clearRect(0, 0, this.width, this.height);
const gradient = this.ctx.createRadialGradient(
this.width * 0.2,
this.height * 0.1,
0,
this.width * 0.5,
this.height * 0.5,
Math.max(this.width, this.height)
);
gradient.addColorStop(0, 'rgba(88, 166, 255, 0.16)');
gradient.addColorStop(0.4, 'rgba(88, 166, 255, 0.2)');
gradient.addColorStop(0.7, 'rgba(147, 102, 255, 0.12)');
gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, this.width, this.height);
if (this.localDensity.length !== this.particles.length) {
this.localDensity.length = this.particles.length;
}
if (this.lifeAlpha.length !== this.particles.length) {
this.lifeAlpha.length = this.particles.length;
}
this.localDensity.fill(0);
for (let i = 0; i < this.particles.length; i += 1) {
this.lifeAlpha[i] = this.getLifeAlpha(this.particles[i]);
}
this.ctx.lineWidth = 1;
for (let i = 0; i < this.particles.length; i += 1) {
const a = this.particles[i];
const lifeA = this.lifeAlpha[i];
if (lifeA <= 0.01) {
continue;
}
for (let j = i + 1; j < this.particles.length; j += 1) {
const b = this.particles[j];
const lifeB = this.lifeAlpha[j];
if (lifeB <= 0.01) {
continue;
}
const ax = a.x * this.width;
const ay = a.y * this.height;
const bx = b.x * this.width;
const by = b.y * this.height;
const dx = ax - bx;
const dy = ay - by;
const distSq = dx * dx + dy * dy;
if (distSq < this.densityRadiusSq) {
this.localDensity[i] += 1;
this.localDensity[j] += 1;
}
if (distSq < this.connectionDistanceSq) {
const alpha = 1 - distSq / this.connectionDistanceSq;
this.ctx.strokeStyle = `rgba(106, 194, 255, ${alpha * 0.38 * Math.min(lifeA, lifeB)})`;
this.ctx.beginPath();
this.ctx.moveTo(ax, ay);
this.ctx.lineTo(bx, by);
this.ctx.stroke();
}
}
}
for (let i = 0; i < this.particles.length; i += 1) {
const particle = this.particles[i];
const x = particle.x * this.width;
const y = particle.y * this.height;
const crowding = Math.min(
Math.max((this.localDensity[i] - this.densityFadeStart) / this.densityFadeRange, 0),
1
);
const effectiveAlpha = Math.max(
particle.alpha * this.lifeAlpha[i] * (1 - crowding * this.maxDensityFade),
0.02
);
if (effectiveAlpha <= 0.021) {
continue;
}
this.ctx.beginPath();
this.ctx.arc(x, y, particle.size, 0, Math.PI * 2);
this.ctx.fillStyle = `rgba(222, 244, 255, ${effectiveAlpha})`;
this.ctx.fill();
}
}
private randomLifetimeMs(): number {
return this.minLifetimeMs + Math.random() * (this.maxLifetimeMs - this.minLifetimeMs);
}
private randomRespawnDelayMs(): number {
return this.minRespawnDelayMs + Math.random() * (this.maxRespawnDelayMs - this.minRespawnDelayMs);
}
private resetParticle(particle: Particle, withDelay: boolean): void {
particle.x = Math.random();
particle.y = Math.random();
particle.vx = (Math.random() - 0.5) * 0.24;
particle.vy = (Math.random() - 0.5) * 0.24;
particle.size = 1.2 + Math.random() * 2.8;
particle.alpha = 0.5 + Math.random() * 0.45;
particle.ageMs = 0;
particle.lifetimeMs = this.randomLifetimeMs();
particle.respawnDelayMs = withDelay ? this.randomRespawnDelayMs() : 0;
}
private getLifeAlpha(particle: Particle): number {
if (particle.respawnDelayMs > 0 || particle.lifetimeMs <= 0) {
return 0;
}
const progress = Math.min(Math.max(particle.ageMs / particle.lifetimeMs, 0), 1);
const fadeIn = Math.min(progress / 0.25, 1);
const fadeOut = Math.min((1 - progress) / 0.32, 1);
return Math.min(fadeIn, fadeOut, 1);
}
}
@Component({ @Component({
selector: 'app-interactive-background', selector: 'app-interactive-background',
@@ -275,332 +6,53 @@ class ParticleEngine {
templateUrl: './interactive-background.html', templateUrl: './interactive-background.html',
styleUrl: './interactive-background.css' styleUrl: './interactive-background.css'
}) })
export class InteractiveBackground implements AfterViewInit, OnDestroy { export class InteractiveBackground implements AfterViewInit {
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly router = inject(Router); private ctx: CanvasRenderingContext2D | null = null;
isCursorVisible = InteractiveBackground.detectFinePointer();
isPointerDown = false;
cursorTransform = 'translate3d(-9999px, -9999px, 0)';
private particleEngine: ParticleEngine | null = null;
private destroyCallbacks: Array<() => void> = [];
private frameId: number | null = null;
private lastFrameTime = 0;
private pointerX = 0;
private pointerY = 0;
private pointerActive = false;
private activeTouchId: number | null = null;
private reducedMotion = false;
private mediaQuery!: MediaQueryList;
ngAfterViewInit(): void { ngAfterViewInit(): void {
const canvas = this.canvasRef.nativeElement; this.ctx = this.canvasRef.nativeElement.getContext('2d');
const context = canvas.getContext('2d'); this.resizeAndDraw();
this.mediaQuery = this.getMediaQuery('(prefers-reduced-motion: reduce)');
this.reducedMotion = this.mediaQuery.matches;
if (context) {
const area = window.innerWidth * window.innerHeight;
const dynamicCount = Math.round(Math.min(170, Math.max(80, area / 14500)));
const particleCount = this.reducedMotion ? 42 : dynamicCount;
this.particleEngine = new ParticleEngine(context, particleCount);
this.resizeCanvas();
this.renderInitialFrame();
if (!this.reducedMotion) {
this.startAnimation();
}
}
const onResize = () => this.resizeCanvas();
const supportsPointerEvents = 'PointerEvent' in window;
const onPointerMove = (event: PointerEvent) => this.handlePointerMove(event);
const onPointerLeave = () => this.handlePointerLeave();
const onPointerDown = (event: PointerEvent) => this.handlePointerDown(event);
const onPointerUp = (event: PointerEvent) => this.handlePointerUp(event);
const onPointerCancel = () => this.handlePointerLeave();
const onMouseMove = (event: MouseEvent) => this.handleMouseMove(event);
const onMouseLeave = () => this.handlePointerLeave();
const onMouseDown = (event: MouseEvent) => this.handleMouseDown(event);
const onMouseUp = () => this.handleMouseUp();
const onTouchStart = (event: TouchEvent) => this.handleTouchStart(event);
const onTouchMove = (event: TouchEvent) => this.handleTouchMove(event);
const onTouchEnd = (event: TouchEvent) => this.handleTouchEnd(event);
const onTouchCancel = () => this.handlePointerLeave();
const onMotionChange = (event: MediaQueryListEvent) => this.handleMotionPreferenceChange(event.matches);
const routerSubscription = this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
this.resetPointerInteraction();
}
});
window.addEventListener('resize', onResize);
if (supportsPointerEvents) {
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerleave', onPointerLeave);
window.addEventListener('pointerdown', onPointerDown, { passive: true });
window.addEventListener('pointerup', onPointerUp, { passive: true });
window.addEventListener('pointercancel', onPointerCancel, { passive: true });
} else {
window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseleave', onMouseLeave);
window.addEventListener('mousedown', onMouseDown, { passive: true });
window.addEventListener('mouseup', onMouseUp, { passive: true });
window.addEventListener('touchstart', onTouchStart, { passive: true });
window.addEventListener('touchmove', onTouchMove, { passive: true });
window.addEventListener('touchend', onTouchEnd, { passive: true });
window.addEventListener('touchcancel', onTouchCancel, { passive: true });
}
this.mediaQuery.addEventListener('change', onMotionChange);
this.destroyCallbacks = [
() => window.removeEventListener('resize', onResize),
() => window.removeEventListener('pointermove', onPointerMove),
() => window.removeEventListener('pointerleave', onPointerLeave),
() => window.removeEventListener('pointerdown', onPointerDown),
() => window.removeEventListener('pointerup', onPointerUp),
() => window.removeEventListener('pointercancel', onPointerCancel),
() => window.removeEventListener('mousemove', onMouseMove),
() => window.removeEventListener('mouseleave', onMouseLeave),
() => window.removeEventListener('mousedown', onMouseDown),
() => window.removeEventListener('mouseup', onMouseUp),
() => window.removeEventListener('touchstart', onTouchStart),
() => window.removeEventListener('touchmove', onTouchMove),
() => window.removeEventListener('touchend', onTouchEnd),
() => window.removeEventListener('touchcancel', onTouchCancel),
() => this.mediaQuery.removeEventListener('change', onMotionChange),
() => routerSubscription.unsubscribe(),
];
} }
ngOnDestroy(): void { @HostListener('window:resize')
this.stopAnimation(); onResize(): void {
for (const callback of this.destroyCallbacks) { this.resizeAndDraw();
callback();
}
this.destroyCallbacks = [];
} }
private resizeCanvas(): void { private resizeAndDraw(): void {
if (!this.particleEngine) { const ctx = this.ctx;
if (!ctx) {
return; return;
} }
const width = Math.max(1, window.innerWidth);
const height = Math.max(1, window.innerHeight);
const canvas = this.canvasRef.nativeElement; const canvas = this.canvasRef.nativeElement;
const width = window.innerWidth;
const height = window.innerHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(width * dpr); if (canvas.width === width && canvas.height === height) {
canvas.height = Math.floor(height * dpr); return;
}
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`; canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`; canvas.style.height = `${height}px`;
this.particleEngine.resize(width, height, dpr); const gradient = ctx.createRadialGradient(
this.renderInitialFrame(); width * 0.24,
} height * 0.12,
0,
private startAnimation(): void { width * 0.5,
if (this.frameId !== null) { height * 0.5,
return; Math.max(width, height)
} );
gradient.addColorStop(0, 'rgba(88, 166, 255, 0.14)');
this.lastFrameTime = performance.now(); gradient.addColorStop(0.42, 'rgba(88, 166, 255, 0.18)');
const step = (now: number) => { gradient.addColorStop(0.72, 'rgba(147, 102, 255, 0.1)');
if (!this.particleEngine) { gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
return; ctx.clearRect(0, 0, width, height);
} ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
const delta = now - this.lastFrameTime;
this.lastFrameTime = now;
this.syncPointer();
this.particleEngine.renderFrame(delta);
this.frameId = requestAnimationFrame(step);
};
this.frameId = requestAnimationFrame(step);
}
private stopAnimation(): void {
if (this.frameId !== null) {
cancelAnimationFrame(this.frameId);
this.frameId = null;
}
}
private handlePointerMove(event: PointerEvent): void {
if (event.pointerType === 'touch' && this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
return;
}
this.updatePointer(event.clientX, event.clientY, true);
}
private handlePointerDown(event: PointerEvent): void {
if (event.pointerType === 'touch') {
this.activeTouchId = event.pointerId;
}
this.isPointerDown = true;
this.updatePointer(event.clientX, event.clientY, true);
}
private handlePointerUp(event: PointerEvent): void {
this.isPointerDown = false;
if (event.pointerType === 'touch') {
if (this.activeTouchId !== null && event.pointerId !== this.activeTouchId) {
return;
}
this.activeTouchId = null;
this.resetPointerInteraction();
return;
}
this.resetPointerInteraction();
}
private handleMouseMove(event: MouseEvent): void {
this.updatePointer(event.clientX, event.clientY, true);
}
private handleMouseDown(event: MouseEvent): void {
this.isPointerDown = true;
this.updatePointer(event.clientX, event.clientY, true);
}
private handleMouseUp(): void {
this.resetPointerInteraction();
}
private handleTouchStart(event: TouchEvent): void {
const touch = event.changedTouches.item(0);
if (!touch) {
return;
}
this.activeTouchId = touch.identifier;
this.isPointerDown = true;
this.updatePointer(touch.clientX, touch.clientY, true);
}
private handleTouchMove(event: TouchEvent): void {
if (this.activeTouchId === null) {
return;
}
const touch = this.findTouch(event.touches, this.activeTouchId);
if (!touch) {
return;
}
this.updatePointer(touch.clientX, touch.clientY, true);
}
private handleTouchEnd(event: TouchEvent): void {
if (this.activeTouchId === null) {
return;
}
const touch = this.findTouch(event.changedTouches, this.activeTouchId);
if (!touch) {
return;
}
this.isPointerDown = false;
this.resetPointerInteraction();
}
private handlePointerLeave(): void {
this.resetPointerInteraction();
}
private resetPointerInteraction(): void {
this.pointerActive = false;
this.isPointerDown = false;
this.activeTouchId = null;
this.cursorTransform = 'translate3d(-9999px, -9999px, 0)';
}
private updatePointer(x: number, y: number, active: boolean): void {
this.pointerX = x;
this.pointerY = y;
this.pointerActive = active;
this.cursorTransform = `translate3d(${x}px, ${y}px, 0)`;
}
private findTouch(touches: TouchList, identifier: number): Touch | null {
for (let index = 0; index < touches.length; index += 1) {
const touch = touches.item(index);
if (touch && touch.identifier === identifier) {
return touch;
}
}
return null;
}
private syncPointer(): void {
this.particleEngine?.setPointer({
x: this.pointerX,
y: this.pointerY,
active: this.pointerActive,
pressed: this.isPointerDown,
});
}
private handleMotionPreferenceChange(reducedMotion: boolean): void {
this.reducedMotion = reducedMotion;
if (this.reducedMotion) {
this.stopAnimation();
this.syncPointer();
this.particleEngine?.renderStatic();
return;
}
this.startAnimation();
}
private renderInitialFrame(): void {
this.syncPointer();
this.particleEngine?.renderStatic();
}
private getMediaQuery(query: string): MediaQueryList {
if (typeof window.matchMedia === 'function') {
return window.matchMedia(query);
}
return {
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
};
}
private static detectFinePointer(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(pointer: fine)').matches;
} }
} }

View File

@@ -0,0 +1,208 @@
:host {
display: block;
}
.section {
margin-bottom: 4rem;
scroll-margin-top: 5.5rem;
}
h2 {
margin: 0;
font-size: clamp(1.5rem, 2.2vw, 2rem);
}
.section-title-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 1.3rem;
}
.section-title-row p {
margin: 0;
color: #8b949e;
}
.stack-carousel-shell {
--carousel-nav-space: 4.25rem;
position: relative;
width: calc(100% + (var(--carousel-nav-space) * 2));
margin-inline: calc(var(--carousel-nav-space) * -1);
overflow: visible;
}
.stack-carousel-column {
position: relative;
width: calc(100% - (var(--carousel-nav-space) * 2));
margin-inline: auto;
overflow: hidden;
}
.stack-carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
width: 100%;
padding: 0.2rem;
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
}
.stack-slide {
flex: 0 0 min(320px, calc(100% - 0.2rem));
scroll-snap-align: start;
}
.skill-heading {
display: flex;
align-items: center;
gap: 0.65rem;
}
.skill-heading h3 {
margin: 0;
font-size: 1.08rem;
}
.skill-icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: 2rem;
height: 2rem;
border-radius: 10px;
border: 1px solid rgba(88, 166, 255, 0.4);
background: radial-gradient(circle at 30% 20%, rgba(88, 166, 255, 0.2), rgba(22, 27, 34, 0.95));
box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.08);
overflow: hidden;
transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
}
.skill-logo {
width: 1.2rem;
height: 1.2rem;
object-fit: contain;
opacity: 0.92;
transition: transform 140ms ease, opacity 140ms ease;
}
.skill-fallback {
display: none;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
color: #9ecbff;
}
.skill-icon.is-fallback .skill-logo {
display: none;
}
.skill-icon.is-fallback .skill-fallback {
display: inline;
}
.skill-card {
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.skill-card .meta {
margin: 0.9rem 0 0;
color: #8b949e;
font-size: 0.92rem;
}
.stack-slide:hover .skill-icon,
.stack-slide:focus-within .skill-icon {
border-color: rgba(121, 192, 255, 0.75);
box-shadow: 0 0 0 3px rgba(56, 139, 253, 0.18), inset 0 1px 0 rgba(240, 246, 252, 0.14);
background: radial-gradient(circle at 30% 20%, rgba(121, 192, 255, 0.28), rgba(22, 27, 34, 0.95));
}
.stack-slide:hover .skill-logo,
.stack-slide:focus-within .skill-logo {
opacity: 1;
transform: scale(1.04);
}
.carousel-btn {
position: absolute;
top: 50%;
width: 2.5rem;
height: 2.5rem;
border: 1px solid #3d444d;
border-radius: 999px;
background: rgba(33, 38, 45, 0.8);
color: #e6edf3;
cursor: pointer;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(calc(-50% + 2px));
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease, opacity 120ms ease;
}
.stack-carousel-shell > .carousel-btn:first-of-type {
left: 0.85rem;
}
.stack-carousel-shell > .carousel-btn:last-of-type {
right: 0.85rem;
}
.stack-carousel-shell:hover .carousel-btn,
.stack-carousel-shell:focus-within .carousel-btn {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(-50%);
}
.carousel-btn:hover {
border-color: #58a6ff;
background: rgba(56, 139, 253, 0.2);
transform: translateY(calc(-50% - 1px));
}
.carousel-btn:focus-visible {
outline: 2px solid #58a6ff;
outline-offset: 2px;
}
@media (max-width: 760px) {
.stack-carousel-shell {
--carousel-nav-space: 0;
width: 100%;
margin-inline: 0;
}
.stack-carousel-column {
width: 100%;
}
.stack-carousel {
scroll-padding-inline: 0.5rem;
}
.carousel-btn {
display: none;
}
.stack-slide {
flex-basis: min(280px, 85%);
scroll-snap-align: center;
}
}
@media (min-width: 1000px) {
.stack-slide {
flex-basis: calc((100% - 3rem) / 4);
}
}

View File

@@ -0,0 +1,48 @@
<section class="section" id="stack">
<div class="section-title-row">
<h2>Tech Stack</h2>
<p>Primary technologies I use to deliver production-ready software.</p>
</div>
<div
class="stack-carousel-shell"
data-disable-bg-pointer
>
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
<span aria-hidden="true">&larr;</span>
</button>
<div class="stack-carousel-column">
<div
class="stack-carousel"
#stackCarousel
role="region"
aria-label="Tech stack carousel"
>
@for (skill of skills; track skill.name) {
<app-card cardClass="skill-card stack-slide">
<div class="skill-heading">
<span class="skill-icon" role="img" [attr.aria-label]="skill.name + ' logo'">
<img
class="skill-logo"
[src]="skill.logoUrl"
[alt]="skill.name + ' logo'"
loading="lazy"
decoding="async"
(error)="onLogoError($event)"
/>
<span class="skill-fallback" aria-hidden="true">{{ skill.fallbackLabel }}</span>
</span>
<h3>{{ skill.name }}</h3>
</div>
<p class="meta">{{ skill.category }} - {{ skill.level }}</p>
</app-card>
}
</div>
</div>
<button class="carousel-btn" type="button" aria-label="Next technologies" (click)="scrollStack(1)">
<span aria-hidden="true">&rarr;</span>
</button>
</div>
</section>

View File

@@ -0,0 +1,94 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { CardComponent } from '../card/card';
interface Skill {
name: string;
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment';
level: string;
logoUrl: string;
fallbackLabel: string;
}
@Component({
selector: 'app-tech-stack-carousel',
imports: [CardComponent],
templateUrl: './tech-stack-carousel.html',
styleUrl: './tech-stack-carousel.css',
})
export class TechStackCarousel implements AfterViewInit, OnDestroy {
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
private autoScrollIntervalId: ReturnType<typeof setInterval> | null = null;
private readonly autoScrollIntervalMs = 5000;
readonly skills: Skill[] = [
{ name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' },
{ name: 'React', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/react.svg', fallbackLabel: 'RE' },
{ name: 'Ionic', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/ionic.svg', fallbackLabel: 'IO' },
{ name: 'TypeScript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/typescript.svg', fallbackLabel: 'TS' },
{ name: 'Javascript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/javascript.svg', fallbackLabel: 'JS' },
{ name: 'Docker', category: 'Deployment', level: 'Advanced', logoUrl: '/tech-logos/docker.svg', fallbackLabel: 'DK' },
{ name: 'C# / .NET', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/csharp.svg', fallbackLabel: 'CS' },
{ name: 'ASP.NET Core', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/dotnet.svg', fallbackLabel: 'AS' },
{ name: 'WPF', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/wpf.svg', fallbackLabel: 'WP' },
{ name: 'Java', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/java.svg', fallbackLabel: 'JV' },
{ name: 'Spring Boot', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/spring.svg', fallbackLabel: 'SB' },
{ name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' },
];
ngAfterViewInit(): void {
this.startAutoScroll();
}
ngOnDestroy(): void {
this.stopAutoScroll();
}
scrollStack(direction: -1 | 1): void {
const carousel = this.stackCarousel?.nativeElement;
if (!carousel) {
return;
}
const maxScrollLeft = Math.max(carousel.scrollWidth - carousel.clientWidth, 0);
if (maxScrollLeft <= 0) {
return;
}
if (direction === 1 && carousel.scrollLeft >= maxScrollLeft) {
carousel.scrollTo({ left: 0, behavior: 'auto' });
return;
}
carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
}
onLogoError(event: Event): void {
const image = event.target as HTMLImageElement | null;
const wrapper = image?.closest('.skill-icon');
wrapper?.classList.add('is-fallback');
}
private getScrollStep(carousel: HTMLElement): number {
return Math.max(carousel.clientWidth * 0.82, 260);
}
private startAutoScroll(): void {
if (this.autoScrollIntervalId !== null) {
return;
}
this.autoScrollIntervalId = setInterval(() => {
this.scrollStack(1);
}, this.autoScrollIntervalMs);
}
private stopAutoScroll(): void {
if (this.autoScrollIntervalId === null) {
return;
}
clearInterval(this.autoScrollIntervalId);
this.autoScrollIntervalId = null;
}
}

View File

@@ -161,10 +161,11 @@ h2 {
@media (max-width: 760px) { @media (max-width: 760px) {
.page-wrap { .page-wrap {
padding-top: 0.5rem; padding: 0.5rem 0.75rem 3.5rem;
} }
.hero { .hero {
padding: 2rem 1.2rem; padding: 2rem 1.2rem;
} }
} }

View File

@@ -29,19 +29,6 @@
</div> </div>
</section> </section>
<section class="section" id="stack"> <app-tech-stack-carousel></app-tech-stack-carousel>
<div class="section-title-row">
<h2>Tech Stack</h2>
<p>Primary technologies I use to deliver production-ready software.</p>
</div>
<div class="card-grid">
@for (skill of skills; track skill.name) {
<app-card cardClass="skill-card">
<h3>{{ skill.name }}</h3>
<p class="meta">{{ skill.category }} - {{ skill.level }}</p>
</app-card>
}
</div>
</section>
</div> </div>

View File

@@ -37,4 +37,20 @@ describe('Home', () => {
expect(links.length).toBe(component.projects.length); expect(links.length).toBe(component.projects.length);
expect(links[0]?.getAttribute('href')).toContain('/projects/tasktimer'); expect(links[0]?.getAttribute('href')).toContain('/projects/tasktimer');
}); });
it('should render stack carousel with icon badges', () => {
const compiled = fixture.nativeElement as HTMLElement;
const stackSection = compiled.querySelector('app-tech-stack-carousel #stack');
const carousel = compiled.querySelector('app-tech-stack-carousel .stack-carousel');
const icons = compiled.querySelectorAll('app-tech-stack-carousel .skill-icon');
const logos = compiled.querySelectorAll('app-tech-stack-carousel .skill-logo');
const controls = compiled.querySelectorAll('app-tech-stack-carousel .carousel-btn');
expect(stackSection).toBeTruthy();
expect(carousel).toBeTruthy();
expect(icons.length).toBeGreaterThan(0);
expect(logos.length).toBe(icons.length);
expect(logos[0]?.getAttribute('src')).toContain('/tech-logos/');
expect(controls.length).toBe(2);
});
}); });

View File

@@ -1,17 +1,12 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { CardComponent } from '../../components/card/card'; import { CardComponent } from '../../components/card/card';
import { TechStackCarousel } from '../../components/tech-stack-carousel/tech-stack-carousel';
import { PROJECTS, Project } from '../projects/project-data'; import { PROJECTS, Project } from '../projects/project-data';
interface Skill {
name: string;
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment';
level: string;
}
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [RouterLink, CardComponent], imports: [RouterLink, CardComponent, TechStackCarousel],
templateUrl: './home.html', templateUrl: './home.html',
styleUrl: './home.css', styleUrl: './home.css',
}) })
@@ -23,20 +18,5 @@ export class Home {
focus: 'Focused on performance, clean architecture, and product-minded delivery.', focus: 'Focused on performance, clean architecture, and product-minded delivery.',
}; };
readonly skills: Skill[] = [
{ name: 'Angular', category: 'Frontend', level: 'Advanced' },
{ name: 'React', category: 'Frontend', level: 'Advanced' },
{ name: 'Ionic', category: 'Frontend', level: 'Advanced' },
{ name: 'TypeScript', category: 'Frontend / Backend', level: 'Advanced' },
{ name: 'Javascript', category: 'Frontend / Backend', level: 'Advanced' },
{ name: 'Docker', category: 'Deployment', level: 'Advanced' },
{ name: 'C# / .NET', category: 'Frontend / Backend', level: 'Advanced' },
{ name: 'ASP.NET Core', category: 'Backend', level: 'Advanced' },
{ name: 'WPF', category: 'Frontend', level: 'Advanced' },
{ name: 'Java', category: 'Backend', level: 'Advanced' },
{ name: 'Spring Boot', category: 'Backend', level: 'Advanced' },
{ name: 'Flutter', category: 'Mobile', level: 'Advanced' },
];
readonly projects: Project[] = PROJECTS; readonly projects: Project[] = PROJECTS;
} }

View File

@@ -82,7 +82,7 @@ a {
@media (max-width: 760px) { @media (max-width: 760px) {
.page-shell { .page-shell {
padding-top: 0.5rem; padding: 0.5rem 0.75rem 3.5rem;
} }
.hero-panel { .hero-panel {