Files
portfolio/src/routes/index.tsx

338 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createFileRoute, Link } from '@tanstack/react-router'
import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio'
import type { TExperience, TEducation, TSkill } from '#/types/strapi'
import {
fetchExperiences,
fetchEducations,
fetchProfile,
} from '#/data/loaders/portfolio'
export const Route = createFileRoute('/')({
loader: async () => {
const [experiences, educations, profile] = await Promise.all([
fetchExperiences(),
fetchEducations(),
fetchProfile(),
])
return { experiences, educations, profile }
},
errorComponent: StrapiErrorPage,
component: HomePage,
})
// ── Error page ────────────────────────────────────────────────────────────
function StrapiErrorPage() {
return (
<div className="page-wrap flex min-h-[60vh] flex-col items-center justify-center gap-4 text-center">
<p className="island-kicker">Service Unavailable</p>
<h1 className="display-title text-4xl">We'll be right back</h1>
<p
style={{ color: 'var(--sea-ink-soft)' }}
className="max-w-md text-base"
>
The portfolio data is temporarily unavailable. Please check back in a
few minutes.
</p>
</div>
)
}
// ── Shared UI primitives ──────────────────────────────────────────────────
type ButtonVariant = 'primary' | 'ghost'
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'rounded-full border border-[rgba(50,143,151,0.35)] bg-[rgba(79,184,178,0.15)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.26)]',
ghost:
'rounded-full border border-[var(--line)] bg-[var(--surface-strong)] px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.3)]',
}
function ButtonLink({
href,
variant,
children,
}: {
readonly href: string
readonly variant: ButtonVariant
readonly children: React.ReactNode
}) {
return (
<a href={href} className={BUTTON_VARIANT_CLASSES[variant]}>
{children}
</a>
)
}
function TechChip({ label }: { readonly label: string }) {
return (
<span className="inline-block rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--lagoon-deep)]">
{label}
</span>
)
}
// ── Section heading ───────────────────────────────────────────────────────
function SectionHeading({
kicker,
title,
id,
}: {
readonly kicker: string
readonly title: string
readonly id: string
}) {
return (
<div id={id} className="mb-8 scroll-mt-20">
<p className="island-kicker mb-2">{kicker}</p>
<h2 className="display-title m-0 text-2xl font-bold tracking-tight text-[var(--sea-ink)] sm:text-3xl">
{title}
</h2>
</div>
)
}
// ── Experience card ───────────────────────────────────────────────────────
const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = {
'full-time': 'Full-time',
'part-time': 'Part-time',
}
function ExperienceCard({
exp,
index,
}: {
readonly exp: TExperience
readonly index: number
}) {
const startIso = exp.startDate.slice(0, 7)
const endIso = exp.endDate?.slice(0, 7)
const duration = computeDuration(startIso, endIso)
const startLabel = formatDate(startIso)
const endLabel = endIso ? formatDate(endIso) : 'Present'
return (
<div
className="rise-in relative flex gap-4 sm:gap-6"
style={{ animationDelay: `${index * 80}ms` }}
>
{/* Timeline dot + connector line */}
<div className="flex flex-col items-center">
<div className="mt-1.5 h-3.5 w-3.5 flex-shrink-0 rounded-full border-2 border-[var(--lagoon)] bg-[var(--lagoon-deep)] shadow-[0_0_0_4px_rgba(79,184,178,0.18)]" />
<div className="mt-1.5 w-0.5 flex-1 bg-[linear-gradient(to_bottom,var(--lagoon),transparent)]" />
</div>
{/* Card */}
<div className="island-shell feature-card mb-6 flex-1 rounded-2xl p-5 pb-6">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<Link
to="/experience/$slug"
params={{ slug: exp.slug }}
className="group min-w-0"
>
<p className="island-kicker mb-0.5">{exp.company}</p>
<h3 className="m-0 text-base font-bold text-[var(--sea-ink)] transition group-hover:text-[var(--lagoon-deep)]">
{exp.role}
</h3>
</Link>
{exp.type != null && (
<span className="mt-0.5 flex-shrink-0 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--sea-ink-soft)]">
{EMPLOYMENT_TYPE_LABEL[exp.type]}
</span>
)}
</div>
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
<span>
{startLabel} — {endLabel}
</span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{duration}</span>
</div>
{(exp.objectives ?? []).length > 0 && (
<ul className="mb-3 ml-0 list-none space-y-1.5 p-0">
{(exp.objectives ?? []).map((obj) => (
<li
key={obj.id}
className="flex gap-2 text-sm text-[var(--sea-ink-soft)]"
>
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" />
{obj.description}
</li>
))}
</ul>
)}
{(exp.technologies ?? []).length > 0 && (
<div className="flex flex-wrap gap-1.5">
{(exp.technologies ?? []).map((tech) => (
<TechChip key={tech.id} label={tech.name} />
))}
</div>
)}
</div>
</div>
)
}
// ── Education card ────────────────────────────────────────────────────────
function EducationCard({ edu }: { readonly edu: TEducation }) {
const startYear = new Date(edu.startDate).getFullYear()
const endYear = edu.endDate ? new Date(edu.endDate).getFullYear() : null
return (
<div className="island-shell feature-card rise-in rounded-2xl p-6">
<p className="island-kicker mb-2">{edu.institution}</p>
<h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]">
{edu.degree}
{edu.fieldOfStudy ? ` ${edu.fieldOfStudy}` : ''}
</h3>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
<span>
{startYear} {endYear ?? 'Present'}
</span>
{edu.grade != null && (
<>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span className="font-semibold text-[var(--lagoon-deep)]">
Grade: {edu.grade}
</span>
</>
)}
</div>
</div>
)
}
// ── Skill group card ──────────────────────────────────────────────────────
function SkillGroupCard({
category,
skills,
}: {
readonly category: string
readonly skills: readonly TSkill[]
}) {
return (
<div className="island-shell feature-card rise-in rounded-2xl p-5">
<p className="island-kicker mb-3">{category}</p>
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<TechChip key={skill.id} label={skill.name} />
))}
</div>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────
function HomePage() {
const { experiences, educations, profile } = Route.useLoaderData()
const skillsByGroup = profile.skills.reduce<Record<string, TSkill[]>>(
(acc, skill) => {
const key = skill.group
acc[key] = [...(acc[key] ?? []), skill]
return acc
},
{},
)
return (
<main className="page-wrap px-4 pb-16 pt-10">
{/* ── Hero ──────────────────────────────────────────── */}
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-12 sm:px-12 sm:py-16">
<div className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.3),transparent_65%)]" />
<div className="pointer-events-none absolute -bottom-24 -right-24 h-72 w-72 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_65%)]" />
<div className="pointer-events-none absolute right-1/3 top-0 h-48 w-48 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.12),transparent_70%)]" />
<p className="island-kicker mb-4">
Software Engineer · Copenhagen, Denmark
</p>
<h1 className="display-title mb-4 max-w-2xl text-5xl font-bold leading-[1.02] tracking-tight text-[var(--sea-ink)] sm:text-7xl">
Hi, I'm {profile.firstname}.
</h1>
<p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg">
{profile.about ??
'Crafting robust full-stack solutions with a passion for clean architecture, great developer experience, and shipping products people love.'}
</p>
<div className="flex flex-wrap gap-3">
<ButtonLink href="#experience" variant="primary">
View Experience
</ButtonLink>
<ButtonLink href={`mailto:${CONTACT_EMAIL}`} variant="ghost">
Get in Touch
</ButtonLink>
</div>
</section>
{/* ── Experience ────────────────────────────────────── */}
<section className="mt-16">
<SectionHeading
kicker="Career"
title="Work Experience"
id="experience"
/>
<div>
{experiences.map((exp, i) => (
<ExperienceCard key={exp.id} exp={exp} index={i} />
))}
</div>
</section>
{/* ── Education ─────────────────────────────────────── */}
<section className="mt-16">
<SectionHeading kicker="Background" title="Education" id="education" />
<div className="grid gap-4 sm:grid-cols-2">
{educations.map((edu) => (
<EducationCard key={edu.id} edu={edu} />
))}
</div>
</section>
{/* ── Skills ────────────────────────────────────────── */}
<section className="mt-16">
<SectionHeading
kicker="Toolkit"
title="Skills & Technologies"
id="skills"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(skillsByGroup).map(([groupKey, skills]) => (
<SkillGroupCard
key={groupKey}
category={groupKey}
skills={skills}
/>
))}
</div>
</section>
{/* ── Contact CTA ───────────────────────────────────── */}
<section id="contact" className="mt-16 scroll-mt-20">
<div className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-12 text-center sm:px-12">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(79,184,178,0.1),transparent_70%)]" />
<p className="island-kicker mb-3">Let's connect</p>
<h2 className="display-title mb-4 text-2xl font-bold text-[var(--sea-ink)] sm:text-4xl">
Open to new opportunities
</h2>
<p className="mx-auto mb-8 max-w-md text-[var(--sea-ink-soft)]">
I'm always interested in hearing about exciting engineering
challenges and the teams building them.
</p>
<ButtonLink href={`mailto:${CONTACT_EMAIL}`} variant="primary">
Say hello
</ButtonLink>
</div>
</section>
</main>
)
}