Add basic FE and prettier

This commit is contained in:
2025-04-18 01:05:48 +01:00
parent 0046a34241
commit 498fce465a
32 changed files with 719 additions and 119 deletions

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"arrowParens": "avoid",
"experimentalOperatorPosition": "start",
"experimentalTernaries": true,
"jsxSingleQuote": true,
"printWidth": 120,
"singleQuote": true
}

View File

@@ -1,15 +1,19 @@
# Elysia with Bun runtime
## Getting Started
To get started with this template, simply paste this command into your terminal:
```bash
bun create elysia ./elysia-example
```
## Development
To start the development server run:
```bash
bun run dev
```
Open http://localhost:3000/ with your browser to see the result.
Open http://localhost:3000/ with your browser to see the result.

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,15 +1,18 @@
{
"name": "todo3",
"version": "0.0.0",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "bun run --watch src/index.ts"
},
"dependencies": {
"@elysiajs/jwt": "^1.2.0",
"elysia": "^1.2.25"
"dev": "bun run --filter '*' dev"
},
"devDependencies": {
"bun-types": "latest"
"bun-types": "latest",
"prettier": "^3.5.3"
},
"module": "src/index.js"
}
"type": "module",
"trustedDependencies": [
"@swc/core"
]
}

2
packages/be/.env Normal file
View File

@@ -0,0 +1,2 @@
JWT_SECRET=FOOBAR
PORT=3000

11
packages/be/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "be",
"version": "0.0.0",
"scripts": {
"dev": "bun run --watch src/index.ts"
},
"dependencies": {
"@elysiajs/jwt": "^1.2.0",
"elysia": "^1.2.25"
}
}

98
packages/be/src/index.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Elysia, t } from 'elysia';
import { userDTO } from './user-dto';
import { jwtConfig } from './jwt.config';
import { randomUUID } from 'crypto';
const authBody = t.Object({
email: t.String(),
password: t.String(),
});
const validTokens = new Set<string>();
const app = new Elysia()
.use(jwtConfig)
.derive(async ({ cookie, jwt_auth }) => {
const token = cookie.auth_token?.value;
if (!token || !validTokens.has(token)) return { user: null };
const user = await jwt_auth.verify(token);
return { user };
})
.post(
'/signup',
async ({ body, error, jwt_auth, cookie }) => {
const foundUser = userDTO.findUserByEmail(body.email);
if (foundUser) return error(400, 'User already exists');
const newUser = await userDTO.createUser({
email: body.email,
password: body.password,
});
if (!newUser) return error(400, 'Problems creating user');
const token = await jwt_auth.sign({
id: newUser.id,
sessionId: randomUUID(),
});
validTokens.add(token);
cookie.auth_token.set({
value: token,
httpOnly: true,
secure: true,
path: '/',
});
return { success: true };
},
{ body: authBody },
)
.post(
'/login',
async ({ body, error, jwt_auth, cookie }) => {
const foundUser = userDTO.findUserByEmail(body.email);
if (!foundUser) return error(400, 'User does not exist');
const isPasswordCorrect = await userDTO.verifyPassword(body.password, foundUser.password_hash);
if (!isPasswordCorrect) error(400, 'Password is incorrect');
const token = await jwt_auth.sign({
id: foundUser.id,
sessionId: randomUUID(),
});
validTokens.add(token);
cookie.auth_token.set({
value: token,
httpOnly: true,
secure: true,
path: '/',
});
return { success: true };
},
{ body: authBody },
)
.get('/me', ({ user, error }) => {
if (!user) return error(401, 'Not Authorized');
return { user };
})
.post('/logout', ({ cookie }) => {
const token = cookie.auth_token.value;
if (token) {
validTokens.delete(token);
}
cookie.auth_token.remove();
return { success: true };
});
app.listen(process.env.PORT!);

View File

@@ -0,0 +1,6 @@
import { jwt } from '@elysiajs/jwt';
export const jwtConfig = jwt({
name: 'jwt_auth',
secret: process.env.JWT_SECRET!,
});

View File

@@ -2,14 +2,14 @@ import { Database } from 'bun:sqlite';
import { randomUUID } from 'crypto';
type User = {
id: string,
email: string,
password_hash: string,
}
id: string;
email: string;
password_hash: string;
};
type UserWithoutId = Omit<User, 'id' | 'password_hash'> & {
password: string,
}
password: string;
};
const db = new Database('db.sqlite');
@@ -28,12 +28,12 @@ export const userDTO = {
},
createUser: async (user: UserWithoutId) => {
const password_hash = await Bun.password.hash(user.password);
try {
const query = db.query(
'INSERT INTO users (id, email, password_hash) VALUES ($id, $email, $password_hash) RETURNING *'
'INSERT INTO users (id, email, password_hash) VALUES ($id, $email, $password_hash) RETURNING *',
);
return query.get({
$id: randomUUID(),
$email: user.email,
@@ -45,5 +45,5 @@ export const userDTO = {
},
verifyPassword: async (password: string, hash: string) => {
return await Bun.password.verify(password, hash);
}
}
},
};

24
packages/fe/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
packages/fe/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
});
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
});
```

View File

@@ -0,0 +1,25 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);

24
packages/fe/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Courier+Prime:ital,wght@0,400;0,700;1,400;1,700&display=swap"
/>
<link
href="https://fonts.googleapis.com/css2?family=Courier+Prime:ital,wght@0,400;0,700;1,400;1,700&display=swap"
rel="stylesheet"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo 3.0</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
packages/fe/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "fe",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"be": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react-swc": "^3.8.0",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}

139
packages/fe/src/App.tsx Normal file
View File

@@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from './components/core/button';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { List } from './components/list';
import { ItemType, ListType } from './types';
const initialState: ListType[] = [
{
id: '1',
items: [
{ id: '1', label: 'Item 1', checked: false },
{ id: '2', label: 'Item 2', checked: false },
{ id: '3', label: 'Item 3', checked: false },
],
title: 'List 1',
createdAt: new Date(),
},
{
id: '2',
items: [
{ id: '4', label: 'Item 4', checked: false },
{ id: '5', label: 'Item 5', checked: false },
{ id: '6', label: 'Item 6', checked: false },
],
title: 'List 2',
createdAt: new Date(),
},
{
id: '3',
items: [
{ id: '7', label: 'Item 7', checked: false },
{ id: '8', label: 'Item 8', checked: false },
{ id: '9', label: 'Item 9', checked: false },
],
title: 'List 3',
createdAt: new Date(),
},
];
const title = ` _____ _ _____ _____
|_ _| | | |____ || _ |
| | ___ __| | ___ / /| |/' |
| |/ _ \\ / _\` |/ _ \\ \\ \\| /| |
| | (_) | (_| | (_) | .___/ /\\ |_/ /
\\_/\\___/ \\__,_|\\___/ \\____(_)\\___/`;
function App() {
const [ref] = useAutoAnimate();
const [state, setState] = useState<ListType[]>(initialState);
const [widthPx, setWidthPx] = useState(Math.min(1280, window.innerWidth));
const widthCh = useMemo(() => Math.floor(widthPx / 12), [widthPx]);
const columns = useMemo(() => Math.ceil(widthCh / (1280 / (12 * 3))), [widthCh]);
const listWidthCh = useMemo(() => Math.ceil(widthCh / columns - 1 * (columns - 1)), [columns, widthCh]);
const horizontalBorder = useMemo(() => {
let string = '+';
for (let i = 0; i < widthCh / columns - 2 - 1 * (columns - 1); i++) {
string += '-';
}
string += '+';
return string;
}, [columns, widthCh]);
useEffect(() => {
const handleResize = () => {
setWidthPx(Math.min(1280, window.innerWidth));
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const addList = () => {
setState([{ id: window.crypto.randomUUID(), items: [], title: '', createdAt: new Date() }, ...state]);
};
const updateList = (listId: string, list: ListType) => {
setState(state.map(l => (l.id === listId ? list : l)));
};
const deleteList = (listId: string) => {
setState(state.filter(list => list.id !== listId));
};
const addItem = (listId: string) => {
setState(
state.map(list =>
list.id === listId ?
{ ...list, items: [...list.items, { id: window.crypto.randomUUID(), label: '', checked: false }] }
: list,
),
);
};
const updateItem = (listId: string, itemId: string, item: ItemType) => {
setState(
state.map(list =>
list.id === listId ? { ...list, items: list.items.map(i => (i.id === itemId ? item : i)) } : list,
),
);
};
const deleteItem = (listId: string, itemId: string) => {
setState(
state.map(list => (list.id === listId ? { ...list, items: list.items.filter(i => i.id !== itemId) } : list)),
);
};
return (
<>
<h1>{title}</h1>
<Button hasBorder onClick={addList}>
Add list
</Button>
<div
ref={ref}
style={{ display: 'grid', gap: 12, gridTemplateColumns: `repeat(${columns}, minmax(${listWidthCh}ch, 1fr))` }}
>
{state.map(list => (
<List
key={list.id}
{...list}
deleteList={deleteList}
updateItem={updateItem}
deleteItem={deleteItem}
addItem={addItem}
horizontalBorder={horizontalBorder}
listWidthCh={listWidthCh}
/>
))}
</div>
</>
);
}
export default App;

View File

@@ -0,0 +1,38 @@
import { ComponentPropsWithRef } from 'react';
import styles from './styles.module.css';
type Props = ComponentPropsWithRef<'button'> & {
hasBorder?: boolean;
};
export const Button = ({ className, hasBorder, children, ...props }: Props) => {
if (hasBorder) {
return (
<button className={`${styles.button} ${styles.bordered} ${className}`} {...props}>
<span>
+-
{children
?.toString()
.split('')
.map(() => '-')}
-+
</span>
<span>| {children} |</span>
<span>
+-
{children
?.toString()
.split('')
.map(() => '-')}
-+
</span>
</button>
);
}
return (
<button className={`${styles.button} ${className}`} {...props}>
{children}
</button>
);
};

View File

@@ -0,0 +1,13 @@
.button {
appearance: none;
border: none;
outline: none;
cursor: pointer;
background: transparent;
font-family: inherit;
padding: 0;
}
.bordered span {
display: block;
}

View File

@@ -0,0 +1,8 @@
import { ComponentPropsWithRef } from 'react';
import styles from './styles.module.css';
type Props = ComponentPropsWithRef<'input'>;
export const Input = ({ className, ...props }: Props) => {
return <input className={`${styles.input} ${className}`} {...props} />;
};

View File

@@ -0,0 +1,13 @@
.input {
appearance: none;
border: none;
outline: none;
background: none;
font-family: inherit;
padding: 0;
height: 1rem;
&:focus {
color: #fffa;
}
}

View File

@@ -0,0 +1,31 @@
import { Button } from '../core/button';
import { Input } from '../core/input';
import { ItemType } from '../../types';
type Props = ItemType & {
listId: string;
updateItem: (listId: string, itemId: string, item: ItemType) => void;
deleteItem: (listId: string, itemId: string) => void;
};
export const Item = ({ listId, updateItem, deleteItem, ...item }: Props) => {
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, max-content) 1fr max-content' }}>
<span>|</span>
<Button onClick={() => updateItem(listId, item.id, { ...item, checked: !item.checked })}>
{' '}
{item.checked ? 'X' : 'O'}{' '}
</Button>
<div style={{ overflow: 'hidden' }}>
<Input
className='itemLabel'
type='text'
value={item.label}
onChange={({ target }) => updateItem(listId, item.id, { ...item, label: target.value })}
style={{ width: '100%' }}
/>
</div>
<span> |</span>
</div>
);
};

View File

@@ -0,0 +1,57 @@
import { Button } from '../core/button';
import { ItemType, ListType } from '../../types';
import { Item } from '../item';
import { useAutoAnimate } from '@formkit/auto-animate/react';
type Props = ListType & {
deleteList: (listId: string) => void;
updateItem: (listId: string, itemId: string, item: ItemType) => void;
deleteItem: (listId: string, itemId: string) => void;
addItem: (listId: string) => void;
horizontalBorder: string;
listWidthCh: number;
};
export const List = ({
deleteList,
updateItem,
deleteItem,
addItem,
horizontalBorder,
listWidthCh,
...list
}: Props) => {
const [ref] = useAutoAnimate();
return (
<div key={list.id} style={{ width: listWidthCh + 'ch', overflow: 'hidden' }}>
<div>{horizontalBorder}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'max-content 1fr repeat(2, max-content)' }}>
<span>| </span>
<h2>{list.title}</h2>
<Button onClick={() => deleteList(list.id)}> X </Button>
<span>|</span>
</div>
<div>{horizontalBorder}</div>
<div ref={ref}>
{list.items.map(item => (
<Item key={item.id} listId={list.id} updateItem={updateItem} deleteItem={deleteItem} {...item} />
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'max-content max-content 1fr max-content max-content' }}>
<span>| </span>
<Button onClick={() => addItem(list.id)} style={{ textDecoration: 'underline' }}>
Add item
</Button>
<span> </span>
<i>{list.createdAt.toLocaleDateString()}</i>
<span> |</span>
</div>
<div>{horizontalBorder}</div>
</div>
);
};

27
packages/fe/src/index.css Normal file
View File

@@ -0,0 +1,27 @@
:root {
font-family: 'Courier Prime', monospace;
font-weight: 400;
font-size: 20px;
font-style: normal;
color-scheme: dark;
color: #fff;
background-color: #000;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
body {
margin: 0 auto;
max-width: 1280px;
width: 100vw;
min-height: 100vh;
white-space: pre;
}
* {
margin: 0;
font-size: 1rem;
line-height: 1;
}

10
packages/fe/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

12
packages/fe/src/types.ts Normal file
View File

@@ -0,0 +1,12 @@
export type ItemType = {
id: string;
label: string;
checked: boolean;
};
export type ListType = {
id: string;
items: ItemType[];
title: string;
createdAt: Date;
};

1
packages/fe/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});

View File

@@ -1,93 +0,0 @@
import { Elysia, t } from "elysia"
import { userDTO } from "./user-dto"
import { jwtConfig } from "./jwt.config"
import { randomUUID } from "crypto"
const authBody = t.Object({
email: t.String(),
password: t.String(),
})
const validTokens = new Set<string>()
const app = new Elysia()
.use(jwtConfig)
.derive(async ({ cookie, jwt_auth }) => {
const token = cookie.auth_token?.value
if (!token || !validTokens.has(token)) return { user: null }
const user = await jwt_auth.verify(token)
return { user }
})
.post("/signup", async ({ body, error, jwt_auth, cookie }) => {
const foundUser = userDTO.findUserByEmail(body.email)
if (foundUser) return error(400, "User already exists")
const newUser = await userDTO.createUser({
email: body.email,
password: body.password,
})
if (!newUser) return error(400, "Problems creating user")
const token = await jwt_auth.sign({
id: newUser.id,
sessionId: randomUUID()
})
validTokens.add(token)
cookie.auth_token.set({
value: token,
httpOnly: true,
secure: true,
path: '/'
})
return { success: true }
}, { body: authBody })
.post("/login", async ({ body, error, jwt_auth, cookie }) => {
const foundUser = userDTO.findUserByEmail(body.email)
if (!foundUser) return error(400, "User does not exist")
const isPasswordCorrect = await userDTO.verifyPassword(
body.password,
foundUser.password_hash,
)
if (!isPasswordCorrect) error(400, "Password is incorrect")
const token = await jwt_auth.sign({
id: foundUser.id,
sessionId: randomUUID()
})
validTokens.add(token)
cookie.auth_token.set({
value: token,
httpOnly: true,
secure: true,
path: '/'
})
return { success: true }
}, { body: authBody })
.get("/me", ({ user, error }) => {
if (!user) return error(401, "Not Authorized")
return { user }
})
.post("/logout", ({ cookie }) => {
const token = cookie.auth_token.value
if (token) {
validTokens.delete(token)
}
cookie.auth_token.remove()
return { success: true }
})
app.listen(3000)

View File

@@ -1,6 +0,0 @@
import { jwt } from '@elysiajs/jwt'
export const jwtConfig = jwt({
name: 'jwt_auth',
secret: process.env.JWT_SECRET!
})