diff --git a/src/data/loaders/portfolio.ts b/src/data/loaders/portfolio.ts index 0b327ae..0cf8ef3 100644 --- a/src/data/loaders/portfolio.ts +++ b/src/data/loaders/portfolio.ts @@ -10,7 +10,7 @@ const buildHeaders = (): Record => { export const fetchExperiences = createServerFn({ method: 'GET' }).handler( async (): Promise => { - const url = `${STRAPI_BASE}/api/experiences?populate[objectives]=*&populate[technologies]=*` + 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) @@ -21,6 +21,21 @@ export const fetchExperiences = createServerFn({ method: 'GET' }).handler( }, ) +export const fetchExperience = createServerFn({ method: 'GET' }) + .inputValidator((slug: string) => slug) + .handler(async ({ data: slug }): Promise => { + 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 => { const url = `${STRAPI_BASE}/api/educations?populate=*` diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 421daf2..98063f1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as AboutRouteImport } from './routes/about' import { Route as IndexRouteImport } from './routes/index' +import { Route as ExperienceSlugRouteImport } from './routes/experience/$slug' const AboutRoute = AboutRouteImport.update({ id: '/about', @@ -22,31 +23,40 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ExperienceSlugRoute = ExperienceSlugRouteImport.update({ + id: '/experience/$slug', + path: '/experience/$slug', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/about': typeof AboutRoute + '/experience/$slug': typeof ExperienceSlugRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/about': typeof AboutRoute + '/experience/$slug': typeof ExperienceSlugRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/about': typeof AboutRoute + '/experience/$slug': typeof ExperienceSlugRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/about' + fullPaths: '/' | '/about' | '/experience/$slug' fileRoutesByTo: FileRoutesByTo - to: '/' | '/about' - id: '__root__' | '/' | '/about' + to: '/' | '/about' | '/experience/$slug' + id: '__root__' | '/' | '/about' | '/experience/$slug' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute AboutRoute: typeof AboutRoute + ExperienceSlugRoute: typeof ExperienceSlugRoute } declare module '@tanstack/react-router' { @@ -65,12 +75,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/experience/$slug': { + id: '/experience/$slug' + path: '/experience/$slug' + fullPath: '/experience/$slug' + preLoaderRoute: typeof ExperienceSlugRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AboutRoute: AboutRoute, + ExperienceSlugRoute: ExperienceSlugRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/experience/$slug.tsx b/src/routes/experience/$slug.tsx new file mode 100644 index 0000000..88da3a4 --- /dev/null +++ b/src/routes/experience/$slug.tsx @@ -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 ( +
+

Not Found

+

Experience not found

+ + ← Back to portfolio + +
+ ) +} + +// ── 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 ( + + {label} + + ) +} + +// ── 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 ( +
+ {/* ── Back link ─────────────────────────────────────── */} +
+ + Back + +
+ + {/* ── Header ────────────────────────────────────────── */} +
+
+
+ +

{exp.company}

+

+ {exp.role} +

+ + {/* Meta row */} +
+ {exp.type != null && ( + + {EMPLOYMENT_TYPE_LABEL[exp.type]} + + )} + + {startLabel} — {endLabel} + + + {duration} +
+ + {/* Technologies */} + {exp.technologies.length > 0 && ( +
+ {exp.technologies.map((tech) => ( + + ))} +
+ )} +
+ + {/* ── Objectives ────────────────────────────────────── */} + {exp.objectives.length > 0 && ( +
+

Responsibilities

+
+
    + {exp.objectives.map((obj) => ( +
  • + + {obj.description} +
  • + ))} +
+
+
+ )} + + {/* ── Body (markdown) ───────────────────────────────── */} + {exp.body != null && exp.body.trim().length > 0 && ( +
+
+
+ + {exp.body} + +
+
+
+ )} +
+ ) +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 2e46c37..f3773d7 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,4 +1,4 @@ -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 { @@ -128,12 +128,16 @@ function ExperienceCard({ {/* Card */}
-
+

{exp.company}

-

+

{exp.role}

-
+ {exp.type != null && ( {EMPLOYMENT_TYPE_LABEL[exp.type]} diff --git a/src/types/strapi.ts b/src/types/strapi.ts index a97ceb3..2e4d5df 100644 --- a/src/types/strapi.ts +++ b/src/types/strapi.ts @@ -126,6 +126,8 @@ export type TExperience = { 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