create-voltapp 0.1.0 → 0.2.0
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/bin/create-voltapp.js +4 -3
- package/package.json +2 -1
- package/templates/fullstack/README.md +12 -7
- package/templates/fullstack/package.json +0 -2
- package/templates/fullstack/server.ts +4 -5
- package/templates/fullstack/src/App.tsx +7 -77
- package/templates/fullstack/src/Welcome.tsx +28 -0
- package/templates/fullstack/src/entry-client.tsx +1 -4
- package/templates/fullstack/src/entry-server.tsx +1 -4
- package/templates/fullstack/src/styles.css +33 -48
- package/templates/fullstack/vite.config.ts +1 -1
- package/templates/minimal/README.md +2 -2
- package/templates/minimal/src/main.tsx +17 -64
- package/templates/minimal/src/styles.css +26 -60
- package/templates/todo/README.md +28 -0
- package/templates/todo/_gitignore +3 -0
- package/templates/todo/index.html +13 -0
- package/templates/todo/package.json +26 -0
- package/templates/todo/server.ts +66 -0
- package/templates/todo/src/App.tsx +101 -0
- package/templates/todo/src/entry-client.tsx +11 -0
- package/templates/todo/src/entry-server.tsx +14 -0
- package/templates/todo/src/styles.css +80 -0
- package/templates/todo/tsconfig.json +17 -0
- package/templates/todo/vite.config.ts +9 -0
- /package/templates/{fullstack → todo}/src/todos.server.ts +0 -0
package/bin/create-voltapp.js
CHANGED
|
@@ -18,8 +18,9 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
18
18
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
19
19
|
const TEMPLATES = {
|
|
20
20
|
minimal: "Client app with Vite: signals, JSX, For/Show. No server.",
|
|
21
|
-
fullstack: "SSR + Vite dev server, router, server functions
|
|
21
|
+
fullstack: "SSR + Vite dev server with HMR, typed router, server functions. The default starter.",
|
|
22
22
|
api: "Server only: Web-standard app with validation, sessions and server functions.",
|
|
23
|
+
todo: "Full-stack demo: live queries, optimistic mutations, offline queue, cross-tab sync.",
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const args = process.argv.slice(2);
|
|
@@ -45,7 +46,7 @@ try {
|
|
|
45
46
|
console.log(`\n${bold("Template")}`);
|
|
46
47
|
const names = Object.keys(TEMPLATES);
|
|
47
48
|
names.forEach((n, i) => console.log(` ${accent(String(i + 1))} ${n.padEnd(10)} ${dim(TEMPLATES[n])}`));
|
|
48
|
-
const answer = (await rl.question(`\nChoose ${dim("(1-
|
|
49
|
+
const answer = (await rl.question(`\nChoose ${dim("(1-4, default 2)")}: `)).trim();
|
|
49
50
|
template = names[Number(answer) - 1] ?? (names.includes(answer) ? answer : "fullstack");
|
|
50
51
|
}
|
|
51
52
|
} finally {
|
|
@@ -77,7 +78,7 @@ const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirect
|
|
|
77
78
|
for (const file of walk(target)) {
|
|
78
79
|
if (!/\.(json|ts|tsx|html|md|css)$/.test(file)) continue;
|
|
79
80
|
const text = readFileSync(file, "utf8");
|
|
80
|
-
const next = text.replaceAll("{{name}}", name).replaceAll("{{version}}", pkg.version);
|
|
81
|
+
const next = text.replaceAll("{{name}}", name).replaceAll("{{version}}", pkg.frameworkVersion ?? pkg.version);
|
|
81
82
|
if (next !== text) writeFileSync(file, next);
|
|
82
83
|
}
|
|
83
84
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# {{name}}
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A Volt app with server-side rendering, a Vite dev server with HMR and typed routing.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm install
|
|
@@ -13,16 +13,21 @@ npm start # production server
|
|
|
13
13
|
|
|
14
14
|
```
|
|
15
15
|
server.ts HTTP server (dev: Vite middleware; prod: dist/)
|
|
16
|
-
src/
|
|
17
|
-
src/
|
|
16
|
+
src/App.tsx routes: / and /about
|
|
17
|
+
src/Welcome.tsx starter content — delete it when you begin
|
|
18
18
|
src/entry-client.tsx render() in the browser
|
|
19
19
|
src/entry-server.tsx renderToString() for SSR
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Add server code
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
Create `src/hello.server.ts`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { serverFn } from "@prouzbek/server/fn";
|
|
28
|
+
export const greet = serverFn(async (name: string) => `Hello, ${name}!`);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Then call `greet("world")` from any component. The file never ships to the browser; the client gets a typed RPC stub. For live queries with automatic invalidation, add `@prouzbek/data` or start from the `todo` template.
|
|
27
32
|
|
|
28
33
|
Requires Node 24 or newer (TypeScript runs natively).
|
|
@@ -10,10 +10,8 @@
|
|
|
10
10
|
"check": "tsc --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@prouzbek/data": "{{version}}",
|
|
14
13
|
"@prouzbek/dom": "{{version}}",
|
|
15
14
|
"@prouzbek/router": "{{version}}",
|
|
16
|
-
"@prouzbek/serialize": "{{version}}",
|
|
17
15
|
"@prouzbek/server": "{{version}}",
|
|
18
16
|
"@prouzbek/signals": "{{version}}"
|
|
19
17
|
},
|
|
@@ -4,22 +4,21 @@
|
|
|
4
4
|
* npm run build → client + server bundles in dist/
|
|
5
5
|
* npm start → serves dist/ with SSR
|
|
6
6
|
*
|
|
7
|
-
* Server functions (`*.server.ts`)
|
|
7
|
+
* Server functions (`*.server.ts`) are plain middleware.
|
|
8
8
|
*/
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { dirname, extname, join } from "node:path";
|
|
13
13
|
import { createApp, logger } from "@prouzbek/server";
|
|
14
14
|
import { serverFunctions } from "@prouzbek/server/fn";
|
|
15
|
-
import { live } from "@prouzbek/data/server";
|
|
16
15
|
import { toRequest, sendResponse } from "@prouzbek/server/node";
|
|
17
16
|
|
|
18
17
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
18
|
const isProd = process.argv.includes("--prod") || process.env.NODE_ENV === "production";
|
|
20
19
|
const port = Number(process.env.PORT ?? 5173);
|
|
21
20
|
|
|
22
|
-
const app = createApp().use(logger()).use(
|
|
21
|
+
const app = createApp().use(logger()).use(serverFunctions());
|
|
23
22
|
|
|
24
23
|
let vite: import("vite").ViteDevServer | undefined;
|
|
25
24
|
let template: string;
|
|
@@ -31,7 +30,7 @@ if (!isProd) {
|
|
|
31
30
|
template = readFileSync(join(here, "index.html"), "utf8");
|
|
32
31
|
} else {
|
|
33
32
|
template = readFileSync(join(here, "dist/client/index.html"), "utf8");
|
|
34
|
-
renderPage = (await import(join(here, "dist/server/entry-server.js").
|
|
33
|
+
renderPage = (await import(pathToFileURL(join(here, "dist/server/entry-server.js")).href)).render;
|
|
35
34
|
}
|
|
36
35
|
|
|
37
36
|
app.get("*", async (c) => {
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { route, Link, Outlet, useSearchParams, type RouteDef } from "@prouzbek/router";
|
|
4
|
-
import { useQuery, useMutation } from "@prouzbek/data/client";
|
|
5
|
-
import { listTodos, addTodo, toggleTodo, removeTodo, type Filter, type Todo } from "./todos.server.ts";
|
|
1
|
+
import { route, Link, Outlet, type RouteDef } from "@prouzbek/router";
|
|
2
|
+
import { Welcome } from "./Welcome.tsx";
|
|
6
3
|
|
|
7
4
|
function Layout() {
|
|
8
5
|
return (
|
|
9
6
|
<>
|
|
10
7
|
<nav>
|
|
11
|
-
<Link href="/" activeClass="active">
|
|
8
|
+
<Link href="/" activeClass="active">Home</Link>
|
|
12
9
|
<Link href="/about" activeClass="active">About</Link>
|
|
13
10
|
</nav>
|
|
14
11
|
<Outlet />
|
|
@@ -16,79 +13,12 @@ function Layout() {
|
|
|
16
13
|
);
|
|
17
14
|
}
|
|
18
15
|
|
|
19
|
-
function Todos() {
|
|
20
|
-
const [params, setParams] = useSearchParams();
|
|
21
|
-
const filter = () => (params.value.get("filter") as Filter | null) ?? "all";
|
|
22
|
-
const list = useQuery(listTodos, filter);
|
|
23
|
-
const draft = signal("");
|
|
24
|
-
|
|
25
|
-
const add = useMutation(addTodo, {
|
|
26
|
-
optimistic: (cache, title) =>
|
|
27
|
-
cache.updateAll(listTodos, (cur) => [...cur, { id: -Date.now(), title, done: false, createdAt: new Date() }]),
|
|
28
|
-
});
|
|
29
|
-
const toggle = useMutation(toggleTodo, {
|
|
30
|
-
optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.map((t) => (t.id === id ? { ...t, done: !t.done } : t))),
|
|
31
|
-
});
|
|
32
|
-
const remove = useMutation(removeTodo, {
|
|
33
|
-
optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.filter((t) => t.id !== id)),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
return (
|
|
37
|
-
<section>
|
|
38
|
-
<h1>⚡ {{name}}</h1>
|
|
39
|
-
<p class="muted">
|
|
40
|
-
Server-rendered, live-synced todos. Open this page in two tabs.
|
|
41
|
-
<Show when={add.queued.value > 0}> · {add.queued.value} queued offline</Show>
|
|
42
|
-
</p>
|
|
43
|
-
<p class="filters">
|
|
44
|
-
{(["all", "open", "done"] as Filter[]).map((f) => (
|
|
45
|
-
<button type="button" disabled={filter() === f} onClick={() => void setParams({ filter: f === "all" ? null : f })}>
|
|
46
|
-
{f}
|
|
47
|
-
</button>
|
|
48
|
-
))}
|
|
49
|
-
</p>
|
|
50
|
-
<form
|
|
51
|
-
onSubmit={(e) => {
|
|
52
|
-
e.preventDefault();
|
|
53
|
-
const title = draft.value;
|
|
54
|
-
draft.value = "";
|
|
55
|
-
void add.run(title).catch(() => {});
|
|
56
|
-
}}
|
|
57
|
-
>
|
|
58
|
-
<input value={draft} onInput={(e) => (draft.value = e.currentTarget.value)} placeholder="What needs doing?" />
|
|
59
|
-
<button type="submit" disabled={!draft.value.trim()}>Add</button>
|
|
60
|
-
</form>
|
|
61
|
-
<Show when={add.error.value}>{(err) => <p class="muted">⚠ {String((err.value as Error).message)}</p>}</Show>
|
|
62
|
-
<Show when={list.data.value} fallback={<p class="muted">Loading…</p>}>
|
|
63
|
-
<ul>
|
|
64
|
-
<For each={list.data.value ?? []} fallback={<li class="muted">Nothing here.</li>}>
|
|
65
|
-
{(todo: Todo) => (
|
|
66
|
-
<li classList={{ done: todo.done, pending: todo.id < 0 }}>
|
|
67
|
-
<label>
|
|
68
|
-
<input type="checkbox" checked={todo.done} disabled={todo.id < 0} onChange={() => void toggle.run(todo.id)} />
|
|
69
|
-
<span>{todo.title}</span>
|
|
70
|
-
</label>
|
|
71
|
-
<button class="link" type="button" disabled={todo.id < 0} onClick={() => void remove.run(todo.id)}>
|
|
72
|
-
remove
|
|
73
|
-
</button>
|
|
74
|
-
</li>
|
|
75
|
-
)}
|
|
76
|
-
</For>
|
|
77
|
-
</ul>
|
|
78
|
-
</Show>
|
|
79
|
-
</section>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
16
|
function About() {
|
|
84
17
|
return (
|
|
85
|
-
<
|
|
18
|
+
<main>
|
|
86
19
|
<h1>About</h1>
|
|
87
|
-
<p>
|
|
88
|
-
|
|
89
|
-
every query refetches automatically when a mutation writes the source it read.
|
|
90
|
-
</p>
|
|
91
|
-
</section>
|
|
20
|
+
<p>Routes live in <code>src/App.tsx</code>. Each page is a component; the server renders the first one and the browser takes over from there.</p>
|
|
21
|
+
</main>
|
|
92
22
|
);
|
|
93
23
|
}
|
|
94
24
|
|
|
@@ -96,6 +26,6 @@ export const routes: RouteDef[] = [
|
|
|
96
26
|
route({
|
|
97
27
|
path: "/",
|
|
98
28
|
component: Layout,
|
|
99
|
-
children: [route({ path: "", component:
|
|
29
|
+
children: [route({ path: "", component: Welcome }), route({ path: "about", component: About })],
|
|
100
30
|
}),
|
|
101
31
|
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { signal } from "@prouzbek/signals";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Starter content. Delete this file and its route in App.tsx when you begin.
|
|
5
|
+
*/
|
|
6
|
+
export function Welcome() {
|
|
7
|
+
const count = signal(0);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<main class="welcome">
|
|
11
|
+
<a href="https://www.npmjs.com/package/create-voltapp" target="_blank" rel="noreferrer">
|
|
12
|
+
<svg class="logo" viewBox="0 0 24 24" aria-hidden="true">
|
|
13
|
+
<path d="M13.5 2 4 14h7l-1.5 8L20 10h-7z" />
|
|
14
|
+
</svg>
|
|
15
|
+
</a>
|
|
16
|
+
<h1>{{name}}</h1>
|
|
17
|
+
<div class="card">
|
|
18
|
+
<button type="button" onClick={() => count.value++}>
|
|
19
|
+
count is {count}
|
|
20
|
+
</button>
|
|
21
|
+
<p>
|
|
22
|
+
Edit <code>src/Welcome.tsx</code> and save to test HMR
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
<p class="muted">This page was rendered on the server, then hydrated. Add a file ending in <code>.server.ts</code> to call server code as typed functions.</p>
|
|
26
|
+
</main>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { render } from "@prouzbek/dom";
|
|
2
2
|
import { createRouter, RouterProvider } from "@prouzbek/router";
|
|
3
|
-
import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
|
|
4
3
|
import { routes } from "./App.tsx";
|
|
5
4
|
|
|
6
|
-
const client = createQueryClient({ storage: localStorage });
|
|
7
5
|
const router = createRouter(routes);
|
|
8
|
-
|
|
9
6
|
const root = document.getElementById("app")!;
|
|
10
7
|
root.innerHTML = "";
|
|
11
|
-
render(() =>
|
|
8
|
+
render(() => <RouterProvider router={router} />, root);
|
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { renderToString } from "@prouzbek/dom";
|
|
2
2
|
import { createRouter, RouterProvider } from "@prouzbek/router";
|
|
3
|
-
import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
|
|
4
3
|
import { routes } from "./App.tsx";
|
|
5
4
|
|
|
6
5
|
export async function render(url: string, request: Request): Promise<string> {
|
|
7
|
-
const client = createQueryClient({ liveUrl: false });
|
|
8
6
|
const router = createRouter(routes, { url, request });
|
|
9
7
|
await router.ready;
|
|
10
|
-
const html = renderToString(() =>
|
|
11
|
-
client.dispose();
|
|
8
|
+
const html = renderToString(() => <RouterProvider router={router} />);
|
|
12
9
|
router.dispose();
|
|
13
10
|
return html;
|
|
14
11
|
}
|
|
@@ -1,80 +1,65 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
color-scheme: light dark;
|
|
3
3
|
font: 16px/1.5 system-ui, sans-serif;
|
|
4
|
+
--accent: #8b8bff;
|
|
4
5
|
--border: color-mix(in srgb, currentColor 18%, transparent);
|
|
5
6
|
}
|
|
6
7
|
body {
|
|
7
|
-
max-width:
|
|
8
|
-
margin:
|
|
9
|
-
padding:
|
|
8
|
+
max-width: 640px;
|
|
9
|
+
margin: 0 auto;
|
|
10
|
+
padding: 1.5rem 1rem;
|
|
10
11
|
}
|
|
11
12
|
nav {
|
|
12
13
|
display: flex;
|
|
13
14
|
gap: 1rem;
|
|
14
|
-
margin-bottom: 1.5rem;
|
|
15
15
|
}
|
|
16
16
|
nav a {
|
|
17
17
|
color: inherit;
|
|
18
18
|
opacity: 0.7;
|
|
19
|
+
text-decoration: none;
|
|
19
20
|
}
|
|
20
21
|
nav a.active {
|
|
21
22
|
opacity: 1;
|
|
22
23
|
font-weight: 600;
|
|
23
|
-
text-decoration: underline;
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
main {
|
|
26
|
+
padding-top: 3rem;
|
|
27
27
|
}
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
font-size: 0.9rem;
|
|
28
|
+
.welcome {
|
|
29
|
+
text-align: center;
|
|
31
30
|
}
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
.logo {
|
|
32
|
+
width: 5rem;
|
|
33
|
+
height: 5rem;
|
|
34
|
+
fill: var(--accent);
|
|
35
|
+
filter: drop-shadow(0 0 1.5rem color-mix(in srgb, var(--accent) 60%, transparent));
|
|
37
36
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
h1 {
|
|
38
|
+
font-size: 2.5rem;
|
|
39
|
+
margin: 0.5rem 0 1.5rem;
|
|
40
|
+
}
|
|
41
|
+
.card {
|
|
42
|
+
display: grid;
|
|
43
|
+
gap: 1rem;
|
|
44
|
+
justify-items: center;
|
|
45
|
+
padding: 2rem;
|
|
45
46
|
}
|
|
46
47
|
button {
|
|
47
|
-
padding: 0.
|
|
48
|
+
padding: 0.6rem 1.2rem;
|
|
49
|
+
font: inherit;
|
|
48
50
|
border: 1px solid var(--border);
|
|
49
|
-
border-radius:
|
|
51
|
+
border-radius: 8px;
|
|
50
52
|
background: transparent;
|
|
51
53
|
color: inherit;
|
|
52
54
|
cursor: pointer;
|
|
53
55
|
}
|
|
54
|
-
button:
|
|
55
|
-
|
|
56
|
-
cursor: not-allowed;
|
|
57
|
-
}
|
|
58
|
-
button.link {
|
|
59
|
-
border: 0;
|
|
60
|
-
opacity: 0.6;
|
|
61
|
-
font-size: 0.85rem;
|
|
56
|
+
button:hover {
|
|
57
|
+
border-color: var(--accent);
|
|
62
58
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
padding: 0;
|
|
66
|
-
margin: 0;
|
|
67
|
-
}
|
|
68
|
-
li {
|
|
69
|
-
display: flex;
|
|
70
|
-
justify-content: space-between;
|
|
71
|
-
align-items: center;
|
|
72
|
-
padding: 0.3rem 0;
|
|
59
|
+
code {
|
|
60
|
+
font-size: 0.9em;
|
|
73
61
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
li.pending {
|
|
79
|
-
opacity: 0.5;
|
|
62
|
+
.muted {
|
|
63
|
+
opacity: 0.65;
|
|
64
|
+
font-size: 0.9rem;
|
|
80
65
|
}
|
|
@@ -8,6 +8,6 @@ npm run dev # http://localhost:5173
|
|
|
8
8
|
npm run build # production build in dist/
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
- `src/main.tsx` is the whole app. Components run once; signals drive updates.
|
|
11
|
+
- `src/main.tsx` is the whole app. Components run once; signals drive updates. Replace the `App` component to begin.
|
|
12
12
|
- `For`, `Show` and friends come from `@prouzbek/dom`; `signal`/`computed` from `@prouzbek/signals`.
|
|
13
|
-
- Need
|
|
13
|
+
- Need SSR and routing? Scaffold the `fullstack` template. Want to see live queries? Try `todo`: `npm create voltapp@latest -- --template todo`.
|
|
@@ -1,74 +1,27 @@
|
|
|
1
|
-
import { signal
|
|
2
|
-
import { render
|
|
1
|
+
import { signal } from "@prouzbek/signals";
|
|
2
|
+
import { render } from "@prouzbek/dom";
|
|
3
3
|
import "./styles.css";
|
|
4
4
|
|
|
5
|
-
type Item = { id: number; text: string; done: boolean };
|
|
6
|
-
|
|
7
5
|
function App() {
|
|
8
6
|
const count = signal(0);
|
|
9
|
-
const double = computed(() => count.value * 2);
|
|
10
|
-
|
|
11
|
-
const items = signal<Item[]>([
|
|
12
|
-
{ id: 1, text: "Read the docs", done: false },
|
|
13
|
-
{ id: 2, text: "Build something", done: false },
|
|
14
|
-
]);
|
|
15
|
-
const draft = signal("");
|
|
16
|
-
let nextId = 3;
|
|
17
|
-
|
|
18
|
-
const add = (e: Event) => {
|
|
19
|
-
e.preventDefault();
|
|
20
|
-
const text = draft.value.trim();
|
|
21
|
-
if (!text) return;
|
|
22
|
-
items.value = [...items.value, { id: nextId++, text, done: false }];
|
|
23
|
-
draft.value = "";
|
|
24
|
-
};
|
|
25
7
|
|
|
26
8
|
return (
|
|
27
9
|
<main>
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
</
|
|
42
|
-
|
|
43
|
-
<section class="card">
|
|
44
|
-
<h2>List</h2>
|
|
45
|
-
<form class="row" onSubmit={add}>
|
|
46
|
-
<input value={draft} onInput={(e) => (draft.value = e.currentTarget.value)} placeholder="Add an item" />
|
|
47
|
-
<button type="submit" disabled={!draft.value.trim()}>Add</button>
|
|
48
|
-
</form>
|
|
49
|
-
<ul>
|
|
50
|
-
<For each={items.value} fallback={<li class="muted">Nothing here yet.</li>}>
|
|
51
|
-
{(item) => (
|
|
52
|
-
<li classList={{ done: item.done }}>
|
|
53
|
-
<label>
|
|
54
|
-
<input
|
|
55
|
-
type="checkbox"
|
|
56
|
-
checked={item.done}
|
|
57
|
-
onChange={() => (items.value = items.value.map((i) => (i.id === item.id ? { ...i, done: !i.done } : i)))}
|
|
58
|
-
/>
|
|
59
|
-
{item.text}
|
|
60
|
-
</label>
|
|
61
|
-
<button class="link" onClick={() => (items.value = items.value.filter((i) => i.id !== item.id))}>
|
|
62
|
-
remove
|
|
63
|
-
</button>
|
|
64
|
-
</li>
|
|
65
|
-
)}
|
|
66
|
-
</For>
|
|
67
|
-
</ul>
|
|
68
|
-
<Show when={items.value.some((i) => i.done)}>
|
|
69
|
-
<p class="muted">{items.value.filter((i) => i.done).length} done</p>
|
|
70
|
-
</Show>
|
|
71
|
-
</section>
|
|
10
|
+
<a href="https://www.npmjs.com/package/create-voltapp" target="_blank" rel="noreferrer">
|
|
11
|
+
<svg class="logo" viewBox="0 0 24 24" aria-hidden="true">
|
|
12
|
+
<path d="M13.5 2 4 14h7l-1.5 8L20 10h-7z" />
|
|
13
|
+
</svg>
|
|
14
|
+
</a>
|
|
15
|
+
<h1>{{name}}</h1>
|
|
16
|
+
<div class="card">
|
|
17
|
+
<button type="button" onClick={() => count.value++}>
|
|
18
|
+
count is {count}
|
|
19
|
+
</button>
|
|
20
|
+
<p>
|
|
21
|
+
Edit <code>src/main.tsx</code> and save to test HMR
|
|
22
|
+
</p>
|
|
23
|
+
</div>
|
|
24
|
+
<p class="muted">Components run once. <code>count</code> is a signal bound to exactly one text node.</p>
|
|
72
25
|
</main>
|
|
73
26
|
);
|
|
74
27
|
}
|
|
@@ -1,84 +1,50 @@
|
|
|
1
1
|
:root {
|
|
2
2
|
color-scheme: light dark;
|
|
3
3
|
font: 16px/1.5 system-ui, sans-serif;
|
|
4
|
+
--accent: #8b8bff;
|
|
4
5
|
--border: color-mix(in srgb, currentColor 18%, transparent);
|
|
5
6
|
}
|
|
6
7
|
body {
|
|
7
8
|
margin: 0;
|
|
8
9
|
}
|
|
9
10
|
main {
|
|
10
|
-
max-width:
|
|
11
|
-
margin:
|
|
12
|
-
padding:
|
|
13
|
-
|
|
14
|
-
gap: 1rem;
|
|
11
|
+
max-width: 640px;
|
|
12
|
+
margin: 0 auto;
|
|
13
|
+
padding: 4rem 1rem;
|
|
14
|
+
text-align: center;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
.logo {
|
|
17
|
+
width: 5rem;
|
|
18
|
+
height: 5rem;
|
|
19
|
+
fill: var(--accent);
|
|
20
|
+
filter: drop-shadow(0 0 1.5rem color-mix(in srgb, var(--accent) 60%, transparent));
|
|
20
21
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
h1 {
|
|
23
|
+
font-size: 2.5rem;
|
|
24
|
+
margin: 0.5rem 0 1.5rem;
|
|
24
25
|
}
|
|
25
26
|
.card {
|
|
26
|
-
border: 1px solid var(--border);
|
|
27
|
-
border-radius: 10px;
|
|
28
|
-
padding: 1rem;
|
|
29
27
|
display: grid;
|
|
30
|
-
gap:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
display: flex;
|
|
34
|
-
gap: 0.5rem;
|
|
35
|
-
align-items: center;
|
|
36
|
-
}
|
|
37
|
-
output {
|
|
38
|
-
min-width: 3ch;
|
|
39
|
-
text-align: center;
|
|
40
|
-
font-size: 1.5rem;
|
|
41
|
-
font-weight: 600;
|
|
42
|
-
}
|
|
43
|
-
input[type="text"],
|
|
44
|
-
input:not([type]) {
|
|
45
|
-
flex: 1;
|
|
46
|
-
padding: 0.5rem;
|
|
47
|
-
border: 1px solid var(--border);
|
|
48
|
-
border-radius: 6px;
|
|
49
|
-
background: transparent;
|
|
50
|
-
color: inherit;
|
|
28
|
+
gap: 1rem;
|
|
29
|
+
justify-items: center;
|
|
30
|
+
padding: 2rem;
|
|
51
31
|
}
|
|
52
32
|
button {
|
|
53
|
-
padding: 0.
|
|
33
|
+
padding: 0.6rem 1.2rem;
|
|
34
|
+
font: inherit;
|
|
54
35
|
border: 1px solid var(--border);
|
|
55
|
-
border-radius:
|
|
36
|
+
border-radius: 8px;
|
|
56
37
|
background: transparent;
|
|
57
38
|
color: inherit;
|
|
58
39
|
cursor: pointer;
|
|
59
40
|
}
|
|
60
|
-
button:
|
|
61
|
-
|
|
62
|
-
cursor: not-allowed;
|
|
41
|
+
button:hover {
|
|
42
|
+
border-color: var(--accent);
|
|
63
43
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
opacity: 0.6;
|
|
67
|
-
font-size: 0.85rem;
|
|
44
|
+
code {
|
|
45
|
+
font-size: 0.9em;
|
|
68
46
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
padding: 0;
|
|
73
|
-
display: grid;
|
|
74
|
-
gap: 0.25rem;
|
|
75
|
-
}
|
|
76
|
-
li {
|
|
77
|
-
display: flex;
|
|
78
|
-
justify-content: space-between;
|
|
79
|
-
align-items: center;
|
|
80
|
-
}
|
|
81
|
-
li.done label {
|
|
82
|
-
text-decoration: line-through;
|
|
83
|
-
opacity: 0.6;
|
|
47
|
+
.muted {
|
|
48
|
+
opacity: 0.65;
|
|
49
|
+
font-size: 0.9rem;
|
|
84
50
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A full-stack Volt demo: server-side rendering, a Vite dev server with HMR, typed routing, server functions and live queries with automatic invalidation.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm run dev # http://localhost:5173
|
|
8
|
+
npm run build # client + server bundles in dist/
|
|
9
|
+
npm start # production server
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Layout
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
server.ts HTTP server (dev: Vite middleware; prod: dist/)
|
|
16
|
+
src/todos.server.ts queries and mutations — never shipped to the browser
|
|
17
|
+
src/App.tsx routes and components
|
|
18
|
+
src/entry-client.tsx render() in the browser
|
|
19
|
+
src/entry-server.tsx renderToString() for SSR
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## How data flows
|
|
23
|
+
|
|
24
|
+
1. `listTodos` is a `query`: while it runs, `todos.read()` records a dependency.
|
|
25
|
+
2. `addTodo` is a `mutation`: `todos.write()` records a change.
|
|
26
|
+
3. The client's `useQuery(listTodos, filter)` refetches whenever a mutation writes `todos`, in every open tab, pushed over `/_volt/live`.
|
|
27
|
+
|
|
28
|
+
Requires Node 24 or newer (TypeScript runs natively).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
<link rel="stylesheet" href="/src/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"><!--ssr-outlet--></div>
|
|
11
|
+
<script type="module" src="/src/entry-client.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "node server.ts",
|
|
8
|
+
"build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server",
|
|
9
|
+
"start": "node server.ts --prod",
|
|
10
|
+
"check": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@prouzbek/data": "{{version}}",
|
|
14
|
+
"@prouzbek/dom": "{{version}}",
|
|
15
|
+
"@prouzbek/router": "{{version}}",
|
|
16
|
+
"@prouzbek/serialize": "{{version}}",
|
|
17
|
+
"@prouzbek/server": "{{version}}",
|
|
18
|
+
"@prouzbek/signals": "{{version}}"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^24.0.0",
|
|
22
|
+
"@prouzbek/vite": "{{version}}",
|
|
23
|
+
"typescript": "^5.9.0",
|
|
24
|
+
"vite": "^8.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One server for development and production.
|
|
3
|
+
* npm run dev → Vite in middleware mode (HMR) + SSR compiled on the fly
|
|
4
|
+
* npm run build → client + server bundles in dist/
|
|
5
|
+
* npm start → serves dist/ with SSR
|
|
6
|
+
*
|
|
7
|
+
* Server functions (`*.server.ts`) and the live channel are plain middleware.
|
|
8
|
+
*/
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
import { dirname, extname, join } from "node:path";
|
|
13
|
+
import { createApp, logger } from "@prouzbek/server";
|
|
14
|
+
import { serverFunctions } from "@prouzbek/server/fn";
|
|
15
|
+
import { live } from "@prouzbek/data/server";
|
|
16
|
+
import { toRequest, sendResponse } from "@prouzbek/server/node";
|
|
17
|
+
|
|
18
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const isProd = process.argv.includes("--prod") || process.env.NODE_ENV === "production";
|
|
20
|
+
const port = Number(process.env.PORT ?? 5173);
|
|
21
|
+
|
|
22
|
+
const app = createApp().use(logger()).use(live()).use(serverFunctions());
|
|
23
|
+
|
|
24
|
+
let vite: import("vite").ViteDevServer | undefined;
|
|
25
|
+
let template: string;
|
|
26
|
+
let renderPage: (url: string, request: Request) => Promise<string>;
|
|
27
|
+
|
|
28
|
+
if (!isProd) {
|
|
29
|
+
const { createServer: createVite } = await import("vite");
|
|
30
|
+
vite = await createVite({ root: here, server: { middlewareMode: true }, appType: "custom" });
|
|
31
|
+
template = readFileSync(join(here, "index.html"), "utf8");
|
|
32
|
+
} else {
|
|
33
|
+
template = readFileSync(join(here, "dist/client/index.html"), "utf8");
|
|
34
|
+
renderPage = (await import(pathToFileURL(join(here, "dist/server/entry-server.js")).href)).render;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
app.get("*", async (c) => {
|
|
38
|
+
const url = c.url.pathname + c.url.search;
|
|
39
|
+
let html = template;
|
|
40
|
+
if (vite) {
|
|
41
|
+
html = await vite.transformIndexHtml(url, template);
|
|
42
|
+
renderPage = (await vite.ssrLoadModule("/src/entry-server.tsx")).render;
|
|
43
|
+
}
|
|
44
|
+
return c.html(html.replace("<!--ssr-outlet-->", await renderPage(url, c.request)));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const types: Record<string, string> = { ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2" };
|
|
48
|
+
|
|
49
|
+
createServer((req, res) => {
|
|
50
|
+
const handle = () =>
|
|
51
|
+
app
|
|
52
|
+
.fetch(toRequest(req))
|
|
53
|
+
.then((r) => sendResponse(r, res))
|
|
54
|
+
.catch((e) => {
|
|
55
|
+
console.error(e);
|
|
56
|
+
if (!res.headersSent) res.writeHead(500);
|
|
57
|
+
res.end("Internal Server Error");
|
|
58
|
+
});
|
|
59
|
+
if (vite) return vite.middlewares(req, res, () => void handle());
|
|
60
|
+
const asset = join(here, "dist/client", req.url ?? "");
|
|
61
|
+
if (isProd && req.url?.startsWith("/assets/") && existsSync(asset)) {
|
|
62
|
+
res.writeHead(200, { "content-type": types[extname(asset)] ?? "application/octet-stream", "cache-control": "public, max-age=31536000, immutable" });
|
|
63
|
+
return res.end(readFileSync(asset));
|
|
64
|
+
}
|
|
65
|
+
void handle();
|
|
66
|
+
}).listen(port, () => console.log(`⚡ {{name}} → http://localhost:${port}`));
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { signal } from "@prouzbek/signals";
|
|
2
|
+
import { For, Show } from "@prouzbek/dom";
|
|
3
|
+
import { route, Link, Outlet, useSearchParams, type RouteDef } from "@prouzbek/router";
|
|
4
|
+
import { useQuery, useMutation } from "@prouzbek/data/client";
|
|
5
|
+
import { listTodos, addTodo, toggleTodo, removeTodo, type Filter, type Todo } from "./todos.server.ts";
|
|
6
|
+
|
|
7
|
+
function Layout() {
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<nav>
|
|
11
|
+
<Link href="/" activeClass="active">Todos</Link>
|
|
12
|
+
<Link href="/about" activeClass="active">About</Link>
|
|
13
|
+
</nav>
|
|
14
|
+
<Outlet />
|
|
15
|
+
</>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Todos() {
|
|
20
|
+
const [params, setParams] = useSearchParams();
|
|
21
|
+
const filter = () => (params.value.get("filter") as Filter | null) ?? "all";
|
|
22
|
+
const list = useQuery(listTodos, filter);
|
|
23
|
+
const draft = signal("");
|
|
24
|
+
|
|
25
|
+
const add = useMutation(addTodo, {
|
|
26
|
+
optimistic: (cache, title) =>
|
|
27
|
+
cache.updateAll(listTodos, (cur) => [...cur, { id: -Date.now(), title, done: false, createdAt: new Date() }]),
|
|
28
|
+
});
|
|
29
|
+
const toggle = useMutation(toggleTodo, {
|
|
30
|
+
optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.map((t) => (t.id === id ? { ...t, done: !t.done } : t))),
|
|
31
|
+
});
|
|
32
|
+
const remove = useMutation(removeTodo, {
|
|
33
|
+
optimistic: (cache, id) => cache.updateAll(listTodos, (cur) => cur.filter((t) => t.id !== id)),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section>
|
|
38
|
+
<h1>⚡ {{name}}</h1>
|
|
39
|
+
<p class="muted">
|
|
40
|
+
Server-rendered, live-synced todos. Open this page in two tabs.
|
|
41
|
+
<Show when={add.queued.value > 0}> · {add.queued.value} queued offline</Show>
|
|
42
|
+
</p>
|
|
43
|
+
<p class="filters">
|
|
44
|
+
{(["all", "open", "done"] as Filter[]).map((f) => (
|
|
45
|
+
<button type="button" disabled={filter() === f} onClick={() => void setParams({ filter: f === "all" ? null : f })}>
|
|
46
|
+
{f}
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</p>
|
|
50
|
+
<form
|
|
51
|
+
onSubmit={(e) => {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
const title = draft.value;
|
|
54
|
+
draft.value = "";
|
|
55
|
+
void add.run(title).catch(() => {});
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<input value={draft} onInput={(e) => (draft.value = e.currentTarget.value)} placeholder="What needs doing?" />
|
|
59
|
+
<button type="submit" disabled={!draft.value.trim()}>Add</button>
|
|
60
|
+
</form>
|
|
61
|
+
<Show when={add.error.value}>{(err) => <p class="muted">⚠ {String((err.value as Error).message)}</p>}</Show>
|
|
62
|
+
<Show when={list.data.value} fallback={<p class="muted">Loading…</p>}>
|
|
63
|
+
<ul>
|
|
64
|
+
<For each={list.data.value ?? []} fallback={<li class="muted">Nothing here.</li>}>
|
|
65
|
+
{(todo: Todo) => (
|
|
66
|
+
<li classList={{ done: todo.done, pending: todo.id < 0 }}>
|
|
67
|
+
<label>
|
|
68
|
+
<input type="checkbox" checked={todo.done} disabled={todo.id < 0} onChange={() => void toggle.run(todo.id)} />
|
|
69
|
+
<span>{todo.title}</span>
|
|
70
|
+
</label>
|
|
71
|
+
<button class="link" type="button" disabled={todo.id < 0} onClick={() => void remove.run(todo.id)}>
|
|
72
|
+
remove
|
|
73
|
+
</button>
|
|
74
|
+
</li>
|
|
75
|
+
)}
|
|
76
|
+
</For>
|
|
77
|
+
</ul>
|
|
78
|
+
</Show>
|
|
79
|
+
</section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function About() {
|
|
84
|
+
return (
|
|
85
|
+
<section>
|
|
86
|
+
<h1>About</h1>
|
|
87
|
+
<p>
|
|
88
|
+
<code>src/todos.server.ts</code> never reaches the browser. The client calls its exports as typed RPC stubs, and
|
|
89
|
+
every query refetches automatically when a mutation writes the source it read.
|
|
90
|
+
</p>
|
|
91
|
+
</section>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const routes: RouteDef[] = [
|
|
96
|
+
route({
|
|
97
|
+
path: "/",
|
|
98
|
+
component: Layout,
|
|
99
|
+
children: [route({ path: "", component: Todos }), route({ path: "about", component: About })],
|
|
100
|
+
}),
|
|
101
|
+
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { render } from "@prouzbek/dom";
|
|
2
|
+
import { createRouter, RouterProvider } from "@prouzbek/router";
|
|
3
|
+
import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
|
|
4
|
+
import { routes } from "./App.tsx";
|
|
5
|
+
|
|
6
|
+
const client = createQueryClient({ storage: localStorage });
|
|
7
|
+
const router = createRouter(routes);
|
|
8
|
+
|
|
9
|
+
const root = document.getElementById("app")!;
|
|
10
|
+
root.innerHTML = "";
|
|
11
|
+
render(() => provideQueryClient(client, () => <RouterProvider router={router} />), root);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { renderToString } from "@prouzbek/dom";
|
|
2
|
+
import { createRouter, RouterProvider } from "@prouzbek/router";
|
|
3
|
+
import { createQueryClient, provideQueryClient } from "@prouzbek/data/client";
|
|
4
|
+
import { routes } from "./App.tsx";
|
|
5
|
+
|
|
6
|
+
export async function render(url: string, request: Request): Promise<string> {
|
|
7
|
+
const client = createQueryClient({ liveUrl: false });
|
|
8
|
+
const router = createRouter(routes, { url, request });
|
|
9
|
+
await router.ready;
|
|
10
|
+
const html = renderToString(() => provideQueryClient(client, () => <RouterProvider router={router} />));
|
|
11
|
+
client.dispose();
|
|
12
|
+
router.dispose();
|
|
13
|
+
return html;
|
|
14
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
font: 16px/1.5 system-ui, sans-serif;
|
|
4
|
+
--border: color-mix(in srgb, currentColor 18%, transparent);
|
|
5
|
+
}
|
|
6
|
+
body {
|
|
7
|
+
max-width: 560px;
|
|
8
|
+
margin: 2rem auto;
|
|
9
|
+
padding: 0 1rem;
|
|
10
|
+
}
|
|
11
|
+
nav {
|
|
12
|
+
display: flex;
|
|
13
|
+
gap: 1rem;
|
|
14
|
+
margin-bottom: 1.5rem;
|
|
15
|
+
}
|
|
16
|
+
nav a {
|
|
17
|
+
color: inherit;
|
|
18
|
+
opacity: 0.7;
|
|
19
|
+
}
|
|
20
|
+
nav a.active {
|
|
21
|
+
opacity: 1;
|
|
22
|
+
font-weight: 600;
|
|
23
|
+
text-decoration: underline;
|
|
24
|
+
}
|
|
25
|
+
h1 {
|
|
26
|
+
margin: 0 0 0.25rem;
|
|
27
|
+
}
|
|
28
|
+
.muted {
|
|
29
|
+
opacity: 0.65;
|
|
30
|
+
font-size: 0.9rem;
|
|
31
|
+
}
|
|
32
|
+
.filters,
|
|
33
|
+
form {
|
|
34
|
+
display: flex;
|
|
35
|
+
gap: 0.5rem;
|
|
36
|
+
margin: 1rem 0;
|
|
37
|
+
}
|
|
38
|
+
form input {
|
|
39
|
+
flex: 1;
|
|
40
|
+
padding: 0.5rem;
|
|
41
|
+
border: 1px solid var(--border);
|
|
42
|
+
border-radius: 6px;
|
|
43
|
+
background: transparent;
|
|
44
|
+
color: inherit;
|
|
45
|
+
}
|
|
46
|
+
button {
|
|
47
|
+
padding: 0.4rem 0.9rem;
|
|
48
|
+
border: 1px solid var(--border);
|
|
49
|
+
border-radius: 999px;
|
|
50
|
+
background: transparent;
|
|
51
|
+
color: inherit;
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
}
|
|
54
|
+
button:disabled {
|
|
55
|
+
opacity: 0.4;
|
|
56
|
+
cursor: not-allowed;
|
|
57
|
+
}
|
|
58
|
+
button.link {
|
|
59
|
+
border: 0;
|
|
60
|
+
opacity: 0.6;
|
|
61
|
+
font-size: 0.85rem;
|
|
62
|
+
}
|
|
63
|
+
ul {
|
|
64
|
+
list-style: none;
|
|
65
|
+
padding: 0;
|
|
66
|
+
margin: 0;
|
|
67
|
+
}
|
|
68
|
+
li {
|
|
69
|
+
display: flex;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
align-items: center;
|
|
72
|
+
padding: 0.3rem 0;
|
|
73
|
+
}
|
|
74
|
+
li.done span {
|
|
75
|
+
text-decoration: line-through;
|
|
76
|
+
opacity: 0.6;
|
|
77
|
+
}
|
|
78
|
+
li.pending {
|
|
79
|
+
opacity: 0.5;
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"jsx": "preserve",
|
|
8
|
+
"jsxImportSource": "@prouzbek/dom",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": ["node", "vite/client"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src", "server.ts", "vite.config.ts"]
|
|
17
|
+
}
|
|
File without changes
|