Compare commits
26 Commits
fdaf49ab26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd765b8522 | |||
| 0da85f7f1d | |||
| 4514bc1d8b | |||
| 19551194c3 | |||
| 108f61b509 | |||
| 170fec7a20 | |||
| f89bdda3be | |||
| f10916532e | |||
| e06a5a29b4 | |||
| 00a49f0a11 | |||
| 4540f1895d | |||
| 3a47b5fcbc | |||
| 5f3d3314ed | |||
| 8ad136cd6c | |||
| 3de176d3b7 | |||
| 866ee455ea | |||
| 291f1bd93b | |||
| a0fa0455ed | |||
| cfd1782ca7 | |||
| 608c32cd8c | |||
| c5e602e9f0 | |||
| c767c0b1a0 | |||
| 4510d9e72e | |||
| f41b7de156 | |||
| ceb413e672 | |||
| 92c67bf956 |
24
.gitea/workflows/publish.yml
Normal file
24
.gitea/workflows/publish.yml
Normal 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
|
||||
16
.github/workflows/publish.yml
vendored
16
.github/workflows/publish.yml
vendored
@@ -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
|
||||
85
public/cursor.svg
Normal file
85
public/cursor.svg
Normal 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 |
145
src/app/app.css
145
src/app/app.css
@@ -1,28 +1,44 @@
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
max-width: 1500px;
|
||||
min-height: 100svh;
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
width: 280px;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(1200px, calc(100% - 3rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.2rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
padding: 0.9rem 1.2rem;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 20px;
|
||||
background: rgba(22, 27, 34, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 20;
|
||||
border-radius: 16px;
|
||||
background: rgba(22, 27, 34, 0.75);
|
||||
backdrop-filter: blur(9px);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -41,12 +57,14 @@
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
padding: 0.7rem 0.8rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
color: #c9d1d9;
|
||||
@@ -63,19 +81,110 @@
|
||||
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) {
|
||||
.content {
|
||||
max-width: 1040px;
|
||||
}
|
||||
|
||||
.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;
|
||||
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 {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex: 0 0 auto;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nav.nav-open {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,66 @@
|
||||
<div class="shell">
|
||||
<main class="content">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-interactive-background></app-interactive-background>
|
||||
|
||||
<aside class="sidebar">
|
||||
<a class="brand" routerLink="/home">Alex Carter</a>
|
||||
<p class="subtitle">Fullstack Developer</p>
|
||||
<div class="sidebar-header">
|
||||
<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">
|
||||
<a routerLink="/home" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
|
||||
<a routerLink="/about" routerLinkActive="active">About</a>
|
||||
<a routerLink="/home" fragment="stack">Tech Stack</a>
|
||||
<a routerLink="/home" fragment="projects">Projects</a>
|
||||
<a routerLink="/home" fragment="experience">Experience</a>
|
||||
<a routerLink="/contact" routerLinkActive="active">Contact</a>
|
||||
<nav class="nav" aria-label="Primary" [class.nav-open]="isMenuOpen">
|
||||
<a routerLink="/home" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }" (click)="closeMenu()">Home</a>
|
||||
<a routerLink="/home" fragment="stack" (click)="closeMenu()">Tech Stack</a>
|
||||
<a routerLink="/home" fragment="projects" (click)="closeMenu()">Projects</a>
|
||||
<a routerLink="/home" fragment="experience" (click)="closeMenu()">Experience</a>
|
||||
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">About</a>
|
||||
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">Contact</a>
|
||||
</nav>
|
||||
</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>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { AboutComponent } from '../pages/about/about.component';
|
||||
import { Contact } from '../pages/contact/contact';
|
||||
import { Home } from '../pages/home/home';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||
{ path: 'home', component: Home },
|
||||
{ path: 'about', component: AboutComponent },
|
||||
{ path: 'contact', component: Contact },
|
||||
{ path: 'home', loadComponent: () => import('../pages/home/home').then((x) => x.Home) },
|
||||
{
|
||||
path: 'about',
|
||||
loadComponent: () => import('../pages/about/about').then((x) => x.About),
|
||||
},
|
||||
{
|
||||
path: 'contact',
|
||||
loadComponent: () => import('../pages/contact/contact').then((x) => x.Contact),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,7 +22,9 @@ describe('App', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('app-interactive-background')).toBeTruthy();
|
||||
expect(compiled.querySelector('.sidebar')).toBeTruthy();
|
||||
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
|
||||
expect(compiled.querySelector('.site-footer')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { InteractiveBackground } from '../components/interactive-background/interactive-background';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterLink, RouterLinkActive, RouterOutlet],
|
||||
imports: [RouterLink, RouterLinkActive, RouterOutlet, InteractiveBackground],
|
||||
templateUrl: './app.html',
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
589
src/components/interactive-background/interactive-background.ts
Normal file
589
src/components/interactive-background/interactive-background.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
|
||||
|
||||
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>;
|
||||
|
||||
isCursorVisible = false;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
this.isCursorVisible = this.getMediaQuery('(pointer: fine)').matches;
|
||||
|
||||
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);
|
||||
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
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.pointerActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatePointer(event.clientX, event.clientY, true);
|
||||
}
|
||||
|
||||
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.isPointerDown = false;
|
||||
}
|
||||
|
||||
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.activeTouchId = null;
|
||||
this.pointerActive = false;
|
||||
}
|
||||
|
||||
private handlePointerLeave(): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
367
src/generate-logo.py
Normal file
367
src/generate-logo.py
Normal 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()
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Website</title>
|
||||
<title>Tim Kainz - Software Engineer</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
||||
2
src/logos/.gitignore
vendored
Normal file
2
src/logos/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.ico
|
||||
*.png
|
||||
@@ -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.',
|
||||
];
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
.eyebrow {
|
||||
width: fit-content;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
padding: 0.35rem 0.5rem 0.1rem 0.5rem;
|
||||
border: 1px solid rgba(88, 166, 255, 0.5);
|
||||
border-radius: 999px;
|
||||
color: #9ecbff;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<p class="eyebrow">About</p>
|
||||
<h1>Fullstack developer crafting modern web, backend, and mobile products.</h1>
|
||||
<p class="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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AboutComponent } from './about.component';
|
||||
import { About } from './about';
|
||||
|
||||
describe('About', () => {
|
||||
let component: AboutComponent;
|
||||
let fixture: ComponentFixture<AboutComponent>;
|
||||
let component: About;
|
||||
let fixture: ComponentFixture<About>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AboutComponent],
|
||||
imports: [About],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AboutComponent);
|
||||
fixture = TestBed.createComponent(About);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
.eyebrow {
|
||||
width: fit-content;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
padding: 0.35rem 0.5rem 0.1rem 0.5rem;
|
||||
border: 1px solid rgba(88, 166, 255, 0.5);
|
||||
border-radius: 999px;
|
||||
color: #9ecbff;
|
||||
@@ -67,6 +67,35 @@ h1 {
|
||||
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 {
|
||||
border-color: rgba(47, 129, 247, 0.45);
|
||||
background: linear-gradient(120deg, rgba(31, 111, 235, 0.16), rgba(22, 27, 34, 0.92));
|
||||
@@ -77,6 +106,23 @@ h1 {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.contact-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.contact-inline a {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.contact-inline .contact-cta {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
padding-top: 0.5rem;
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
@for (channel of channels; track channel.label) {
|
||||
<app-card cardClass="contact-card">
|
||||
<p class="label">{{ channel.label }}</p>
|
||||
@if (channel.href) {
|
||||
<a [href]="channel.href">{{ channel.value }}</a>
|
||||
@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>
|
||||
} @else {
|
||||
<p class="value">{{ channel.value }}</p>
|
||||
<p class="value">{{ channel.value }}</p>
|
||||
}
|
||||
</app-card>
|
||||
}
|
||||
@@ -27,8 +32,7 @@
|
||||
<app-card>
|
||||
<h2>Preferred first step</h2>
|
||||
<p>
|
||||
Send a short message with your goals, timeline, and the stack you’re using. I’ll reply
|
||||
with a clear next step.
|
||||
Send a short message with your goals, timeline, and the stack you’re using.
|
||||
</p>
|
||||
</app-card>
|
||||
</section>
|
||||
|
||||
@@ -5,6 +5,7 @@ interface Channel {
|
||||
label: string;
|
||||
value: string;
|
||||
href?: string;
|
||||
hrefLabel?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -17,16 +18,21 @@ export class Contact {
|
||||
readonly channels: Channel[] = [
|
||||
{
|
||||
label: 'Email',
|
||||
value: 'alex.carter.dev@mail.com',
|
||||
href: 'mailto:alex.carter.dev@mail.com',
|
||||
value: 'tikaiz@gmx.at',
|
||||
href: 'mailto:tikaiz@gmx.at',
|
||||
hrefLabel: 'Send an email',
|
||||
},
|
||||
{
|
||||
label: 'Location',
|
||||
value: 'Remote / UTC+2',
|
||||
value: 'Austria / UTC+1',
|
||||
},
|
||||
{
|
||||
label: 'Availability',
|
||||
value: 'Open to freelance and full-time roles',
|
||||
},
|
||||
];
|
||||
|
||||
protected gotoHref(href: string) {
|
||||
window.location.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,12 +161,6 @@ h2 {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-wrap {
|
||||
padding-top: 0.5rem;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<div class="page-wrap">
|
||||
<header class="hero section" id="top">
|
||||
<p class="eyebrow">Available for full-time and freelance work</p>
|
||||
<h1>{{ hero.name }}</h1>
|
||||
<p class="hero-role">{{ hero.role }}</p>
|
||||
<p class="hero-intro">{{ hero.intro }}</p>
|
||||
@@ -61,8 +60,4 @@
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="section footer">
|
||||
<p>(c) {{ currentYear }} {{ hero.name }} - Fullstack Developer (Angular - C# - Flutter)</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('Home', () => {
|
||||
|
||||
it('should render hero title and project section', () => {
|
||||
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('.hero-cta a[routerlink="/about"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CardComponent } from '../../components/card/card';
|
||||
|
||||
interface Skill {
|
||||
name: string;
|
||||
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud';
|
||||
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment';
|
||||
level: string;
|
||||
}
|
||||
|
||||
@@ -29,63 +29,62 @@ interface TimelineItem {
|
||||
styleUrl: './home.css',
|
||||
})
|
||||
export class Home {
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
|
||||
readonly hero = {
|
||||
name: 'Alex Carter',
|
||||
role: 'Fullstack Developer',
|
||||
intro:
|
||||
'I build polished web, backend, and mobile products with Angular, C#, and Flutter.',
|
||||
name: 'Tim Kainz',
|
||||
role: 'Fullstack Developer from Austria',
|
||||
intro: 'I build polished web, backend, and mobile products with Angular, C#, and Flutter.',
|
||||
focus: 'Focused on performance, clean architecture, and product-minded delivery.',
|
||||
};
|
||||
|
||||
readonly skills: Skill[] = [
|
||||
{ name: 'Angular', category: 'Frontend', level: 'Advanced' },
|
||||
{ name: 'TypeScript', category: 'Frontend', level: 'Advanced' },
|
||||
{ name: 'C# / .NET', category: 'Backend', level: 'Advanced' },
|
||||
{ name: 'REST APIs', category: 'Backend', 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' },
|
||||
{ name: 'Firebase', category: 'Cloud', level: 'Intermediate' },
|
||||
];
|
||||
|
||||
readonly projects: Project[] = [
|
||||
{
|
||||
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: 'Tasktimer',
|
||||
description:
|
||||
'Jira time-tracking app with custom reporting and connectivity, build for mobile and web.',
|
||||
stack: ['Ionic', 'React', 'Spring Boot', 'Oracle', 'Web', 'Android', 'iOS'],
|
||||
impact:
|
||||
'Led backend and architectural work: implemented Spring Boot backend, database access for Oracle with reporting capabilities, including reliable Jira connectivity.',
|
||||
},
|
||||
{
|
||||
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: 'Synopsis Platform Core',
|
||||
description: 'Main Synopsis Platform codebase (backend + frontend pieces).',
|
||||
stack: ['C#', 'TypeScript', 'HTML', 'Docker', 'Web'],
|
||||
impact:
|
||||
'Contributed via issues, code and investigations (plugin-loader, microfrontend research), influencing platform direction and stability.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
title: 'Website SV Hofkirchen (Chess Club)',
|
||||
description: 'Club management web app for the SV Hofkirchen chess club.',
|
||||
stack: ['C#', '.NET', 'Blazor', 'SQLite', 'Web'],
|
||||
impact:
|
||||
'Designed the technical core across backend, database, and Blazor UI: implemented backend services, and partially built the Blazor frontend',
|
||||
},
|
||||
];
|
||||
|
||||
readonly timeline: TimelineItem[] = [
|
||||
{
|
||||
role: 'Senior Fullstack Developer',
|
||||
company: 'Northline Digital',
|
||||
period: '2023 - Present',
|
||||
role: 'Internship - Backend & Architecture Engineer',
|
||||
company: 'Industrie Informatik GmbH',
|
||||
period: '2025',
|
||||
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.',
|
||||
'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.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -19,3 +19,33 @@ body {
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
html,
|
||||
body,
|
||||
button,
|
||||
[role='button'],
|
||||
input[type='submit'],
|
||||
input[type='button'] {
|
||||
cursor: url('/web/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('/web/cursor.svg') 0 0, pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable='true'] {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user