new design
This commit is contained in:
commit
6cb6ec6e42
15
.cta.json
Normal file
15
.cta.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"projectName": "portfolio",
|
||||||
|
"mode": "file-router",
|
||||||
|
"typescript": true,
|
||||||
|
"packageManager": "pnpm",
|
||||||
|
"tailwind": true,
|
||||||
|
"addOnOptions": {},
|
||||||
|
"git": true,
|
||||||
|
"version": 1,
|
||||||
|
"framework": "react-cra",
|
||||||
|
"chosenAddOns": [
|
||||||
|
"biome",
|
||||||
|
"t3env"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
src/generated
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
count.txt
|
||||||
|
.env
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.wrangler
|
||||||
|
.vscode
|
||||||
|
.zed
|
||||||
319
README.md
Normal file
319
README.md
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
Welcome to your new TanStack app!
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
To run this application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
# Building For Production
|
||||||
|
|
||||||
|
To build this application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||||
|
|
||||||
|
|
||||||
|
## Linting & Formatting
|
||||||
|
|
||||||
|
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm format
|
||||||
|
pnpm check
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## T3Env
|
||||||
|
|
||||||
|
- You can use T3Env to add type safety to your environment variables.
|
||||||
|
- Add Environment variables to the `src/env.mjs` file.
|
||||||
|
- Use the environment variables in your code.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
console.log(env.VITE_APP_TITLE);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
|
||||||
|
|
||||||
|
### Adding A Route
|
||||||
|
|
||||||
|
To add a new route to your application just add another 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 use the `<Outlet />` component.
|
||||||
|
|
||||||
|
Here is an example layout that includes a header:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||||
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||||
|
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<Link to="/">Home</Link>
|
||||||
|
<Link to="/about">About</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<Outlet />
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
|
||||||
|
|
||||||
|
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||||
|
|
||||||
|
|
||||||
|
## 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
|
||||||
|
const peopleRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/people",
|
||||||
|
loader: async () => {
|
||||||
|
const response = await fetch("https://swapi.dev/api/people");
|
||||||
|
return response.json() as Promise<{
|
||||||
|
results: {
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
}>;
|
||||||
|
},
|
||||||
|
component: () => {
|
||||||
|
const data = peopleRoute.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).
|
||||||
|
|
||||||
|
### React-Query
|
||||||
|
|
||||||
|
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
|
||||||
|
|
||||||
|
First add your dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @tanstack/react-query @tanstack/react-query-devtools
|
||||||
|
```
|
||||||
|
|
||||||
|
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
if (!rootElement.innerHTML) {
|
||||||
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also add TanStack Query Devtools to the root route (optional).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
<ReactQueryDevtools buttonPosition="top-right" />
|
||||||
|
<TanStackRouterDevtools />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can use `useQuery` to fetch your data.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["people"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch("https://swapi.dev/api/people")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => data.results as { name: string }[]),
|
||||||
|
initialData: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
{data.map((person) => (
|
||||||
|
<li key={person.name}>{person.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
|
||||||
|
|
||||||
|
First you need to add TanStack Store as a dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @tanstack/store
|
||||||
|
```
|
||||||
|
|
||||||
|
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStore } from "@tanstack/react-store";
|
||||||
|
import { Store } from "@tanstack/store";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
const countStore = new Store(0);
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const count = useStore(countStore);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => countStore.setState((n) => n + 1)}>
|
||||||
|
Increment - {count}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
|
||||||
|
|
||||||
|
Let's check this out by doubling the count using derived state.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStore } from "@tanstack/react-store";
|
||||||
|
import { Store, Derived } from "@tanstack/store";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
const countStore = new Store(0);
|
||||||
|
|
||||||
|
const doubledStore = new Derived({
|
||||||
|
fn: () => countStore.state * 2,
|
||||||
|
deps: [countStore],
|
||||||
|
});
|
||||||
|
doubledStore.mount();
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const count = useStore(countStore);
|
||||||
|
const doubledCount = useStore(doubledStore);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => countStore.setState((n) => n + 1)}>
|
||||||
|
Increment - {count}
|
||||||
|
</button>
|
||||||
|
<div>Doubled - {doubledCount}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
```
|
||||||
|
|
||||||
|
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
|
||||||
|
|
||||||
|
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
|
||||||
|
|
||||||
|
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
|
||||||
|
|
||||||
|
# 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).
|
||||||
36
biome.json
Normal file
36
biome.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": false,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": false
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false,
|
||||||
|
"includes": [
|
||||||
|
"**/src/**/*",
|
||||||
|
"**/.vscode/**/*",
|
||||||
|
"**/index.html",
|
||||||
|
"**/vite.config.ts",
|
||||||
|
"!**/src/routeTree.gen.ts",
|
||||||
|
"!**/src/styles.css"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab"
|
||||||
|
},
|
||||||
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
index.html
Normal file
20
index.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-tsrouter-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>Create TanStack App - portfolio</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
package.json
Normal file
48
package.json
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "portfolio",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm build:posts && vite --port 3000",
|
||||||
|
"build": "pnpm build:posts && vite build && tsc",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"format": "biome format",
|
||||||
|
"lint": "biome lint",
|
||||||
|
"check": "biome check",
|
||||||
|
"build:posts": "node scripts/build-posts.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@t3-oss/env-core": "^0.13.8",
|
||||||
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
|
"@tanstack/react-devtools": "^0.7.0",
|
||||||
|
"@tanstack/react-router": "^1.132.0",
|
||||||
|
"@tanstack/react-router-devtools": "^1.132.0",
|
||||||
|
"@tanstack/router-plugin": "^1.132.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"remark": "^15.0.1",
|
||||||
|
"remark-html": "^16.0.1",
|
||||||
|
"tailwindcss": "^4.0.6",
|
||||||
|
"zod": "^4.1.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.2.4",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tanstack/devtools-vite": "^0.3.11",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^3.0.5",
|
||||||
|
"web-vitals": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3795
pnpm-lock.yaml
generated
Normal file
3795
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
scripts/build-posts.js
Normal file
49
scripts/build-posts.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// scripts/build-posts.js
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { remark } from "remark";
|
||||||
|
import html from "remark-html";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const postsDirectory = path.join(__dirname, "../src/posts");
|
||||||
|
const outputFile = path.join(__dirname, "../src/generated/posts.json");
|
||||||
|
|
||||||
|
async function buildPosts() {
|
||||||
|
const filenames = fs.readdirSync(postsDirectory);
|
||||||
|
|
||||||
|
const posts = await Promise.all(
|
||||||
|
filenames
|
||||||
|
.filter((name) => name.endsWith(".md"))
|
||||||
|
.map(async (filename) => {
|
||||||
|
const filePath = path.join(postsDirectory, filename);
|
||||||
|
const fileContents = fs.readFileSync(filePath, "utf8");
|
||||||
|
const { data, content } = matter(fileContents);
|
||||||
|
|
||||||
|
const processedContent = await remark().use(html).process(content);
|
||||||
|
|
||||||
|
const slug = data.slug || filename.replace(".md", "");
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug,
|
||||||
|
title: data.title,
|
||||||
|
date: data.date,
|
||||||
|
description: data.description,
|
||||||
|
contentHtml: processedContent.toString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const outputDir = path.dirname(outputFile);
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to JSON file
|
||||||
|
fs.writeFileSync(outputFile, JSON.stringify(posts, null, 2));
|
||||||
|
console.log(`✅ Built ${posts.length} posts to ${outputFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPosts().catch(console.error);
|
||||||
39
src/env.ts
Normal file
39
src/env.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { createEnv } from '@t3-oss/env-core'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
server: {
|
||||||
|
SERVER_URL: z.string().url().optional(),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix that client-side variables must have. This is enforced both at
|
||||||
|
* a type-level and at runtime.
|
||||||
|
*/
|
||||||
|
clientPrefix: 'VITE_',
|
||||||
|
|
||||||
|
client: {
|
||||||
|
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What object holds the environment variables at runtime. This is usually
|
||||||
|
* `process.env` or `import.meta.env`.
|
||||||
|
*/
|
||||||
|
runtimeEnv: import.meta.env,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* By default, this library will feed the environment variables directly to
|
||||||
|
* the Zod validator.
|
||||||
|
*
|
||||||
|
* This means that if you have an empty string for a value that is supposed
|
||||||
|
* to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag
|
||||||
|
* it as a type mismatch violation. Additionally, if you have an empty string
|
||||||
|
* for a value that is supposed to be a string with a default value (e.g.
|
||||||
|
* `DOMAIN=` in an ".env" file), the default value will never be applied.
|
||||||
|
*
|
||||||
|
* In order to solve these issues, we recommend that all new projects
|
||||||
|
* explicitly specify this option as true.
|
||||||
|
*/
|
||||||
|
emptyStringAsUndefined: true,
|
||||||
|
})
|
||||||
19
src/lib/posts.ts
Normal file
19
src/lib/posts.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import postsData from "@/generated/posts.json";
|
||||||
|
import type { Post, PostMeta } from "@/types/posts";
|
||||||
|
|
||||||
|
const allPosts: Post[] = postsData as Post[];
|
||||||
|
|
||||||
|
export function getAllPosts(): PostMeta[] {
|
||||||
|
return allPosts
|
||||||
|
.map(({ slug, title, date, description }) => ({
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
description,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPostBySlug(slug: string): Post | null {
|
||||||
|
return allPosts.find((post) => post.slug === slug) || null;
|
||||||
|
}
|
||||||
12
src/logo.svg
Normal file
12
src/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
42
src/main.tsx
Normal file
42
src/main.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
// Import the generated route tree
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
|
||||||
|
import './styles.css'
|
||||||
|
import reportWebVitals from './reportWebVitals.ts'
|
||||||
|
|
||||||
|
// Create a new router instance
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
context: {},
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultStructuralSharing: true,
|
||||||
|
defaultPreloadStaleTime: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register the router instance for type safety
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the app
|
||||||
|
const rootElement = document.getElementById('app')
|
||||||
|
if (rootElement && !rootElement.innerHTML) {
|
||||||
|
const root = ReactDOM.createRoot(rootElement)
|
||||||
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
reportWebVitals()
|
||||||
222
src/posts/advanced-authorizatin-in-nestjs.md
Normal file
222
src/posts/advanced-authorizatin-in-nestjs.md
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
---
|
||||||
|
title: "Advanced Authorization in NestJS"
|
||||||
|
date: "2025-04-05"
|
||||||
|
description: "Implementing advanced authorization in NestJS using roles, permissions, actions, and resources for fine-grained access control."
|
||||||
|
slug: "advanced-authorization-in-nestjs"
|
||||||
|
---
|
||||||
|
|
||||||
|
In the [previous post](/blog/nestjs-role-based-access-control), we implemented a simple yet powerful Role-Based Access Control (RBAC) system in NestJS using guards and decorators. While that setup works great for small to medium applications, real-world systems often require more control over what users can do and where they can do it.
|
||||||
|
|
||||||
|
That's where Policy-Based Access Control (PBAC) steps in. Think of PBAC as RBAC's smarter cousin—it gives you fine-grained control at the action and resource level. Today, we're combining both of them for a solid, flexible, and scalable auth system.
|
||||||
|
|
||||||
|
## Why Go Beyond Basic RBAC?
|
||||||
|
|
||||||
|
RBAC is simple and intuitive—you assign users to roles (e.g., `admin`, `user`, `manager`), and those roles grant access to routes or resources. However, problems arise when:
|
||||||
|
- You need to allow `admin` to edit settings, but only allow them to read user data.
|
||||||
|
- You want `manager` to view reports but not update anything.
|
||||||
|
- You need flexibility to define custom permissions without rewriting a lot of code.
|
||||||
|
|
||||||
|
By introducing permissions, we can map roles to actions (like `CREATE`, `READ`, `UPDATE`, `DELETE`) on specific resources (like `ADMIN_SETTINGS`, `COURSE`, `USER_PROFILE`). This allows for declarative, readable, and maintainable access control.
|
||||||
|
|
||||||
|
Instead of scattering checks across your code, you define them once, assign them to roles, and the rest of the app can rely on centralized logic for checking access. This reduces duplication, prevents errors, and gives your team confidence in how access is managed across the system.
|
||||||
|
|
||||||
|
## 1. Define the Authorization Data Model
|
||||||
|
|
||||||
|
We'll begin by creating a flexible schema in Prisma for this approach.
|
||||||
|
|
||||||
|
You can use whatever ORM, Query Builder or what you prefer. Just do SQL how you're most comfortable with.
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum PermissionAction {
|
||||||
|
READ
|
||||||
|
CREATE
|
||||||
|
UPDATE
|
||||||
|
DELETE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PermissionResource {
|
||||||
|
ADMIN_SETTINGS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserType {
|
||||||
|
ADMIN
|
||||||
|
TEACHER
|
||||||
|
STUDENT
|
||||||
|
}
|
||||||
|
|
||||||
|
model Role {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
users User[]
|
||||||
|
permissions Permission[] @relation("RoleToPermission")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Permission {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
actions PermissionAction[]
|
||||||
|
resource PermissionResource
|
||||||
|
roleId String
|
||||||
|
role Role @relation(fields: [roleId], references: [id], name: "RoleToPermission")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
username String @unique
|
||||||
|
password String
|
||||||
|
roleId String?
|
||||||
|
role Role? @relation(fields: [roleId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This schema defines a relationship between users, roles, and the permissions assigned to those roles. The `Permission` model connects a role to specific `actions` it can perform on a given `resource`. You can easily extend this to include new resources or actions as your application grows.
|
||||||
|
|
||||||
|
A nice benefit of this setup is that you don't need to hardcode permissions in your codebase. You can manage them in the database and even build an admin panel later to assign and change them dynamically.
|
||||||
|
|
||||||
|
## 2. The Permissions Decorator
|
||||||
|
|
||||||
|
From the [previous post](/blog/nestjs-role-based-access-control) we saw how to use decorators. We will create a `@Permissions` decorator here to attach the necessary auth metadata to routes.
|
||||||
|
|
||||||
|
Decorators in NestJS are a powerful way to declare metadata that can later be accessed by guards. This keeps your controller methods clean and expressive.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { PermissionAction, PermissionResource } from '@prisma/client';
|
||||||
|
|
||||||
|
export const PERMISSIONS_KEY = 'permissions';
|
||||||
|
|
||||||
|
export const Permissions = (permissions: {
|
||||||
|
resource: PermissionResource;
|
||||||
|
actions: PermissionAction[];
|
||||||
|
}[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
|
```
|
||||||
|
|
||||||
|
Now when defining a controller method, you can do something like:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Permissions([
|
||||||
|
{ resource: PermissionResource.ADMIN_SETTINGS, actions: [PermissionAction.READ] },
|
||||||
|
])
|
||||||
|
@Get('settings')
|
||||||
|
getSettings() {
|
||||||
|
return this.settingsService.getSettings();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps your route logic clean while still ensuring strict access control.
|
||||||
|
|
||||||
|
## 3. The Authorization Guard
|
||||||
|
|
||||||
|
Now let's build a guard that checks whether the current user has the required permission to access a route. This guard will extract the metadata from the `@Permissions()` decorator and compare it with the permissions granted to the user's role.
|
||||||
|
|
||||||
|
Guards in NestJS are classes that implement the `CanActivate` interface and determine whether a request should proceed or not. Here's how you might implement a robust authorization guard:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { AuthService } from 'src/auth/auth.service';
|
||||||
|
import { PERMISSIONS_KEY } from 'src/auth/decorators/permissions.decorator';
|
||||||
|
import { RequestWithUser } from 'src/auth/types';
|
||||||
|
import { PermissionDto } from 'src/roles/dto/request.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthorizationGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||||
|
|
||||||
|
if (!request.user.sub) throw new UnauthorizedException();
|
||||||
|
|
||||||
|
const routePermissions: PermissionDto[] = this.reflector.getAllAndOverride(
|
||||||
|
PERMISSIONS_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!routePermissions) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userPermissions = await this.authService.getUserPermissions(
|
||||||
|
request.user.sub,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userPermissions) throw new ForbiddenException();
|
||||||
|
|
||||||
|
for (const routePermission of routePermissions) {
|
||||||
|
const userPermission = userPermissions.find(
|
||||||
|
(perm) => perm.resource === routePermission.resource,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!userPermission) throw new ForbiddenException();
|
||||||
|
|
||||||
|
const allActionsAvailable = routePermission.actions.every(
|
||||||
|
(requiredAction) => userPermission.actions.includes(requiredAction),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allActionsAvailable) throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This guard ensures that the current user's role includes the appropriate permissions to perform the requested action. If not, it throws a `ForbiddenException` and blocks access.
|
||||||
|
|
||||||
|
## Applying It to Real Routes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Controller('settings/admin')
|
||||||
|
export class AdminSettingsController {
|
||||||
|
constructor(private readonly adminSettingsService: AdminSettingsService) {}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticationGuard, AuthorizationGuard)
|
||||||
|
@Permissions([
|
||||||
|
{
|
||||||
|
resource: PermissionResource.ADMIN_SETTINGS,
|
||||||
|
actions: [PermissionAction.READ],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
@Get('general')
|
||||||
|
getGeneralSettings() {
|
||||||
|
return this.adminSettingsService.getGeneralSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticationGuard, AuthorizationGuard)
|
||||||
|
@Permissions([
|
||||||
|
{
|
||||||
|
resource: PermissionResource.ADMIN_SETTINGS,
|
||||||
|
actions: [PermissionAction.UPDATE],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
@Put('general')
|
||||||
|
updateGeneralSettings(@Body() dto: UpdateGeneralSettingsDto) {
|
||||||
|
return this.adminSettingsService.updateGeneralSettings(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With this setup, even admins can't update settings unless they specifically have the `UPDATE` action on `ADMIN_SETTINGS`.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
A simple `@Roles()` check might be enough to get started, but complex applications need granular and maintainable authorization logic. Always make sure your auth scales with you. Don't go super advanced when you don't need it but also never compromise your app by going so basic.
|
||||||
157
src/posts/nestjs-role-based-access-control.md
Normal file
157
src/posts/nestjs-role-based-access-control.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
title: "Handling Role-Based Access Control in NestJS"
|
||||||
|
date: "2025-04-01"
|
||||||
|
description: "Learn how to implement RBAC in NestJS using decorators, guards, and JWT authentication."
|
||||||
|
slug: "nestjs-role-based-access-control"
|
||||||
|
---
|
||||||
|
|
||||||
|
Role-Based Access Control (RBAC) is a common approach to managing permissions in applications, ensuring that only authorized users can access specific routes or perform certain actions. In this post, we'll explore how to implement RBAC in a NestJS application using guards and decorators.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Even though using a fully managed auth provider like Clerk is easy and probably saves time, learning how to do authentication from scratch is important for your learning experience.
|
||||||
|
|
||||||
|
This is not a beginners guide to RBAC but rather an overview of the overall implementation.
|
||||||
|
|
||||||
|
## 1. Setting Up User Roles
|
||||||
|
|
||||||
|
First, define the roles available in your application using an enum:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export enum Role {
|
||||||
|
Admin = 'admin',
|
||||||
|
User = 'user',
|
||||||
|
Manager = 'manager',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This enum will help us enforce role-based access at different levels of the application.
|
||||||
|
|
||||||
|
## 2. Creating a Roles Decorator
|
||||||
|
|
||||||
|
NestJS allows us to attach metadata to routes using decorators. Let's create a custom `@Roles()` decorator:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Role } from '../enums/role.enum';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
```
|
||||||
|
|
||||||
|
This decorator will allow us to specify roles for each route, which our guard will later check.
|
||||||
|
|
||||||
|
## 3. Implementing the Roles Guard
|
||||||
|
|
||||||
|
Now, let's create a `RolesGuard` that will check if a user has the required role before allowing access to a route.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||||
|
import { Role } from '../enums/role.enum';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RolesGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!requiredRoles) {
|
||||||
|
return true; // If no roles are set, allow access
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = context.switchToHttp().getRequest();
|
||||||
|
return user && user.roles?.some((role) => requiredRoles.includes(role));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This guard extracts the roles from metadata and verifies if the current user has one of the required roles.
|
||||||
|
|
||||||
|
## 4. Applying RBAC to Routes
|
||||||
|
|
||||||
|
Now, let's secure routes using our `@Roles()` decorator and `RolesGuard`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
import { Role } from '../enums/role.enum';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(RolesGuard)
|
||||||
|
export class UsersController {
|
||||||
|
@Get('admin')
|
||||||
|
@Roles(Role.Admin)
|
||||||
|
getAdminData() {
|
||||||
|
return 'This is only accessible to admins';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('manager')
|
||||||
|
@Roles(Role.Manager, Role.Admin)
|
||||||
|
getManagerData() {
|
||||||
|
return 'This is accessible to managers and admins';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With this setup:
|
||||||
|
- The `/users/admin` route is accessible only to Admin users.
|
||||||
|
- The `/users/manager` route is accessible to both Admin and Manager users.
|
||||||
|
|
||||||
|
## 5. Ensuring User Role in JWT Authentication
|
||||||
|
|
||||||
|
If you're using JWT authentication, ensure that user roles are included when signing the JWT:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const payload = { username: user.username, sub: user.id, roles: user.roles };
|
||||||
|
const token = this.jwtService.sign(payload);
|
||||||
|
```
|
||||||
|
|
||||||
|
When a user logs in, this ensures their roles are available for the `RolesGuard` to check.
|
||||||
|
|
||||||
|
In a real world application you want to setup a `JwtAuthGuard` to decode the `Bearer <TOKEN>` and get the roles then re-attach them to the request before the `RolesGuard` gets the request.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Controller, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../guards/jwt.guard';
|
||||||
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
export class UsersController {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Storing and Managing Roles in a Database
|
||||||
|
|
||||||
|
For a real-world application, user roles should be stored in a database. If you're using TypeORM, define your User entity like this:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { Role } from '../enums/role.enum';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column({ type: 'enum', enum: Role, array: true, default: [Role.User] })
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, fetch the user and roles from the database inside your authentication logic.
|
||||||
|
|
||||||
|
Reading from the database is always an extra network trip and will add a couple of milliseconds to each request.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
By implementing RBAC in NestJS, we can ensure that only authorized users can access specific routes. This approach is flexible and scalable, allowing for fine-grained access control.
|
||||||
13
src/reportWebVitals.ts
Normal file
13
src/reportWebVitals.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const reportWebVitals = (onPerfEntry?: () => void) => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
|
||||||
|
onCLS(onPerfEntry)
|
||||||
|
onINP(onPerfEntry)
|
||||||
|
onFCP(onPerfEntry)
|
||||||
|
onLCP(onPerfEntry)
|
||||||
|
onTTFB(onPerfEntry)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default reportWebVitals
|
||||||
189
src/routeTree.gen.ts
Normal file
189
src/routeTree.gen.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/* 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 WorkRouteImport } from './routes/work'
|
||||||
|
import { Route as StackRouteImport } from './routes/stack'
|
||||||
|
import { Route as ProjectsRouteImport } from './routes/projects'
|
||||||
|
import { Route as ContactRouteImport } from './routes/contact'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as BlogIndexRouteImport } from './routes/blog/index'
|
||||||
|
import { Route as BlogSlugRouteImport } from './routes/blog/$slug'
|
||||||
|
|
||||||
|
const WorkRoute = WorkRouteImport.update({
|
||||||
|
id: '/work',
|
||||||
|
path: '/work',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const StackRoute = StackRouteImport.update({
|
||||||
|
id: '/stack',
|
||||||
|
path: '/stack',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ProjectsRoute = ProjectsRouteImport.update({
|
||||||
|
id: '/projects',
|
||||||
|
path: '/projects',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ContactRoute = ContactRouteImport.update({
|
||||||
|
id: '/contact',
|
||||||
|
path: '/contact',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BlogIndexRoute = BlogIndexRouteImport.update({
|
||||||
|
id: '/blog/',
|
||||||
|
path: '/blog/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BlogSlugRoute = BlogSlugRouteImport.update({
|
||||||
|
id: '/blog/$slug',
|
||||||
|
path: '/blog/$slug',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/contact': typeof ContactRoute
|
||||||
|
'/projects': typeof ProjectsRoute
|
||||||
|
'/stack': typeof StackRoute
|
||||||
|
'/work': typeof WorkRoute
|
||||||
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
|
'/blog/': typeof BlogIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/contact': typeof ContactRoute
|
||||||
|
'/projects': typeof ProjectsRoute
|
||||||
|
'/stack': typeof StackRoute
|
||||||
|
'/work': typeof WorkRoute
|
||||||
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
|
'/blog': typeof BlogIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/contact': typeof ContactRoute
|
||||||
|
'/projects': typeof ProjectsRoute
|
||||||
|
'/stack': typeof StackRoute
|
||||||
|
'/work': typeof WorkRoute
|
||||||
|
'/blog/$slug': typeof BlogSlugRoute
|
||||||
|
'/blog/': typeof BlogIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/contact'
|
||||||
|
| '/projects'
|
||||||
|
| '/stack'
|
||||||
|
| '/work'
|
||||||
|
| '/blog/$slug'
|
||||||
|
| '/blog/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/contact'
|
||||||
|
| '/projects'
|
||||||
|
| '/stack'
|
||||||
|
| '/work'
|
||||||
|
| '/blog/$slug'
|
||||||
|
| '/blog'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/contact'
|
||||||
|
| '/projects'
|
||||||
|
| '/stack'
|
||||||
|
| '/work'
|
||||||
|
| '/blog/$slug'
|
||||||
|
| '/blog/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
ContactRoute: typeof ContactRoute
|
||||||
|
ProjectsRoute: typeof ProjectsRoute
|
||||||
|
StackRoute: typeof StackRoute
|
||||||
|
WorkRoute: typeof WorkRoute
|
||||||
|
BlogSlugRoute: typeof BlogSlugRoute
|
||||||
|
BlogIndexRoute: typeof BlogIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/work': {
|
||||||
|
id: '/work'
|
||||||
|
path: '/work'
|
||||||
|
fullPath: '/work'
|
||||||
|
preLoaderRoute: typeof WorkRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/stack': {
|
||||||
|
id: '/stack'
|
||||||
|
path: '/stack'
|
||||||
|
fullPath: '/stack'
|
||||||
|
preLoaderRoute: typeof StackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/projects': {
|
||||||
|
id: '/projects'
|
||||||
|
path: '/projects'
|
||||||
|
fullPath: '/projects'
|
||||||
|
preLoaderRoute: typeof ProjectsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/contact': {
|
||||||
|
id: '/contact'
|
||||||
|
path: '/contact'
|
||||||
|
fullPath: '/contact'
|
||||||
|
preLoaderRoute: typeof ContactRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/blog/': {
|
||||||
|
id: '/blog/'
|
||||||
|
path: '/blog'
|
||||||
|
fullPath: '/blog/'
|
||||||
|
preLoaderRoute: typeof BlogIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/blog/$slug': {
|
||||||
|
id: '/blog/$slug'
|
||||||
|
path: '/blog/$slug'
|
||||||
|
fullPath: '/blog/$slug'
|
||||||
|
preLoaderRoute: typeof BlogSlugRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
ContactRoute: ContactRoute,
|
||||||
|
ProjectsRoute: ProjectsRoute,
|
||||||
|
StackRoute: StackRoute,
|
||||||
|
WorkRoute: WorkRoute,
|
||||||
|
BlogSlugRoute: BlogSlugRoute,
|
||||||
|
BlogIndexRoute: BlogIndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
26
src/routes/__root.tsx
Normal file
26
src/routes/__root.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||||
|
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||||
|
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<div className="min-h-dvh bg-soft-white text-soft-black">
|
||||||
|
<div className="container px-6 py-4 mx-auto">
|
||||||
|
<Outlet />
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<TanStackDevtools
|
||||||
|
config={{
|
||||||
|
position: "bottom-right",
|
||||||
|
}}
|
||||||
|
plugins={[
|
||||||
|
{
|
||||||
|
name: "Tanstack Router",
|
||||||
|
render: <TanStackRouterDevtoolsPanel />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
43
src/routes/blog/$slug.tsx
Normal file
43
src/routes/blog/$slug.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { getPostBySlug } from "@/lib/posts";
|
||||||
|
import { createFileRoute, Link, notFound } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blog/$slug")({
|
||||||
|
component: BlogPost,
|
||||||
|
loader: ({ params }) => {
|
||||||
|
const post = getPostBySlug(params.slug);
|
||||||
|
if (!post) throw notFound();
|
||||||
|
return { post };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function BlogPost() {
|
||||||
|
const { post } = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="text-sm">
|
||||||
|
← <span className="underline">home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<article className="mt-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold max-w-prose wrap-break-word">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-gray-600">{post.date}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-8 prose prose-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.contentHtml }}
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<Link to="/blog" className="text-sm">
|
||||||
|
← <span className="underline">All Blogs</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/routes/blog/index.tsx
Normal file
52
src/routes/blog/index.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { getAllPosts } from "@/lib/posts";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blog/")({
|
||||||
|
component: Blog,
|
||||||
|
loader: () => {
|
||||||
|
const posts = getAllPosts();
|
||||||
|
return { posts };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Blog() {
|
||||||
|
const { posts } = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="text-sm">
|
||||||
|
← <span className="underline">home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<h1 className="text-lg font-semibold">Blog</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-5 ml-2 text-sm">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<div key={post.slug}>
|
||||||
|
<Link
|
||||||
|
to="/blog/$slug"
|
||||||
|
params={{ slug: post.slug }}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<p className="font-semibold max-w-prose group-hover:underline">
|
||||||
|
{post.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-xs mt-1">{post.date}</p>
|
||||||
|
<p className="text-gray-700 max-w-prose mt-1">
|
||||||
|
{post.description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{posts.length} {posts.length === 1 ? "post" : "posts"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/routes/contact.tsx
Normal file
56
src/routes/contact.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/contact")({
|
||||||
|
component: Contact,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Contact() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="text-sm">
|
||||||
|
← <span className="underline">home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<h1 className="text-lg font-semibold">Contact</h1>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-5 ml-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">Email</p>
|
||||||
|
<a href="mailto:patrickobamascript@gmail.com" className="underline">
|
||||||
|
patrickobamascript@gmail.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">GitHub</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/tracepanic"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
github.com/tracepanic
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">LinkedIn</p>
|
||||||
|
<a
|
||||||
|
href="https://www.linkedin.com/in/patrick-obama-8269152bb"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
https://www.linkedin.com/in/patrick-obama-8269152bb
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<p className="text-sm">Response time: usually within 24 hours.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/routes/index.tsx
Normal file
71
src/routes/index.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: Home,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Patrick Obama</h1>
|
||||||
|
<p className="mt-2">
|
||||||
|
Full-stack developer. I build tools, write things down.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mt-10">
|
||||||
|
<p>This site contains:</p>
|
||||||
|
<ul className="space-y-1 pl-4">
|
||||||
|
<li>
|
||||||
|
→{" "}
|
||||||
|
<Link to="/projects" className="underline">
|
||||||
|
projects
|
||||||
|
</Link>
|
||||||
|
: things I've built
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
→{" "}
|
||||||
|
<Link to="/work" className="underline">
|
||||||
|
work
|
||||||
|
</Link>
|
||||||
|
: where I've worked
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
→{" "}
|
||||||
|
<Link to="/stack" className="underline">
|
||||||
|
stack
|
||||||
|
</Link>
|
||||||
|
: tools and tech I use
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
→{" "}
|
||||||
|
<Link to="/blog" className="underline">
|
||||||
|
blog
|
||||||
|
</Link>
|
||||||
|
: longer writing
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
→{" "}
|
||||||
|
<Link to="/contact" className="underline">
|
||||||
|
contact
|
||||||
|
</Link>
|
||||||
|
: ways to reach me
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<p className="text-xs mb-2">Recent Changes</p>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
2026-02-01 —{" "}
|
||||||
|
<Link to="." className="underline">
|
||||||
|
New post about X
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/routes/projects.tsx
Normal file
168
src/routes/projects.tsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/projects")({
|
||||||
|
component: Projects,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Projects() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="text-sm">
|
||||||
|
← <span className="underline">home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<h1 className="text-lg font-semibold">Projects</h1>
|
||||||
|
|
||||||
|
<div className="space-y-8 mt-5 ml-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Compyle Community Platform</p>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Community platform for Compyle AI.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Built platform for hosting hackathons and publishing apps created
|
||||||
|
with Compyle AI. Handles user management, event organization, and
|
||||||
|
app marketplace. Built with Next.js, Tanstack, Drizzle,
|
||||||
|
PostgreSQL.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-x-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/tracepanic/compyle"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://compyle.tracepanic.com"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
live
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Skill Bridge</p>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
AI-powered learning platform that generates courses and learning
|
||||||
|
paths.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Built for IBM 2025 Global Hackathon. Generates personalized
|
||||||
|
courses and learning paths based on user input and resume
|
||||||
|
analysis. Built with TypeScript, Next.js, IBM Granite AI models.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-x-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/tracepanic/skillbridge"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://skillbridge.tracepanic.com"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
live
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Link Space</p>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Platform to manage and share links, notes, and content.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Built for 2025 Next.js Global Hackathon. Manages personal links
|
||||||
|
and content collections with sharing capabilities. TypeScript,
|
||||||
|
Next.js, Server Actions, Prisma, Tailwind CSS.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-x-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/tracepanic/link-space"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://spaces.tracepanic.com"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
live
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Luzin</p>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Full-stack learning management system.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Feature-rich LMS with course management, user enrollment, and
|
||||||
|
progress tracking. Built with TypeScript, Next.js, Prisma,
|
||||||
|
Tanstack, Tailwind CSS.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-x-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/luzin-labs/luzin"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://luzin-docs.vercel.app/"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
live
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Open Craft</p>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Infinite crafting game for CLI, Telegram, and web.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Version of Infinite Craft that runs in the CLI, as a Telegram bot,
|
||||||
|
or as a backend server. Written in Go.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 space-x-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/tracepanic/opencraft"
|
||||||
|
className="underline text-xs"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<p className="text-sm">Last updated: 04 February 2026</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/routes/stack.tsx
Normal file
45
src/routes/stack.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/stack")({
|
||||||
|
component: Stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Stack() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="text-sm">
|
||||||
|
← <span className="underline">home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<h1 className="text-lg font-semibold">Tech Stack</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-5 ml-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">Languages</p>
|
||||||
|
<p>TypeScript, Python, Go</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">Frontend</p>
|
||||||
|
<p>React, Next.js, Tailwind CSS, Vite, Tanstack</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">Backend</p>
|
||||||
|
<p>Node.js, NestJS, PostgreSQL</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 mb-1">Tools</p>
|
||||||
|
<p>Git, Docker, Linux, Neovim</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<p className="text-sm">Last updated: 04 February 2026</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/routes/work.tsx
Normal file
111
src/routes/work.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/work")({
|
||||||
|
component: Work,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Work() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to="/" className="text-sm">
|
||||||
|
← <span className="underline">home</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<h1 className="text-lg font-semibold">Work Experince</h1>
|
||||||
|
|
||||||
|
<div className="space-y-8 mt-5 ml-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Documentation Engineer (Jan 2026 — Present)
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<a
|
||||||
|
href="https://plakar.io"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Plakar
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Maintaining and improving documentation platform. Restructured
|
||||||
|
docs using Diátaxis framework. Test Plakar backup workflows across
|
||||||
|
platforms. Write guides for setting up automated backups on cloud
|
||||||
|
infrastructure (e.g. OVHcloud).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Full-Stack Developer (Sep 2025 — Present)
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<a
|
||||||
|
href="https://compyle.ai"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Compyle AI
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Built community platform for AI coding agent ecosystem. Developed
|
||||||
|
features for hackathons and app publishing. Worked with Next.js,
|
||||||
|
Tanstack, Drizzle, PostgreSQL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Assistant Learner Experience Manager (Jul — Oct 2024)
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<a
|
||||||
|
href="https://www.powerlearnprojectafrica.org"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Power Learn Project Africa
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Taught web development to students (Node.js, MySQL, JavaScript).
|
||||||
|
Provided mentorship and curriculum support. Facilitated skill
|
||||||
|
development in web technologies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">
|
||||||
|
Lead Frontend Developer (Apr — Jul 2024)
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
<a
|
||||||
|
href="https://www.godan.info"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
GODAN Kenya
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-prose">
|
||||||
|
Led team of 5 developers building farmers and logistics management
|
||||||
|
system. Integrated Google Maps for farm visualization. Presented
|
||||||
|
platform progress to stakeholders. Used React, TypeScript, Redux,
|
||||||
|
Open Street Map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-5 mt-20 border-t border-gray-200">
|
||||||
|
<p className="text-sm">Last updated: 04 February 2026</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/styles.css
Normal file
30
src/styles.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-soft-white: #fafafa;
|
||||||
|
--color-soft-black: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply m-0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
"Open Sans",
|
||||||
|
"Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family:
|
||||||
|
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||||
|
}
|
||||||
10
src/types/posts.ts
Normal file
10
src/types/posts.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export interface PostMeta {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Post extends PostMeta {
|
||||||
|
contentHtml: string;
|
||||||
|
}
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"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,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { devtools } from "@tanstack/devtools-vite";
|
||||||
|
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||||
|
import viteReact from "@vitejs/plugin-react";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
devtools(),
|
||||||
|
tanstackRouter({
|
||||||
|
target: "react",
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
}),
|
||||||
|
viteReact(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user