338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|