Compare commits

...

10 Commits

Author SHA1 Message Date
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
17 changed files with 598 additions and 74 deletions

View File

@@ -7,10 +7,18 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout
- 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
id: push
uses: docker/build-push-action
uses: docker/build-push-action@v6
with:
push: true
tags: docker.io/tikaiz/website-frontend:latest
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@@ -5,8 +5,12 @@
.content {
max-width: 1500px;
min-height: 100svh;
margin: 0 auto;
padding: 1.5rem 1.25rem 4rem;
padding: 1.5rem 1.25rem 3rem;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.sidebar {
@@ -25,6 +29,13 @@
z-index: 20;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.brand {
color: #e6edf3;
font-size: 1.05rem;
@@ -63,6 +74,36 @@
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;
}
@media (max-width: 1320px) {
.content {
max-width: 1040px;
@@ -79,3 +120,38 @@
flex-wrap: wrap;
}
}
@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;
width: auto;
margin: 0;
gap: 0.6rem;
border-radius: 16px;
}
.subtitle {
display: none;
}
.burger {
display: inline-block;
}
.nav {
display: none;
flex-direction: column;
gap: 0.4rem;
}
.nav.nav-open {
display: flex;
}
}

View File

@@ -1,19 +1,34 @@
<div class="shell">
<main class="content">
<router-outlet></router-outlet>
<footer class="site-footer">
<p>(c) {{ currentYear }} Tim Kainz | +43 677 62678219</p>
</footer>
</main>
<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>
</div>

View File

@@ -24,5 +24,6 @@ describe('App', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.sidebar')).toBeTruthy();
expect(compiled.querySelectorAll('.nav a').length).toBeGreaterThanOrEqual(4);
expect(compiled.querySelector('.site-footer')).toBeTruthy();
});
});

View File

@@ -7,4 +7,15 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
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;
}
}

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()

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

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

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -13,7 +13,12 @@
@for (channel of channels; track channel.label) {
<app-card cardClass="contact-card">
<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>
} @else {
<p class="value">{{ channel.value }}</p>
@@ -27,8 +32,7 @@
<app-card>
<h2>Preferred first step</h2>
<p>
Send a short message with your goals, timeline, and the stack youre using. Ill reply
with a clear next step.
Send a short message with your goals, timeline, and the stack youre using.
</p>
</app-card>
</section>

View File

@@ -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: 'Remote / UTC+1',
},
{
label: 'Availability',
value: 'Open to freelance and full-time roles',
},
];
protected gotoHref(href: string) {
window.location.href = href;
}
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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();
});

View File

@@ -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';
level: string;
}
@@ -29,63 +29,62 @@ interface TimelineItem {
styleUrl: './home.css',
})
export class Home {
readonly currentYear = new Date().getFullYear();
readonly hero = {
name: 'Alex Carter',
name: 'Tim Kainz',
role: 'Fullstack Developer',
intro:
'I build polished web, backend, and mobile products with Angular, C#, and Flutter.',
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: 'Express', category: 'Backend', 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.',
],
},
];