diff --git a/src/data/loaders/portfolio.ts b/src/data/loaders/portfolio.ts new file mode 100644 index 0000000..54bb47d --- /dev/null +++ b/src/data/loaders/portfolio.ts @@ -0,0 +1,119 @@ +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' + +const STRAPI_BASE = process.env['STRAPI_URL'] ?? 'https://strapi.moshariri.com' + +const buildHeaders = (): Record => { + const token = process.env['STRAPI_API_TOKEN'] + return token ? { Authorization: `Bearer ${token}` } : {} +} + +const fetchFromStrapi = async (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 => { + try { + const { data } = await fetchFromStrapi( + '/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, + })) + } + }, +) + +export const fetchEducations = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + try { + const { data } = await fetchFromStrapi( + '/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, + })) + } + }, +) + +export const fetchSkillGroups = createServerFn({ method: 'GET' }).handler( + async (): Promise => { + try { + const { data } = await fetchFromStrapi( + '/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, + })) + } + }, +) diff --git a/src/data/strapi-sdk.ts b/src/data/strapi-sdk.ts index 40bf784..909d935 100644 --- a/src/data/strapi-sdk.ts +++ b/src/data/strapi-sdk.ts @@ -1,6 +1,6 @@ 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' // Initialize the Strapi SDK with /api endpoint diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 940b1c9..8987f65 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,15 +1,23 @@ import { createFileRoute } from '@tanstack/react-router' +import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio' +import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi' import { - experiences, - educations, - skillGroups, - computeDuration, - formatDate, - CONTACT_EMAIL, -} from '#/data/portfolio' -import type { Experience, Education, SkillGroup } from '#/data/portfolio' + fetchExperiences, + fetchEducations, + fetchSkillGroups, +} from '#/data/loaders/portfolio' -export const Route = createFileRoute('/')({ component: HomePage }) +export const Route = createFileRoute('/')({ + loader: async () => { + const [experiences, educations, skillGroups] = await Promise.all([ + fetchExperiences(), + fetchEducations(), + fetchSkillGroups(), + ]) + return { experiences, educations, skillGroups } + }, + component: HomePage, +}) // ── Shared UI primitives ────────────────────────────────────────────────── @@ -69,11 +77,16 @@ function SectionHeading({ // ── Experience card ─────────────────────────────────────────────────────── +const EMPLOYMENT_TYPE_LABEL: Record = { + 'full-time': 'Full-time', + 'part-time': 'Part-time', +} + function ExperienceCard({ exp, index, }: { - readonly exp: Experience + readonly exp: TExperience readonly index: number }) { const duration = computeDuration(exp.startDate, exp.endDate ?? undefined) @@ -101,7 +114,7 @@ function ExperienceCard({ - {exp.type} + {EMPLOYMENT_TYPE_LABEL[exp.employmentType]} @@ -143,7 +156,7 @@ function ExperienceCard({ // ── Education card ──────────────────────────────────────────────────────── -function EducationCard({ edu }: { readonly edu: Education }) { +function EducationCard({ edu }: { readonly edu: TEducation }) { return (

{edu.institution}

@@ -154,7 +167,7 @@ function EducationCard({ edu }: { readonly edu: Education }) { {edu.startYear} – {edu.endYear} - {edu.grade !== null && ( + {edu.grade != null && ( <> @@ -169,7 +182,7 @@ function EducationCard({ edu }: { readonly edu: Education }) { // ── Skill group card ────────────────────────────────────────────────────── -function SkillGroupCard({ group }: { readonly group: SkillGroup }) { +function SkillGroupCard({ group }: { readonly group: TSkillGroup }) { return (

{group.category}

@@ -185,6 +198,7 @@ function SkillGroupCard({ group }: { readonly group: SkillGroup }) { // ── Page ────────────────────────────────────────────────────────────────── function HomePage() { + const { experiences, educations, skillGroups } = Route.useLoaderData() return (
{/* ── Hero ──────────────────────────────────────────── */} @@ -248,7 +262,7 @@ function HomePage() { />
{skillGroups.map((group) => ( - + ))}
diff --git a/src/types/strapi.ts b/src/types/strapi.ts index 54f56a1..03488b6 100644 --- a/src/types/strapi.ts +++ b/src/types/strapi.ts @@ -3,7 +3,7 @@ * 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 export type TImage = { @@ -88,3 +88,34 @@ export type TStrapiResponse = { pagination?: TStrapiPagination } } + +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 +} + +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 +}