change order of technology component
This commit is contained in:
119
src/data/loaders/portfolio.ts
Normal file
119
src/data/loaders/portfolio.ts
Normal file
@@ -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<string, string> => {
|
||||||
|
const token = process.env['STRAPI_API_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(
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { computeDuration, formatDate, CONTACT_EMAIL } from '#/data/portfolio'
|
||||||
|
import type { TExperience, TEducation, TSkillGroup } from '#/types/strapi'
|
||||||
import {
|
import {
|
||||||
experiences,
|
fetchExperiences,
|
||||||
educations,
|
fetchEducations,
|
||||||
skillGroups,
|
fetchSkillGroups,
|
||||||
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, skillGroups] = await Promise.all([
|
||||||
|
fetchExperiences(),
|
||||||
|
fetchEducations(),
|
||||||
|
fetchSkillGroups(),
|
||||||
|
])
|
||||||
|
return { experiences, educations, skillGroups }
|
||||||
|
},
|
||||||
|
component: HomePage,
|
||||||
|
})
|
||||||
|
|
||||||
// ── Shared UI primitives ──────────────────────────────────────────────────
|
// ── Shared UI primitives ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -69,11 +77,16 @@ function SectionHeading({
|
|||||||
|
|
||||||
// ── Experience card ───────────────────────────────────────────────────────
|
// ── Experience card ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const EMPLOYMENT_TYPE_LABEL: Record<TExperience['employmentType'], 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 duration = computeDuration(exp.startDate, exp.endDate ?? undefined)
|
||||||
@@ -101,7 +114,7 @@ function ExperienceCard({
|
|||||||
</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)]">
|
<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}
|
{EMPLOYMENT_TYPE_LABEL[exp.employmentType]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -143,7 +156,7 @@ function ExperienceCard({
|
|||||||
|
|
||||||
// ── Education card ────────────────────────────────────────────────────────
|
// ── Education card ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function EducationCard({ edu }: { readonly edu: Education }) {
|
function EducationCard({ edu }: { readonly edu: TEducation }) {
|
||||||
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>
|
||||||
@@ -154,7 +167,7 @@ function EducationCard({ edu }: { readonly edu: Education }) {
|
|||||||
<span>
|
<span>
|
||||||
{edu.startYear} – {edu.endYear}
|
{edu.startYear} – {edu.endYear}
|
||||||
</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,7 +182,7 @@ function EducationCard({ edu }: { readonly edu: Education }) {
|
|||||||
|
|
||||||
// ── Skill group card ──────────────────────────────────────────────────────
|
// ── Skill group card ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
|
function SkillGroupCard({ group }: { readonly group: TSkillGroup }) {
|
||||||
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">{group.category}</p>
|
||||||
@@ -185,6 +198,7 @@ function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
|
|||||||
// ── Page ──────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
|
const { experiences, educations, skillGroups } = Route.useLoaderData()
|
||||||
return (
|
return (
|
||||||
<main className="page-wrap px-4 pb-16 pt-10">
|
<main className="page-wrap px-4 pb-16 pt-10">
|
||||||
{/* ── Hero ──────────────────────────────────────────── */}
|
{/* ── Hero ──────────────────────────────────────────── */}
|
||||||
@@ -248,7 +262,7 @@ function HomePage() {
|
|||||||
/>
|
/>
|
||||||
<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) => (
|
{skillGroups.map((group) => (
|
||||||
<SkillGroupCard key={group.category} group={group} />
|
<SkillGroupCard key={group.id} group={group} />
|
||||||
))}
|
))}
|
||||||
</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,34 @@ export type TStrapiResponse<T = null> = {
|
|||||||
pagination?: TStrapiPagination
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user