Compare commits
22 Commits
lightningT
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2fc5e65f85
|
|||
| 38f1db1321 | |||
|
fb06dca81d
|
|||
|
7108f22e8b
|
|||
|
f7eceda442
|
|||
|
1a00b77511
|
|||
|
9531e5aad4
|
|||
|
2de74a9882
|
|||
|
211a018321
|
|||
| 861fdb59ec | |||
| 1470b0723b | |||
| 67676721d6 | |||
| 15294f95a1 | |||
| 7288edd019 | |||
| e5474716ec | |||
| 834d884e07 | |||
| 9864a9c6cb | |||
| 17fbfc5022 | |||
| 6f30fa2260 | |||
| 3072668e05 | |||
| 7032968fe2 | |||
| eb1dd36195 |
@@ -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
|
||||||
34
README.md
@@ -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:
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "5kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "5kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const { contextBridge } = require('electron');
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('desktop', {
|
|
||||||
isElectron: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
3981
package-lock.json
generated
27
package.json
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
public/tech-logos/angular.svg
Normal 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 |
1
public/tech-logos/csharp.svg
Normal 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 |
1
public/tech-logos/docker.svg
Normal 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 |
1
public/tech-logos/dotnet.svg
Normal 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 |
1
public/tech-logos/flutter.svg
Normal 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 |
1
public/tech-logos/ionic.svg
Normal 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 |
1
public/tech-logos/java.svg
Normal 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 |
1
public/tech-logos/javascript.svg
Normal 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 |
1
public/tech-logos/react.svg
Normal 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 |
1
public/tech-logos/spring.svg
Normal 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 |
1
public/tech-logos/typescript.svg
Normal 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 |
1
public/tech-logos/wpf.svg
Normal 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
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
208
src/components/tech-stack-carousel/tech-stack-carousel.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
48
src/components/tech-stack-carousel/tech-stack-carousel.html
Normal 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">←</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">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
94
src/components/tech-stack-carousel/tech-stack-carousel.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||