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