feat: integrate Strapi CMS for dynamic portfolio data

This commit is contained in:
2026-04-19 12:43:32 +02:00
parent 67e01e9a5e
commit ef9f16c2cc
3 changed files with 154 additions and 156 deletions

View File

@@ -1,10 +1,5 @@
import { createServerFn } from '@tanstack/react-start' import { createServerFn } from '@tanstack/react-start'
import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi' import type { TExperience, TEducation, TProfile } from '#/types/strapi'
import {
experiences as fallbackExperiences,
educations as fallbackEducations,
skillGroups as fallbackSkillGroups,
} from '#/data/portfolio'
const STRAPI_BASE = process.env['STRAPI_URL'] ?? 'https://strapi.moshariri.com' const STRAPI_BASE = process.env['STRAPI_URL'] ?? 'https://strapi.moshariri.com'
@@ -13,107 +8,41 @@ const buildHeaders = (): Record<string, string> => {
return token ? { Authorization: `Bearer ${token}` } : {} return token ? { Authorization: `Bearer ${token}` } : {}
} }
const fetchFromStrapi = async <T>(path: string): Promise<{ data: T[] }> => {
const url = `${STRAPI_BASE}/api${path}`
console.log(`[Strapi] → GET ${url}`)
const response = await fetch(url, { headers: buildHeaders() })
if (!response.ok) {
throw new Error(`Strapi request failed: ${response.status} ${url}`)
}
const json = (await response.json()) as { data: T[] }
console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`)
return json
}
export const fetchExperiences = createServerFn({ method: 'GET' }).handler( export const fetchExperiences = createServerFn({ method: 'GET' }).handler(
async (): Promise<TExperience[]> => { async (): Promise<TExperience[]> => {
try { const url = `${STRAPI_BASE}/api/experiences?populate[objectives]=*&populate[technologies]=*`
const { data } = await fetchFromStrapi<TExperience>( console.log(`[Strapi] → GET ${url}`)
'/experiences?sort=order:asc&populate=*', const res = await fetch(url, { headers: buildHeaders() })
) if (!res.ok)
return data.map((item) => ({ throw new Error(`Strapi request failed: ${res.status} ${res.url}`)
id: item.id, const json = (await res.json()) as { data: TExperience[] }
company: item.company, console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`)
role: item.role, return json.data
employmentType: item.employmentType,
startDate: item.startDate,
endDate: item.endDate,
location: item.location,
bullets: item.bullets ?? [],
tech: item.tech ?? [],
order: item.order,
}))
} catch (err) {
console.error('[Strapi] fetchExperiences failed, using fallback:', err)
return fallbackExperiences.map((e, i) => ({
id: i,
company: e.company,
role: e.role,
employmentType:
e.type === 'Full-time' ? 'full-time' : ('part-time' as const),
startDate: e.startDate,
endDate: e.endDate ?? undefined,
location: e.location,
bullets: [...e.bullets],
tech: [...e.tech],
order: i,
}))
}
}, },
) )
export const fetchEducations = createServerFn({ method: 'GET' }).handler( export const fetchEducations = createServerFn({ method: 'GET' }).handler(
async (): Promise<TEducation[]> => { async (): Promise<TEducation[]> => {
try { const url = `${STRAPI_BASE}/api/educations?populate=*`
const { data } = await fetchFromStrapi<TEducation>( console.log(`[Strapi] → GET ${url}`)
'/educations?sort=order:asc&populate=*', const res = await fetch(url, { headers: buildHeaders() })
) if (!res.ok)
return data.map((item) => ({ throw new Error(`Strapi request failed: ${res.status} ${res.url}`)
id: item.id, const json = (await res.json()) as { data: TEducation[] }
institution: item.institution, console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`)
degree: item.degree, return json.data
fieldOfStudy: item.fieldOfStudy,
startYear: item.startYear,
endYear: item.endYear,
grade: item.grade,
order: item.order,
}))
} catch (err) {
console.error('[Strapi] fetchEducations failed, using fallback:', err)
return fallbackEducations.map((e, i) => ({
id: i,
institution: e.institution,
degree: e.degree,
fieldOfStudy: undefined,
startYear: e.startYear,
endYear: e.endYear,
grade: e.grade ?? undefined,
order: i,
}))
}
}, },
) )
export const fetchSkillGroups = createServerFn({ method: 'GET' }).handler( export const fetchProfile = createServerFn({ method: 'GET' }).handler(
async (): Promise<TSkillGroup[]> => { async (): Promise<TProfile> => {
try { const url = `${STRAPI_BASE}/api/profile?populate[skills][populate]=*`
const { data } = await fetchFromStrapi<TSkillGroup>( console.log(`[Strapi] → GET ${url}`)
'/skill-groups?sort=order:asc&populate=*', const res = await fetch(url, { headers: buildHeaders() })
) if (!res.ok)
return data.map((item) => ({ throw new Error(`Strapi request failed: ${res.status} ${res.url}`)
id: item.id, const json = (await res.json()) as { data: TProfile }
category: item.category, console.log(`[Strapi] ← GET ${url} OK`)
skills: item.skills ?? [], return json.data
order: item.order,
}))
} catch (err) {
console.error('[Strapi] fetchSkillGroups failed, using fallback:', err)
return fallbackSkillGroups.map((g, i) => ({
id: i,
category: g.category,
skills: [...g.skills],
order: i,
}))
}
}, },
) )

View File

@@ -1,24 +1,43 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio' import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio'
import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi' import type { TExperience, TEducation, TSkill } from '#/types/strapi'
import { import {
fetchExperiences, fetchExperiences,
fetchEducations, fetchEducations,
fetchSkillGroups, fetchProfile,
} from '#/data/loaders/portfolio' } from '#/data/loaders/portfolio'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
loader: async () => { loader: async () => {
const [experiences, educations, skillGroups] = await Promise.all([ const [experiences, educations, profile] = await Promise.all([
fetchExperiences(), fetchExperiences(),
fetchEducations(), fetchEducations(),
fetchSkillGroups(), fetchProfile(),
]) ])
return { experiences, educations, skillGroups } return { experiences, educations, profile }
}, },
errorComponent: StrapiErrorPage,
component: HomePage, 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 ────────────────────────────────────────────────── // ── Shared UI primitives ──────────────────────────────────────────────────
type ButtonVariant = 'primary' | 'ghost' type ButtonVariant = 'primary' | 'ghost'
@@ -77,7 +96,7 @@ function SectionHeading({
// ── Experience card ─────────────────────────────────────────────────────── // ── Experience card ───────────────────────────────────────────────────────
const EMPLOYMENT_TYPE_LABEL: Record<TExperience['employmentType'], string> = { const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = {
'full-time': 'Full-time', 'full-time': 'Full-time',
'part-time': 'Part-time', 'part-time': 'Part-time',
} }
@@ -89,9 +108,11 @@ function ExperienceCard({
readonly exp: TExperience readonly exp: TExperience
readonly index: number readonly index: number
}) { }) {
const duration = computeDuration(exp.startDate, exp.endDate ?? undefined) const startIso = exp.startDate.slice(0, 7)
const startLabel = formatDate(exp.startDate) const endIso = exp.endDate?.slice(0, 7)
const endLabel = exp.endDate ? formatDate(exp.endDate) : 'Present' const duration = computeDuration(startIso, endIso)
const startLabel = formatDate(startIso)
const endLabel = endIso ? formatDate(endIso) : 'Present'
return ( return (
<div <div
@@ -113,9 +134,11 @@ function ExperienceCard({
{exp.role} {exp.role}
</h3> </h3>
</div> </div>
<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)]"> {exp.type != null && (
{EMPLOYMENT_TYPE_LABEL[exp.employmentType]} <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)]">
</span> {EMPLOYMENT_TYPE_LABEL[exp.type]}
</span>
)}
</div> </div>
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]"> <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
@@ -124,28 +147,26 @@ function ExperienceCard({
</span> </span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" /> <span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{duration}</span> <span>{duration}</span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{exp.location}</span>
</div> </div>
{exp.bullets.length > 0 && ( {exp.objectives.length > 0 && (
<ul className="mb-3 ml-0 list-none space-y-1.5 p-0"> <ul className="mb-3 ml-0 list-none space-y-1.5 p-0">
{exp.bullets.map((bullet) => ( {exp.objectives.map((obj) => (
<li <li
key={bullet} key={obj.id}
className="flex gap-2 text-sm text-[var(--sea-ink-soft)]" 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)]" /> <span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" />
{bullet} {obj.description}
</li> </li>
))} ))}
</ul> </ul>
)} )}
{exp.tech.length > 0 && ( {exp.technologies.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{exp.tech.map((t) => ( {exp.technologies.map((tech) => (
<TechChip key={t} label={t} /> <TechChip key={tech.id} label={tech.name} />
))} ))}
</div> </div>
)} )}
@@ -157,15 +178,19 @@ function ExperienceCard({
// ── Education card ──────────────────────────────────────────────────────── // ── Education card ────────────────────────────────────────────────────────
function EducationCard({ edu }: { readonly edu: TEducation }) { function EducationCard({ edu }: { readonly edu: TEducation }) {
const startYear = new Date(edu.startDate).getFullYear()
const endYear = edu.endDate ? new Date(edu.endDate).getFullYear() : null
return ( return (
<div className="island-shell feature-card rise-in rounded-2xl p-6"> <div className="island-shell feature-card rise-in rounded-2xl p-6">
<p className="island-kicker mb-2">{edu.institution}</p> <p className="island-kicker mb-2">{edu.institution}</p>
<h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]"> <h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]">
{edu.degree} {edu.degree}
{edu.fieldOfStudy ? ` ${edu.fieldOfStudy}` : ''}
</h3> </h3>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
<span> <span>
{edu.startYear} {edu.endYear} {startYear} {endYear ?? 'Present'}
</span> </span>
{edu.grade != null && ( {edu.grade != null && (
<> <>
@@ -182,13 +207,19 @@ function EducationCard({ edu }: { readonly edu: TEducation }) {
// ── Skill group card ────────────────────────────────────────────────────── // ── Skill group card ──────────────────────────────────────────────────────
function SkillGroupCard({ group }: { readonly group: TSkillGroup }) { function SkillGroupCard({
category,
skills,
}: {
readonly category: string
readonly skills: readonly TSkill[]
}) {
return ( return (
<div className="island-shell feature-card rise-in rounded-2xl p-5"> <div className="island-shell feature-card rise-in rounded-2xl p-5">
<p className="island-kicker mb-3">{group.category}</p> <p className="island-kicker mb-3">{category}</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{group.skills.map((skill) => ( {skills.map((skill) => (
<TechChip key={skill} label={skill} /> <TechChip key={skill.id} label={skill.name} />
))} ))}
</div> </div>
</div> </div>
@@ -198,7 +229,17 @@ function SkillGroupCard({ group }: { readonly group: TSkillGroup }) {
// ── Page ────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────
function HomePage() { function HomePage() {
const { experiences, educations, skillGroups } = Route.useLoaderData() 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 ( return (
<main className="page-wrap px-4 pb-16 pt-10"> <main className="page-wrap px-4 pb-16 pt-10">
{/* ── Hero ──────────────────────────────────────────── */} {/* ── Hero ──────────────────────────────────────────── */}
@@ -211,12 +252,11 @@ function HomePage() {
Software Engineer · Copenhagen, Denmark Software Engineer · Copenhagen, Denmark
</p> </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"> <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 Mohammad. Hi, I'm {profile.firstname}.
</h1> </h1>
<p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg"> <p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg">
Crafting robust full-stack solutions with a passion for clean {profile.about ??
architecture, great developer experience, and shipping products people 'Crafting robust full-stack solutions with a passion for clean architecture, great developer experience, and shipping products people love.'}
love.
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -261,8 +301,12 @@ function HomePage() {
id="skills" id="skills"
/> />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{skillGroups.map((group) => ( {Object.entries(skillsByGroup).map(([groupKey, skills]) => (
<SkillGroupCard key={group.id} group={group} /> <SkillGroupCard
key={groupKey}
category={groupKey}
skills={skills}
/>
))} ))}
</div> </div>
</section> </section>

View File

@@ -89,33 +89,58 @@ export type TStrapiResponse<T = null> = {
} }
} }
export type TSkill = {
readonly id: number
readonly name: string
readonly group: string
readonly link: string | null
}
export type TProfile = {
readonly id: number
readonly documentId: string
readonly firstname: string
readonly lastname: string
readonly fullname: string
readonly phone?: string
readonly workStatus: string
readonly dateOfBirth?: string
readonly about?: string
readonly skills: readonly TSkill[]
}
export type TObjective = {
readonly id: number
readonly description: string
}
export type TTechnology = {
readonly id: number
readonly name: string
readonly group: 'frontend' | 'backend' | 'database' | 'infrastructure'
readonly link: string | null
}
export type TExperience = { export type TExperience = {
id: number readonly id: number
company: string readonly documentId: string
role: string readonly company: string
employmentType: 'full-time' | 'part-time' readonly role: string
startDate: string readonly startDate: string
endDate?: string readonly endDate: string | null
location?: string readonly type: 'full-time' | 'part-time' | null
bullets: string[] readonly locale?: string
tech: string[] readonly objectives: readonly TObjective[]
order: number readonly technologies: readonly TTechnology[]
} }
export type TEducation = { export type TEducation = {
id: number readonly id: number
institution: string readonly documentId: string
degree: string readonly institution: string
fieldOfStudy?: string readonly degree: string
startYear: number readonly fieldOfStudy?: string
endYear?: number readonly startDate: string
grade?: string readonly endDate: string | null
order: number readonly grade: string | null
}
export type TSkillGroup = {
id: number
category: string
skills: string[]
order: number
} }