@atom.io/template-react-node-backend 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +13 -0
- package/dist/assets/index-CNf_2_tw.js +51 -0
- package/dist/assets/index-CqTfTEH3.css +1 -0
- package/dist/index.html +2 -2
- package/node/server.ts +139 -50
- package/package.json +3 -2
- package/src/App.tsx +229 -22
- package/src/index.css +136 -81
- package/src/main.tsx +1 -1
- package/tsconfig.app.json +1 -1
- package/tsconfig.node.json +1 -1
- package/dist/assets/index-By2j7w9s.css +0 -1
- package/dist/assets/index-CKoSUs2U.js +0 -19
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light dark;color:#ffffffde;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:#242424;font-family:Charter,serif;font-weight:400;line-height:1.5}@media (prefers-color-scheme:dark){:root{--lightningcss-light: ;--lightningcss-dark:initial}}@media (prefers-color-scheme:light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}a{color:#646cff;-webkit-text-decoration:inherit;text-decoration:inherit;font-weight:500}a:hover{color:#535bf2}body{flex-flow:column;place-items:center;min-width:320px;min-height:100vh;margin:0;display:flex}body #root{flex-flow:column;place-items:center;width:100%;height:100%;display:flex}body #root>header{z-index:100;box-sizing:border-box;justify-content:flex-end;align-items:center;gap:10px;width:100%;padding:20px;display:flex;position:fixed;top:0;left:0}body #root>header>.spacer{flex-grow:1}body #root>header>.pfp{border:1px solid #ccc3;border-radius:50%;place-content:center;place-items:center;width:40px;height:40px;display:flex;position:relative}body #root>header>.pfp.signed-in:before{content:"👤"}body #root>main{flex-direction:column;gap:10px;width:100%;max-width:600px;height:100%;display:flex;position:relative}body #root>main>header{flex-flow:column;justify-content:flex-end;height:200px;display:flex}body #root>main>header>h1{margin:0;padding:0;font-size:72px;line-height:72px}body #root>main>main{flex-flow:column;gap:5px;height:100%;display:flex;position:relative}body #root>main>main>.todo{box-sizing:border-box;text-align:center;border:1px solid #ccc3;flex-flow:row;place-items:center;gap:5px;width:100%;height:29px;padding:0 3px;font-size:18px;display:flex;position:relative}body #root>main>main>.todo>span,body #root>main>main>.todo input[type=text]{text-align:left;flex-grow:1;font-family:Charter,serif;font-size:18px}body #root>main>main>.todo>button.delete:disabled{opacity:.5;background:0 0}body #root>main>main>.todo>button.delete:after{content:"×"}body #root>.takeover{z-index:100;box-sizing:border-box;background-color:#00000080;place-content:center;place-items:center;width:100svw;height:100svh;padding-bottom:60px;display:flex;position:fixed;top:0;left:0}body #root>.takeover>main.card{background-color:#444;flex-direction:column;place-items:center;gap:10px;display:flex}@media (prefers-color-scheme:light){body #root>.takeover>main.card{background-color:#fff}}body #root>.takeover>main.card{border-radius:8px;width:100%;max-width:200px;padding:20px}.loading{background:linear-gradient(90deg,#7773,#7771,#7773) 0 0/200% 100%;animation:4s ease-in-out infinite pulse}.loading:after{content:"⏳";color:#fff;font-size:10px;position:absolute;top:-5px;right:-5px}button{border:1px solid #0000;border-radius:8px}@keyframes pulse{0%{background-position:200% 0}50%{background-position:0 0}to{background-position:200% 0}}
|
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>vite-react</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CNf_2_tw.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CqTfTEH3.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/node/server.ts
CHANGED
|
@@ -1,10 +1,33 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node --watch
|
|
2
2
|
|
|
3
3
|
import * as http from "node:http"
|
|
4
|
-
import {
|
|
4
|
+
import { DatabaseSync } from "node:sqlite"
|
|
5
|
+
|
|
6
|
+
import { atom, getState, setState } from "atom.io"
|
|
5
7
|
|
|
6
8
|
const PORT = process.env.PORT ?? 3000
|
|
7
|
-
const
|
|
9
|
+
const SERVER_ORIGIN = `http://localhost:3000`
|
|
10
|
+
const FRONTEND_ORIGIN = `http://localhost:5173`
|
|
11
|
+
|
|
12
|
+
const db = new DatabaseSync(`:memory:`)
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE todos (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
text TEXT NOT NULL,
|
|
17
|
+
done INTEGER DEFAULT 0
|
|
18
|
+
)
|
|
19
|
+
`)
|
|
20
|
+
|
|
21
|
+
const insertStmt = db.prepare(`INSERT INTO todos (text) VALUES (?)`)
|
|
22
|
+
const getAllStmt = db.prepare(`SELECT * FROM todos ORDER BY id`)
|
|
23
|
+
const getOneStmt = db.prepare(`SELECT * FROM todos WHERE id = ?`)
|
|
24
|
+
const updateStmt = db.prepare(`UPDATE todos SET done = ? WHERE id = ?`)
|
|
25
|
+
const deleteStmt = db.prepare(`DELETE FROM todos WHERE id = ?`)
|
|
26
|
+
|
|
27
|
+
const longLoadTimesAtom = atom<boolean>({
|
|
28
|
+
key: `longLoadTimes`,
|
|
29
|
+
default: false,
|
|
30
|
+
})
|
|
8
31
|
|
|
9
32
|
function parseCookies(cookieHeader = ``) {
|
|
10
33
|
return Object.fromEntries(
|
|
@@ -35,66 +58,132 @@ function sendJSON(
|
|
|
35
58
|
}
|
|
36
59
|
|
|
37
60
|
const server = http.createServer(async (req, res) => {
|
|
38
|
-
|
|
61
|
+
try {
|
|
62
|
+
const r = req as http.IncomingMessage & { url: string; method: string }
|
|
39
63
|
|
|
40
|
-
|
|
64
|
+
const { pathname, searchParams } = new URL(r.url, SERVER_ORIGIN)
|
|
41
65
|
|
|
42
|
-
|
|
66
|
+
console.log(r.method, pathname, [...searchParams.entries()])
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
const cookies = parseCookies(r.headers.cookie)
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"Access-Control-Allow-Origin": FRONTEND_ORIGIN,
|
|
49
|
-
"Access-Control-Allow-Credentials": `true`,
|
|
50
|
-
"Access-Control-Allow-Methods": `GET,POST,OPTIONS`,
|
|
51
|
-
"Access-Control-Allow-Headers": `Content-Type`,
|
|
52
|
-
})
|
|
53
|
-
return res.end()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
|
|
57
|
-
switch (pathname) {
|
|
58
|
-
case `/redirect`: {
|
|
59
|
-
const token = query.token
|
|
60
|
-
if (typeof token !== `string`) {
|
|
61
|
-
sendJSON(res, 400, { error: `Missing token` })
|
|
62
|
-
return
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
res.writeHead(302, {
|
|
66
|
-
"Set-Cookie": `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=10`,
|
|
70
|
+
if (req.method === `OPTIONS`) {
|
|
71
|
+
res.writeHead(204, {
|
|
67
72
|
"Access-Control-Allow-Origin": FRONTEND_ORIGIN,
|
|
68
73
|
"Access-Control-Allow-Credentials": `true`,
|
|
69
|
-
|
|
74
|
+
"Access-Control-Allow-Methods": `GET,POST,PUT,DELETE,OPTIONS`,
|
|
75
|
+
"Access-Control-Allow-Headers": `Content-Type`,
|
|
70
76
|
})
|
|
71
77
|
return res.end()
|
|
72
78
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
|
|
80
|
+
switch (pathname) {
|
|
81
|
+
case `/redirect`:
|
|
82
|
+
{
|
|
83
|
+
const token = searchParams.get(`token`)
|
|
84
|
+
if (typeof token !== `string`) {
|
|
85
|
+
sendJSON(res, 400, { error: `Missing token` })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
res.writeHead(302, {
|
|
89
|
+
"Set-Cookie": `auth_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=1000`,
|
|
90
|
+
"Access-Control-Allow-Origin": FRONTEND_ORIGIN,
|
|
91
|
+
"Access-Control-Allow-Credentials": `true`,
|
|
92
|
+
Location: FRONTEND_ORIGIN,
|
|
93
|
+
})
|
|
94
|
+
res.end()
|
|
95
|
+
}
|
|
96
|
+
return
|
|
97
|
+
case `/long-load-times`:
|
|
98
|
+
switch (r.method) {
|
|
99
|
+
case `GET`:
|
|
100
|
+
break
|
|
101
|
+
case `POST`:
|
|
102
|
+
setState(longLoadTimesAtom, (prev) => !prev)
|
|
103
|
+
break
|
|
104
|
+
default:
|
|
105
|
+
sendJSON(res, 405, { error: `Method not allowed` }, true)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
sendJSON(res, 200, getState(longLoadTimesAtom), true)
|
|
79
109
|
return
|
|
80
|
-
}
|
|
81
110
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
111
|
+
case `/todos`:
|
|
112
|
+
{
|
|
113
|
+
const longLoadTimes = getState(longLoadTimesAtom)
|
|
114
|
+
if (longLoadTimes) {
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
116
|
+
}
|
|
117
|
+
const token = cookies[`auth_token`]
|
|
118
|
+
if (!token) {
|
|
119
|
+
sendJSON(res, 401, { error: `Unauthenticated` }, true)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
switch (r.method) {
|
|
123
|
+
case `GET`:
|
|
124
|
+
{
|
|
125
|
+
const id = Number.parseInt(searchParams.get(`id`) as string, 10)
|
|
126
|
+
if (Number.isNaN(id)) {
|
|
127
|
+
sendJSON(res, 200, { todos: getAllStmt.all() }, true)
|
|
128
|
+
} else {
|
|
129
|
+
const todo = getOneStmt.get(id)
|
|
130
|
+
sendJSON(res, 200, { todo }, true)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return
|
|
134
|
+
case `POST`:
|
|
135
|
+
{
|
|
136
|
+
let body = ``
|
|
137
|
+
for await (const chunk of r) body += chunk
|
|
138
|
+
const { lastInsertRowid } = insertStmt.run(body)
|
|
139
|
+
const todo = getOneStmt.get(lastInsertRowid)
|
|
140
|
+
sendJSON(res, 200, { todo }, true)
|
|
141
|
+
}
|
|
142
|
+
return
|
|
143
|
+
case `PUT`:
|
|
144
|
+
{
|
|
145
|
+
const id = Number.parseInt(searchParams.get(`id`) as string, 10)
|
|
146
|
+
if (Number.isNaN(id)) {
|
|
147
|
+
sendJSON(res, 400, { error: `Invalid id` }, true)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
let body = ``
|
|
151
|
+
for await (const chunk of r) body += chunk
|
|
152
|
+
const nowChecked = JSON.parse(body)
|
|
153
|
+
updateStmt.run(nowChecked, id)
|
|
154
|
+
sendJSON(res, 200, { success: true }, true)
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
case `DELETE`:
|
|
158
|
+
{
|
|
159
|
+
const id = Number.parseInt(searchParams.get(`id`) as string, 10)
|
|
160
|
+
deleteStmt.run(id)
|
|
161
|
+
sendJSON(res, 200, { success: true }, true)
|
|
162
|
+
}
|
|
163
|
+
return
|
|
164
|
+
default:
|
|
165
|
+
sendJSON(res, 405, { error: `Method not allowed` }, true)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
case `/logout`:
|
|
170
|
+
{
|
|
171
|
+
res.writeHead(302, {
|
|
172
|
+
"Set-Cookie": `auth_token=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`,
|
|
173
|
+
"Access-Control-Allow-Origin": FRONTEND_ORIGIN,
|
|
174
|
+
"Access-Control-Allow-Credentials": `true`,
|
|
175
|
+
Location: FRONTEND_ORIGIN,
|
|
176
|
+
})
|
|
177
|
+
res.end()
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
default:
|
|
181
|
+
sendJSON(res, 404, { error: `Not found` })
|
|
94
182
|
}
|
|
183
|
+
} catch (thrown) {
|
|
184
|
+
console.error(thrown)
|
|
185
|
+
sendJSON(res, 500, null, true)
|
|
95
186
|
}
|
|
96
|
-
|
|
97
|
-
sendJSON(res, 404, { error: `Not found` })
|
|
98
187
|
})
|
|
99
188
|
|
|
100
189
|
// Start server
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atom.io/template-react-node-backend",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"react": "19.2.0",
|
|
10
10
|
"react-dom": "19.2.0",
|
|
11
|
-
"
|
|
11
|
+
"zod": "4.1.12",
|
|
12
|
+
"atom.io": "0.44.3"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
15
|
"@eslint/js": "9.39.1",
|
package/src/App.tsx
CHANGED
|
@@ -1,29 +1,128 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { Loadable } from "atom.io"
|
|
2
|
+
import {
|
|
3
|
+
atom,
|
|
4
|
+
atomFamily,
|
|
5
|
+
disposeState,
|
|
6
|
+
getState,
|
|
7
|
+
resetState,
|
|
8
|
+
selector,
|
|
9
|
+
setState,
|
|
10
|
+
} from "atom.io"
|
|
11
|
+
import { useI, useLoadable, useO } from "atom.io/react"
|
|
12
|
+
import { useCallback } from "react"
|
|
13
|
+
import z from "zod"
|
|
3
14
|
|
|
4
15
|
const SERVER_URL = `http://localhost:3000`
|
|
5
16
|
const AUTHENTICATOR_URL = `http://localhost:4000`
|
|
6
17
|
|
|
7
|
-
const
|
|
8
|
-
|
|
18
|
+
const todoSchema = z.object({
|
|
19
|
+
id: z.number(), // real keys are integers; virtual keys are made with Math.random()
|
|
20
|
+
text: z.string(),
|
|
21
|
+
done: z.union([z.literal(0), z.literal(1)]), // keeps things simple with sqlite
|
|
22
|
+
})
|
|
23
|
+
type Todo = z.infer<typeof todoSchema>
|
|
24
|
+
const todoKeysAtom = atom<Loadable<number[]>, Error>({
|
|
25
|
+
key: `todoKeys`,
|
|
9
26
|
default: async () => {
|
|
10
|
-
const url = new URL(`/
|
|
27
|
+
const url = new URL(`/todos`, SERVER_URL)
|
|
28
|
+
const response = await fetch(url, { credentials: `include` })
|
|
29
|
+
if (!response.ok) throw new Error(response.status.toString())
|
|
30
|
+
const json = (await response.json()) as { todos: unknown }
|
|
31
|
+
const todos = todoSchema.array().parse(json.todos)
|
|
32
|
+
for (const todo of todos) setState(todoAtoms, todo.id, todo)
|
|
33
|
+
return todos.map((todo) => todo.id)
|
|
34
|
+
},
|
|
35
|
+
catch: [Error],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const todoAtoms = atomFamily<Loadable<Todo>, number, Error>({
|
|
39
|
+
key: `todos`,
|
|
40
|
+
default: async (id) => {
|
|
41
|
+
const url = new URL(`/todos`, SERVER_URL)
|
|
42
|
+
url.searchParams.set(`id`, id.toString())
|
|
11
43
|
const response = await fetch(url, { credentials: `include` })
|
|
12
44
|
if (!response.ok) throw new Error(response.status.toString())
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return 0
|
|
45
|
+
const json = (await response.json()) as { todo: unknown }
|
|
46
|
+
const todo = todoSchema.parse(json.todo)
|
|
47
|
+
return todo
|
|
17
48
|
},
|
|
18
49
|
catch: [Error],
|
|
19
50
|
})
|
|
20
51
|
|
|
21
|
-
|
|
22
|
-
|
|
52
|
+
const todosStatsSelector = selector<
|
|
53
|
+
Loadable<{
|
|
54
|
+
total: number
|
|
55
|
+
done: number
|
|
56
|
+
}>
|
|
57
|
+
>({
|
|
58
|
+
key: `todosStats`,
|
|
59
|
+
get: async ({ get }) => {
|
|
60
|
+
const todoKeys = await get(todoKeysAtom)
|
|
61
|
+
if (Error.isError(todoKeys)) return { total: 0, done: 0 }
|
|
62
|
+
const total = todoKeys.length
|
|
63
|
+
const todos = await Promise.all(todoKeys.map((id) => get(todoAtoms, id)))
|
|
64
|
+
const done = todos.filter((todo) => !Error.isError(todo) && todo.done).length
|
|
65
|
+
return { total, done }
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
async function addTodo() {
|
|
70
|
+
const text = getState(newTodoTextAtom)
|
|
71
|
+
setState(newTodoTextAtom, ``)
|
|
72
|
+
const url = new URL(`todos`, SERVER_URL)
|
|
73
|
+
const tempId = Math.random()
|
|
74
|
+
setState(todoAtoms, tempId, { id: tempId, text, done: 0 })
|
|
75
|
+
setState(todoKeysAtom, async (loadable) => {
|
|
76
|
+
const prev = await loadable
|
|
77
|
+
if (Error.isError(prev)) return prev
|
|
78
|
+
return [...prev, tempId]
|
|
79
|
+
})
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
method: `POST`,
|
|
82
|
+
credentials: `include`,
|
|
83
|
+
body: text,
|
|
84
|
+
})
|
|
85
|
+
if (!res.ok) throw new Error(res.status.toString())
|
|
86
|
+
const { todo } = await res.json()
|
|
87
|
+
const realId = todo.id
|
|
88
|
+
setState(todoAtoms, realId, todo)
|
|
89
|
+
setState(todoKeysAtom, async (loadable) => {
|
|
90
|
+
const prev = await loadable
|
|
91
|
+
if (Error.isError(prev)) return prev
|
|
92
|
+
return prev.map((id) => (id === tempId ? realId : id))
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function deleteTodo() {
|
|
97
|
+
const todoKeys = await getState(todoKeysAtom)
|
|
98
|
+
if (Error.isError(todoKeys)) return
|
|
99
|
+
const todoKey = todoKeys.find((key) => !Error.isError(key))
|
|
100
|
+
if (todoKey === undefined) return
|
|
101
|
+
setState(todoKeysAtom, async (loadable) => {
|
|
102
|
+
const prev = await loadable
|
|
103
|
+
if (Error.isError(prev)) return prev
|
|
104
|
+
return prev.filter((id) => id !== todoKey)
|
|
105
|
+
})
|
|
106
|
+
disposeState(todoAtoms, todoKey)
|
|
107
|
+
const url = new URL(`/todos`, SERVER_URL)
|
|
108
|
+
url.searchParams.set(`id`, todoKey.toString())
|
|
109
|
+
await fetch(url, {
|
|
110
|
+
method: `DELETE`,
|
|
111
|
+
credentials: `include`,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const TODO_FALLBACK: Todo = { id: 0, text: ``, done: 0 }
|
|
116
|
+
const SKELETON_KEYS = Array.from({ length: 5 }).map(Math.random)
|
|
117
|
+
for (const key of SKELETON_KEYS) setState(todoAtoms, key, TODO_FALLBACK)
|
|
118
|
+
|
|
119
|
+
export function App(): React.JSX.Element {
|
|
120
|
+
const todoKeys = useLoadable(todoKeysAtom, SKELETON_KEYS)
|
|
121
|
+
const stats = useLoadable(todosStatsSelector, { total: 0, done: 0 })
|
|
23
122
|
|
|
24
123
|
return (
|
|
25
|
-
|
|
26
|
-
{error ? (
|
|
124
|
+
<>
|
|
125
|
+
{todoKeys.error ? (
|
|
27
126
|
<article className="takeover">
|
|
28
127
|
<main className="card">
|
|
29
128
|
<h1>Signed Out</h1>
|
|
@@ -39,9 +138,11 @@ function App(): React.JSX.Element {
|
|
|
39
138
|
</article>
|
|
40
139
|
) : null}
|
|
41
140
|
<header>
|
|
42
|
-
|
|
141
|
+
<LongLoadTimes />
|
|
142
|
+
<span className="spacer" />
|
|
143
|
+
{todoKeys.error ? (
|
|
43
144
|
<div className="pfp signed-out" />
|
|
44
|
-
) : loading ? (
|
|
145
|
+
) : todoKeys.loading ? (
|
|
45
146
|
<div className="pfp loading" />
|
|
46
147
|
) : (
|
|
47
148
|
<>
|
|
@@ -57,13 +158,119 @@ function App(): React.JSX.Element {
|
|
|
57
158
|
</>
|
|
58
159
|
)}
|
|
59
160
|
</header>
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
161
|
+
<main>
|
|
162
|
+
<header>
|
|
163
|
+
<h1>todo list</h1>
|
|
164
|
+
<span>
|
|
165
|
+
{stats.value.done}/{stats.value.total} items done
|
|
166
|
+
</span>
|
|
167
|
+
</header>
|
|
168
|
+
<main>
|
|
169
|
+
{todoKeys.value.map((todoKey) => (
|
|
170
|
+
<Todo key={todoKey} todoKey={todoKey} />
|
|
171
|
+
))}
|
|
172
|
+
<NewTodo />
|
|
173
|
+
</main>
|
|
174
|
+
</main>
|
|
175
|
+
</>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function Todo({ todoKey }: { todoKey: number }): React.JSX.Element {
|
|
180
|
+
const todo = useLoadable(todoAtoms, todoKey, TODO_FALLBACK)
|
|
181
|
+
const isSuspended = todo.loading || !Number.isInteger(todoKey)
|
|
182
|
+
const toggle = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
183
|
+
const url = new URL(`todos`, SERVER_URL)
|
|
184
|
+
url.searchParams.set(`id`, todo.value.id.toString())
|
|
185
|
+
const nowChecked = e.target.checked ? 1 : 0
|
|
186
|
+
setState(todoAtoms, todoKey, async (loadable) => {
|
|
187
|
+
const prev = await loadable
|
|
188
|
+
if (Error.isError(prev)) return prev
|
|
189
|
+
return { ...prev, done: nowChecked } satisfies Todo
|
|
190
|
+
})
|
|
191
|
+
await fetch(url, {
|
|
192
|
+
method: `PUT`,
|
|
193
|
+
credentials: `include`,
|
|
194
|
+
body: nowChecked.toString(),
|
|
195
|
+
})
|
|
196
|
+
resetState(todoAtoms, todoKey)
|
|
197
|
+
}, [])
|
|
198
|
+
return (
|
|
199
|
+
<div className={cn(`todo`, isSuspended && `loading`)}>
|
|
200
|
+
<input
|
|
201
|
+
type="checkbox"
|
|
202
|
+
checked={Boolean(todo.value.done)}
|
|
203
|
+
onChange={toggle}
|
|
204
|
+
disabled={isSuspended}
|
|
205
|
+
/>
|
|
206
|
+
<span>{todo.value.text}</span>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className="delete"
|
|
210
|
+
onClick={deleteTodo}
|
|
211
|
+
disabled={isSuspended}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const newTodoTextAtom = atom<string>({
|
|
218
|
+
key: `newTodo`,
|
|
219
|
+
default: ``,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
function NewTodo(): React.JSX.Element {
|
|
223
|
+
const text = useO(newTodoTextAtom)
|
|
224
|
+
const setText = useI(newTodoTextAtom)
|
|
225
|
+
const change = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
226
|
+
setText(e.target.value)
|
|
227
|
+
}, [])
|
|
228
|
+
const submit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
|
229
|
+
e.preventDefault()
|
|
230
|
+
void addTodo()
|
|
231
|
+
}, [])
|
|
232
|
+
return (
|
|
233
|
+
<form className="todo" onSubmit={submit}>
|
|
234
|
+
<input type="checkbox" checked={false} disabled />
|
|
235
|
+
<input
|
|
236
|
+
type="text"
|
|
237
|
+
contentEditable
|
|
238
|
+
suppressContentEditableWarning
|
|
239
|
+
value={text}
|
|
240
|
+
onChange={change}
|
|
241
|
+
/>
|
|
242
|
+
<button type="submit">Add Todo</button>
|
|
243
|
+
</form>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const longLoadTimesAtom = atom<Loadable<boolean>>({
|
|
248
|
+
key: `longLoadTimes`,
|
|
249
|
+
default: () =>
|
|
250
|
+
fetch(new URL(`/long-load-times`, SERVER_URL), {
|
|
251
|
+
credentials: `include`,
|
|
252
|
+
}).then(async (res) => res.json()),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
function LongLoadTimes(): React.JSX.Element {
|
|
256
|
+
const longLoadTimes = useLoadable(longLoadTimesAtom, false)
|
|
257
|
+
const toggle = useCallback(async () => {
|
|
258
|
+
const url = new URL(`/long-load-times`, SERVER_URL)
|
|
259
|
+
const res = await fetch(url, {
|
|
260
|
+
method: `POST`,
|
|
261
|
+
credentials: `include`,
|
|
262
|
+
})
|
|
263
|
+
const newState = await res.json()
|
|
264
|
+
setState(longLoadTimesAtom, newState)
|
|
265
|
+
}, [longLoadTimes])
|
|
266
|
+
return (
|
|
267
|
+
<div className="long-load-times">
|
|
268
|
+
<label>
|
|
269
|
+
<input type="checkbox" checked={longLoadTimes.value} onChange={toggle} />
|
|
270
|
+
Enable long load times
|
|
271
|
+
</label>
|
|
272
|
+
</div>
|
|
66
273
|
)
|
|
67
274
|
}
|
|
68
275
|
|
|
69
|
-
|
|
276
|
+
const cn = (...c: (boolean | string)[]) => c.filter(Boolean).join(` `)
|