feat: rebuild portfolio with React + TanStack Start

This commit is contained in:
2026-04-17 22:16:44 +02:00
commit 3bda58288d
42 changed files with 3740 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { createServerFn } from '@tanstack/react-start'
import { sdk } from '@/data/strapi-sdk'
import type {
TArticle,
TStrapiResponseCollection,
TStrapiResponseSingle,
} from '@/types/strapi'
const PAGE_SIZE = 3
const articles = sdk.collection('articles')
/**
* Fetch articles with optional filtering, search, and pagination
*/
const getArticles = async (
page?: number,
category?: string,
query?: string,
) => {
const filterConditions: Array<Record<string, unknown>> = []
// Add search query filter
if (query) {
filterConditions.push({
$or: [
{ title: { $containsi: query } },
{ description: { $containsi: query } },
],
})
}
// Add category filter
if (category) {
filterConditions.push({
category: {
slug: { $eq: category },
},
})
}
const filters =
filterConditions.length === 0
? undefined
: filterConditions.length === 1
? filterConditions[0]
: { $and: filterConditions }
return articles.find({
sort: ['createdAt:desc'],
pagination: {
page: page || 1,
pageSize: PAGE_SIZE,
},
populate: ['cover', 'author', 'category'],
filters,
}) as Promise<TStrapiResponseCollection<TArticle>>
}
/**
* Fetch a single article by documentId
*/
const getArticleById = async (documentId: string) => {
return articles.findOne(documentId, {
populate: ['cover', 'author', 'category', 'blocks.file', 'blocks.files'],
}) as Promise<TStrapiResponseSingle<TArticle>>
}
/**
* Fetch a single article by slug
*/
const getArticleBySlug = async (slug: string) => {
return articles.find({
filters: {
slug: { $eq: slug },
},
populate: ['cover', 'author', 'category', 'blocks.file', 'blocks.files'],
}) as Promise<TStrapiResponseCollection<TArticle>>
}
// Server Functions - these run on the server and can be called from components
export const getArticlesData = createServerFn({
method: 'GET',
})
.inputValidator(
(input?: { page?: number; category?: string; query?: string }) => input,
)
.handler(async ({ data }): Promise<TStrapiResponseCollection<TArticle>> => {
const response = await getArticles(data?.page, data?.category, data?.query)
return response
})
export const getArticleByIdData = createServerFn({
method: 'GET',
})
.inputValidator((documentId: string) => documentId)
.handler(
async ({ data: documentId }): Promise<TStrapiResponseSingle<TArticle>> => {
const response = await getArticleById(documentId)
return response
},
)
export const getArticleBySlugData = createServerFn({
method: 'GET',
})
.inputValidator((slug: string) => slug)
.handler(
async ({ data: slug }): Promise<TStrapiResponseCollection<TArticle>> => {
const response = await getArticleBySlug(slug)
return response
},
)

28
src/data/loaders/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import {
getArticlesData,
getArticleByIdData,
getArticleBySlugData,
} from './articles'
/**
* Strapi API - Server functions for fetching data from Strapi
*
* Usage in route loaders:
* ```ts
* import { strapiApi } from "@/data/loaders";
*
* export const Route = createFileRoute("/articles")({
* loader: async () => {
* const { data, meta } = await strapiApi.articles.getArticlesData();
* return data;
* },
* });
* ```
*/
export const strapiApi = {
articles: {
getArticlesData,
getArticleByIdData,
getArticleBySlugData,
},
}

199
src/data/portfolio.ts Normal file
View File

@@ -0,0 +1,199 @@
// ── Constants ──────────────────────────────────────────────────────────────
export const CONTACT_EMAIL = 'moh682@gmail.com'
// ── Interfaces ────────────────────────────────────────────────────────────
export interface Experience {
readonly id: string
readonly company: string
readonly role: string
readonly type: 'Full-time' | 'Part-time'
/** ISO month string e.g. "2022-07" */
readonly startDate: string
/** ISO month string e.g. "2022-07", or null for current role */
readonly endDate: string | null
readonly location: string
readonly bullets: readonly string[]
readonly tech: readonly string[]
}
export interface Education {
readonly id: string
readonly institution: string
readonly degree: string
readonly startYear: number
readonly endYear: number
readonly grade: string | null
}
export interface SkillGroup {
readonly category: string
readonly skills: readonly string[]
}
// ── Utilities ─────────────────────────────────────────────────────────────
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
] as const
/**
* Formats an ISO month string ("YYYY-MM") to a human-readable label ("Jul 2022").
*/
export function formatDate(iso: string): string {
const [yearStr, monthStr] = iso.split('-')
const month = parseInt(monthStr, 10)
return `${MONTH_NAMES[month - 1]} ${yearStr}`
}
/**
* Computes a human-readable duration between two ISO month strings.
* If endDate is omitted, the current month is used.
*
* @example computeDuration("2022-07") // "2yr 10mo"
* @example computeDuration("2021-08", "2022-07") // "11mo"
*/
export function computeDuration(startDate: string, endDate?: string): string {
const parseIso = (iso: string): Date => {
const [yearStr, monthStr] = iso.split('-')
return new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1)
}
const start = parseIso(startDate)
const end = endDate ? parseIso(endDate) : new Date()
const totalMonths =
(end.getFullYear() - start.getFullYear()) * 12 +
(end.getMonth() - start.getMonth())
const years = Math.floor(totalMonths / 12)
const months = totalMonths % 12
const parts: string[] = []
if (years > 0) parts.push(`${years}yr`)
if (months > 0) parts.push(`${months}mo`)
if (parts.length === 0) parts.push('< 1mo')
return parts.join(' ')
}
// ── Data ──────────────────────────────────────────────────────────────────
export const experiences: readonly Experience[] = [
{
id: 'awaze',
company: 'Awaze',
role: 'Software Engineer',
type: 'Full-time',
startDate: '2022-07',
endDate: null,
location: 'Copenhagen, Denmark',
bullets: [
'Design, develop and maintain scalable software solutions.',
'Collaborate cross-functionally in agile teams.',
'Drive technical improvements and code quality initiatives.',
],
tech: ['TypeScript', 'React', 'Node.js', 'AWS', 'PostgreSQL'],
},
{
id: 'kiatec',
company: 'KIAtec',
role: 'Full Stack Engineer',
type: 'Full-time',
startDate: '2021-08',
endDate: '2022-07',
location: 'Denmark',
bullets: [
'Design, develop, test and maintain old and new solutions.',
'Breakdown, estimate and refine product requirements.',
],
tech: [
'TypeScript',
'React',
'Redux',
'PHP Laravel',
'Golang',
'PostgreSQL',
],
},
{
id: 'proactive',
company: 'ProActive A/S',
role: 'Fullstack Developer',
type: 'Part-time',
startDate: '2019-10',
endDate: '2021-06',
location: 'Copenhagen, Denmark',
bullets: [
'Breakdown, design and develop prototypes.',
'Bug fix and root cause analysis.',
'Develop unit and acceptance tests.',
'Maintain, optimise and improve the application.',
],
tech: ['TypeScript', 'React', 'MobX', 'SPFX', '.NET Core', 'C#', 'Azure'],
},
{
id: 'cba-tutor',
company: 'Copenhagen Business Academy',
role: 'Tutor',
type: 'Part-time',
startDate: '2019-02',
endDate: '2019-06',
location: 'Copenhagen, Denmark',
bullets: [],
tech: [],
},
]
export const educations: readonly Education[] = [
{
id: 'bachelor-sd',
institution: 'Copenhagen Business Academy',
degree: "Bachelor's Degree in Software Development",
startYear: 2020,
endYear: 2021,
grade: null,
},
{
id: 'basc-cs',
institution: 'Copenhagen Business Academy',
degree: 'Bachelor of Applied Science (BASc), Computer Science',
startYear: 2017,
endYear: 2020,
grade: 'A',
},
]
export const skillGroups: readonly SkillGroup[] = [
{
category: 'Frontend',
skills: ['TypeScript', 'React', 'Redux', 'MobX', 'SPFX'],
},
{
category: 'Backend',
skills: [
'Golang',
'Node.js',
'PHP Laravel',
'PostgreSQL',
'.NET Core',
'C#',
],
},
{
category: 'Tools & Infrastructure',
skills: ['Docker', 'Git', 'CI/CD', 'AWS', 'Azure'],
},
]

9
src/data/strapi-sdk.ts Normal file
View File

@@ -0,0 +1,9 @@
import { strapi } from '@strapi/client'
// Strapi base URL (without /api)
const STRAPI_BASE = import.meta.env.VITE_STRAPI_URL ?? 'http://localhost:1337'
// Initialize the Strapi SDK with /api endpoint
const sdk = strapi({ baseURL: new URL('/api', STRAPI_BASE).href })
export { sdk }