feat: rebuild portfolio with React + TanStack Start
This commit is contained in:
18
.cta.json
Normal file
18
.cta.json
Normal 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
13
.gitignore
vendored
Normal 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
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal 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
370
README.md
Normal 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).
|
||||||
20
eslint.config.js
Normal file
20
eslint.config.js
Normal 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
56
package.json
Normal 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
10
prettier.config.js
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
40
src/components/Footer.tsx
Normal file
40
src/components/Footer.tsx
Normal 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">
|
||||||
|
© {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
55
src/components/Header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/components/ThemeToggle.tsx
Normal file
81
src/components/ThemeToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/components/blocks/block-renderer.tsx
Normal file
55
src/components/blocks/block-renderer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/components/blocks/index.ts
Normal file
14
src/components/blocks/index.ts
Normal 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'
|
||||||
27
src/components/blocks/media.tsx
Normal file
27
src/components/blocks/media.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/components/blocks/quote.tsx
Normal file
19
src/components/blocks/quote.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/blocks/rich-text.tsx
Normal file
11
src/components/blocks/rich-text.tsx
Normal 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} />
|
||||||
|
}
|
||||||
28
src/components/blocks/slider.tsx
Normal file
28
src/components/blocks/slider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
src/components/icons/index.tsx
Normal file
43
src/components/icons/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/components/markdown-content.tsx
Normal file
92
src/components/markdown-content.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/components/pagination.tsx
Normal file
120
src/components/pagination.tsx
Normal 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
35
src/components/search.tsx
Normal 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}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
src/components/strapi-image.tsx
Normal file
47
src/components/strapi-image.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
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 }
|
||||||
29
src/lib/strapi-utils.ts
Normal file
29
src/lib/strapi-utils.ts
Normal 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
86
src/routeTree.gen.ts
Normal 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
19
src/router.tsx
Normal 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
68
src/routes/__root.tsx
Normal 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
23
src/routes/about.tsx
Normal 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
275
src/routes/index.tsx
Normal 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
259
src/styles.css
Normal 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
90
src/types/strapi.ts
Normal 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
30
tsconfig.json
Normal 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
14
vite.config.ts
Normal 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
|
||||||
Reference in New Issue
Block a user