Compare commits
5 Commits
feat/react
...
feat/strap
| Author | SHA1 | Date | |
|---|---|---|---|
| efadaa6918 | |||
| 7285dfd74d | |||
| ef9f16c2cc | |||
| 67e01e9a5e | |||
| 06e0d022c2 |
67
src/data/loaders/portfolio.ts
Normal file
67
src/data/loaders/portfolio.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
import type { TExperience, TEducation, TProfile } from '#/types/strapi'
|
||||||
|
|
||||||
|
const STRAPI_BASE = process.env['STRAPI_URL'] ?? 'https://strapi.moshariri.com'
|
||||||
|
|
||||||
|
const buildHeaders = (): Record<string, string> => {
|
||||||
|
const token = process.env['STRAPI_API_TOKEN']
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchExperiences = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async (): Promise<TExperience[]> => {
|
||||||
|
const url = `${STRAPI_BASE}/api/experiences?populate=*&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.map((item) => ({
|
||||||
|
...item,
|
||||||
|
objectives: item.objectives ?? [],
|
||||||
|
technologies: item.technologies ?? [],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const fetchExperience = createServerFn({ method: 'GET' })
|
||||||
|
.inputValidator((slug: string) => slug)
|
||||||
|
.handler(async ({ data: slug }): Promise<TExperience> => {
|
||||||
|
const url = `${STRAPI_BASE}/api/experiences?filters[slug][$eq]=${slug}&populate=*&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[] }
|
||||||
|
const item = json.data[0]
|
||||||
|
if (!item) throw new Error(`Experience not found: ${slug}`)
|
||||||
|
console.log(`[Strapi] ← GET ${url} OK (${item.company})`)
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fetchEducations = createServerFn({ method: 'GET' }).handler(
|
||||||
|
async (): Promise<TEducation[]> => {
|
||||||
|
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 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,6 +1,6 @@
|
|||||||
import { strapi } from '@strapi/client'
|
import { strapi } from '@strapi/client'
|
||||||
|
|
||||||
// Strapi base URL (without /api)
|
// Strapi base URL (without /api) — client-safe env var only
|
||||||
const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? 'http://localhost:1337'
|
const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? 'http://localhost:1337'
|
||||||
|
|
||||||
// Initialize the Strapi SDK with /api endpoint
|
// Initialize the Strapi SDK with /api endpoint
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as AboutRouteImport } from './routes/about'
|
import { Route as AboutRouteImport } from './routes/about'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as ExperienceSlugRouteImport } from './routes/experience/$slug'
|
||||||
|
|
||||||
const AboutRoute = AboutRouteImport.update({
|
const AboutRoute = AboutRouteImport.update({
|
||||||
id: '/about',
|
id: '/about',
|
||||||
@@ -22,31 +23,40 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ExperienceSlugRoute = ExperienceSlugRouteImport.update({
|
||||||
|
id: '/experience/$slug',
|
||||||
|
path: '/experience/$slug',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/experience/$slug': typeof ExperienceSlugRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/experience/$slug': typeof ExperienceSlugRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/about': typeof AboutRoute
|
'/about': typeof AboutRoute
|
||||||
|
'/experience/$slug': typeof ExperienceSlugRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/about'
|
fullPaths: '/' | '/about' | '/experience/$slug'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/about'
|
to: '/' | '/about' | '/experience/$slug'
|
||||||
id: '__root__' | '/' | '/about'
|
id: '__root__' | '/' | '/about' | '/experience/$slug'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AboutRoute: typeof AboutRoute
|
AboutRoute: typeof AboutRoute
|
||||||
|
ExperienceSlugRoute: typeof ExperienceSlugRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -65,12 +75,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/experience/$slug': {
|
||||||
|
id: '/experience/$slug'
|
||||||
|
path: '/experience/$slug'
|
||||||
|
fullPath: '/experience/$slug'
|
||||||
|
preLoaderRoute: typeof ExperienceSlugRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AboutRoute: AboutRoute,
|
AboutRoute: AboutRoute,
|
||||||
|
ExperienceSlugRoute: ExperienceSlugRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
134
src/routes/experience/$slug.tsx
Normal file
134
src/routes/experience/$slug.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { fetchExperience } from '#/data/loaders/portfolio'
|
||||||
|
import { computeDuration, formatDate } from '#/data/portfolio'
|
||||||
|
import type { TExperience } from '#/types/strapi'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/experience/$slug')({
|
||||||
|
loader: ({ params }) => fetchExperience({ data: params.slug }),
|
||||||
|
errorComponent: ExperienceErrorPage,
|
||||||
|
component: ExperienceDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Error page ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ExperienceErrorPage() {
|
||||||
|
return (
|
||||||
|
<div className="page-wrap flex min-h-[60vh] flex-col items-center justify-center gap-4 text-center">
|
||||||
|
<p className="island-kicker">Not Found</p>
|
||||||
|
<h1 className="display-title text-4xl">Experience not found</h1>
|
||||||
|
<Link to="/" className="nav-link">
|
||||||
|
← Back to portfolio
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = {
|
||||||
|
'full-time': 'Full-time',
|
||||||
|
'part-time': 'Part-time',
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail page ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ExperienceDetailPage() {
|
||||||
|
const exp: TExperience = Route.useLoaderData()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="page-wrap px-4 pb-16 pt-10">
|
||||||
|
{/* ── Back link ─────────────────────────────────────── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="nav-link inline-flex items-center gap-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span> Back
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Header ────────────────────────────────────────── */}
|
||||||
|
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-12">
|
||||||
|
<div className="pointer-events-none absolute -left-20 -top-20 h-64 w-64 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.25),transparent_65%)]" />
|
||||||
|
<div className="pointer-events-none absolute -bottom-20 -right-20 h-64 w-64 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.15),transparent_65%)]" />
|
||||||
|
|
||||||
|
<p className="island-kicker mb-2">{exp.company}</p>
|
||||||
|
<h1 className="display-title mb-4 text-4xl font-bold leading-tight text-[var(--sea-ink)]">
|
||||||
|
{exp.role}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
|
{exp.type != null && (
|
||||||
|
<span className="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>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-[var(--sea-ink-soft)]">
|
||||||
|
{startLabel} — {endLabel}
|
||||||
|
</span>
|
||||||
|
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||||
|
<span className="text-sm text-[var(--sea-ink-soft)]">{duration}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technologies */}
|
||||||
|
{(exp.technologies ?? []).length > 0 && (
|
||||||
|
<div className="mt-5 flex flex-wrap gap-1.5">
|
||||||
|
{(exp.technologies ?? []).map((tech) => (
|
||||||
|
<TechChip key={tech.id} label={tech.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Objectives ────────────────────────────────────── */}
|
||||||
|
{(exp.objectives ?? []).length > 0 && (
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="island-kicker mb-4">Responsibilities</h2>
|
||||||
|
<div className="island-shell feature-card rounded-2xl p-6">
|
||||||
|
<ul className="ml-0 list-none space-y-3 p-0">
|
||||||
|
{(exp.objectives ?? []).map((obj) => (
|
||||||
|
<li
|
||||||
|
key={obj.id}
|
||||||
|
className="flex gap-3 text-sm leading-relaxed 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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Body (markdown) ───────────────────────────────── */}
|
||||||
|
{exp.body != null && exp.body.trim().length > 0 && (
|
||||||
|
<section className="mt-10">
|
||||||
|
<div className="island-shell feature-card rounded-2xl p-6 sm:p-8">
|
||||||
|
<article className="prose prose-sm max-w-none prose-headings:font-bold prose-headings:text-[var(--sea-ink)] prose-p:text-[var(--sea-ink-soft)] prose-li:text-[var(--sea-ink-soft)] prose-strong:text-[var(--sea-ink)] prose-a:text-[var(--lagoon-deep)]">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{exp.body}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,42 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio'
|
||||||
|
import type { TExperience, TEducation, TSkill } from '#/types/strapi'
|
||||||
import {
|
import {
|
||||||
experiences,
|
fetchExperiences,
|
||||||
educations,
|
fetchEducations,
|
||||||
skillGroups,
|
fetchProfile,
|
||||||
computeDuration,
|
} from '#/data/loaders/portfolio'
|
||||||
formatDate,
|
|
||||||
CONTACT_EMAIL,
|
|
||||||
} from '#/data/portfolio'
|
|
||||||
import type { Experience, Education, SkillGroup } from '#/data/portfolio'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({ component: HomePage })
|
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 ──────────────────────────────────────────────────
|
// ── Shared UI primitives ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -69,16 +96,23 @@ function SectionHeading({
|
|||||||
|
|
||||||
// ── Experience card ───────────────────────────────────────────────────────
|
// ── Experience card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPLOYMENT_TYPE_LABEL: Record<'full-time' | 'part-time', string> = {
|
||||||
|
'full-time': 'Full-time',
|
||||||
|
'part-time': 'Part-time',
|
||||||
|
}
|
||||||
|
|
||||||
function ExperienceCard({
|
function ExperienceCard({
|
||||||
exp,
|
exp,
|
||||||
index,
|
index,
|
||||||
}: {
|
}: {
|
||||||
readonly exp: Experience
|
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
|
||||||
@@ -94,15 +128,21 @@ function ExperienceCard({
|
|||||||
{/* Card */}
|
{/* Card */}
|
||||||
<div className="island-shell feature-card mb-6 flex-1 rounded-2xl p-5 pb-6">
|
<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">
|
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||||||
<div>
|
<Link
|
||||||
|
to="/experience/$slug"
|
||||||
|
params={{ slug: exp.slug }}
|
||||||
|
className="group min-w-0"
|
||||||
|
>
|
||||||
<p className="island-kicker mb-0.5">{exp.company}</p>
|
<p className="island-kicker mb-0.5">{exp.company}</p>
|
||||||
<h3 className="m-0 text-base font-bold text-[var(--sea-ink)]">
|
<h3 className="m-0 text-base font-bold text-[var(--sea-ink)] transition group-hover:text-[var(--lagoon-deep)]">
|
||||||
{exp.role}
|
{exp.role}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</Link>
|
||||||
<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 && (
|
||||||
{exp.type}
|
<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)]">
|
||||||
@@ -111,28 +151,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>
|
||||||
)}
|
)}
|
||||||
@@ -143,18 +181,22 @@ function ExperienceCard({
|
|||||||
|
|
||||||
// ── Education card ────────────────────────────────────────────────────────
|
// ── Education card ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function EducationCard({ edu }: { readonly edu: Education }) {
|
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 && (
|
||||||
<>
|
<>
|
||||||
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
|
||||||
<span className="font-semibold text-[var(--lagoon-deep)]">
|
<span className="font-semibold text-[var(--lagoon-deep)]">
|
||||||
@@ -169,13 +211,19 @@ function EducationCard({ edu }: { readonly edu: Education }) {
|
|||||||
|
|
||||||
// ── Skill group card ──────────────────────────────────────────────────────
|
// ── Skill group card ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
|
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>
|
||||||
@@ -185,6 +233,17 @@ function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
|
|||||||
// ── Page ──────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HomePage() {
|
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 (
|
return (
|
||||||
<main className="page-wrap px-4 pb-16 pt-10">
|
<main className="page-wrap px-4 pb-16 pt-10">
|
||||||
{/* ── Hero ──────────────────────────────────────────── */}
|
{/* ── Hero ──────────────────────────────────────────── */}
|
||||||
@@ -197,12 +256,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">
|
||||||
@@ -247,8 +305,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.category} group={group} />
|
<SkillGroupCard
|
||||||
|
key={groupKey}
|
||||||
|
category={groupKey}
|
||||||
|
skills={skills}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* These types match the Strapi Cloud Template Blog schema
|
* These types match the Strapi Cloud Template Blog schema
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Block } from '@/components/blocks'
|
import type { Block } from '#/components/blocks'
|
||||||
|
|
||||||
// Base image type from Strapi media library
|
// Base image type from Strapi media library
|
||||||
export type TImage = {
|
export type TImage = {
|
||||||
@@ -88,3 +88,61 @@ export type TStrapiResponse<T = null> = {
|
|||||||
pagination?: TStrapiPagination
|
pagination?: TStrapiPagination
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
readonly id: number
|
||||||
|
readonly documentId: string
|
||||||
|
readonly company: string
|
||||||
|
readonly role: string
|
||||||
|
readonly slug: string
|
||||||
|
readonly body: string | null
|
||||||
|
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 = {
|
||||||
|
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