feat: rebuild portfolio with React + TanStack Start
This commit is contained in:
114
src/data/loaders/articles.ts
Normal file
114
src/data/loaders/articles.ts
Normal 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
28
src/data/loaders/index.ts
Normal 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
199
src/data/portfolio.ts
Normal 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
9
src/data/strapi-sdk.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user