Compare commits

..

39 Commits

Author SHA1 Message Date
32d9d22f98 add gitignore to release 2026-04-16 08:37:26 +02:00
602348c934 Add electron 2026-04-16 08:04:32 +02:00
13a6c24cd1 rename to allProjects
All checks were successful
publish.yml / publish (push) Successful in 1m2s
2026-04-15 23:26:07 +02:00
92cf05a6ef fixed ring around click on button
All checks were successful
publish.yml / publish (push) Successful in 1m12s
2026-04-15 23:23:26 +02:00
0b2ea1f3bc fixed error
All checks were successful
publish.yml / publish (push) Successful in 1m17s
2026-04-15 23:17:39 +02:00
22e20a04db simplify styling
All checks were successful
publish.yml / publish (push) Successful in 1m5s
2026-04-15 23:14:43 +02:00
adf7f310fe use inter font to prevent different layouts on different browsers
All checks were successful
publish.yml / publish (push) Successful in 1m6s
2026-04-15 23:09:39 +02:00
2317949b53 fixed eyebrow vertical centering
All checks were successful
publish.yml / publish (push) Successful in 1m8s
2026-04-15 22:56:57 +02:00
14cbf6ab0d move experience section from home to about and center eyebrows properly
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-15 22:28:48 +02:00
4730d3592f Add Projects page with detail view, removed navbar entries that only led to home and made featured projects links to the new site
All checks were successful
publish.yml / publish (push) Successful in 1m9s
2026-04-15 22:14:45 +02:00
f0f07b4719 change to preloaded components
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-13 21:35:31 +02:00
255eeffce2 fix base href
All checks were successful
publish.yml / publish (push) Successful in 1m18s
2026-04-11 23:15:36 +02:00
ce135f6bf2 move to / subpath
All checks were successful
publish.yml / publish (push) Successful in 1m11s
2026-04-10 12:45:37 +02:00
dd765b8522 revert workflow changes
All checks were successful
publish.yml / publish (push) Successful in 1m10s
2026-04-09 23:36:45 +02:00
0da85f7f1d change Title
Some checks failed
publish.yml / publish (push) Failing after 5s
2026-04-09 23:34:30 +02:00
4514bc1d8b add custom cursor
All checks were successful
publish.yml / publish (push) Successful in 2m51s
2026-04-09 22:13:26 +02:00
19551194c3 Add Docker
Some checks failed
publish.yml / publish (push) Has been cancelled
2026-04-09 22:13:14 +02:00
108f61b509 update text
All checks were successful
publish.yml / publish (push) Successful in 1m0s
2026-04-08 23:05:16 +02:00
170fec7a20 add github + LinkedIn to footer
All checks were successful
publish.yml / publish (push) Successful in 59s
2026-04-08 10:25:00 +02:00
f89bdda3be Move navbar to top and increase transparency
All checks were successful
publish.yml / publish (push) Successful in 1m7s
2026-04-08 09:59:35 +02:00
f10916532e Add lifetime to particles
All checks were successful
publish.yml / publish (push) Successful in 1m0s
2026-04-08 09:46:07 +02:00
e06a5a29b4 Add interactivity
All checks were successful
publish.yml / publish (push) Successful in 56s
2026-04-08 09:27:45 +02:00
00a49f0a11 Add particle effect
All checks were successful
publish.yml / publish (push) Successful in 1m9s
2026-04-08 09:20:17 +02:00
4540f1895d no longer preloads
All checks were successful
publish.yml / publish (push) Successful in 59s
2026-04-06 20:36:10 +02:00
3a47b5fcbc update workflow to gitea folder
All checks were successful
publish.yml / publish (push) Successful in 57s
2026-04-06 20:32:40 +02:00
5f3d3314ed update workflow
All checks were successful
publish.yml / publish (push) Successful in 1m4s
2026-04-06 20:30:39 +02:00
8ad136cd6c update workflow
Some checks failed
publish.yml / publish (push) Has been cancelled
2026-04-06 20:26:31 +02:00
3de176d3b7 update workflow
Some checks failed
publish.yml / publish (push) Failing after 37s
2026-04-06 20:24:23 +02:00
866ee455ea update workflow
Some checks failed
publish.yml / publish (push) Has been cancelled
2026-04-06 20:23:45 +02:00
291f1bd93b added corrected timeline info
Some checks failed
publish.yml / publish (push) Failing after 2m21s
2026-04-05 15:39:22 +02:00
a0fa0455ed Add favicon generation script and use favicon 2026-04-05 15:10:05 +02:00
cfd1782ca7 center about/contact pill 2026-04-05 14:44:10 +02:00
608c32cd8c add contact call-to-action button and improve contact layout 2026-04-05 14:06:45 +02:00
c5e602e9f0 update contact information, adjust layout styles, and add data to skills/projects sections 2026-04-05 13:47:49 +02:00
c767c0b1a0 add correct name and improve navbar 2026-04-05 12:38:04 +02:00
4510d9e72e add caching 2026-04-05 00:34:42 +02:00
f41b7de156 add login 2026-04-05 00:32:44 +02:00
ceb413e672 update action 2026-04-05 00:30:19 +02:00
92c67bf956 add versions to uses 2026-04-05 00:20:01 +02:00
47 changed files with 6149 additions and 381 deletions

View File

@@ -0,0 +1,24 @@
name: publish.yml
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKERHUB_PROJECT_NAME }}:latest
cache-from: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKERHUB_PROJECT_NAME }}:cache
cache-to: type=registry,ref=${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKERHUB_PROJECT_NAME }}:cache,mode=max

View File

@@ -1,16 +0,0 @@
name: publish.yml
on:
push:
branches:
- main
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout
- name: Build and push Docker image
id: push
uses: docker/build-push-action
with:
push: true
tags: docker.io/tikaiz/website-frontend:latest

View File

@@ -7,6 +7,6 @@ RUN npm run build
FROM nginx:alpine-slim FROM nginx:alpine-slim
EXPOSE 80 EXPOSE 80
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/website/browser /usr/share/nginx/html/web COPY --from=build /app/dist/website/browser /usr/share/nginx/html
ENTRYPOINT ["nginx", "-g", "daemon off;"] ENTRYPOINT ["nginx", "-g", "daemon off;"]

View File

@@ -36,6 +36,40 @@ 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

@@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json", "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1, "version": 1,
"cli": { "cli": {
"packageManager": "npm" "packageManager": "npm",
"analytics": false
}, },
"newProjectRoot": "projects", "newProjectRoot": "projects",
"projects": { "projects": {
@@ -16,7 +17,7 @@
"build": { "build": {
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"baseHref": "/web/", "baseHref": "/",
"browser": "src/main.ts", "browser": "src/main.ts",
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [

50
electron/main.cjs Normal file
View File

@@ -0,0 +1,50 @@
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();
}
});

6
electron/preload.cjs Normal file
View File

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

View File

@@ -3,7 +3,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
location /web/ { location / {
try_files $uri $uri/ /web/index.html; try_files $uri $uri/ /index.html;
} }
} }

3981
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,17 @@
{ {
"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",
@@ -24,9 +29,29 @@
"@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"
}
} }
} }

85
public/cursor.svg Normal file
View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="20"
height="32"
viewBox="0 0 9.0002502 11.493061"
version="1.1"
id="svg1"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
sodipodi:docname="cursor.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="32.982939"
inkscape:cx="7.4280827"
inkscape:cy="14.841006"
inkscape:window-width="2560"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="mm"
originx="-0.52855417"
originy="-2.9445797"
spacingx="0.99999998"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="false" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect1"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0.31220508,0,1 @ F,0,0,1,0,0.58656829,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.60351479,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-0.52855407,-2.989978)">
<path
style="fill:#0f141c;fill-opacity:1;stroke:#2576f0;stroke-width:0.800063;stroke-linecap:butt;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal"
d="m 42.19942,41.257705 7.778092,5.922499 a 0.57220158,0.57220158 82.997192 0 1 0.09987,0.813075 l -1.557549,1.943616 1.386271,1.867573 -1.91543,1.448072 -1.404442,-2.060386 -2.258596,0.699105 A 0.52549615,0.52549615 31.754153 0 1 43.653656,51.474118 L 42.001441,41.376677 a 0.12430571,0.12430571 148.99703 0 1 0.197979,-0.118972 z"
id="path1"
sodipodi:nodetypes="cccccccc"
inkscape:path-effect="#path-effect1"
inkscape:original-d="m 41.951026,41.068569 8.493167,6.466982 -1.924357,2.401344 1.386271,1.867573 -1.91543,1.448072 -1.404442,-2.060386 -2.835124,0.877558 z"
transform="translate(-41.071737,-38.504754)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

2
release/.gitignore vendored Normal file
View File

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

View File

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

View File

@@ -1,28 +1,44 @@
.shell { .shell {
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
isolation: isolate;
} }
.content { .content {
position: relative;
z-index: 2;
max-width: 1500px; max-width: 1500px;
min-height: 100svh;
margin: 0 auto; margin: 0 auto;
padding: 1.5rem 1.25rem 4rem; padding: 7.5rem 1.25rem 3rem;
display: flex;
flex-direction: column;
box-sizing: border-box;
} }
.sidebar { .sidebar {
position: fixed; position: fixed;
top: 1.5rem; top: 1rem;
right: 1.5rem; left: 50%;
width: 280px; transform: translateX(-50%);
width: min(1200px, calc(100% - 3rem));
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 1rem; justify-content: space-between;
padding: 1.2rem; gap: 1.25rem;
padding: 0.9rem 1.2rem;
border: 1px solid #30363d; border: 1px solid #30363d;
border-radius: 20px; border-radius: 16px;
background: rgba(22, 27, 34, 0.92); background: rgba(22, 27, 34, 0.75);
backdrop-filter: blur(10px); backdrop-filter: blur(9px);
z-index: 20; z-index: 3;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
} }
.brand { .brand {
@@ -41,12 +57,14 @@
.nav { .nav {
display: flex; display: flex;
flex-direction: column; flex: 1;
gap: 0.35rem; justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
} }
.nav a { .nav a {
padding: 0.7rem 0.8rem; padding: 0.55rem 0.8rem;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 12px; border-radius: 12px;
color: #c9d1d9; color: #c9d1d9;
@@ -63,19 +81,110 @@
outline: none; outline: none;
} }
.burger {
display: none;
width: 2.25rem;
height: 2.25rem;
border: 1px solid #30363d;
border-radius: 10px;
background: rgba(13, 17, 23, 0.65);
cursor: pointer;
padding: 0.25rem;
}
.burger span {
display: block;
width: 1.2rem;
height: 2px;
margin: 0.24rem auto;
background: #e6edf3;
}
.site-footer {
margin-top: auto;
padding-top: 2rem;
text-align: center;
color: #8b949e;
}
.site-footer p {
margin: 0;
}
.site-footer a {
color: #58a6ff;
text-decoration: none;
}
.footer-socials {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.footer-icon-link {
display: inline-flex;
vertical-align: text-bottom;
}
.footer-icon-link svg {
width: 1.1rem;
height: 1.1rem;
fill: currentColor;
}
.site-footer a:hover,
.site-footer a:focus-visible {
color: #79c0ff;
text-decoration: underline;
}
@media (max-width: 1320px) { @media (max-width: 1320px) {
.content { .content {
max-width: 1040px; max-width: 1040px;
} }
.sidebar { .sidebar {
position: static; width: calc(100% - 2rem);
}
}
@media (max-width: 768px) {
.content {
padding-top: calc(6.5rem + env(safe-area-inset-top));
}
.sidebar {
position: fixed;
top: 0.75rem;
right: 0.75rem;
left: 0.75rem;
transform: none;
width: auto; width: auto;
margin: 0 1.25rem 1.5rem; margin: 0;
gap: 0.6rem;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
border-radius: 16px;
}
.subtitle {
display: none;
}
.burger {
display: inline-block;
} }
.nav { .nav {
flex-direction: row; flex: 0 0 auto;
flex-wrap: wrap; display: none;
flex-direction: column;
gap: 0.4rem;
}
.nav.nav-open {
display: flex;
} }
} }

View File

@@ -1,19 +1,64 @@
<div class="shell"> <div class="shell">
<main class="content"> <app-interactive-background></app-interactive-background>
<router-outlet></router-outlet>
</main>
<aside class="sidebar"> <aside class="sidebar">
<a class="brand" routerLink="/home">Alex Carter</a> <div class="sidebar-header">
<p class="subtitle">Fullstack Developer</p> <a class="brand" routerLink="/home" (click)="closeMenu()">Tim Kainz</a>
<button
class="burger"
type="button"
aria-label="Toggle navigation"
[attr.aria-expanded]="isMenuOpen"
(click)="toggleMenu()"
>
<span></span>
<span></span>
<span></span>
</button>
</div>
<nav class="nav" aria-label="Primary"> <nav class="nav" aria-label="Primary" [class.nav-open]="isMenuOpen">
<a routerLink="/home" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a> <a routerLink="/home" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }" (click)="closeMenu()">Home</a>
<a routerLink="/about" routerLinkActive="active">About</a> <a routerLink="/projects" routerLinkActive="active" (click)="closeMenu()">Projects</a>
<a routerLink="/home" fragment="stack">Tech Stack</a> <a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">About</a>
<a routerLink="/home" fragment="projects">Projects</a> <a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">Contact</a>
<a routerLink="/home" fragment="experience">Experience</a>
<a routerLink="/contact" routerLinkActive="active">Contact</a>
</nav> </nav>
</aside> </aside>
<main class="content">
<router-outlet></router-outlet>
<footer class="site-footer">
<p>
(c) {{ currentYear }} Tim Kainz | +43 677 62678219 |
<span class="footer-socials">
<a
class="footer-icon-link"
href="https://github.com/KainTim"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub profile"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M12 0.297a12 12 0 0 0-3.793 23.389c0.6 0.111 0.82-0.261 0.82-0.579 0-0.286-0.01-1.043-0.016-2.047-3.338 0.725-4.042-1.61-4.042-1.61-0.546-1.388-1.333-1.757-1.333-1.757-1.09-0.745 0.082-0.729 0.082-0.729 1.206 0.085 1.841 1.238 1.841 1.238 1.072 1.836 2.813 1.305 3.498 0.998 0.108-0.776 0.42-1.305 0.763-1.605-2.665-0.304-5.467-1.333-5.467-5.931 0-1.31 0.468-2.381 1.235-3.221-0.124-0.303-0.535-1.524 0.117-3.176 0 0 1.008-0.322 3.3 1.23a11.52 11.52 0 0 1 6.006 0c2.291-1.552 3.297-1.23 3.297-1.23 0.653 1.653 0.242 2.874 0.118 3.176 0.769 0.84 1.233 1.911 1.233 3.221 0 4.609-2.807 5.624-5.48 5.921 0.431 0.371 0.815 1.103 0.815 2.222 0 1.604-0.015 2.896-0.015 3.289 0 0.321 0.216 0.694 0.825 0.576A12 12 0 0 0 12 0.297"
></path>
</svg>
</a>
<a
class="footer-icon-link"
href="https://www.linkedin.com/in/tim-kainz-360772280"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn profile"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
d="M20.447 20.452h-3.554v-5.569c0-1.328-0.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667h-3.554v-11.453h3.412v1.561h0.049c0.476-0.9 1.637-1.85 3.37-1.85 3.604 0 4.27 2.372 4.27 5.456v6.286zM5.337 7.433a2.063 2.063 0 1 1 0-4.126 2.063 2.063 0 0 1 0 4.126zM7.114 20.452h-3.554v-11.453h3.554v11.453zM22.225 0h-20.451c-0.979 0-1.774 0.774-1.774 1.729v20.542c0 0.955 0.795 1.729 1.774 1.729h20.451c0.979 0 1.775-0.774 1.775-1.729v-20.542c0-0.955-0.796-1.729-1.775-1.729z"
></path>
</svg>
</a>
</span>
</p>
</footer>
</main>
</div> </div>

View File

@@ -1,11 +1,15 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { AboutComponent } from '../pages/about/about.component';
import { Contact } from '../pages/contact/contact';
import { Home } from '../pages/home/home'; import { Home } from '../pages/home/home';
import { About } from '../pages/about/about';
import { Contact } from '../pages/contact/contact';
import { Projects } from '../pages/projects/projects';
import { ProjectDetail } from '../pages/projects/project-detail/project-detail';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' }, { path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: Home }, { path: 'home', component: Home },
{ path: 'about', component: AboutComponent }, { path: 'about', component: About },
{ path: 'contact', component: Contact }, { path: 'contact', component: Contact },
{ path: 'projects', component: Projects },
{ path: 'projects/:projectName', component: ProjectDetail },
]; ];

View File

@@ -22,7 +22,9 @@ describe('App', () => {
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-interactive-background')).toBeTruthy();
expect(compiled.querySelector('.sidebar')).toBeTruthy(); expect(compiled.querySelector('.sidebar')).toBeTruthy();
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4); expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
expect(compiled.querySelector('.site-footer')).toBeTruthy();
}); });
}); });

View File

@@ -1,10 +1,22 @@
import { Component } from '@angular/core'; import { Component } 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';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterLink, RouterLinkActive, RouterOutlet], imports: [RouterLink, RouterLinkActive, RouterOutlet, InteractiveBackground],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.css' styleUrl: './app.css'
}) })
export class App {} export class App {
isMenuOpen = false;
readonly currentYear = new Date().getFullYear();
toggleMenu(): void {
this.isMenuOpen = !this.isMenuOpen;
}
closeMenu(): void {
this.isMenuOpen = false;
}
}

View File

@@ -0,0 +1,49 @@
:host {
position: fixed;
inset: 0;
z-index: 1;
pointer-events: none;
}
.background-layer {
position: absolute;
inset: 0;
overflow: hidden;
}
.particle-canvas {
width: 100%;
height: 100%;
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

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

View File

@@ -0,0 +1,606 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild, inject } 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({
selector: 'app-interactive-background',
standalone: true,
templateUrl: './interactive-background.html',
styleUrl: './interactive-background.css'
})
export class InteractiveBackground implements AfterViewInit, OnDestroy {
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
private readonly router = inject(Router);
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 {
const canvas = this.canvasRef.nativeElement;
const context = canvas.getContext('2d');
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 {
this.stopAnimation();
for (const callback of this.destroyCallbacks) {
callback();
}
this.destroyCallbacks = [];
}
private resizeCanvas(): void {
if (!this.particleEngine) {
return;
}
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);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
this.particleEngine.resize(width, height, dpr);
this.renderInitialFrame();
}
private startAnimation(): void {
if (this.frameId !== null) {
return;
}
this.lastFrameTime = performance.now();
const step = (now: number) => {
if (!this.particleEngine) {
return;
}
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;
}
}

367
src/generate-logo.py Normal file
View File

@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
Logo & Favicon Generator
Extracted from CSS color scheme:
Background: #161b22 (dark navy)
Border: #30363d (dark grey)
Primary text: #e6edf3 (near-white)
Secondary: #c9d1d9 (light grey)
Muted: #8b949e (medium grey)
Accent: #2f81f7 (vivid blue)
Outputs:
logo.png — 600 × 180 px (wide wordmark)
logo@2x.png — 1200 × 360 px (retina)
favicon.ico — 16×16, 32×32, 48×48 multi-size ICO
favicon-32.png
favicon-16.png
"""
from PIL import Image, ImageDraw, ImageFont
import math
import os
import struct
import io
# ── Palette ────────────────────────────────────────────────────────────────────
BG = (22, 27, 34, 255) # #161b22
BORDER = (48, 54, 61, 255) # #30363d
TEXT_PRI = (230, 237, 243, 255) # #e6edf3
TEXT_SEC = (201, 209, 217, 255) # #c9d1d9
TEXT_MUTE = (139, 148, 158, 255) # #8b949e
ACCENT = (47, 129, 247, 255) # #2f81f7
ACCENT_DIM= (47, 129, 247, 36) # translucent blue fill
# ── Helpers ────────────────────────────────────────────────────────────────────
def lerp_color(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(4))
def draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):
x0, y0, x1, y1 = xy
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=fill,
outline=outline, width=width)
def add_glow(img, center, radius, color, intensity=0.55):
"""Soft radial glow overlay using additive blending."""
glow = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(glow)
cx, cy = center
steps = 18
for i in range(steps, 0, -1):
t = i / steps
r = int(radius * t)
alpha = int(intensity * (1 - t) ** 0.6 * 255)
c = (*color[:3], alpha)
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c)
return Image.alpha_composite(img, glow)
def make_font(size):
"""Try to load a system sans-serif; fall back to PIL default."""
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
"/usr/share/fonts/truetype/ubuntu/Ubuntu-B.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"C:/Windows/Fonts/arialbd.ttf",
"C:/Windows/Fonts/calibrib.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except OSError:
pass
# Ask fontconfig for whatever sans-serif is available
try:
import subprocess
result = subprocess.run(
["fc-match", "--format=%{file}", "sans-serif:bold"],
capture_output=True, text=True
)
path = result.stdout.strip()
if path:
return ImageFont.truetype(path, size)
except Exception:
pass
return ImageFont.load_default(size)
def make_font_regular(size):
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/calibri.ttf",
]
for path in candidates:
if os.path.exists(path):
try:
return ImageFont.truetype(path, size)
except OSError:
pass
# Ask fontconfig for whatever sans-serif is available
try:
import subprocess
result = subprocess.run(
["fc-match", "--format=%{file}", "sans-serif"],
capture_output=True, text=True
)
path = result.stdout.strip()
if path:
return ImageFont.truetype(path, size)
except Exception:
pass
return ImageFont.load_default(size)
# ── Icon mark (the "gem" shape) ────────────────────────────────────────────────
def draw_icon_mark(draw, img, cx, cy, size):
"""
Draw a stylised hexagonal mark:
- dark fill with blue border
- inner accent diamond
- subtle glow
"""
s = size
# Flat-top hexagon vertices
hex_pts = [
(cx + s * math.cos(math.radians(a)),
cy + s * math.sin(math.radians(a)))
for a in range(0, 360, 60)
]
# Fill with subtle gradient-like layering
for layer in range(int(s), 0, -1):
t = layer / s
col = lerp_color(
(35, 43, 54, 255),
(22, 27, 34, 255),
t
)
pts = [
(cx + layer * math.cos(math.radians(a)),
cy + layer * math.sin(math.radians(a)))
for a in range(0, 360, 60)
]
draw.polygon(pts, fill=col)
# Border
draw.polygon(hex_pts, outline=ACCENT[:3] + (200,), width=max(2, size // 18))
# Inner diamond
d = s * 0.42
diamond = [
(cx, cy - d),
(cx + d, cy),
(cx, cy + d),
(cx - d, cy),
]
draw.polygon(diamond, fill=ACCENT[:3] + (230,))
# Tiny highlight dot
h = s * 0.12
draw.ellipse([cx - h, cy - h - d * 0.28,
cx + h, cy + h - d * 0.28],
fill=(255, 255, 255, 180))
return img
# ── Logo ───────────────────────────────────────────────────────────────────────
def build_logo(width=600, height=180, scale=1):
W, H = width * scale, height * scale
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
pad = int(16 * scale)
radius = int(20 * scale)
# Background card
draw_rounded_rect(draw,
[pad, pad, W - pad, H - pad],
radius=radius,
fill=BG,
outline=BORDER[:3],
width=max(1, scale))
icon_cx = int(H * 0.5)
icon_cy = int(H * 0.5)
icon_r = int(H * 0.28)
# Glow clipped to card bounds
card = img.crop([pad, pad, W - pad, H - pad])
glow_cx = icon_cx - pad
glow_cy = icon_cy - pad
card = add_glow(card, (glow_cx, glow_cy), int(icon_r * 1.6), ACCENT, 0.28)
img.paste(card, (pad, pad))
draw = ImageDraw.Draw(img)
# Icon mark
draw_icon_mark(draw, img, icon_cx, icon_cy, icon_r)
draw = ImageDraw.Draw(img) # refresh after composite
# Text area
text_x = icon_cx + icon_r + int(22 * scale)
brand_font = make_font(int(38 * scale))
sub_font = make_font_regular(int(16 * scale))
brand_text = "YourBrand"
sub_text = "Your tagline here"
# Brand name
draw.text((text_x, int(H * 0.22)), brand_text,
font=brand_font, fill=TEXT_PRI)
# Accent underline
bbox = draw.textbbox((text_x, int(H * 0.22)), brand_text, font=brand_font)
uw = bbox[2] - bbox[0]
uy = bbox[3] + int(4 * scale)
draw.rectangle([text_x, uy, text_x + uw, uy + max(2, int(3 * scale))],
fill=ACCENT[:3])
# Subtitle
draw.text((text_x, int(H * 0.63)), sub_text,
font=sub_font, fill=TEXT_MUTE)
# Thin right-side accent bar
bar_x = W - pad - max(3, int(4 * scale))
draw.rectangle([bar_x, pad + radius,
bar_x + max(3, int(4 * scale)), H - pad - radius],
fill=ACCENT[:3] + (120,))
return img
# ── Favicon mark ───────────────────────────────────────────────────────────────
def build_favicon_image(size):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
pad = max(1, size // 24)
radius = max(2, size // 8)
# Background
draw_rounded_rect(draw,
[pad, pad, size - pad, size - pad],
radius=radius,
fill=BG,
outline=BORDER[:3],
width=max(1, size // 24))
cx, cy = size // 2, size // 2
icon_r = int(size * 0.30)
card = img.crop([pad, pad, size - pad, size - pad])
card = add_glow(card, (cx - pad, cy - pad), int(icon_r * 1.6), ACCENT, 0.28)
img.paste(card, (pad, pad))
draw = ImageDraw.Draw(img)
draw_icon_mark(draw, img, cx, cy, icon_r)
return img
# ── ICO writer ─────────────────────────────────────────────────────────────────
def save_ico(images_dict, path):
"""
images_dict: {size: PIL.Image, ...} e.g. {16: img16, 32: img32, 48: img48}
"""
sizes = sorted(images_dict.keys())
entries = []
png_data_list = []
for s in sizes:
img = images_dict[s].convert("RGBA")
buf = io.BytesIO()
img.save(buf, format="PNG")
data = buf.getvalue()
png_data_list.append(data)
entries.append((s, len(data)))
# ICO header: RESERVED(2) TYPE(2) COUNT(2)
header = struct.pack("<HHH", 0, 1, len(sizes))
# Directory entries (16 bytes each), offsets calculated after header + dir
dir_size = 16 * len(sizes)
offset = 6 + dir_size
directory = b""
for i, (s, data_len) in enumerate(entries):
w = s if s < 256 else 0
h = s if s < 256 else 0
directory += struct.pack("<BBBBHHII",
w, h, # width, height (0 = 256)
0, # color count
0, # reserved
1, # color planes
32, # bits per pixel
data_len,
offset)
offset += data_len
with open(path, "wb") as f:
f.write(header + directory)
for data in png_data_list:
f.write(data)
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
out_dir = "./logos"
os.makedirs(out_dir, exist_ok=True)
print("Generating logo.png (600×180) …")
logo = build_logo(600, 180, scale=1)
logo.save(os.path.join(out_dir, "logo.png"))
print("Generating logo@2x.png (1200×360) …")
logo2x = build_logo(600, 180, scale=2)
logo2x.save(os.path.join(out_dir, "logo@2x.png"))
print("Generating favicon PNGs …")
fav16 = build_favicon_image(16)
fav32 = build_favicon_image(32)
fav48 = build_favicon_image(48)
fav64 = build_favicon_image(64)
fav128 = build_favicon_image(128)
fav16.save(os.path.join(out_dir, "favicon-16.png"))
fav32.save(os.path.join(out_dir, "favicon-32.png"))
fav64.save(os.path.join(out_dir, "favicon-64.png"))
fav128.save(os.path.join(out_dir, "favicon-128.png"))
print("Generating favicon.ico (48 + 64 + 128) …")
save_ico({ 48: fav48, 64: fav64, 128: fav128},
os.path.join(out_dir, "favicon.ico"))
print("\nDone! Files written:")
for name in ("logo.png", "logo@2x.png",
"favicon-16.png", "favicon-32.png", "favicon.ico"):
path = os.path.join(out_dir, name)
size_kb = os.path.getsize(path) / 1024
print(f" {name:<20} {size_kb:.1f} KB")
print("""
Customisation tips
──────────────────
• Change `brand_text` and `sub_text` near the top of build_logo().
• Swap the palette constants at the top of the file.
• The icon mark is drawn in draw_icon_mark() — replace it with an SVG
path or raster image to use your own symbol.
• Call build_logo(scale=3) for a 1800×540 print-resolution version.
""")
if __name__ == "__main__":
main()

View File

@@ -2,10 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Website</title> <title>Tim Kainz - Software Engineer</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>

2
src/logos/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*.ico
*.png

View File

@@ -1,36 +0,0 @@
import { Component } from '@angular/core';
import { CardComponent } from '../../components/card/card';
export interface Pillar {
title: string;
description: string;
}
@Component({
selector: 'app-about',
imports: [CardComponent],
templateUrl: './about.html',
styleUrl: './about.css',
})
export class AboutComponent {
readonly pillars: Pillar[] = [
{
title: 'Angular UI',
description: 'Component-driven interfaces with a strong focus on clarity, speed, and accessibility.',
},
{
title: 'C# Backend',
description: 'Reliable APIs and services shaped with clean architecture and maintainable domain logic.',
},
{
title: 'Flutter Mobile',
description: 'Polished mobile apps with shared design systems and a smooth native feel.',
},
];
readonly values: string[] = [
'Ship practical features with a thoughtful product mindset.',
'Keep the codebase simple, testable, and easy to evolve.',
'Balance visual polish with performance and real-world usability.',
];
}

View File

@@ -1,32 +1,7 @@
.page {
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.section {
margin-bottom: 3rem;
}
.hero { .hero {
padding: 2.5rem;
border: 1px solid rgba(110, 118, 129, 0.28);
border-radius: 24px;
background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.16), rgba(13, 17, 23, 0.9)); background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.16), rgba(13, 17, 23, 0.9));
} }
.eyebrow {
width: fit-content;
margin: 0 0 1rem;
padding: 0.3rem 0.7rem;
border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 999px;
color: #9ecbff;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1, h1,
h2, h2,
h3, h3,
@@ -35,38 +10,12 @@ ul {
margin: 0; margin: 0;
} }
h1 {
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 1.1;
}
.lede {
max-width: 65ch;
margin-top: 1rem;
color: #c9d1d9;
}
.section-title-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
margin-bottom: 1rem;
}
.section-title-row p, .section-title-row p,
.card p, .card p,
.story li { .story li {
color: #b6bec8; color: #b6bec8;
} }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
/* base .card container styles moved to src/components/card/card.css */ /* base .card container styles moved to src/components/card/card.css */
.card h3 { .card h3 {
@@ -81,12 +30,38 @@ h1 {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
@media (max-width: 760px) { .timeline {
.page { display: grid;
padding-top: 0.5rem; gap: 1rem;
} }
.hero { .timeline-item {
padding: 2rem 1.2rem; display: grid;
grid-template-columns: 260px 1fr;
gap: 1rem;
padding: 1.1rem;
border-left: 3px solid #2f81f7;
border-radius: 12px;
background: rgba(22, 27, 34, 0.85);
}
.timeline-item ul {
margin: 0;
padding-left: 1rem;
color: #c9d1d9;
}
.timeline-item li + li {
margin-top: 0.45rem;
}
.meta {
color: #8b949e;
}
@media (max-width: 760px) {
.timeline-item {
grid-template-columns: 1fr;
} }
} }

View File

@@ -1,19 +1,19 @@
<div class="page"> <div class="page page-shell">
<section class="hero section"> <section class="hero section section-base hero-panel">
<p class="eyebrow">About</p> <p class="eyebrow">About</p>
<h1>Fullstack developer crafting modern web, backend, and mobile products.</h1> <h1 class="hero-title">Fullstack developer crafting modern web, backend, and mobile products.</h1>
<p class="lede"> <p class="lede hero-lede">
I am Alex Carter, and I enjoy building software that feels clean, fast, and reliable from I am Tim Kainz, and I enjoy building software that feels clean, fast, and reliable from
the first screen to the last API call. the first screen to the last API call.
</p> </p>
</section> </section>
<section class="section"> <section class="section section-base">
<div class="section-title-row"> <div class="section-title-row section-title-shared">
<h2>What I bring</h2> <h2>What I bring</h2>
<p>Core strengths across the stack.</p> <p>Core strengths across the stack.</p>
</div> </div>
<div class="card-grid"> <div class="card-grid card-grid-shared">
@for (pillar of pillars; track pillar.title) { @for (pillar of pillars; track pillar.title) {
<app-card> <app-card>
<h3>{{ pillar.title }}</h3> <h3>{{ pillar.title }}</h3>
@@ -23,7 +23,7 @@
</div> </div>
</section> </section>
<section class="section story"> <section class="section section-base story">
<app-card> <app-card>
<h2>How I work</h2> <h2>How I work</h2>
<ul> <ul>
@@ -33,4 +33,26 @@
</ul> </ul>
</app-card> </app-card>
</section> </section>
<section class="section section-base section-anchor" id="experience">
<div class="section-title-row section-title-shared">
<h2>Experience</h2>
<p>Hands-on delivery in backend systems and product architecture.</p>
</div>
<div class="timeline">
@for (item of timeline; track item.role + item.company) {
<article class="timeline-item">
<div>
<h3>{{ item.role }}</h3>
<p class="meta">{{ item.company }} - {{ item.period }}</p>
</div>
<ul>
@for (highlight of item.highlights; track highlight) {
<li>{{ highlight }}</li>
}
</ul>
</article>
}
</div>
</section>
</div> </div>

View File

@@ -1,17 +1,17 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AboutComponent } from './about.component'; import { About } from './about';
describe('About', () => { describe('About', () => {
let component: AboutComponent; let component: About;
let fixture: ComponentFixture<AboutComponent>; let fixture: ComponentFixture<About>;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [AboutComponent], imports: [About],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(AboutComponent); fixture = TestBed.createComponent(About);
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
@@ -25,5 +25,7 @@ describe('About', () => {
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Fullstack developer'); expect(compiled.querySelector('h1')?.textContent).toContain('Fullstack developer');
expect(compiled.querySelectorAll('.card').length).toBe(component.pillars.length + 1); expect(compiled.querySelectorAll('.card').length).toBe(component.pillars.length + 1);
expect(compiled.querySelector('#experience h2')?.textContent).toContain('Experience');
expect(compiled.querySelectorAll('#experience .timeline-item').length).toBe(component.timeline.length);
}); });
}); });

View File

@@ -6,6 +6,13 @@ export interface Pillar {
description: string; description: string;
} }
interface TimelineItem {
role: string;
company: string;
period: string;
highlights: string[];
}
@Component({ @Component({
selector: 'app-about', selector: 'app-about',
imports: [CardComponent], imports: [CardComponent],
@@ -35,6 +42,19 @@ export class About {
'Keep the codebase simple, testable, and easy to evolve.', 'Keep the codebase simple, testable, and easy to evolve.',
'Balance visual polish with performance and real-world usability.', 'Balance visual polish with performance and real-world usability.',
]; ];
readonly timeline: TimelineItem[] = [
{
role: 'Internship - Backend & Architecture Engineer',
company: 'Industrie Informatik GmbH',
period: '2025',
highlights: [
'Implemented a Spring Boot API for the Tasktimer Application including database access and reporting capabilities.',
'Designed and implemented a reliable Jira connectivity solution for the Tasktimer backend, ensuring consistent data synchronization and performance.',
'Contributed to architectural discussions and decisions, influencing the overall direction and stability of the Tasktimer application.',
],
},
];
} }
export {}; export {};

View File

@@ -1,55 +1,13 @@
.page {
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.section {
margin-bottom: 3rem;
}
.hero { .hero {
padding: 2.5rem;
border: 1px solid rgba(110, 118, 129, 0.28);
border-radius: 24px;
background: radial-gradient(circle at top right, rgba(47, 129, 247, 0.18), rgba(13, 17, 23, 0.9)); background: radial-gradient(circle at top right, rgba(47, 129, 247, 0.18), rgba(13, 17, 23, 0.9));
} }
.eyebrow {
width: fit-content;
margin: 0 0 1rem;
padding: 0.3rem 0.7rem;
border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 999px;
color: #9ecbff;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
h1, h1,
h2, h2,
p { p {
margin: 0; margin: 0;
} }
h1 {
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 1.1;
}
.lede {
max-width: 65ch;
margin-top: 1rem;
color: #c9d1d9;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
/* base .card container styles moved to src/components/card/card.css */ /* base .card container styles moved to src/components/card/card.css */
.contact-card .label { .contact-card .label {
@@ -67,6 +25,35 @@ h1 {
text-decoration: none; text-decoration: none;
} }
.contact-card button.contact-cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.72rem 1rem;
border: 1px solid rgba(88, 166, 255, 0.45);
border-radius: 999px;
background: linear-gradient(135deg, rgba(31, 111, 235, 0.22), rgba(47, 129, 247, 0.08));
color: #e6edf3;
font: inherit;
font-weight: 600;
cursor: pointer;
box-shadow: 0 10px 22px rgba(31, 111, 235, 0.14);
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease, background-color 120ms ease;
}
.contact-card button.contact-cta:hover,
.contact-card button.contact-cta:focus-visible {
transform: translateY(-1px);
border-color: #58a6ff;
background: linear-gradient(135deg, rgba(31, 111, 235, 0.35), rgba(47, 129, 247, 0.16));
box-shadow: 0 14px 28px rgba(31, 111, 235, 0.22);
outline: none;
}
.contact-card button.contact-cta:active {
transform: translateY(0);
}
.note .card { .note .card {
border-color: rgba(47, 129, 247, 0.45); border-color: rgba(47, 129, 247, 0.45);
background: linear-gradient(120deg, rgba(31, 111, 235, 0.16), rgba(22, 27, 34, 0.92)); background: linear-gradient(120deg, rgba(31, 111, 235, 0.16), rgba(22, 27, 34, 0.92));
@@ -77,12 +64,21 @@ h1 {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
@media (max-width: 760px) { .contact-inline {
.page { display: flex;
padding-top: 0.5rem; align-items: center;
} width: 100%;
gap: 0.75rem;
.hero { flex-wrap: wrap;
padding: 2rem 1.2rem; margin-top: 0.1rem;
}
} }
.contact-inline a {
word-break: break-word;
}
.contact-inline .contact-cta {
margin-left: auto;
}

View File

@@ -1,19 +1,24 @@
<div class="page"> <div class="page page-shell">
<section class="hero section"> <section class="hero section section-base hero-panel">
<p class="eyebrow">Contact</p> <p class="eyebrow">Contact</p>
<h1>Lets build something polished, useful, and fast.</h1> <h1 class="hero-title">Lets build something polished, useful, and fast.</h1>
<p class="lede"> <p class="lede hero-lede">
If you have a product idea, an open role, or an app that needs a reliable fullstack hand, If you have a product idea, an open role, or an app that needs a reliable fullstack hand,
Id be happy to talk. Id be happy to talk.
</p> </p>
</section> </section>
<section class="section"> <section class="section section-base">
<div class="card-grid"> <div class="card-grid card-grid-shared">
@for (channel of channels; track channel.label) { @for (channel of channels; track channel.label) {
<app-card cardClass="contact-card"> <app-card cardClass="contact-card">
<p class="label">{{ channel.label }}</p> <p class="label">{{ channel.label }}</p>
@if (channel.href) { @if (channel.href && channel.hrefLabel) {
<div class="contact-inline">
<a [href]="channel.href">{{ channel.value }}</a>
<button class="contact-cta" type="button" (click)="gotoHref(channel.href)">{{channel.hrefLabel}}</button>
</div>
} @else if (channel.href) {
<a [href]="channel.href">{{ channel.value }}</a> <a [href]="channel.href">{{ channel.value }}</a>
} @else { } @else {
<p class="value">{{ channel.value }}</p> <p class="value">{{ channel.value }}</p>
@@ -23,12 +28,11 @@
</div> </div>
</section> </section>
<section class="section note"> <section class="section section-base note">
<app-card> <app-card>
<h2>Preferred first step</h2> <h2>Preferred first step</h2>
<p> <p>
Send a short message with your goals, timeline, and the stack youre using. Ill reply Send a short message with your goals, timeline, and the stack youre using.
with a clear next step.
</p> </p>
</app-card> </app-card>
</section> </section>

View File

@@ -5,6 +5,7 @@ interface Channel {
label: string; label: string;
value: string; value: string;
href?: string; href?: string;
hrefLabel?: string;
} }
@Component({ @Component({
@@ -17,16 +18,21 @@ export class Contact {
readonly channels: Channel[] = [ readonly channels: Channel[] = [
{ {
label: 'Email', label: 'Email',
value: 'alex.carter.dev@mail.com', value: 'tikaiz@gmx.at',
href: 'mailto:alex.carter.dev@mail.com', href: 'mailto:tikaiz@gmx.at',
hrefLabel: 'Send an email',
}, },
{ {
label: 'Location', label: 'Location',
value: 'Remote / UTC+2', value: 'Austria / UTC+1',
}, },
{ {
label: 'Availability', label: 'Availability',
value: 'Open to freelance and full-time roles', value: 'Open to freelance and full-time roles',
}, },
]; ];
protected gotoHref(href: string) {
window.location.href = href;
}
} }

View File

@@ -20,7 +20,8 @@
.eyebrow { .eyebrow {
width: fit-content; width: fit-content;
margin: 0 0 1rem; margin: 0 0 1rem;
padding: 0.3rem 0.7rem; padding: 0.35rem 0.7rem;
line-height: 1;
border: 1px solid rgba(88, 166, 255, 0.5); border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 999px; border-radius: 999px;
font-size: 0.82rem; font-size: 0.82rem;
@@ -117,6 +118,28 @@ h2 {
gap: 1rem; gap: 1rem;
} }
.project-link {
display: block;
color: inherit;
text-decoration: none;
}
.project-card {
height: 100%;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.project-link:hover .project-card,
.project-link:focus-visible .project-card {
border-color: rgba(88, 166, 255, 0.8);
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(31, 111, 235, 0.2);
}
.project-link:focus-visible {
outline: none;
}
.card h3 { .card h3 {
margin: 0; margin: 0;
font-size: 1.08rem; font-size: 1.08rem;
@@ -136,37 +159,6 @@ h2 {
font-weight: 500; font-weight: 500;
} }
.timeline {
display: grid;
gap: 1rem;
}
.timeline-item {
display: grid;
grid-template-columns: 260px 1fr;
gap: 1rem;
padding: 1.1rem;
border-left: 3px solid #2f81f7;
border-radius: 12px;
background: rgba(22, 27, 34, 0.85);
}
.timeline-item ul {
margin: 0;
padding-left: 1rem;
color: #c9d1d9;
}
.timeline-item li + li {
margin-top: 0.45rem;
}
.footer {
margin-bottom: 0;
text-align: center;
color: #8b949e;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.page-wrap { .page-wrap {
padding-top: 0.5rem; padding-top: 0.5rem;
@@ -175,8 +167,4 @@ h2 {
.hero { .hero {
padding: 2rem 1.2rem; padding: 2rem 1.2rem;
} }
.timeline-item {
grid-template-columns: 1fr;
}
} }

View File

@@ -1,6 +1,5 @@
<div class="page-wrap"> <div class="page-wrap">
<header class="hero section" id="top"> <header class="hero section" id="top">
<p class="eyebrow">Available for full-time and freelance work</p>
<h1>{{ hero.name }}</h1> <h1>{{ hero.name }}</h1>
<p class="hero-role">{{ hero.role }}</p> <p class="hero-role">{{ hero.role }}</p>
<p class="hero-intro">{{ hero.intro }}</p> <p class="hero-intro">{{ hero.intro }}</p>
@@ -11,6 +10,25 @@
</div> </div>
</header> </header>
<section class="section" id="projects">
<div class="section-title-row">
<h2>Featured Projects</h2>
<p>Recent work across web platforms, APIs, and mobile apps.</p>
</div>
<div class="card-grid projects-grid">
@for (project of projects; track project.slug) {
<a class="project-link" [routerLink]="['/projects', project.slug]">
<app-card cardClass="project-card">
<h3>{{ project.title }}</h3>
<p>{{ project.description }}</p>
<p class="meta">{{ project.stack.join(' - ') }}</p>
<p class="impact">{{ project.impact }}</p>
</app-card>
</a>
}
</div>
</section>
<section class="section" id="stack"> <section class="section" id="stack">
<div class="section-title-row"> <div class="section-title-row">
<h2>Tech Stack</h2> <h2>Tech Stack</h2>
@@ -26,43 +44,4 @@
</div> </div>
</section> </section>
<section class="section" id="projects">
<div class="section-title-row">
<h2>Featured Projects</h2>
<p>Recent work across web platforms, APIs, and mobile apps.</p>
</div>
<div class="card-grid projects-grid">
@for (project of projects; track project.title) {
<app-card cardClass="project-card">
<h3>{{ project.title }}</h3>
<p>{{ project.description }}</p>
<p class="meta">{{ project.stack.join(' - ') }}</p>
<p class="impact">{{ project.impact }}</p>
</app-card>
}
</div>
</section>
<section class="section" id="experience">
<h2>Experience</h2>
<div class="timeline">
@for (item of timeline; track item.role + item.company) {
<article class="timeline-item">
<div>
<h3>{{ item.role }}</h3>
<p class="meta">{{ item.company }} - {{ item.period }}</p>
</div>
<ul>
@for (highlight of item.highlights; track highlight) {
<li>{{ highlight }}</li>
}
</ul>
</article>
}
</div>
</section>
<footer class="section footer">
<p>(c) {{ currentYear }} {{ hero.name }} - Fullstack Developer (Angular - C# - Flutter)</p>
</footer>
</div> </div>

View File

@@ -25,13 +25,16 @@ describe('Home', () => {
it('should render hero title and project section', () => { it('should render hero title and project section', () => {
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Alex Carter'); expect(compiled.querySelector('h1')?.textContent).toContain('Tim Kainz');
expect(compiled.querySelector('#projects h2')?.textContent).toContain('Featured Projects'); expect(compiled.querySelector('#projects h2')?.textContent).toContain('Featured Projects');
expect(compiled.querySelector('.hero-cta a[routerlink="/about"]')).toBeTruthy(); expect(compiled.querySelector('.hero-cta a[routerlink="/about"]')).toBeTruthy();
}); });
it('should render all project cards', () => { it('should render all project cards as links', () => {
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelectorAll('.project-card').length).toBe(component.projects.length); const links = compiled.querySelectorAll('#projects .project-link');
expect(links.length).toBe(component.projects.length);
expect(links[0]?.getAttribute('href')).toContain('/projects/tasktimer');
}); });
}); });

View File

@@ -1,27 +1,14 @@
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 { PROJECTS, Project } from '../projects/project-data';
interface Skill { interface Skill {
name: string; name: string;
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud'; category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment';
level: string; level: string;
} }
interface Project {
title: string;
description: string;
stack: string[];
impact: string;
}
interface TimelineItem {
role: string;
company: string;
period: string;
highlights: string[];
}
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
imports: [RouterLink, CardComponent], imports: [RouterLink, CardComponent],
@@ -29,64 +16,27 @@ interface TimelineItem {
styleUrl: './home.css', styleUrl: './home.css',
}) })
export class Home { export class Home {
readonly currentYear = new Date().getFullYear();
readonly hero = { readonly hero = {
name: 'Alex Carter', name: 'Tim Kainz',
role: 'Fullstack Developer', role: 'Fullstack Developer from Austria',
intro: intro: 'I build polished web, backend, and mobile products with Angular, C#, and Flutter.',
'I build polished web, backend, and mobile products with Angular, C#, and Flutter.',
focus: 'Focused on performance, clean architecture, and product-minded delivery.', focus: 'Focused on performance, clean architecture, and product-minded delivery.',
}; };
readonly skills: Skill[] = [ readonly skills: Skill[] = [
{ name: 'Angular', category: 'Frontend', level: 'Advanced' }, { name: 'Angular', category: 'Frontend', level: 'Advanced' },
{ name: 'TypeScript', category: 'Frontend', level: 'Advanced' }, { name: 'React', category: 'Frontend', level: 'Advanced' },
{ name: 'C# / .NET', category: 'Backend', level: 'Advanced' }, { name: 'Ionic', category: 'Frontend', level: 'Advanced' },
{ name: 'REST APIs', category: 'Backend', 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' }, { name: 'Flutter', category: 'Mobile', level: 'Advanced' },
{ name: 'Firebase', category: 'Cloud', level: 'Intermediate' },
]; ];
readonly projects: Project[] = [ readonly projects: Project[] = PROJECTS;
{
title: 'ClinicFlow Platform',
description: 'Patient scheduling and billing dashboard for multi-location clinics.',
stack: ['Angular', 'C#', '.NET API', 'PostgreSQL'],
impact: 'Reduced booking mistakes by 38% and improved team response speed.',
},
{
title: 'FieldOps Mobile App',
description: 'Offline-first mobile app for technicians to manage service tasks.',
stack: ['Flutter', 'C#', 'SQLite', 'Azure Functions'],
impact: 'Enabled same-day job updates even in low-connectivity zones.',
},
{
title: 'Insights Portal',
description: 'Real-time analytics workspace with modular report widgets.',
stack: ['Angular', 'SignalR', 'C#', '.NET'],
impact: 'Cut reporting time from hours to minutes for operations teams.',
},
];
readonly timeline: TimelineItem[] = [
{
role: 'Senior Fullstack Developer',
company: 'Northline Digital',
period: '2023 - Present',
highlights: [
'Led Angular frontend redesign for enterprise dashboard products.',
'Built C# microservices and optimized API response times by 30%.',
],
},
{
role: 'Software Engineer',
company: 'CloudMotion Labs',
period: '2020 - 2023',
highlights: [
'Delivered Flutter apps with shared design system and CI pipelines.',
'Developed secure backend services with clean architecture patterns.',
],
},
];
} }

View File

@@ -0,0 +1,63 @@
export interface Project {
slug: string;
title: string;
description: string;
stack: string[];
impact: string;
overview: string;
highlights: string[];
}
export const PROJECTS: Project[] = [
{
slug: 'tasktimer',
title: 'Tasktimer',
description:
'Jira time-tracking app with custom reporting and connectivity, built for mobile and web.',
stack: ['Ionic', 'React', 'Spring Boot', 'Oracle', 'Web', 'Android', 'iOS'],
impact:
'Led backend and architecture work for reporting and Jira synchronization reliability.',
overview:
'Tasktimer helps teams capture and report time across Jira projects while keeping data synchronized and audit-friendly.',
highlights: [
'Implemented a Spring Boot API with Oracle-backed reporting endpoints.',
'Designed resilient Jira connectivity to keep remote data in sync.',
'Supported cross-platform delivery for web and mobile clients.',
],
},
{
slug: 'synopsis-platform-core',
title: 'Synopsis Platform Core',
description: 'Main Synopsis Platform codebase spanning backend and frontend modules.',
stack: ['C#', 'TypeScript', 'HTML', 'Docker', 'Web'],
impact:
'Contributed investigations and implementation work that improved platform stability and direction.',
overview:
'The platform core powers shared capabilities used by multiple product teams, with a focus on maintainable architecture.',
highlights: [
'Delivered issue-driven improvements in plugin loading behavior.',
'Contributed to microfrontend architecture research and experiments.',
'Supported production hardening with iterative bug-fixing and code cleanup.',
],
},
{
slug: 'sv-hofkirchen-website',
title: 'Website SV Hofkirchen (Chess Club)',
description: 'Club management web app for the SV Hofkirchen chess club.',
stack: ['C#', '.NET', 'Blazor', 'SQLite', 'Web'],
impact:
'Built core backend services and key parts of the Blazor UI to support club operations.',
overview:
'A practical web app for memberships, event coordination, and day-to-day administration for a local chess club.',
highlights: [
'Implemented backend services and database flows with SQLite.',
'Designed and built major parts of the Blazor frontend.',
'Shaped the project architecture to stay lightweight and maintainable.',
],
},
];
export function getProjectBySlug(projectSlug: string): Project | undefined {
return PROJECTS.find((project) => project.slug === projectSlug);
}

View File

@@ -0,0 +1,58 @@
.hero {
background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.2), rgba(13, 17, 23, 0.85));
}
h1,
h2,
p,
ul {
margin: 0;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
.btn {
display: inline-block;
padding: 0.72rem 1.1rem;
border: 1px solid #3d444d;
border-radius: 10px;
color: #e6edf3;
background: rgba(33, 38, 45, 0.55);
font-weight: 600;
text-decoration: none;
transition: transform 120ms ease, border-color 120ms ease;
}
.btn:hover,
.btn:focus-visible {
transform: translateY(-2px);
border-color: #58a6ff;
outline: none;
}
.detail-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.meta {
color: #8b949e;
font-size: 0.95rem;
}
ul {
padding-left: 1.2rem;
}
li + li {
margin-top: 0.45rem;
}

View File

@@ -0,0 +1,45 @@
<div class="page-wrap page-shell">
@if (project; as project) {
<section class="hero section section-base hero-panel">
<p class="eyebrow">Project</p>
<h1 class="hero-title">{{ project.title }}</h1>
<p class="lede hero-lede">{{ project.overview }}</p>
<div class="hero-actions">
<a class="btn btn-ghost" routerLink="/projects">All Projects</a>
</div>
</section>
<section class="section section-base">
<div class="detail-grid">
<app-card>
<h2>Stack</h2>
<p class="meta">{{ project.stack.join(' - ') }}</p>
</app-card>
<app-card>
<h2>Impact</h2>
<p>{{ project.impact }}</p>
</app-card>
</div>
</section>
<section class="section section-base">
<app-card>
<h2>Highlights</h2>
<ul>
@for (highlight of project.highlights; track highlight) {
<li>{{ highlight }}</li>
}
</ul>
</app-card>
</section>
} @else {
<section class="hero section section-base hero-panel">
<p class="eyebrow">Project</p>
<h1 class="hero-title">Project not found</h1>
<p class="lede hero-lede">The requested project does not exist. Please pick one from the projects page.</p>
<a class="btn btn-ghost" routerLink="/projects">All Projects</a>
</section>
}
</div>

View File

@@ -0,0 +1,47 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { ProjectDetail } from './project-detail';
describe('ProjectDetail', () => {
async function createComponent(projectName: string): Promise<ComponentFixture<ProjectDetail>> {
await TestBed.configureTestingModule({
imports: [ProjectDetail],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ projectName }),
},
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ProjectDetail);
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
afterEach(() => {
TestBed.resetTestingModule();
});
it('should render project details for known project', async () => {
const fixture = await createComponent('tasktimer');
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Tasktimer');
expect(compiled.querySelectorAll('li').length).toBeGreaterThan(0);
});
it('should show not found state for unknown project', async () => {
const fixture = await createComponent('unknown-project');
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Project not found');
});
});

View File

@@ -0,0 +1,20 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { CardComponent } from '../../../components/card/card';
import { getProjectBySlug, Project } from '../project-data';
@Component({
selector: 'app-project-detail',
imports: [RouterLink, CardComponent],
templateUrl: './project-detail.html',
styleUrl: './project-detail.css',
})
export class ProjectDetail {
private readonly route = inject(ActivatedRoute);
get project(): Project | undefined {
const projectName = this.route.snapshot.paramMap.get('projectName') ?? '';
return getProjectBySlug(projectName);
}
}

View File

@@ -0,0 +1,51 @@
.hero {
background: radial-gradient(circle at top right, rgba(88, 166, 255, 0.2), rgba(13, 17, 23, 0.85));
}
h1,
h2,
p {
margin: 0;
}
.projects-grid {
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.project-link {
display: block;
color: inherit;
text-decoration: none;
}
.project-card {
height: 100%;
transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
}
.project-link:hover .project-card,
.project-link:focus-visible .project-card {
border-color: rgba(88, 166, 255, 0.8);
transform: translateY(-2px);
box-shadow: 0 14px 28px rgba(31, 111, 235, 0.2);
}
.project-link:focus-visible {
outline: none;
}
.project-card p {
margin-top: 0.75rem;
}
.meta {
color: #8b949e;
font-size: 0.92rem;
}
.impact {
color: #9ecbff;
font-weight: 500;
}

View File

@@ -0,0 +1,26 @@
<div class="page-wrap page-shell">
<section class="hero section section-base hero-panel">
<p class="eyebrow">Projects</p>
<h1 class="hero-title">Featured work across web, backend, and mobile.</h1>
<p class="lede hero-lede">
A selection of projects that demonstrate my skills in building production-ready software
solutions across various platforms and technologies.
</p>
</section>
<section class="section section-base">
<div class="card-grid card-grid-shared projects-grid">
@for (project of projects; track project.slug) {
<a class="project-link" [routerLink]="['/projects', project.slug]">
<app-card cardClass="project-card">
<h2>{{ project.title }}</h2>
<p>{{ project.description }}</p>
<p class="meta">{{ project.stack.join(' - ') }}</p>
<p class="impact">{{ project.impact }}</p>
</app-card>
</a>
}
</div>
</section>
</div>

View File

@@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Projects } from './projects';
describe('Projects', () => {
let component: Projects;
let fixture: ComponentFixture<Projects>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Projects],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(Projects);
component = fixture.componentInstance;
await fixture.whenStable();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render project cards and links', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Featured work');
expect(compiled.querySelectorAll('.project-card').length).toBe(component.projects.length);
expect(compiled.querySelectorAll('.project-link').length).toBe(component.projects.length);
});
});

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { CardComponent } from '../../components/card/card';
import { PROJECTS, Project } from './project-data';
@Component({
selector: 'app-projects',
imports: [RouterLink, CardComponent],
templateUrl: './projects.html',
styleUrl: './projects.css',
})
export class Projects {
readonly projects: Project[] = PROJECTS;
}

View File

@@ -16,6 +16,106 @@ body {
line-height: 1.6; line-height: 1.6;
} }
.page-shell {
margin: 0 auto;
padding: 1rem 1.25rem 4rem;
}
.section-base {
margin-bottom: 3rem;
}
.section-anchor {
scroll-margin-top: 5.5rem;
}
.hero-panel {
padding: 2.5rem;
border: 1px solid rgba(110, 118, 129, 0.28);
border-radius: 24px;
}
.eyebrow {
width: fit-content;
margin: 0 0 1rem;
padding: 0.35rem 0.7rem;
line-height: 1;
border: 1px solid rgba(88, 166, 255, 0.5);
border-radius: 999px;
color: #9ecbff;
font-size: 0.82rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.hero-title {
margin: 0;
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 1.1;
}
.hero-lede {
max-width: 65ch;
margin-top: 1rem;
color: #c9d1d9;
}
.section-title-shared {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
margin-bottom: 1rem;
}
.card-grid-shared {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
a { a {
color: inherit; color: inherit;
} }
@media (max-width: 760px) {
.page-shell {
padding-top: 0.5rem;
}
.hero-panel {
padding: 2rem 1.2rem;
}
}
@media (hover: hover) and (pointer: fine) {
html,
body,
button,
[role='button'],
input[type='submit'],
input[type='button'] {
cursor: url('/cursor.svg') 0 0, auto;
}
a,
button,
[role='button'],
label,
summary,
select,
input[type='checkbox'],
input[type='radio'],
input[type='submit'],
input[type='button'] {
cursor: url('/cursor.svg') 0 0, pointer;
}
input,
textarea,
[contenteditable='true'] {
cursor: text;
}
}