Compare commits
59 Commits
fdaf49ab26
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2fc5e65f85
|
|||
| 38f1db1321 | |||
|
fb06dca81d
|
|||
|
7108f22e8b
|
|||
|
f7eceda442
|
|||
|
1a00b77511
|
|||
|
9531e5aad4
|
|||
|
2de74a9882
|
|||
|
211a018321
|
|||
| 861fdb59ec | |||
| 1470b0723b | |||
| 67676721d6 | |||
| 15294f95a1 | |||
| 7288edd019 | |||
| e5474716ec | |||
| 834d884e07 | |||
| 9864a9c6cb | |||
| 17fbfc5022 | |||
| 6f30fa2260 | |||
| 3072668e05 | |||
| 7032968fe2 | |||
| eb1dd36195 | |||
| 13a6c24cd1 | |||
| 92cf05a6ef | |||
| 0b2ea1f3bc | |||
| 22e20a04db | |||
| adf7f310fe | |||
| 2317949b53 | |||
| 14cbf6ab0d | |||
| 4730d3592f | |||
| f0f07b4719 | |||
| 255eeffce2 | |||
| ce135f6bf2 | |||
| 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 |
25
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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 Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.kaintim.duckdns.org
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tags: git.kaintim.duckdns.org/${{ secrets.REGISTRY_USERNAME }}/${{ secrets.REGISTRY_PROJECT_NAME }}:latest
|
||||||
|
cache-from: type=registry,ref=git.kaintim.duckdns.org/${{ secrets.REGISTRY_USERNAME }}/${{ secrets.REGISTRY_PROJECT_NAME }}:cache
|
||||||
|
cache-to: type=registry,ref=git.kaintim.duckdns.org/${{ secrets.REGISTRY_USERNAME }}/${{ secrets.REGISTRY_PROJECT_NAME }}:cache,mode=max
|
||||||
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
|
|
||||||
@@ -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;"]
|
||||||
|
|||||||
@@ -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": [
|
||||||
@@ -37,8 +38,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "5kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "5kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 6.0 KiB |
1
public/tech-logos/angular.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Angular</title><path d="M16.712 17.711H7.288l-1.204 2.916L12 24l5.916-3.373-1.204-2.916ZM14.692 0l7.832 16.855.814-12.856L14.692 0ZM9.308 0 .662 3.999l.814 12.856L9.308 0Zm-.405 13.93h6.198L12 6.396 8.903 13.93Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
1
public/tech-logos/csharp.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>sharp</title><path d="M14.2209.0875v5.9613l-3.7433.5012v3.5233l3.7433-.5012v3.5735l3.492-.4672V9.1047L24 8.2634l-.4631-3.4613-5.824.7794V.0875zM6.287 1.145v5.9618L0 7.9483l.4634 3.4613 5.8514-.7834 3.4644-.4637V1.145zm3.5198 9.7185l-3.492.4675v3.578l-6.183.8276.4633 3.4613 5.8239-.7796v5.4942h3.492v-5.962l3.6114-.4834V13.944l-3.7156.4973zm13.73 1.7405l-5.824.779-3.492.4673v9.0179h3.492v-5.9618L24 16.0652Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
1
public/tech-logos/docker.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Docker</title><path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
public/tech-logos/dotnet.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>.NET</title><path d="M24 8.77h-2.468v7.565h-1.425V8.77h-2.462V7.53H24zm-6.852 7.565h-4.821V7.53h4.63v1.24h-3.205v2.494h2.953v1.234h-2.953v2.604h3.396zm-6.708 0H8.882L4.78 9.863a2.896 2.896 0 0 1-.258-.51h-.036c.032.189.048.592.048 1.21v5.772H3.157V7.53h1.659l3.965 6.32c.167.261.275.442.323.54h.024c-.04-.233-.06-.629-.06-1.185V7.529h1.372zm-8.703-.693a.868.829 0 0 1-.869.829.868.829 0 0 1-.868-.83.868.829 0 0 1 .868-.828.868.829 0 0 1 .869.829Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 549 B |
1
public/tech-logos/flutter.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Flutter</title><path d="M14.314 0L2.3 12 6 15.7 21.684.013h-7.357zm.014 11.072L7.857 17.53l6.47 6.47H21.7l-6.46-6.468 6.46-6.46h-7.37z"/></svg>
|
||||||
|
After Width: | Height: | Size: 236 B |
1
public/tech-logos/ionic.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Ionic</title><path d="M22.922 7.027l-.103-.23-.169.188c-.408.464-.928.82-1.505 1.036l-.159.061.066.155a9.745 9.745 0 0 1 .75 3.759c0 5.405-4.397 9.806-9.806 9.806-5.409 0-9.802-4.397-9.802-9.802 0-5.405 4.402-9.806 9.806-9.806 1.467 0 2.883.319 4.2.947l.155.075.066-.155a3.767 3.767 0 0 1 1.106-1.453l.197-.159-.225-.117A11.905 11.905 0 0 0 12.001.001c-6.619 0-12 5.381-12 12s5.381 12 12 12 12-5.381 12-12c0-1.73-.361-3.403-1.078-4.973zM12 6.53A5.476 5.476 0 0 0 6.53 12 5.476 5.476 0 0 0 12 17.47 5.476 5.476 0 0 0 17.47 12 5.479 5.479 0 0 0 12 6.53zm10.345-2.007a2.494 2.494 0 1 1-4.988 0 2.494 2.494 0 0 1 4.988 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 719 B |
1
public/tech-logos/java.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenJDK</title><path d="M11.915 0 11.7.215C9.515 2.4 7.47 6.39 6.046 10.483c-1.064 1.024-3.633 2.81-3.711 3.551-.093.87 1.746 2.611 1.55 3.235-.198.625-1.304 1.408-1.014 1.939.1.188.823.011 1.277-.491a13.389 13.389 0 0 0-.017 2.14c.076.906.27 1.668.643 2.232.372.563.956.911 1.667.911.397 0 .727-.114 1.024-.264.298-.149.571-.33.91-.5.68-.34 1.634-.666 3.53-.604 1.903.062 2.872.39 3.559.704.687.314 1.15.664 1.925.664.767 0 1.395-.336 1.807-.9.412-.563.631-1.33.72-2.24.06-.623.055-1.32 0-2.066.454.45 1.117.604 1.213.424.29-.53-.816-1.314-1.013-1.937-.198-.624 1.642-2.366 1.549-3.236-.08-.748-2.707-2.568-3.748-3.586C16.428 6.374 14.308 2.394 12.13.215zm.175 6.038a2.95 2.95 0 0 1 2.943 2.942 2.95 2.95 0 0 1-2.943 2.943A2.95 2.95 0 0 1 9.148 8.98a2.95 2.95 0 0 1 2.942-2.942zM8.685 7.983a3.515 3.515 0 0 0-.145.997c0 1.951 1.6 3.55 3.55 3.55 1.95 0 3.55-1.598 3.55-3.55 0-.329-.046-.648-.132-.951.334.095.64.208.915.336a42.699 42.699 0 0 1 2.042 5.829c.678 2.545 1.01 4.92.846 6.607-.082.844-.29 1.51-.606 1.94-.315.431-.713.651-1.315.651-.593 0-.932-.27-1.673-.61-.741-.338-1.825-.694-3.792-.758-1.974-.064-3.073.293-3.821.669-.375.188-.659.373-.911.5s-.466.2-.752.2c-.53 0-.876-.209-1.16-.64-.285-.43-.474-1.101-.545-1.948-.141-1.693.176-4.069.823-6.614a43.155 43.155 0 0 1 1.934-5.783c.348-.167.749-.31 1.192-.425zm-3.382 4.362a.216.216 0 0 1 .13.031c-.166.56-.323 1.116-.463 1.665a33.849 33.849 0 0 0-.547 2.555 3.9 3.9 0 0 0-.2-.39c-.58-1.012-.914-1.642-1.16-2.08.315-.24 1.679-1.755 2.24-1.781zm13.394.01c.562.027 1.926 1.543 2.24 1.783-.246.438-.58 1.068-1.16 2.08a4.428 4.428 0 0 0-.163.309 32.354 32.354 0 0 0-.562-2.49 40.579 40.579 0 0 0-.482-1.652.216.216 0 0 1 .127-.03z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
1
public/tech-logos/javascript.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>JavaScript</title><path d="M0 0h24v24H0V0zm22.034 18.276c-.175-1.095-.888-2.015-3.003-2.873-.736-.345-1.554-.585-1.797-1.14-.091-.33-.105-.51-.046-.705.15-.646.915-.84 1.515-.66.39.12.75.42.976.9 1.034-.676 1.034-.676 1.755-1.125-.27-.42-.404-.601-.586-.78-.63-.705-1.469-1.065-2.834-1.034l-.705.089c-.676.165-1.32.525-1.71 1.005-1.14 1.291-.811 3.541.569 4.471 1.365 1.02 3.361 1.244 3.616 2.205.24 1.17-.87 1.545-1.966 1.41-.811-.18-1.26-.586-1.755-1.336l-1.83 1.051c.21.48.45.689.81 1.109 1.74 1.756 6.09 1.666 6.871-1.004.029-.09.24-.705.074-1.65l.046.067zm-8.983-7.245h-2.248c0 1.938-.009 3.864-.009 5.805 0 1.232.063 2.363-.138 2.711-.33.689-1.18.601-1.566.48-.396-.196-.597-.466-.83-.855-.063-.105-.11-.196-.127-.196l-1.825 1.125c.305.63.75 1.172 1.324 1.517.855.51 2.004.675 3.207.405.783-.226 1.458-.691 1.811-1.411.51-.93.402-2.07.397-3.346.012-2.054 0-4.109 0-6.179l.004-.056z"/></svg>
|
||||||
|
After Width: | Height: | Size: 989 B |
1
public/tech-logos/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>React</title><path d="M14.23 12.004a2.236 2.236 0 0 1-2.235 2.236 2.236 2.236 0 0 1-2.236-2.236 2.236 2.236 0 0 1 2.235-2.236 2.236 2.236 0 0 1 2.236 2.236zm2.648-10.69c-1.346 0-3.107.96-4.888 2.622-1.78-1.653-3.542-2.602-4.887-2.602-.41 0-.783.093-1.106.278-1.375.793-1.683 3.264-.973 6.365C1.98 8.917 0 10.42 0 12.004c0 1.59 1.99 3.097 5.043 4.03-.704 3.113-.39 5.588.988 6.38.32.187.69.275 1.102.275 1.345 0 3.107-.96 4.888-2.624 1.78 1.654 3.542 2.603 4.887 2.603.41 0 .783-.09 1.106-.275 1.374-.792 1.683-3.263.973-6.365C22.02 15.096 24 13.59 24 12.004c0-1.59-1.99-3.097-5.043-4.032.704-3.11.39-5.587-.988-6.38-.318-.184-.688-.277-1.092-.278zm-.005 1.09v.006c.225 0 .406.044.558.127.666.382.955 1.835.73 3.704-.054.46-.142.945-.25 1.44-.96-.236-2.006-.417-3.107-.534-.66-.905-1.345-1.727-2.035-2.447 1.592-1.48 3.087-2.292 4.105-2.295zm-9.77.02c1.012 0 2.514.808 4.11 2.28-.686.72-1.37 1.537-2.02 2.442-1.107.117-2.154.298-3.113.538-.112-.49-.195-.964-.254-1.42-.23-1.868.054-3.32.714-3.707.19-.09.4-.127.563-.132zm4.882 3.05c.455.468.91.992 1.36 1.564-.44-.02-.89-.034-1.345-.034-.46 0-.915.01-1.36.034.44-.572.895-1.096 1.345-1.565zM12 8.1c.74 0 1.477.034 2.202.093.406.582.802 1.203 1.183 1.86.372.64.71 1.29 1.018 1.946-.308.655-.646 1.31-1.013 1.95-.38.66-.773 1.288-1.18 1.87-.728.063-1.466.098-2.21.098-.74 0-1.477-.035-2.202-.093-.406-.582-.802-1.204-1.183-1.86-.372-.64-.71-1.29-1.018-1.946.303-.657.646-1.313 1.013-1.954.38-.66.773-1.286 1.18-1.868.728-.064 1.466-.098 2.21-.098zm-3.635.254c-.24.377-.48.763-.704 1.16-.225.39-.435.782-.635 1.174-.265-.656-.49-1.31-.676-1.947.64-.15 1.315-.283 2.015-.386zm7.26 0c.695.103 1.365.23 2.006.387-.18.632-.405 1.282-.66 1.933-.2-.39-.41-.783-.64-1.174-.225-.392-.465-.774-.705-1.146zm3.063.675c.484.15.944.317 1.375.498 1.732.74 2.852 1.708 2.852 2.476-.005.768-1.125 1.74-2.857 2.475-.42.18-.88.342-1.355.493-.28-.958-.646-1.956-1.1-2.98.45-1.017.81-2.01 1.085-2.964zm-13.395.004c.278.96.645 1.957 1.1 2.98-.45 1.017-.812 2.01-1.086 2.964-.484-.15-.944-.318-1.37-.5-1.732-.737-2.852-1.706-2.852-2.474 0-.768 1.12-1.742 2.852-2.476.42-.18.88-.342 1.356-.494zm11.678 4.28c.265.657.49 1.312.676 1.948-.64.157-1.316.29-2.016.39.24-.375.48-.762.705-1.158.225-.39.435-.788.636-1.18zm-9.945.02c.2.392.41.783.64 1.175.23.39.465.772.705 1.143-.695-.102-1.365-.23-2.006-.386.18-.63.406-1.282.66-1.933zM17.92 16.32c.112.493.2.968.254 1.423.23 1.868-.054 3.32-.714 3.708-.147.09-.338.128-.563.128-1.012 0-2.514-.807-4.11-2.28.686-.72 1.37-1.536 2.02-2.44 1.107-.118 2.154-.3 3.113-.54zm-11.83.01c.96.234 2.006.415 3.107.532.66.905 1.345 1.727 2.035 2.446-1.595 1.483-3.092 2.295-4.11 2.295-.22-.005-.406-.05-.553-.132-.666-.38-.955-1.834-.73-3.703.054-.46.142-.944.25-1.438zm4.56.64c.44.02.89.034 1.345.034.46 0 .915-.01 1.36-.034-.44.572-.895 1.095-1.345 1.565-.455-.47-.91-.993-1.36-1.565z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
1
public/tech-logos/spring.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Spring</title><path d="M21.8537 1.4158a10.4504 10.4504 0 0 1-1.284 2.2471A11.9666 11.9666 0 1 0 3.8518 20.7757l.4445.3951a11.9543 11.9543 0 0 0 19.6316-8.2971c.3457-3.0126-.568-6.8649-2.0743-11.458zM5.5805 20.8745a1.0174 1.0174 0 1 1-.1482-1.4323 1.0396 1.0396 0 0 1 .1482 1.4323zm16.1991-3.5806c-2.9385 3.9263-9.2601 2.5928-13.2852 2.7904 0 0-.7161.0494-1.4323.1481 0 0 .2717-.1234.6174-.2469 2.8398-.9877 4.1732-1.1853 5.9018-2.0743 3.2349-1.6545 6.4698-5.2844 7.1118-9.0379-1.2347 3.6053-4.9881 6.7167-8.3959 7.9761-2.3459.8643-6.5685 1.7039-6.5685 1.7039l-.1729-.0988c-2.8645-1.4076-2.9632-7.6304 2.2718-9.6306 2.2966-.889 4.4696-.395 6.9637-.9877 2.6422-.6174 5.7043-2.5929 6.939-5.1857 1.3828 4.1732 3.062 10.643.0493 14.6434z"/></svg>
|
||||||
|
After Width: | Height: | Size: 834 B |
1
public/tech-logos/typescript.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="#FFFFFF" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>TypeScript</title><path d="M1.125 0C.502 0 0 .502 0 1.125v21.75C0 23.498.502 24 1.125 24h21.75c.623 0 1.125-.502 1.125-1.125V1.125C24 .502 23.498 0 22.875 0zm17.363 9.75c.612 0 1.154.037 1.627.111a6.38 6.38 0 0 1 1.306.34v2.458a3.95 3.95 0 0 0-.643-.361 5.093 5.093 0 0 0-.717-.26 5.453 5.453 0 0 0-1.426-.2c-.3 0-.573.028-.819.086a2.1 2.1 0 0 0-.623.242c-.17.104-.3.229-.393.374a.888.888 0 0 0-.14.49c0 .196.053.373.156.529.104.156.252.304.443.444s.423.276.696.41c.273.135.582.274.926.416.47.197.892.407 1.266.628.374.222.695.473.963.753.268.279.472.598.614.957.142.359.214.776.214 1.253 0 .657-.125 1.21-.373 1.656a3.033 3.033 0 0 1-1.012 1.085 4.38 4.38 0 0 1-1.487.596c-.566.12-1.163.18-1.79.18a9.916 9.916 0 0 1-1.84-.164 5.544 5.544 0 0 1-1.512-.493v-2.63a5.033 5.033 0 0 0 3.237 1.2c.333 0 .624-.03.872-.09.249-.06.456-.144.623-.25.166-.108.29-.234.373-.38a1.023 1.023 0 0 0-.074-1.089 2.12 2.12 0 0 0-.537-.5 5.597 5.597 0 0 0-.807-.444 27.72 27.72 0 0 0-1.007-.436c-.918-.383-1.602-.852-2.053-1.405-.45-.553-.676-1.222-.676-2.005 0-.614.123-1.141.369-1.582.246-.441.58-.804 1.004-1.089a4.494 4.494 0 0 1 1.47-.629 7.536 7.536 0 0 1 1.77-.201zm-15.113.188h9.563v2.166H9.506v9.646H6.789v-9.646H3.375z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/tech-logos/wpf.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#FFFFFF" d="M126 1.637l-67 9.834v49.831l67-.534zM1.647 66.709l.003 42.404 50.791 6.983-.04-49.057zm56.82.68l.094 49.465 67.376 9.509.016-58.863zM1.61 19.297l.047 42.383 50.791-.289-.023-49.016z"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
199
src/app/app.css
@@ -1,33 +1,76 @@
|
|||||||
.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.5rem;
|
||||||
|
padding: 1.1rem 1.45rem;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
border-radius: 20px;
|
border-radius: 18px;
|
||||||
background: rgba(22, 27, 34, 0.92);
|
background: rgba(22, 27, 34, 0.75);
|
||||||
backdrop-filter: blur(10px);
|
z-index: 3;
|
||||||
z-index: 20;
|
transition:
|
||||||
|
top 260ms ease,
|
||||||
|
left 260ms ease,
|
||||||
|
width 260ms ease,
|
||||||
|
transform 260ms ease,
|
||||||
|
border-radius 260ms ease,
|
||||||
|
padding 260ms ease,
|
||||||
|
background-color 260ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-partially-docked {
|
||||||
|
top: 0.2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(1200px, calc(100% - 1rem));
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.35rem;
|
||||||
|
background: rgba(22, 27, 34, 0.84);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-docked {
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: rgba(22, 27, 34, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
color: #e6edf3;
|
color: #e6edf3;
|
||||||
font-size: 1.05rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -41,15 +84,18 @@
|
|||||||
|
|
||||||
.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.62rem 0.95rem;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: #c9d1d9;
|
color: #c9d1d9;
|
||||||
|
font-size: 1rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
|
transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||||
}
|
}
|
||||||
@@ -63,19 +109,134 @@
|
|||||||
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: calc(6.5rem + env(safe-area-inset-top)) 0.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar,
|
||||||
|
.sidebar.is-partially-docked,
|
||||||
|
.sidebar.is-docked {
|
||||||
|
position: fixed;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
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;
|
||||||
|
transition:
|
||||||
|
top 300ms ease,
|
||||||
|
border-radius 300ms ease,
|
||||||
|
padding 300ms ease,
|
||||||
|
background-color 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-partially-docked {
|
||||||
|
top: 0.35rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.98rem 1.1rem;
|
||||||
|
background: rgba(22, 27, 34, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-docked {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.92rem 1rem;
|
||||||
|
background: rgba(22, 27, 34, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,68 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<main class="content">
|
<app-interactive-background></app-interactive-background>
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside
|
||||||
<a class="brand" routerLink="/home">Alex Carter</a>
|
class="sidebar"
|
||||||
<p class="subtitle">Fullstack Developer</p>
|
[class.is-partially-docked]="isNavPartiallyDocked"
|
||||||
|
[class.is-docked]="isNavDocked"
|
||||||
|
>
|
||||||
|
<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">
|
<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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
@@ -22,7 +23,75 @@ 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition navbar from partial to full dock based on scroll depth', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const scrollSpy = vi
|
||||||
|
.spyOn(window, 'scrollY', 'get')
|
||||||
|
.mockReturnValueOnce(24)
|
||||||
|
.mockReturnValueOnce(140)
|
||||||
|
.mockReturnValueOnce(0);
|
||||||
|
|
||||||
|
app.onWindowScroll();
|
||||||
|
expect(app.isNavPartiallyDocked).toBe(true);
|
||||||
|
expect(app.isNavDocked).toBe(false);
|
||||||
|
|
||||||
|
app.onWindowScroll();
|
||||||
|
expect(app.isNavPartiallyDocked).toBe(false);
|
||||||
|
expect(app.isNavDocked).toBe(true);
|
||||||
|
|
||||||
|
app.onWindowScroll();
|
||||||
|
expect(app.isNavPartiallyDocked).toBe(false);
|
||||||
|
expect(app.isNavDocked).toBe(false);
|
||||||
|
|
||||||
|
scrollSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep full dock until scroll drops below the full-dock exit threshold', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
const scrollSpy = vi
|
||||||
|
.spyOn(window, 'scrollY', 'get')
|
||||||
|
.mockReturnValueOnce(110)
|
||||||
|
.mockReturnValueOnce(80)
|
||||||
|
.mockReturnValueOnce(60);
|
||||||
|
|
||||||
|
app.onWindowScroll();
|
||||||
|
expect(app.isNavDocked).toBe(true);
|
||||||
|
|
||||||
|
app.onWindowScroll();
|
||||||
|
expect(app.isNavDocked).toBe(true);
|
||||||
|
|
||||||
|
app.onWindowScroll();
|
||||||
|
expect(app.isNavPartiallyDocked).toBe(true);
|
||||||
|
expect(app.isNavDocked).toBe(false);
|
||||||
|
|
||||||
|
scrollSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect partial dock state on the sidebar class', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
app.isNavPartiallyDocked = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('.sidebar.is-partially-docked')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reflect fully docked state on the sidebar class', () => {
|
||||||
|
const fixture = TestBed.createComponent(App);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
app.isNavDocked = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(compiled.querySelector('.sidebar.is-docked')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,59 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, HostListener } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
import { InteractiveBackground } from '../components/interactive-background/interactive-background';
|
||||||
|
|
||||||
@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;
|
||||||
|
isNavPartiallyDocked = false;
|
||||||
|
isNavDocked = false;
|
||||||
|
readonly currentYear = new Date().getFullYear();
|
||||||
|
private readonly partialDockOffset = 8;
|
||||||
|
private readonly fullDockEnterOffset = 96;
|
||||||
|
private readonly fullDockExitOffset = 64;
|
||||||
|
|
||||||
|
@HostListener('window:scroll')
|
||||||
|
onWindowScroll(): void {
|
||||||
|
const currentScrollY = window.scrollY || document.documentElement.scrollTop || 0;
|
||||||
|
|
||||||
|
if (currentScrollY <= this.partialDockOffset) {
|
||||||
|
this.isNavPartiallyDocked = false;
|
||||||
|
this.isNavDocked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isNavDocked) {
|
||||||
|
if (currentScrollY <= this.fullDockExitOffset) {
|
||||||
|
this.isNavPartiallyDocked = true;
|
||||||
|
this.isNavDocked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isNavPartiallyDocked = false;
|
||||||
|
this.isNavDocked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentScrollY >= this.fullDockEnterOffset) {
|
||||||
|
this.isNavPartiallyDocked = false;
|
||||||
|
this.isNavDocked = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isNavPartiallyDocked = true;
|
||||||
|
this.isNavDocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu(): void {
|
||||||
|
this.isMenuOpen = !this.isMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMenu(): void {
|
||||||
|
this.isMenuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
: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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="background-layer" aria-hidden="true">
|
||||||
|
<canvas #canvas class="particle-canvas"></canvas>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, HostListener, ViewChild } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-interactive-background',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './interactive-background.html',
|
||||||
|
styleUrl: './interactive-background.css'
|
||||||
|
})
|
||||||
|
export class InteractiveBackground implements AfterViewInit {
|
||||||
|
@ViewChild('canvas', { static: true }) private readonly canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private ctx: CanvasRenderingContext2D | null = null;
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.ctx = this.canvasRef.nativeElement.getContext('2d');
|
||||||
|
this.resizeAndDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onResize(): void {
|
||||||
|
this.resizeAndDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resizeAndDraw(): void {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Math.max(1, window.innerWidth);
|
||||||
|
const height = Math.max(1, window.innerHeight);
|
||||||
|
const canvas = this.canvasRef.nativeElement;
|
||||||
|
|
||||||
|
if (canvas.width === width && canvas.height === height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
|
||||||
|
const gradient = ctx.createRadialGradient(
|
||||||
|
width * 0.24,
|
||||||
|
height * 0.12,
|
||||||
|
0,
|
||||||
|
width * 0.5,
|
||||||
|
height * 0.5,
|
||||||
|
Math.max(width, height)
|
||||||
|
);
|
||||||
|
gradient.addColorStop(0, 'rgba(88, 166, 255, 0.14)');
|
||||||
|
gradient.addColorStop(0.42, 'rgba(88, 166, 255, 0.18)');
|
||||||
|
gradient.addColorStop(0.72, 'rgba(147, 102, 255, 0.1)');
|
||||||
|
gradient.addColorStop(1, 'rgba(13, 17, 23, 0)');
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
src/components/tech-stack-carousel/tech-stack-carousel.css
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
scroll-margin-top: 5.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.5rem, 2.2vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-row p {
|
||||||
|
margin: 0;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel-shell {
|
||||||
|
--carousel-nav-space: 4.25rem;
|
||||||
|
position: relative;
|
||||||
|
width: calc(100% + (var(--carousel-nav-space) * 2));
|
||||||
|
margin-inline: calc(var(--carousel-nav-space) * -1);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel-column {
|
||||||
|
position: relative;
|
||||||
|
width: calc(100% - (var(--carousel-nav-space) * 2));
|
||||||
|
margin-inline: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.2rem;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(88, 166, 255, 0.45) rgba(33, 38, 45, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-slide {
|
||||||
|
flex: 0 0 min(320px, calc(100% - 0.2rem));
|
||||||
|
scroll-snap-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(88, 166, 255, 0.4);
|
||||||
|
background: radial-gradient(circle at 30% 20%, rgba(88, 166, 255, 0.2), rgba(22, 27, 34, 0.95));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(240, 246, 252, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 140ms ease, box-shadow 140ms ease, background-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-logo {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.92;
|
||||||
|
transition: transform 140ms ease, opacity 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-fallback {
|
||||||
|
display: none;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #9ecbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-icon.is-fallback .skill-logo {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-icon.is-fallback .skill-fallback {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card {
|
||||||
|
transition: border-color 140ms ease, box-shadow 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-card .meta {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-slide:hover .skill-icon,
|
||||||
|
.stack-slide:focus-within .skill-icon {
|
||||||
|
border-color: rgba(121, 192, 255, 0.75);
|
||||||
|
box-shadow: 0 0 0 3px rgba(56, 139, 253, 0.18), inset 0 1px 0 rgba(240, 246, 252, 0.14);
|
||||||
|
background: radial-gradient(circle at 30% 20%, rgba(121, 192, 255, 0.28), rgba(22, 27, 34, 0.95));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-slide:hover .skill-logo,
|
||||||
|
.stack-slide:focus-within .skill-logo {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border: 1px solid #3d444d;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(33, 38, 45, 0.8);
|
||||||
|
color: #e6edf3;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(calc(-50% + 2px));
|
||||||
|
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease, opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel-shell > .carousel-btn:first-of-type {
|
||||||
|
left: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel-shell > .carousel-btn:last-of-type {
|
||||||
|
right: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel-shell:hover .carousel-btn,
|
||||||
|
.stack-carousel-shell:focus-within .carousel-btn {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-btn:hover {
|
||||||
|
border-color: #58a6ff;
|
||||||
|
background: rgba(56, 139, 253, 0.2);
|
||||||
|
transform: translateY(calc(-50% - 1px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-btn:focus-visible {
|
||||||
|
outline: 2px solid #58a6ff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.stack-carousel-shell {
|
||||||
|
--carousel-nav-space: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel-column {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-carousel {
|
||||||
|
scroll-padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-slide {
|
||||||
|
flex-basis: min(280px, 85%);
|
||||||
|
scroll-snap-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1000px) {
|
||||||
|
.stack-slide {
|
||||||
|
flex-basis: calc((100% - 3rem) / 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
48
src/components/tech-stack-carousel/tech-stack-carousel.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<section class="section" id="stack">
|
||||||
|
<div class="section-title-row">
|
||||||
|
<h2>Tech Stack</h2>
|
||||||
|
<p>Primary technologies I use to deliver production-ready software.</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stack-carousel-shell"
|
||||||
|
data-disable-bg-pointer
|
||||||
|
>
|
||||||
|
<button class="carousel-btn" type="button" aria-label="Previous technologies" (click)="scrollStack(-1)">
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="stack-carousel-column">
|
||||||
|
<div
|
||||||
|
class="stack-carousel"
|
||||||
|
#stackCarousel
|
||||||
|
role="region"
|
||||||
|
aria-label="Tech stack carousel"
|
||||||
|
>
|
||||||
|
@for (skill of skills; track skill.name) {
|
||||||
|
<app-card cardClass="skill-card stack-slide">
|
||||||
|
<div class="skill-heading">
|
||||||
|
<span class="skill-icon" role="img" [attr.aria-label]="skill.name + ' logo'">
|
||||||
|
<img
|
||||||
|
class="skill-logo"
|
||||||
|
[src]="skill.logoUrl"
|
||||||
|
[alt]="skill.name + ' logo'"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
(error)="onLogoError($event)"
|
||||||
|
/>
|
||||||
|
<span class="skill-fallback" aria-hidden="true">{{ skill.fallbackLabel }}</span>
|
||||||
|
</span>
|
||||||
|
<h3>{{ skill.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="meta">{{ skill.category }} - {{ skill.level }}</p>
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="carousel-btn" type="button" aria-label="Next technologies" (click)="scrollStack(1)">
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
94
src/components/tech-stack-carousel/tech-stack-carousel.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
|
||||||
|
import { CardComponent } from '../card/card';
|
||||||
|
|
||||||
|
interface Skill {
|
||||||
|
name: string;
|
||||||
|
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud' | 'Frontend / Backend' | 'Deployment';
|
||||||
|
level: string;
|
||||||
|
logoUrl: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tech-stack-carousel',
|
||||||
|
imports: [CardComponent],
|
||||||
|
templateUrl: './tech-stack-carousel.html',
|
||||||
|
styleUrl: './tech-stack-carousel.css',
|
||||||
|
})
|
||||||
|
export class TechStackCarousel implements AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('stackCarousel') private readonly stackCarousel?: ElementRef<HTMLElement>;
|
||||||
|
private autoScrollIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private readonly autoScrollIntervalMs = 5000;
|
||||||
|
|
||||||
|
readonly skills: Skill[] = [
|
||||||
|
{ name: 'Angular', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/angular.svg', fallbackLabel: 'NG' },
|
||||||
|
{ name: 'React', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/react.svg', fallbackLabel: 'RE' },
|
||||||
|
{ name: 'Ionic', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/ionic.svg', fallbackLabel: 'IO' },
|
||||||
|
{ name: 'TypeScript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/typescript.svg', fallbackLabel: 'TS' },
|
||||||
|
{ name: 'Javascript', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/javascript.svg', fallbackLabel: 'JS' },
|
||||||
|
{ name: 'Docker', category: 'Deployment', level: 'Advanced', logoUrl: '/tech-logos/docker.svg', fallbackLabel: 'DK' },
|
||||||
|
{ name: 'C# / .NET', category: 'Frontend / Backend', level: 'Advanced', logoUrl: '/tech-logos/csharp.svg', fallbackLabel: 'CS' },
|
||||||
|
{ name: 'ASP.NET Core', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/dotnet.svg', fallbackLabel: 'AS' },
|
||||||
|
{ name: 'WPF', category: 'Frontend', level: 'Advanced', logoUrl: '/tech-logos/wpf.svg', fallbackLabel: 'WP' },
|
||||||
|
{ name: 'Java', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/java.svg', fallbackLabel: 'JV' },
|
||||||
|
{ name: 'Spring Boot', category: 'Backend', level: 'Advanced', logoUrl: '/tech-logos/spring.svg', fallbackLabel: 'SB' },
|
||||||
|
{ name: 'Flutter', category: 'Mobile', level: 'Advanced', logoUrl: '/tech-logos/flutter.svg', fallbackLabel: 'FL' },
|
||||||
|
];
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.startAutoScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopAutoScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollStack(direction: -1 | 1): void {
|
||||||
|
const carousel = this.stackCarousel?.nativeElement;
|
||||||
|
if (!carousel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScrollLeft = Math.max(carousel.scrollWidth - carousel.clientWidth, 0);
|
||||||
|
if (maxScrollLeft <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 1 && carousel.scrollLeft >= maxScrollLeft) {
|
||||||
|
carousel.scrollTo({ left: 0, behavior: 'auto' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
carousel.scrollBy({ left: this.getScrollStep(carousel) * direction, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onLogoError(event: Event): void {
|
||||||
|
const image = event.target as HTMLImageElement | null;
|
||||||
|
const wrapper = image?.closest('.skill-icon');
|
||||||
|
wrapper?.classList.add('is-fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getScrollStep(carousel: HTMLElement): number {
|
||||||
|
return Math.max(carousel.clientWidth * 0.82, 260);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startAutoScroll(): void {
|
||||||
|
if (this.autoScrollIntervalId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.autoScrollIntervalId = setInterval(() => {
|
||||||
|
this.scrollStack(1);
|
||||||
|
}, this.autoScrollIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopAutoScroll(): void {
|
||||||
|
if (this.autoScrollIntervalId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(this.autoScrollIntervalId);
|
||||||
|
this.autoScrollIntervalId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,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
@@ -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.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.contact-inline a {
|
||||||
padding: 2rem 1.2rem;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-inline .contact-cta {
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>Let’s build something polished, useful, and fast.</h1>
|
<h1 class="hero-title">Let’s 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,
|
||||||
I’d be happy to talk.
|
I’d 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 you’re using. I’ll reply
|
Send a short message with your goals, timeline, and the stack you’re using.
|
||||||
with a clear next step.
|
|
||||||
</p>
|
</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,47 +159,13 @@ 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: 0.5rem 0.75rem 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
padding: 2rem 1.2rem;
|
padding: 2rem 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-item {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,58 +10,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="section" id="stack">
|
|
||||||
<div class="section-title-row">
|
|
||||||
<h2>Tech Stack</h2>
|
|
||||||
<p>Primary technologies I use to deliver production-ready software.</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-grid">
|
|
||||||
@for (skill of skills; track skill.name) {
|
|
||||||
<app-card cardClass="skill-card">
|
|
||||||
<h3>{{ skill.name }}</h3>
|
|
||||||
<p class="meta">{{ skill.category }} - {{ skill.level }}</p>
|
|
||||||
</app-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="projects">
|
<section class="section" id="projects">
|
||||||
<div class="section-title-row">
|
<div class="section-title-row">
|
||||||
<h2>Featured Projects</h2>
|
<h2>Featured Projects</h2>
|
||||||
<p>Recent work across web platforms, APIs, and mobile apps.</p>
|
<p>Recent work across web platforms, APIs, and mobile apps.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-grid projects-grid">
|
<div class="card-grid projects-grid">
|
||||||
@for (project of projects; track project.title) {
|
@for (project of projects; track project.slug) {
|
||||||
|
<a class="project-link" [routerLink]="['/projects', project.slug]">
|
||||||
<app-card cardClass="project-card">
|
<app-card cardClass="project-card">
|
||||||
<h3>{{ project.title }}</h3>
|
<h3>{{ project.title }}</h3>
|
||||||
<p>{{ project.description }}</p>
|
<p>{{ project.description }}</p>
|
||||||
<p class="meta">{{ project.stack.join(' - ') }}</p>
|
<p class="meta">{{ project.stack.join(' - ') }}</p>
|
||||||
<p class="impact">{{ project.impact }}</p>
|
<p class="impact">{{ project.impact }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" id="experience">
|
<app-tech-stack-carousel></app-tech-stack-carousel>
|
||||||
<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>
|
||||||
|
|||||||
@@ -25,13 +25,32 @@ 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render stack carousel with icon badges', () => {
|
||||||
|
const compiled = fixture.nativeElement as HTMLElement;
|
||||||
|
const stackSection = compiled.querySelector('app-tech-stack-carousel #stack');
|
||||||
|
const carousel = compiled.querySelector('app-tech-stack-carousel .stack-carousel');
|
||||||
|
const icons = compiled.querySelectorAll('app-tech-stack-carousel .skill-icon');
|
||||||
|
const logos = compiled.querySelectorAll('app-tech-stack-carousel .skill-logo');
|
||||||
|
const controls = compiled.querySelectorAll('app-tech-stack-carousel .carousel-btn');
|
||||||
|
|
||||||
|
expect(stackSection).toBeTruthy();
|
||||||
|
expect(carousel).toBeTruthy();
|
||||||
|
expect(icons.length).toBeGreaterThan(0);
|
||||||
|
expect(logos.length).toBe(icons.length);
|
||||||
|
expect(logos[0]?.getAttribute('src')).toContain('/tech-logos/');
|
||||||
|
expect(controls.length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,92 +1,22 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { CardComponent } from '../../components/card/card';
|
import { CardComponent } from '../../components/card/card';
|
||||||
|
import { TechStackCarousel } from '../../components/tech-stack-carousel/tech-stack-carousel';
|
||||||
interface Skill {
|
import { PROJECTS, Project } from '../projects/project-data';
|
||||||
name: string;
|
|
||||||
category: 'Frontend' | 'Backend' | 'Mobile' | 'Cloud';
|
|
||||||
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, TechStackCarousel],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
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 projects: Project[] = PROJECTS;
|
||||||
{ 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: '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: '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.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
63
src/pages/projects/project-data.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
58
src/pages/projects/project-detail/project-detail.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
45
src/pages/projects/project-detail/project-detail.html
Normal 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>
|
||||||
|
|
||||||
47
src/pages/projects/project-detail/project-detail.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/pages/projects/project-detail/project-detail.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
51
src/pages/projects/projects.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
26
src/pages/projects/projects.html
Normal 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>
|
||||||
|
|
||||||
33
src/pages/projects/projects.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
15
src/pages/projects/projects.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
100
src/styles.css
@@ -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: 0.5rem 0.75rem 3.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||