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

18
.cta.json Normal file
View File

@@ -0,0 +1,18 @@
{
"projectName": "portfolio",
"mode": "file-router",
"typescript": true,
"packageManager": "bun",
"includeExamples": false,
"tailwind": true,
"addOnOptions": {},
"envVarValues": {},
"git": false,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"eslint",
"strapi"
]
}

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
package-lock.json
pnpm-lock.yaml
yarn.lock

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}

370
README.md Normal file
View File

@@ -0,0 +1,370 @@
Welcome to your new TanStack Start app!
# Getting Started
To run this application:
```bash
bun install
bun --bun run dev
```
# Building For Production
To build this application for production:
```bash
bun --bun run build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
bun --bun run test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
### Removing Tailwind CSS
If you prefer not to use Tailwind CSS:
1. Remove the demo pages in `src/routes/demo/`
2. Replace the Tailwind import in `src/styles.css` with your own styles
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
4. Uninstall the packages: `bun install @tailwindcss/vite tailwindcss -D`
## Linting & Formatting
This project uses [eslint](https://eslint.org/) and [prettier](https://prettier.io/) for linting and formatting. Eslint is configured using [tanstack/eslint-config](https://tanstack.com/config/latest/docs/eslint). The following scripts are available:
```bash
bun --bun run lint
bun --bun run format
bun --bun run check
```
## Strapi CMS Integration
This add-on integrates Strapi CMS with your TanStack Start application using the official Strapi Client SDK.
### Features
- Article listing with search and pagination
- Article detail pages with dynamic block rendering
- Rich text, quotes, media, and image slider blocks
- Markdown content rendering with GitHub Flavored Markdown
- Responsive image handling with error fallbacks
- URL-based search and pagination (shareable/bookmarkable)
- Graceful error handling with helpful setup instructions
### Project Structure
```
parent/
├── client/ # TanStack Start frontend (your project name)
│ ├── src/
│ │ ├── components/
│ │ │ ├── blocks/ # Block rendering components
│ │ │ ├── markdown-content.tsx
│ │ │ ├── pagination.tsx
│ │ │ ├── search.tsx
│ │ │ └── strapi-image.tsx
│ │ ├── data/
│ │ │ ├── loaders/ # Server functions
│ │ │ └── strapi-sdk.ts
│ │ ├── lib/
│ │ │ └── strapi-utils.ts
│ │ ├── routes/demo/
│ │ │ ├── strapi.tsx # Articles list
│ │ │ └── strapi.$articleId.tsx # Article detail
│ │ └── types/
│ │ └── strapi.ts
│ ├── .env.local
│ └── package.json
└── server/ # Strapi CMS backend (create manually or use hosted Strapi)
├── src/api/ # Content types
├── config/ # Strapi configuration
└── package.json
```
### Quick Start
Create your Strapi project separately (or use an existing hosted Strapi instance), then point this app to it with `VITE_STRAPI_URL`.
**1. Set up Strapi:**
Follow the Strapi quick-start guide to create a local project, or use your existing Strapi deployment:
- https://docs.strapi.io/dev-docs/quick-start
If you created a local Strapi project in a sibling `server` directory, continue with:
```bash
cd ../server
npm install # or pnpm install / yarn install
```
**2. Start the Strapi server:**
```bash
npm run develop # Starts at http://localhost:1337
```
**3. Create an admin account:**
Open http://localhost:1337/admin and create your first admin user.
**4. Create content:**
In the Strapi admin panel, go to Content Manager > Article and create some articles.
**5. Start your TanStack app (in another terminal):**
```bash
cd ../client # or your project name
npm run dev # Starts at http://localhost:3000
```
**6. View the demo:**
Navigate to http://localhost:3000/demo/strapi to see your articles.
### Environment Variables
The following environment variable is pre-configured in `.env.local`:
```bash
VITE_STRAPI_URL="http://localhost:1337"
```
For production, update this to your deployed Strapi URL.
### Demo Pages
| URL | Description |
|-----|-------------|
| `/demo/strapi` | Articles list with search and pagination |
| `/demo/strapi/:articleId` | Article detail with block rendering |
### Search and Pagination
- **Search**: Type in the search box to filter articles by title or description
- **Pagination**: Navigate between pages using the pagination controls
- **URL State**: Search and page are stored in the URL (`?query=term&page=2`)
### Block Types Supported
| Block | Component | Description |
|-------|-----------|-------------|
| `shared.rich-text` | RichText | Markdown content |
| `shared.quote` | Quote | Blockquote with author |
| `shared.media` | Media | Single image/video |
| `shared.slider` | Slider | Image gallery grid |
### Dependencies
| Package | Purpose |
|---------|---------|
| `@strapi/client` | Official Strapi SDK |
| `react-markdown` | Markdown rendering |
| `remark-gfm` | GitHub Flavored Markdown |
| `use-debounce` | Debounced search input |
### Running Both Servers
Open two terminal windows from the parent directory:
**Terminal 1 - Strapi:**
```bash
cd server && npm run develop
```
**Terminal 2 - TanStack Start:**
```bash
cd client && npm run dev # or your project name
```
### Customization
**Change page size:**
Edit `src/data/loaders/articles.ts` and modify `PAGE_SIZE`.
**Add new block types:**
1. Create component in `src/components/blocks/`
2. Export from `src/components/blocks/index.ts`
3. Add case to `block-renderer.tsx` switch statement
4. Update populate in articles loader
**Add new content types:**
1. Add types to `src/types/strapi.ts`
2. Create loader in `src/data/loaders/`
3. Create route in `src/routes/demo/`
### Learn More
- [Strapi Documentation](https://docs.strapi.io/)
- [Strapi Client SDK](https://www.npmjs.com/package/@strapi/client)
- [Strapi Cloud Template Blog](https://github.com/strapi/strapi-cloud-template-blog)
- [TanStack Start Documentation](https://tanstack.com/start/latest)
- [TanStack Router Search Params](https://tanstack.com/router/latest/docs/framework/react/guide/search-params)
## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
Here is an example layout that includes a header:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
}),
shellComponent: ({ children }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
{children}
<Scripts />
</body>
</html>
),
})
```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Server Functions
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx
import { createServerFn } from '@tanstack/react-start'
const getServerTime = createServerFn({
method: 'GET',
}).handler(async () => {
return new Date().toISOString()
})
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
useEffect(() => {
getServerTime().then(setTime)
}, [])
return <div>Server time: {time}</div>
}
```
## API Routes
You can create API routes by using the `server` property in your route definitions:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/hello')({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
},
},
})
```
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/people')({
loader: async () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
},
component: PeopleComponent,
})
function PeopleComponent() {
const data = Route.useLoaderData()
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
}
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).

1301
bun.lock Normal file

File diff suppressed because it is too large Load Diff

20
eslint.config.js Normal file
View File

@@ -0,0 +1,20 @@
// @ts-check
import { tanstackConfig } from '@tanstack/eslint-config'
export default [
...tanstackConfig,
{
rules: {
'import/no-cycle': 'off',
'import/order': 'off',
'sort-imports': 'off',
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/require-await': 'off',
'pnpm/json-enforce-catalog': 'off',
},
},
{
ignores: ['eslint.config.js', 'prettier.config.js'],
},
]

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "portfolio",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint",
"format": "prettier --check .",
"check": "prettier --write . && eslint --fix"
},
"dependencies": {
"@strapi/client": "^1.6.1",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.132.0",
"lucide-react": "^0.545.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^9.0.1",
"remark-gfm": "^4.0.0",
"tailwindcss": "^4.1.18",
"use-debounce": "^10.1.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/eslint-config": "latest",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0",
"prettier": "^3.8.1",
"typescript": "^5.7.2",
"vite": "^8.0.0",
"vitest": "^3.0.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}

10
prettier.config.js Normal file
View File

@@ -0,0 +1,10 @@
// @ts-check
/** @type {import('prettier').Config} */
const config = {
semi: false,
singleQuote: true,
trailingComma: "all",
};
export default config;

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

40
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { GitHubIcon, LinkedInIcon } from '#/components/icons'
export default function Footer() {
const year = new Date().getFullYear()
return (
<footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]">
<div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left">
<p className="m-0 text-sm">
&copy; {year} Mohammad. All rights reserved.
</p>
<p className="island-kicker m-0">
Built by Mohammad · Copenhagen, Denmark
</p>
</div>
<div className="mt-4 flex justify-center gap-2">
<a
href="https://github.com/moh682"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
>
<GitHubIcon width={18} height={18} />
GitHub
</a>
<a
href="https://www.linkedin.com/in/moh682"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
>
<LinkedInIcon width={18} height={18} />
LinkedIn
</a>
</div>
</footer>
)
}

55
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,55 @@
import { Link } from '@tanstack/react-router'
import { GitHubIcon } from '#/components/icons'
import { CONTACT_EMAIL } from '#/data/portfolio'
import ThemeToggle from './ThemeToggle'
export default function Header() {
return (
<header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg">
<nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4">
<h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight">
<Link
to="/"
className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2"
>
<span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" />
Mohammad
</Link>
</h2>
<div className="ml-auto flex items-center gap-1.5 sm:ml-0 sm:gap-2">
<a
href="https://github.com/moh682"
target="_blank"
rel="noreferrer"
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
>
<span className="sr-only">GitHub profile</span>
<GitHubIcon width={24} height={24} />
</a>
<ThemeToggle />
</div>
<div className="order-3 flex w-full flex-wrap items-center gap-x-4 gap-y-1 pb-1 text-sm font-semibold sm:order-2 sm:w-auto sm:flex-nowrap sm:pb-0">
<Link
to="/"
className="nav-link"
activeProps={{ className: 'nav-link is-active' }}
>
Home
</Link>
<a href="/#experience" className="nav-link">
Experience
</a>
<a href="/#skills" className="nav-link">
Skills
</a>
<a href={`mailto:${CONTACT_EMAIL}`} className="nav-link">
Contact
</a>
</div>
</nav>
</header>
)
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
type ThemeMode = 'light' | 'dark' | 'auto'
function getInitialMode(): ThemeMode {
if (typeof window === 'undefined') {
return 'auto'
}
const stored = window.localStorage.getItem('theme')
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
return stored
}
return 'auto'
}
function applyThemeMode(mode: ThemeMode) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
if (mode === 'auto') {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', mode)
}
document.documentElement.style.colorScheme = resolved
}
export default function ThemeToggle() {
const [mode, setMode] = useState<ThemeMode>('auto')
useEffect(() => {
const initialMode = getInitialMode()
setMode(initialMode)
applyThemeMode(initialMode)
}, [])
useEffect(() => {
if (mode !== 'auto') {
return
}
const media = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = () => applyThemeMode('auto')
media.addEventListener('change', onChange)
return () => {
media.removeEventListener('change', onChange)
}
}, [mode])
function toggleMode() {
const nextMode: ThemeMode =
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
setMode(nextMode)
applyThemeMode(nextMode)
window.localStorage.setItem('theme', nextMode)
}
const label =
mode === 'auto'
? 'Theme mode: auto (system). Click to switch to light mode.'
: `Theme mode: ${mode}. Click to switch mode.`
return (
<button
type="button"
onClick={toggleMode}
aria-label={label}
title={label}
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
>
{mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'}
</button>
)
}

View File

@@ -0,0 +1,55 @@
import { RichText } from './rich-text'
import { Quote } from './quote'
import { Media } from './media'
import { Slider } from './slider'
import type { IRichText } from './rich-text'
import type { IQuote } from './quote'
import type { IMedia } from './media'
import type { ISlider } from './slider'
// Union type of all block types
export type Block = IRichText | IQuote | IMedia | ISlider
interface BlockRendererProps {
blocks: Array<Block>
}
/**
* BlockRenderer - Renders dynamic content blocks from Strapi
*
* Usage:
* ```tsx
* <BlockRenderer blocks={article.blocks} />
* ```
*/
export function BlockRenderer({ blocks }: Readonly<BlockRendererProps>) {
if (!blocks || blocks.length === 0) return null
const renderBlock = (block: Block) => {
switch (block.__component) {
case 'shared.rich-text':
return <RichText {...block} />
case 'shared.quote':
return <Quote {...block} />
case 'shared.media':
return <Media {...block} />
case 'shared.slider':
return <Slider {...block} />
default:
// Log unknown block types in development
console.warn('Unknown block type:', (block as any).__component)
return null
}
}
return (
<div className="space-y-6">
{blocks.map((block, index) => (
<div key={`${block.__component}-${block.id}-${index}`}>
{renderBlock(block)}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,14 @@
export { BlockRenderer } from './block-renderer'
export type { Block } from './block-renderer'
export { RichText } from './rich-text'
export type { IRichText } from './rich-text'
export { Quote } from './quote'
export type { IQuote } from './quote'
export { Media } from './media'
export type { IMedia } from './media'
export { Slider } from './slider'
export type { ISlider } from './slider'

View File

@@ -0,0 +1,27 @@
import { StrapiImage } from '@/components/strapi-image'
import type { TImage } from '@/types/strapi'
export interface IMedia {
__component: 'shared.media'
id: number
file?: TImage
}
export function Media({ file }: Readonly<IMedia>) {
if (!file) return null
return (
<figure className="my-8">
<StrapiImage
src={file.url}
alt={file.alternativeText || ''}
className="rounded-lg w-full"
/>
{file.alternativeText && (
<figcaption className="mt-2 text-center text-sm text-gray-500">
{file.alternativeText}
</figcaption>
)}
</figure>
)
}

View File

@@ -0,0 +1,19 @@
export interface IQuote {
__component: 'shared.quote'
id: number
body: string
title?: string
}
export function Quote({ body, title }: Readonly<IQuote>) {
return (
<blockquote className="border-l-4 border-cyan-400 pl-6 py-4 my-6 bg-slate-800/30 rounded-r-lg">
<p className="text-xl italic text-gray-300 leading-relaxed">{body}</p>
{title && (
<cite className="block mt-4 text-cyan-400 not-italic font-medium">
{title}
</cite>
)}
</blockquote>
)
}

View File

@@ -0,0 +1,11 @@
import { MarkdownContent } from '@/components/markdown-content'
export interface IRichText {
__component: 'shared.rich-text'
id: number
body: string
}
export function RichText({ body }: Readonly<IRichText>) {
return <MarkdownContent content={body} />
}

View File

@@ -0,0 +1,28 @@
import { StrapiImage } from '@/components/strapi-image'
import type { TImage } from '@/types/strapi'
export interface ISlider {
__component: 'shared.slider'
id: number
files?: Array<TImage>
}
export function Slider({ files }: Readonly<ISlider>) {
if (!files || files.length === 0) return null
return (
<div className="my-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{files.map((file, index) => (
<figure key={file.id || index}>
<StrapiImage
src={file.url}
alt={file.alternativeText || ''}
className="rounded-lg w-full h-48 object-cover"
/>
</figure>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
interface IconProps {
readonly width?: number
readonly height?: number
readonly className?: string
}
export function GitHubIcon({ width = 24, height = 24, className }: IconProps) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
width={width}
height={height}
className={className}
>
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
/>
</svg>
)
}
export function LinkedInIcon({
width = 24,
height = 24,
className,
}: IconProps) {
return (
<svg
viewBox="0 0 20 20"
aria-hidden="true"
width={width}
height={height}
className={className}
>
<path
fill="currentColor"
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM6.5 6h-2v6h2V6zm-1-1a1.25 1.25 0 1 1 0-2.5A1.25 1.25 0 0 1 5.5 5zm8.5 1h-2v1.28c0-.007-.003-.012-.003-.018C11.997 6.487 11.314 6 10.5 6 9.12 6 8 7.343 8 9v3h2V9c0-.55.447-1 1-1s1 .45 1 1v3h2V9c0-.685-.09-1.342-.5-2z"
/>
</svg>
)
}

View File

@@ -0,0 +1,92 @@
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface MarkdownContentProps {
content: string | undefined | null
className?: string
}
const styles = {
h1: 'text-3xl font-bold mb-6 text-white',
h2: 'text-2xl font-bold mb-4 text-white',
h3: 'text-xl font-bold mb-3 text-white',
p: 'mb-4 leading-relaxed text-gray-300',
a: 'text-cyan-400 hover:underline',
ul: 'list-disc pl-6 mb-4 space-y-2 text-gray-300',
ol: 'list-decimal pl-6 mb-4 space-y-2 text-gray-300',
li: 'leading-relaxed',
blockquote: 'border-l-4 border-cyan-400 pl-4 italic text-gray-400 my-4',
code: 'bg-slate-800 px-2 py-1 rounded text-cyan-400 text-sm font-mono',
pre: 'bg-slate-800 p-4 rounded-lg overflow-x-auto mb-4',
table: 'w-full border-collapse mb-4',
th: 'border border-slate-700 p-2 bg-slate-800 text-left text-white',
td: 'border border-slate-700 p-2 text-gray-300',
img: 'max-w-full h-auto rounded-lg my-4',
hr: 'border-slate-700 my-8',
strong: 'text-white font-semibold',
}
export function MarkdownContent({
content,
className = '',
}: MarkdownContentProps) {
if (!content) return null
return (
<div className={`prose prose-invert max-w-none ${className}`}>
<Markdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className={styles.h1}>{children}</h1>,
h2: ({ children }) => <h2 className={styles.h2}>{children}</h2>,
h3: ({ children }) => <h3 className={styles.h3}>{children}</h3>,
p: ({ children }) => <p className={styles.p}>{children}</p>,
a: ({ href, children }) => (
<a
href={href}
className={styles.a}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }) => <ul className={styles.ul}>{children}</ul>,
ol: ({ children }) => <ol className={styles.ol}>{children}</ol>,
li: ({ children }) => <li className={styles.li}>{children}</li>,
blockquote: ({ children }) => (
<blockquote className={styles.blockquote}>{children}</blockquote>
),
code: ({ className, children }) => {
const isCodeBlock = className?.includes('language-')
if (isCodeBlock) {
return (
<pre className={styles.pre}>
<code className="text-sm font-mono text-gray-300">
{children}
</code>
</pre>
)
}
return <code className={styles.code}>{children}</code>
},
pre: ({ children }) => <>{children}</>,
table: ({ children }) => (
<table className={styles.table}>{children}</table>
),
th: ({ children }) => <th className={styles.th}>{children}</th>,
td: ({ children }) => <td className={styles.td}>{children}</td>,
img: ({ src, alt }) => (
<img src={src} alt={alt || ''} className={styles.img} />
),
hr: () => <hr className={styles.hr} />,
strong: ({ children }) => (
<strong className={styles.strong}>{children}</strong>
),
}}
>
{content}
</Markdown>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { useRouter, useSearch } from '@tanstack/react-router'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
pageCount: number
className?: string
}
export function Pagination({ pageCount, className = '' }: PaginationProps) {
const router = useRouter()
const search = useSearch({ strict: false })
const currentPage = Number((search as any)?.page) || 1
const handlePageChange = (page: number) => {
router.navigate({
to: '.',
search: (prev) => ({ ...prev, page }),
replace: true,
})
}
// Generate page numbers to display
const getPageNumbers = () => {
const pages: Array<number | 'ellipsis'> = []
const showEllipsis = pageCount > 7
if (showEllipsis) {
pages.push(1)
if (currentPage > 3) {
pages.push('ellipsis')
}
const start = Math.max(2, currentPage - 1)
const end = Math.min(pageCount - 1, currentPage + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (currentPage < pageCount - 2) {
pages.push('ellipsis')
}
if (pageCount > 1) {
pages.push(pageCount)
}
} else {
for (let i = 1; i <= pageCount; i++) {
pages.push(i)
}
}
return pages
}
const pageNumbers = getPageNumbers()
if (pageCount <= 1) return null
return (
<nav className={`flex items-center justify-center gap-1 ${className}`}>
{/* Previous Button */}
<button
onClick={() => currentPage > 1 && handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
currentPage <= 1
? 'text-gray-600 cursor-not-allowed'
: 'text-gray-300 hover:bg-slate-700 hover:text-white'
}`}
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline">Previous</span>
</button>
{/* Page Numbers */}
<div className="flex items-center gap-1">
{pageNumbers.map((page, index) =>
page === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
className="px-2 py-2 text-gray-500 hidden md:block"
>
...
</span>
) : (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`min-w-[40px] px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
currentPage === page
? 'bg-cyan-500 text-white'
: 'text-gray-300 hover:bg-slate-700 hover:text-white'
}`}
>
{page}
</button>
),
)}
</div>
{/* Next Button */}
<button
onClick={() =>
currentPage < pageCount && handlePageChange(currentPage + 1)
}
disabled={currentPage >= pageCount}
className={`flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
currentPage >= pageCount
? 'text-gray-600 cursor-not-allowed'
: 'text-gray-300 hover:bg-slate-700 hover:text-white'
}`}
>
<span className="hidden sm:inline">Next</span>
<ChevronRight className="w-4 h-4" />
</button>
</nav>
)
}

35
src/components/search.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { useRouter, useSearch } from '@tanstack/react-router'
import { useDebouncedCallback } from 'use-debounce'
interface SearchProps {
readonly className?: string
}
export function Search({ className = '' }: SearchProps) {
const search = useSearch({ strict: false })
const router = useRouter()
const handleSearch = useDebouncedCallback((term: string) => {
router.navigate({
to: '.',
search: (prev) => ({
...prev,
page: 1,
query: term || undefined,
}),
replace: true,
})
}, 300)
return (
<input
type="text"
placeholder="Search articles..."
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleSearch(e.target.value)
}
defaultValue={(search as any)?.query || ''}
className={`w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-colors ${className}`}
/>
)
}

View File

@@ -0,0 +1,47 @@
import { useState } from 'react'
import { getStrapiMedia } from '@/lib/strapi-utils'
interface StrapiImageProps {
src: string | undefined | null
alt?: string | null
className?: string
width?: number | string
height?: number | string
}
export function StrapiImage({
src,
alt,
className = '',
width,
height,
}: StrapiImageProps) {
const [hasError, setHasError] = useState(false)
if (!src) return null
const imageUrl = getStrapiMedia(src)
if (hasError) {
return (
<div
className={`bg-slate-700 flex items-center justify-center text-slate-400 text-sm ${className}`}
style={{ width, height }}
>
<span>Image not available</span>
</div>
)
}
return (
<img
src={imageUrl}
alt={alt || ''}
width={width}
height={height}
loading="lazy"
className={`object-cover ${className}`}
onError={() => setHasError(true)}
/>
)
}

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 }

29
src/lib/strapi-utils.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Strapi URL helpers
*/
const DEFAULT_STRAPI_URL = 'http://localhost:1337'
// Base Strapi URL (without /api)
export function getStrapiURL(): string {
// Handle SSR where import.meta.env might not be fully available
if (typeof import.meta !== 'undefined' && import.meta.env?.VITE_STRAPI_URL) {
return import.meta.env.VITE_STRAPI_URL
}
return DEFAULT_STRAPI_URL
}
// Get full URL for media assets
export function getStrapiMedia(url: string | undefined | null): string {
if (!url) return ''
if (
url.startsWith('data:') ||
url.startsWith('http') ||
url.startsWith('//')
) {
return url
}
// Ensure we always have a valid base URL
const baseUrl = getStrapiURL() || DEFAULT_STRAPI_URL
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`
}

86
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,86 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about'
id: '__root__' | '/' | '/about'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

19
src/router.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

68
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,68 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import Footer from '#/components/Footer'
import Header from '#/components/Header'
import appCss from '../styles.css?url'
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'Mohammad — Software Engineer',
},
{
name: 'description',
content:
'Mohammad — Software Engineer crafting robust full-stack solutions from Copenhagen, Denmark.',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<HeadContent />
</head>
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]">
<Header />
{children}
<Footer />
{import.meta.env.DEV && (
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
)}
<Scripts />
</body>
</html>
)
}

23
src/routes/about.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/about')({
component: About,
})
function About() {
return (
<main className="page-wrap px-4 py-12">
<section className="island-shell rounded-2xl p-6 sm:p-8">
<p className="island-kicker mb-2">About</p>
<h1 className="display-title mb-3 text-4xl font-bold text-[var(--sea-ink)] sm:text-5xl">
A small starter with room to grow.
</h1>
<p className="m-0 max-w-3xl text-base leading-8 text-[var(--sea-ink-soft)]">
TanStack Start gives you type-safe routing, server functions, and
modern SSR defaults. Use this as a clean foundation, then layer in
your own routes, styling, and add-ons.
</p>
</section>
</main>
)
}

275
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { createFileRoute } from '@tanstack/react-router'
import {
experiences,
educations,
skillGroups,
computeDuration,
formatDate,
CONTACT_EMAIL,
} from '#/data/portfolio'
import type { Experience, Education, SkillGroup } from '#/data/portfolio'
export const Route = createFileRoute('/')({ component: HomePage })
// ── Shared UI primitives ──────────────────────────────────────────────────
type ButtonVariant = 'primary' | 'ghost'
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'rounded-full border border-[rgba(50,143,151,0.35)] bg-[rgba(79,184,178,0.15)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.26)]',
ghost:
'rounded-full border border-[var(--line)] bg-[var(--surface-strong)] px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.3)]',
}
function ButtonLink({
href,
variant,
children,
}: {
readonly href: string
readonly variant: ButtonVariant
readonly children: React.ReactNode
}) {
return (
<a href={href} className={BUTTON_VARIANT_CLASSES[variant]}>
{children}
</a>
)
}
function TechChip({ label }: { readonly label: string }) {
return (
<span className="inline-block rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-2.5 py-0.5 text-xs font-semibold text-[var(--lagoon-deep)]">
{label}
</span>
)
}
// ── Section heading ───────────────────────────────────────────────────────
function SectionHeading({
kicker,
title,
id,
}: {
readonly kicker: string
readonly title: string
readonly id: string
}) {
return (
<div id={id} className="mb-8 scroll-mt-20">
<p className="island-kicker mb-2">{kicker}</p>
<h2 className="display-title m-0 text-2xl font-bold tracking-tight text-[var(--sea-ink)] sm:text-3xl">
{title}
</h2>
</div>
)
}
// ── Experience card ───────────────────────────────────────────────────────
function ExperienceCard({
exp,
index,
}: {
readonly exp: Experience
readonly index: number
}) {
const duration = computeDuration(exp.startDate, exp.endDate ?? undefined)
const startLabel = formatDate(exp.startDate)
const endLabel = exp.endDate ? formatDate(exp.endDate) : 'Present'
return (
<div
className="rise-in relative flex gap-4 sm:gap-6"
style={{ animationDelay: `${index * 80}ms` }}
>
{/* Timeline dot + connector line */}
<div className="flex flex-col items-center">
<div className="mt-1.5 h-3.5 w-3.5 flex-shrink-0 rounded-full border-2 border-[var(--lagoon)] bg-[var(--lagoon-deep)] shadow-[0_0_0_4px_rgba(79,184,178,0.18)]" />
<div className="mt-1.5 w-0.5 flex-1 bg-[linear-gradient(to_bottom,var(--lagoon),transparent)]" />
</div>
{/* Card */}
<div className="island-shell feature-card mb-6 flex-1 rounded-2xl p-5 pb-6">
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
<div>
<p className="island-kicker mb-0.5">{exp.company}</p>
<h3 className="m-0 text-base font-bold text-[var(--sea-ink)]">
{exp.role}
</h3>
</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)]">
{exp.type}
</span>
</div>
<div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
<span>
{startLabel} {endLabel}
</span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{duration}</span>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span>{exp.location}</span>
</div>
{exp.bullets.length > 0 && (
<ul className="mb-3 ml-0 list-none space-y-1.5 p-0">
{exp.bullets.map((bullet) => (
<li
key={bullet}
className="flex gap-2 text-sm text-[var(--sea-ink-soft)]"
>
<span className="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-[var(--lagoon)]" />
{bullet}
</li>
))}
</ul>
)}
{exp.tech.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{exp.tech.map((t) => (
<TechChip key={t} label={t} />
))}
</div>
)}
</div>
</div>
)
}
// ── Education card ────────────────────────────────────────────────────────
function EducationCard({ edu }: { readonly edu: Education }) {
return (
<div className="island-shell feature-card rise-in rounded-2xl p-6">
<p className="island-kicker mb-2">{edu.institution}</p>
<h3 className="mb-2 text-base font-bold leading-snug text-[var(--sea-ink)]">
{edu.degree}
</h3>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--sea-ink-soft)]">
<span>
{edu.startYear} {edu.endYear}
</span>
{edu.grade !== null && (
<>
<span className="h-1 w-1 rounded-full bg-[var(--lagoon)]" />
<span className="font-semibold text-[var(--lagoon-deep)]">
Grade: {edu.grade}
</span>
</>
)}
</div>
</div>
)
}
// ── Skill group card ──────────────────────────────────────────────────────
function SkillGroupCard({ group }: { readonly group: SkillGroup }) {
return (
<div className="island-shell feature-card rise-in rounded-2xl p-5">
<p className="island-kicker mb-3">{group.category}</p>
<div className="flex flex-wrap gap-2">
{group.skills.map((skill) => (
<TechChip key={skill} label={skill} />
))}
</div>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────
function HomePage() {
return (
<main className="page-wrap px-4 pb-16 pt-10">
{/* ── Hero ──────────────────────────────────────────── */}
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-12 sm:px-12 sm:py-16">
<div className="pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.3),transparent_65%)]" />
<div className="pointer-events-none absolute -bottom-24 -right-24 h-72 w-72 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_65%)]" />
<div className="pointer-events-none absolute right-1/3 top-0 h-48 w-48 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.12),transparent_70%)]" />
<p className="island-kicker mb-4">
Software Engineer · Copenhagen, Denmark
</p>
<h1 className="display-title mb-4 max-w-2xl text-5xl font-bold leading-[1.02] tracking-tight text-[var(--sea-ink)] sm:text-7xl">
Hi, I'm Mohammad.
</h1>
<p className="mb-8 max-w-xl text-base leading-relaxed text-[var(--sea-ink-soft)] sm:text-lg">
Crafting robust full-stack solutions with a passion for clean
architecture, great developer experience, and shipping products people
love.
</p>
<div className="flex flex-wrap gap-3">
<ButtonLink href="#experience" variant="primary">
View Experience
</ButtonLink>
<ButtonLink href={`mailto:${CONTACT_EMAIL}`} variant="ghost">
Get in Touch
</ButtonLink>
</div>
</section>
{/* ── Experience ────────────────────────────────────── */}
<section className="mt-16">
<SectionHeading
kicker="Career"
title="Work Experience"
id="experience"
/>
<div>
{experiences.map((exp, i) => (
<ExperienceCard key={exp.id} exp={exp} index={i} />
))}
</div>
</section>
{/* ── Education ─────────────────────────────────────── */}
<section className="mt-16">
<SectionHeading kicker="Background" title="Education" id="education" />
<div className="grid gap-4 sm:grid-cols-2">
{educations.map((edu) => (
<EducationCard key={edu.id} edu={edu} />
))}
</div>
</section>
{/* ── Skills ────────────────────────────────────────── */}
<section className="mt-16">
<SectionHeading
kicker="Toolkit"
title="Skills & Technologies"
id="skills"
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{skillGroups.map((group) => (
<SkillGroupCard key={group.category} group={group} />
))}
</div>
</section>
{/* ── Contact CTA ───────────────────────────────────── */}
<section id="contact" className="mt-16 scroll-mt-20">
<div className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-12 text-center sm:px-12">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(79,184,178,0.1),transparent_70%)]" />
<p className="island-kicker mb-3">Let's connect</p>
<h2 className="display-title mb-4 text-2xl font-bold text-[var(--sea-ink)] sm:text-4xl">
Open to new opportunities
</h2>
<p className="mx-auto mb-8 max-w-md text-[var(--sea-ink-soft)]">
I'm always interested in hearing about exciting engineering
challenges and the teams building them.
</p>
<ButtonLink href={`mailto:${CONTACT_EMAIL}`} variant="primary">
Say hello
</ButtonLink>
</div>
</section>
</main>
)
}

259
src/styles.css Normal file
View File

@@ -0,0 +1,259 @@
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap");
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
}
:root {
--sea-ink: #173a40;
--sea-ink-soft: #416166;
--lagoon: #4fb8b2;
--lagoon-deep: #328f97;
--palm: #2f6a4a;
--sand: #e7f0e8;
--foam: #f3faf5;
--surface: rgba(255, 255, 255, 0.74);
--surface-strong: rgba(255, 255, 255, 0.9);
--line: rgba(23, 58, 64, 0.14);
--inset-glint: rgba(255, 255, 255, 0.82);
--kicker: rgba(47, 106, 74, 0.9);
--bg-base: #e7f3ec;
--header-bg: rgba(251, 255, 248, 0.84);
--chip-bg: rgba(255, 255, 255, 0.8);
--chip-line: rgba(47, 106, 74, 0.18);
--link-bg-hover: rgba(255, 255, 255, 0.9);
--hero-a: rgba(79, 184, 178, 0.36);
--hero-b: rgba(47, 106, 74, 0.2);
}
:root[data-theme="dark"] {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--sea-ink: #d7ece8;
--sea-ink-soft: #afcdc8;
--lagoon: #60d7cf;
--lagoon-deep: #8de5db;
--palm: #6ec89a;
--sand: #0f1a1e;
--foam: #101d22;
--surface: rgba(16, 30, 34, 0.8);
--surface-strong: rgba(15, 27, 31, 0.92);
--line: rgba(141, 229, 219, 0.18);
--inset-glint: rgba(194, 247, 238, 0.14);
--kicker: #b8efe5;
--bg-base: #0a1418;
--header-bg: rgba(10, 20, 24, 0.8);
--chip-bg: rgba(13, 28, 32, 0.9);
--chip-line: rgba(141, 229, 219, 0.24);
--link-bg-hover: rgba(24, 44, 49, 0.8);
--hero-a: rgba(96, 215, 207, 0.18);
--hero-b: rgba(110, 200, 154, 0.12);
}
}
* {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
color: var(--sea-ink);
font-family: var(--font-sans);
background-color: var(--bg-base);
background:
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.28;
background:
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
}
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
opacity: 0.14;
background-image:
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
}
a {
color: var(--lagoon-deep);
text-decoration-color: rgba(50, 143, 151, 0.4);
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
a:hover {
color: #246f76;
}
code {
font-size: 0.9em;
border: 1px solid var(--line);
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
border-radius: 7px;
padding: 2px 7px;
}
pre code {
border: 0;
background: transparent;
padding: 0;
border-radius: 0;
font-size: inherit;
color: inherit;
}
.page-wrap {
width: min(1080px, calc(100% - 2rem));
margin-inline: auto;
}
.display-title {
font-family: "Fraunces", Georgia, serif;
}
.island-shell {
border: 1px solid var(--line);
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 22px 44px rgba(30, 90, 72, 0.1),
0 6px 18px rgba(23, 58, 64, 0.08);
backdrop-filter: blur(4px);
}
.feature-card {
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
box-shadow:
0 1px 0 var(--inset-glint) inset,
0 18px 34px rgba(30, 90, 72, 0.1),
0 4px 14px rgba(23, 58, 64, 0.06);
}
.feature-card:hover {
transform: translateY(-2px);
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
}
button,
.island-shell,
a {
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
transform 180ms ease;
}
.island-kicker {
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
font-size: 0.69rem;
color: var(--kicker);
}
.nav-link {
position: relative;
display: inline-flex;
align-items: center;
text-decoration: none;
color: var(--sea-ink-soft);
}
.nav-link::after {
content: "";
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
transform: scaleX(0);
transform-origin: left;
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
transition: transform 170ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sea-ink);
}
.nav-link:hover::after,
.nav-link.is-active::after {
transform: scaleX(1);
}
@media (max-width: 640px) {
.nav-link::after {
bottom: -4px;
}
}
.site-footer {
border-top: 1px solid var(--line);
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
}
.rise-in {
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

90
src/types/strapi.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* Strapi type definitions
* These types match the Strapi Cloud Template Blog schema
*/
import type { Block } from '@/components/blocks'
// Base image type from Strapi media library
export type TImage = {
id: number
documentId: string
alternativeText: string | null
url: string
}
// Author content type
export type TAuthor = {
id: number
documentId: string
name: string
email?: string
createdAt: string
updatedAt: string
publishedAt: string
}
// Category content type
export type TCategory = {
id: number
documentId: string
name: string
slug: string
description?: string
createdAt: string
updatedAt: string
publishedAt: string
}
// Article content type
export type TArticle = {
id: number
documentId: string
title: string
description: string
slug: string
cover?: TImage
author?: TAuthor
category?: TCategory
blocks?: Array<Block>
createdAt: string
updatedAt: string
publishedAt: string
}
// Strapi response wrappers
export type TStrapiResponseSingle<T> = {
data: T
meta?: {
pagination?: TStrapiPagination
}
}
export type TStrapiResponseCollection<T> = {
data: Array<T>
meta?: {
pagination?: TStrapiPagination
}
}
export type TStrapiPagination = {
page: number
pageSize: number
pageCount: number
total: number
}
export type TStrapiError = {
status: number
name: string
message: string
details?: Record<string, Array<string>>
}
export type TStrapiResponse<T = null> = {
data?: T
error?: TStrapiError
meta?: {
pagination?: TStrapiPagination
}
}

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"baseUrl": ".",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()],
})
export default config