Redesign portfolio around systems case studies

This commit is contained in:
v7
2026-09-06 19:39:23 +02:00
parent bd3f260700
commit 384c8563de
29 changed files with 1198 additions and 841 deletions

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { SectionNavItem } from '../../shared/models/section-nav.model';
import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.component';
@@ -7,88 +7,49 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
@Component({
selector: 'app-page-shell',
standalone: true,
imports: [CommonModule, RouterLink, ThemeToggleComponent, NgOptimizedImage],
imports: [CommonModule, RouterLink, ThemeToggleComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="min-h-screen bg-[#f4f7f5] text-zinc-800 font-saira transition-colors duration-300 dark:bg-slate-950 dark:text-zinc-100"
>
<header
class="sticky top-0 z-30 border-b border-zinc-200/70 bg-[#f8faf7]/85 backdrop-blur transition-colors duration-300 dark:border-slate-900/60 dark:bg-slate-950/70"
>
<div
class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 md:px-8"
>
<div class="flex items-center gap-3">
<img
ngSrc="/images/profile.jpg"
width="40"
height="40"
priority
alt="Dzanan"
class="h-10 w-10 rounded-full object-cover ring-1 ring-emerald-600/20 transition-colors dark:ring-emerald-500/30"
/>
<div class="flex flex-col leading-tight">
<span class="text-sm text-zinc-500 dark:text-zinc-400"
>Senior Software Engineer</span
>
<span class="text-base font-semibold">Amar Džanan</span>
</div>
</div>
<div class="site-frame">
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header">
<div class="shell header-inner">
<a routerLink="/" class="wordmark" aria-label="Amar Džanan, home">
<span class="wordmark-mark">AD/</span>
<span class="wordmark-copy">Amar Džanan<small>Software systems & AI</small></span>
</a>
<!-- Desktop Nav & Toggle -->
<div class="hidden items-center gap-4 md:flex">
<nav class="flex items-center gap-2">
<div class="desktop-nav">
<nav aria-label="Primary navigation">
@for (section of sections; track section.label) {
<a
[routerLink]="section.path || []"
[fragment]="section.fragment"
class="rounded-full px-3 py-2 text-sm font-medium text-zinc-600 transition hover:bg-emerald-50/80 hover:text-zinc-900 dark:text-zinc-200 dark:hover:bg-slate-900 dark:hover:text-white"
>
{{ section.label }}
</a>
<a [routerLink]="section.path || []" [fragment]="section.fragment">{{ section.label }}</a>
}
</nav>
<div
class="h-6 w-px bg-zinc-200 dark:bg-slate-800"
aria-hidden="true"
></div>
<app-theme-toggle />
</div>
<!-- Mobile Toggle & Menu Placeholder -->
<div class="flex items-center gap-4 md:hidden">
<button class="menu-button" type="button" (click)="toggleMenu()" [attr.aria-expanded]="menuOpen()" aria-controls="mobile-navigation">
<span>Menu</span><span aria-hidden="true">{{ menuOpen() ? '×' : '+' }}</span>
</button>
</div>
@if (menuOpen()) {
<div id="mobile-navigation" class="mobile-nav shell">
<nav aria-label="Mobile navigation">
@for (section of sections; track section.label) {
<a [routerLink]="section.path || []" [fragment]="section.fragment" (click)="closeMenu()">{{ section.label }}</a>
}
</nav>
<app-theme-toggle />
<span class="text-sm text-zinc-500 dark:text-zinc-400"
>Navigate</span
>
</div>
</div>
}
</header>
<main class="relative overflow-hidden opacity-0 animate-[fade-in-up_1s_ease-out_0.3s_forwards]">
<div
class="pointer-events-none absolute inset-0 opacity-60 transition-opacity duration-500 dark:opacity-60"
aria-hidden="true"
>
<div
class="absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_50%_20%,rgba(16,185,129,0.1),transparent_42%)] dark:bg-[radial-gradient(circle_at_50%_20%,rgba(16,185,129,0.2),transparent_40%)]"
></div>
<div
class="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_80%_50%,rgba(14,165,233,0.07),transparent_38%)] dark:bg-[radial-gradient(circle_at_80%_50%,rgba(59,130,246,0.12),transparent_35%)]"
></div>
</div>
<div class="relative">
<ng-content />
</div>
<main id="main-content">
<ng-content />
</main>
<footer
class="border-t border-zinc-200/70 bg-[#f8faf7]/85 transition-colors duration-300 dark:border-slate-900/60 dark:bg-slate-950/80"
>
<div
class="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-sm text-zinc-500 dark:text-zinc-400 md:px-8"
>
<span>Built for clarity and craft.</span>
<span class="text-zinc-600 dark:text-zinc-500">dzanan.net</span>
<footer class="site-footer">
<div class="shell footer-inner">
<span>Amar Džanan · Sarajevo, Bosnia and Herzegovina</span>
<div><a href="mailto:amar@dzanan.net">amar&#64;dzanan.net</a><span aria-hidden="true"> / </span><a href="https://www.linkedin.com/in/amardzanan/">LinkedIn</a></div>
</div>
</footer>
</div>
@@ -96,4 +57,13 @@ import { ThemeToggleComponent } from '../../shared/ui/theme-toggle/theme-toggle.
})
export class PageShellComponent {
@Input({ required: true }) sections: SectionNavItem[] = [];
readonly menuOpen = signal(false);
toggleMenu(): void {
this.menuOpen.update(open => !open);
}
closeMenu(): void {
this.menuOpen.set(false);
}
}

View File

@@ -0,0 +1,45 @@
import { DOCUMENT } from '@angular/common';
import { inject, Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
export type SeoPage = {
title: string;
description: string;
path: string;
type?: 'website' | 'article';
structuredData?: Record<string, unknown>;
};
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly document = inject(DOCUMENT);
private readonly meta = inject(Meta);
private readonly title = inject(Title);
update(page: SeoPage): void {
const canonicalUrl = `https://dzanan.net${page.path}`;
this.title.setTitle(page.title);
this.meta.updateTag({ name: 'description', content: page.description });
this.meta.updateTag({ property: 'og:title', content: page.title });
this.meta.updateTag({ property: 'og:description', content: page.description });
this.meta.updateTag({ property: 'og:type', content: page.type ?? 'website' });
this.meta.updateTag({ property: 'og:url', content: canonicalUrl });
let canonical = this.document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!canonical) {
canonical = this.document.createElement('link');
canonical.rel = 'canonical';
this.document.head.appendChild(canonical);
}
canonical.href = canonicalUrl;
this.document.head.querySelector('script[data-page-structured-data]')?.remove();
if (page.structuredData) {
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-page-structured-data', '');
script.textContent = JSON.stringify(page.structuredData);
this.document.head.appendChild(script);
}
}
}