feat: integrate Strapi CMS for dynamic portfolio data
This commit is contained in:
@@ -1,10 +1,5 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi'
|
||||
import {
|
||||
experiences as fallbackExperiences,
|
||||
educations as fallbackEducations,
|
||||
skillGroups as fallbackSkillGroups,
|
||||
} from '#/data/portfolio'
|
||||
import type { TExperience, TEducation, TProfile } from '#/types/strapi'
|
||||
|
||||
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}` } : {}
|
||||
}
|
||||
|
||||
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(
|
||||
async (): Promise<TExperience[]> => {
|
||||
try {
|
||||
const { data } = await fetchFromStrapi<TExperience>(
|
||||
'/experiences?sort=order:asc&populate=*',
|
||||
)
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
company: item.company,
|
||||
role: item.role,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
const url = `${STRAPI_BASE}/api/experiences?populate[objectives]=*&populate[technologies]=*`
|
||||
console.log(`[Strapi] → GET ${url}`)
|
||||
const res = await fetch(url, { headers: buildHeaders() })
|
||||
if (!res.ok)
|
||||
throw new Error(`Strapi request failed: ${res.status} ${res.url}`)
|
||||
const json = (await res.json()) as { data: TExperience[] }
|
||||
console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`)
|
||||
return json.data
|
||||
},
|
||||
)
|
||||
|
||||
export const fetchEducations = createServerFn({ method: 'GET' }).handler(
|
||||
async (): Promise<TEducation[]> => {
|
||||
try {
|
||||
const { data } = await fetchFromStrapi<TEducation>(
|
||||
'/educations?sort=order:asc&populate=*',
|
||||
)
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
institution: item.institution,
|
||||
degree: item.degree,
|
||||
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,
|
||||
}))
|
||||
}
|
||||
const url = `${STRAPI_BASE}/api/educations?populate=*`
|
||||
console.log(`[Strapi] → GET ${url}`)
|
||||
const res = await fetch(url, { headers: buildHeaders() })
|
||||
if (!res.ok)
|
||||
throw new Error(`Strapi request failed: ${res.status} ${res.url}`)
|
||||
const json = (await res.json()) as { data: TEducation[] }
|
||||
console.log(`[Strapi] ← GET ${url} (${json.data.length} items)`)
|
||||
return json.data
|
||||
},
|
||||
)
|
||||
|
||||
export const fetchSkillGroups = createServerFn({ method: 'GET' }).handler(
|
||||
async (): Promise<TSkillGroup[]> => {
|
||||
try {
|
||||
const { data } = await fetchFromStrapi<TSkillGroup>(
|
||||
'/skill-groups?sort=order:asc&populate=*',
|
||||
)
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
skills: item.skills ?? [],
|
||||
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,
|
||||
}))
|
||||
}
|
||||
export const fetchProfile = createServerFn({ method: 'GET' }).handler(
|
||||
async (): Promise<TProfile> => {
|
||||
const url = `${STRAPI_BASE}/api/profile?populate[skills][populate]=*`
|
||||
console.log(`[Strapi] → GET ${url}`)
|
||||
const res = await fetch(url, { headers: buildHeaders() })
|
||||
if (!res.ok)
|
||||
throw new Error(`Strapi request failed: ${res.status} ${res.url}`)
|
||||
const json = (await res.json()) as { data: TProfile }
|
||||
console.log(`[Strapi] ← GET ${url} OK`)
|
||||
return json.data
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
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 {
|
||||
fetchExperiences,
|
||||
fetchEducations,
|
||||
fetchSkillGroups,
|
||||
fetchProfile,
|
||||
} from '#/data/loaders/portfolio'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
loader: async () => {
|
||||
const [experiences, educations, skillGroups] = await Promise.all([
|
||||
const [experiences, educations, profile] = await Promise.all([
|
||||
fetchExperiences(),
|
||||
fetchEducations(),
|
||||
fetchSkillGroups(),
|
||||
fetchProfile(),
|
||||
])
|
||||
return { experiences, educations, skillGroups }
|
||||
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'
|
||||
@@ -77,7 +96,7 @@ function SectionHeading({
|
||||
|
||||
// ── Experience card ───────────────────────────────────────────────────────
|
||||
|
||||
const EMPLOYMENT_TYPE_LABEL: Record<TExperience['employmentType'], string> = {
|
||||
const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = {
|
||||
'full-time': 'Full-time',
|
||||
'part-time': 'Part-time',
|
||||
}
|
||||
@@ -89,9 +108,11 @@ function ExperienceCard({
|
||||
readonly exp: TExperience
|
||||
readonly index: number
|
||||
}) {
|
||||
const duration = computeDuration(exp.startDate, exp.endDate ?? undefined)
|
||||
const startLabel = formatDate(exp.startDate)
|
||||
const endLabel = exp.endDate ? formatDate(exp.endDate) : 'Present'
|
||||
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
|
||||
@@ -113,9 +134,11 @@ function ExperienceCard({
|
||||
{exp.role}
|
||||
</h3>
|
||||
</div>
|
||||
{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.employmentType]}
|
||||
{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)]">
|
||||
@@ -124,28 +147,26 @@ function ExperienceCard({
|
||||
</span>
|
||||
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||
<span>{duration}</span>
|
||||
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||
<span>{exp.location}</span>
|
||||
</div>
|
||||
|
||||
{exp.bullets.length > 0 && (
|
||||
{exp.objectives.length > 0 && (
|
||||
<ul className="mb-3 ml-0 list-none space-y-1.5 p-0">
|
||||
{exp.bullets.map((bullet) => (
|
||||
{exp.objectives.map((obj) => (
|
||||
<li
|
||||
key={bullet}
|
||||
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)]" />
|
||||
{bullet}
|
||||
{obj.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{exp.tech.length > 0 && (
|
||||
{exp.technologies.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{exp.tech.map((t) => (
|
||||
<TechChip key={t} label={t} />
|
||||
{exp.technologies.map((tech) => (
|
||||
<TechChip key={tech.id} label={tech.name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -157,15 +178,19 @@ function ExperienceCard({
|
||||
// ── 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>
|
||||
{edu.startYear} – {edu.endYear}
|
||||
{startYear} – {endYear ?? 'Present'}
|
||||
</span>
|
||||
{edu.grade != null && (
|
||||
<>
|
||||
@@ -182,13 +207,19 @@ function EducationCard({ edu }: { readonly edu: TEducation }) {
|
||||
|
||||
// ── Skill group card ──────────────────────────────────────────────────────
|
||||
|
||||
function SkillGroupCard({ group }: { readonly group: TSkillGroup }) {
|
||||
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">{group.category}</p>
|
||||
<p className="island-kicker mb-3">{category}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{group.skills.map((skill) => (
|
||||
<TechChip key={skill} label={skill} />
|
||||
{skills.map((skill) => (
|
||||
<TechChip key={skill.id} label={skill.name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +229,17 @@ function SkillGroupCard({ group }: { readonly group: TSkillGroup }) {
|
||||
// ── Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<main className="page-wrap px-4 pb-16 pt-10">
|
||||
{/* ── Hero ──────────────────────────────────────────── */}
|
||||
@@ -211,12 +252,11 @@ function HomePage() {
|
||||
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 Mohammad.
|
||||
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">
|
||||
Crafting robust full-stack solutions with a passion for clean
|
||||
architecture, great developer experience, and shipping products people
|
||||
love.
|
||||
{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">
|
||||
@@ -261,8 +301,12 @@ function HomePage() {
|
||||
id="skills"
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skillGroups.map((group) => (
|
||||
<SkillGroupCard key={group.id} group={group} />
|
||||
{Object.entries(skillsByGroup).map(([groupKey, skills]) => (
|
||||
<SkillGroupCard
|
||||
key={groupKey}
|
||||
category={groupKey}
|
||||
skills={skills}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 = {
|
||||
id: number
|
||||
company: string
|
||||
role: string
|
||||
employmentType: 'full-time' | 'part-time'
|
||||
startDate: string
|
||||
endDate?: string
|
||||
location?: string
|
||||
bullets: string[]
|
||||
tech: string[]
|
||||
order: number
|
||||
readonly id: number
|
||||
readonly documentId: string
|
||||
readonly company: string
|
||||
readonly role: string
|
||||
readonly startDate: string
|
||||
readonly endDate: string | null
|
||||
readonly type: 'full-time' | 'part-time' | null
|
||||
readonly locale?: string
|
||||
readonly objectives: readonly TObjective[]
|
||||
readonly technologies: readonly TTechnology[]
|
||||
}
|
||||
|
||||
export type TEducation = {
|
||||
id: number
|
||||
institution: string
|
||||
degree: string
|
||||
fieldOfStudy?: string
|
||||
startYear: number
|
||||
endYear?: number
|
||||
grade?: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export type TSkillGroup = {
|
||||
id: number
|
||||
category: string
|
||||
skills: string[]
|
||||
order: number
|
||||
readonly id: number
|
||||
readonly documentId: string
|
||||
readonly institution: string
|
||||
readonly degree: string
|
||||
readonly fieldOfStudy?: string
|
||||
readonly startDate: string
|
||||
readonly endDate: string | null
|
||||
readonly grade: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user