create-voltapp 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +14 -2
- package/bin/create-voltapp.js +91 -0
- package/package.json +29 -4
- package/templates/api/README.md +13 -0
- package/templates/api/_gitignore +4 -0
- package/templates/api/package.json +20 -0
- package/templates/api/server.ts +61 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/fullstack/README.md +28 -0
- package/templates/fullstack/_gitignore +3 -0
- package/templates/fullstack/index.html +13 -0
- package/templates/fullstack/package.json +26 -0
- package/templates/fullstack/server.ts +66 -0
- package/templates/fullstack/src/App.tsx +101 -0
- package/templates/fullstack/src/entry-client.tsx +11 -0
- package/templates/fullstack/src/entry-server.tsx +14 -0
- package/templates/fullstack/src/styles.css +80 -0
- package/templates/fullstack/src/todos.server.ts +43 -0
- package/templates/fullstack/tsconfig.json +17 -0
- package/templates/fullstack/vite.config.ts +9 -0
- package/templates/minimal/README.md +13 -0
- package/templates/minimal/_gitignore +3 -0
- package/templates/minimal/index.html +12 -0
- package/templates/minimal/package.json +21 -0
- package/templates/minimal/src/main.tsx +76 -0
- package/templates/minimal/src/styles.css +84 -0
- package/templates/minimal/tsconfig.json +17 -0
- package/templates/minimal/vite.config.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Volt contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-voltapp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffold a new Volt app: npm create voltapp@latest my-app
|
|
4
|
+
|
|
5
|
+
Part of **Volt**, a full-stack TypeScript framework with fine-grained signals, a compiler-checked server boundary and live queries with automatic invalidation. Zero runtime dependencies.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm create voltapp@latest my-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Templates: `minimal` (client app), `fullstack` (SSR, server functions, live queries), `api` (server only).
|
|
12
|
+
|
|
13
|
+
Documentation and examples: see the Volt repository (`llms.txt` has the whole API on one page).
|
|
14
|
+
|
|
15
|
+
MIT licensed.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-voltapp — scaffold a Volt app.
|
|
4
|
+
*
|
|
5
|
+
* npm create voltapp@latest my-app
|
|
6
|
+
* npm create voltapp@latest my-app -- --template fullstack
|
|
7
|
+
* npx create-voltapp my-app --template api
|
|
8
|
+
*
|
|
9
|
+
* Zero dependencies. Copies a template, fills in the project name and the
|
|
10
|
+
* matching @prouzbek/* version, and prints the next steps.
|
|
11
|
+
*/
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { dirname, join, resolve, basename } from "node:path";
|
|
15
|
+
import { createInterface } from "node:readline/promises";
|
|
16
|
+
|
|
17
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
19
|
+
const TEMPLATES = {
|
|
20
|
+
minimal: "Client app with Vite: signals, JSX, For/Show. No server.",
|
|
21
|
+
fullstack: "SSR + Vite dev server, router, server functions, live queries, optimistic mutations.",
|
|
22
|
+
api: "Server only: Web-standard app with validation, sessions and server functions.",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const flag = (name) => {
|
|
27
|
+
const i = args.indexOf(`--${name}`);
|
|
28
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
29
|
+
};
|
|
30
|
+
let dir = args.find((a) => !a.startsWith("--") && args[args.indexOf(a) - 1] !== "--template");
|
|
31
|
+
let template = flag("template");
|
|
32
|
+
|
|
33
|
+
const bold = (s) => `\u001b[1m${s}\u001b[22m`;
|
|
34
|
+
const dim = (s) => `\u001b[2m${s}\u001b[22m`;
|
|
35
|
+
const accent = (s) => `\u001b[38;5;141m${s}\u001b[39m`;
|
|
36
|
+
|
|
37
|
+
console.log(`\n${accent("⚡")} ${bold("create-voltapp")} ${dim("v" + pkg.version)}\n`);
|
|
38
|
+
|
|
39
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
+
try {
|
|
41
|
+
if (!dir) {
|
|
42
|
+
dir = (await rl.question(`${bold("Project name")} ${dim("(my-volt-app)")}: `)).trim() || "my-volt-app";
|
|
43
|
+
}
|
|
44
|
+
if (!template) {
|
|
45
|
+
console.log(`\n${bold("Template")}`);
|
|
46
|
+
const names = Object.keys(TEMPLATES);
|
|
47
|
+
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-3, default 2)")}: `)).trim();
|
|
49
|
+
template = names[Number(answer) - 1] ?? (names.includes(answer) ? answer : "fullstack");
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!TEMPLATES[template]) {
|
|
56
|
+
console.error(`Unknown template "${template}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const target = resolve(process.cwd(), dir);
|
|
61
|
+
const name = basename(target).toLowerCase().replace(/[^a-z0-9-_.]/g, "-");
|
|
62
|
+
if (existsSync(target) && readdirSync(target).length) {
|
|
63
|
+
console.error(`\n✗ ${target} exists and is not empty.`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
mkdirSync(target, { recursive: true });
|
|
67
|
+
|
|
68
|
+
const source = join(here, "..", "templates", template);
|
|
69
|
+
cpSync(source, target, { recursive: true });
|
|
70
|
+
|
|
71
|
+
// npm drops .gitignore from published packages, so templates ship it as _gitignore
|
|
72
|
+
const gi = join(target, "_gitignore");
|
|
73
|
+
if (existsSync(gi)) renameSync(gi, join(target, ".gitignore"));
|
|
74
|
+
|
|
75
|
+
// fill placeholders
|
|
76
|
+
const walk = (d) => readdirSync(d).flatMap((f) => (statSync(join(d, f)).isDirectory() ? walk(join(d, f)) : [join(d, f)]));
|
|
77
|
+
for (const file of walk(target)) {
|
|
78
|
+
if (!/\.(json|ts|tsx|html|md|css)$/.test(file)) continue;
|
|
79
|
+
const text = readFileSync(file, "utf8");
|
|
80
|
+
const next = text.replaceAll("{{name}}", name).replaceAll("{{version}}", pkg.frameworkVersion ?? pkg.version);
|
|
81
|
+
if (next !== text) writeFileSync(file, next);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pm = (process.env.npm_config_user_agent ?? "").split("/")[0] || "npm";
|
|
85
|
+
const run = (s) => (pm === "npm" ? `npm run ${s}` : `${pm} ${s}`);
|
|
86
|
+
|
|
87
|
+
console.log(`\n${accent("✓")} Created ${bold(name)} from the ${bold(template)} template.\n`);
|
|
88
|
+
console.log(` cd ${dir}`);
|
|
89
|
+
console.log(` ${pm} install`);
|
|
90
|
+
console.log(` ${run("dev")}\n`);
|
|
91
|
+
console.log(dim("Docs: the whole API is in node_modules/@prouzbek/*/README.md and the repository's llms.txt.\n"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-voltapp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"frameworkVersion": "0.1.0",
|
|
5
|
+
"description": "Scaffold a new Volt app: npm create voltapp@latest my-app",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"create-voltapp": "bin/create-voltapp.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"templates"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/prouzbek/volt",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"volt",
|
|
24
|
+
"signals",
|
|
25
|
+
"reactive",
|
|
26
|
+
"framework",
|
|
27
|
+
"typescript",
|
|
28
|
+
"create",
|
|
29
|
+
"scaffold"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A Volt API server with validation, sessions and server functions. No build step: Node 24 runs TypeScript directly.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm run dev # http://localhost:3000, restarts on change
|
|
8
|
+
curl -X POST localhost:3000/signup -H 'content-type: application/json' -d '{"email":"a@b.co","password":"secret123"}'
|
|
9
|
+
curl -c jar -X POST localhost:3000/login -H 'content-type: application/json' -d '{"email":"a@b.co","password":"secret123"}'
|
|
10
|
+
curl -b jar localhost:3000/notes
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Set `SESSION_SECRET` in production. Deploy anywhere that speaks `fetch`: Node, Bun, Deno, Cloudflare Workers.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "node --watch server.ts",
|
|
8
|
+
"start": "node server.ts",
|
|
9
|
+
"check": "tsc --noEmit"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@prouzbek/kit": "{{version}}",
|
|
13
|
+
"@prouzbek/serialize": "{{version}}",
|
|
14
|
+
"@prouzbek/server": "{{version}}"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^24.0.0",
|
|
18
|
+
"typescript": "^5.9.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Volt API server: Web-standard app, validation, sessions, server functions.
|
|
3
|
+
* Runs on Node (below), Bun (`Bun.serve({ fetch: app.fetch })`), Deno
|
|
4
|
+
* (`Deno.serve(app.fetch)`) and Cloudflare Workers (`export default app`).
|
|
5
|
+
*/
|
|
6
|
+
import { createApp, HttpError, logger, cors } from "@prouzbek/server";
|
|
7
|
+
import { serverFunctions, serverFn } from "@prouzbek/server/fn";
|
|
8
|
+
import { serve } from "@prouzbek/server/node";
|
|
9
|
+
import { v, body, sessions, requireAuth, hashPassword, verifyPassword, loadEnv } from "@prouzbek/kit";
|
|
10
|
+
|
|
11
|
+
const env = loadEnv({
|
|
12
|
+
PORT: v.number().coerce().default(3000),
|
|
13
|
+
SESSION_SECRET: v.string().min(16).default("change-me-change-me-change-me"),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// ── an in-memory "database" ──
|
|
17
|
+
type User = { id: string; email: string; hash: string };
|
|
18
|
+
const users = new Map<string, User>();
|
|
19
|
+
const notes: { id: number; userId: string; text: string }[] = [];
|
|
20
|
+
|
|
21
|
+
type State = { session: import("@prouzbek/kit").Session<{ userId: string }>; body?: unknown };
|
|
22
|
+
|
|
23
|
+
export const app = createApp<State>()
|
|
24
|
+
.use(logger())
|
|
25
|
+
.use(cors())
|
|
26
|
+
.use(sessions<{ userId: string }>({ secret: env.SESSION_SECRET, cookieOptions: { secure: false } }))
|
|
27
|
+
.use(serverFunctions())
|
|
28
|
+
|
|
29
|
+
.get("/", () => ({ name: "{{name}}", docs: "/notes requires a session: POST /signup then POST /login" }))
|
|
30
|
+
|
|
31
|
+
.post("/signup", body(v.object({ email: v.string().email(), password: v.string().min(8) })), async (c) => {
|
|
32
|
+
const { email, password } = c.state.body as { email: string; password: string };
|
|
33
|
+
if ([...users.values()].some((u) => u.email === email)) throw new HttpError(409, "Email already registered");
|
|
34
|
+
const user: User = { id: crypto.randomUUID(), email, hash: await hashPassword(password) };
|
|
35
|
+
users.set(user.id, user);
|
|
36
|
+
c.status = 201;
|
|
37
|
+
return { id: user.id };
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
.post("/login", body(v.object({ email: v.string().email(), password: v.string() })), async (c) => {
|
|
41
|
+
const { email, password } = c.state.body as { email: string; password: string };
|
|
42
|
+
const user = [...users.values()].find((u) => u.email === email);
|
|
43
|
+
if (!user || !(await verifyPassword(password, user.hash))) throw new HttpError(401, "Invalid credentials");
|
|
44
|
+
c.state.session.set({ userId: user.id });
|
|
45
|
+
c.state.session.regenerate();
|
|
46
|
+
return { ok: true };
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
.get("/notes", requireAuth(), (c) => notes.filter((n) => n.userId === c.state.session.data.userId))
|
|
50
|
+
|
|
51
|
+
.post("/notes", requireAuth(), body(v.object({ text: v.string().min(1).max(500) })), (c) => {
|
|
52
|
+
const note = { id: notes.length + 1, userId: c.state.session.data.userId!, text: (c.state.body as { text: string }).text };
|
|
53
|
+
notes.push(note);
|
|
54
|
+
c.status = 201;
|
|
55
|
+
return note;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// A server function, callable from a Volt client as `await ping("hi")`.
|
|
59
|
+
export const ping = serverFn(async (message: string) => ({ message, at: new Date() }));
|
|
60
|
+
|
|
61
|
+
serve(app, { port: env.PORT, onListen: ({ port }) => console.log(`⚡ {{name}} → http://localhost:${port}`) });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"verbatimModuleSyntax": true,
|
|
11
|
+
"erasableSyntaxOnly": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"types": ["node"]
|
|
14
|
+
},
|
|
15
|
+
"include": ["*.ts", "src"]
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A full-stack Volt app: 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,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only module. The browser build replaces this file with RPC stubs, so
|
|
3
|
+
* nothing here (including the in-memory "database") can reach the client.
|
|
4
|
+
*/
|
|
5
|
+
import { source, query, mutation } from "@prouzbek/data/server";
|
|
6
|
+
import { HttpError } from "@prouzbek/server";
|
|
7
|
+
|
|
8
|
+
export type Todo = { id: number; title: string; done: boolean; createdAt: Date };
|
|
9
|
+
export type Filter = "all" | "open" | "done";
|
|
10
|
+
|
|
11
|
+
const todos = source("todos");
|
|
12
|
+
const db: Todo[] = [{ id: 1, title: "Explore the code", done: false, createdAt: new Date() }];
|
|
13
|
+
let nextId = 2;
|
|
14
|
+
|
|
15
|
+
export const listTodos = query(async (filter: Filter) => {
|
|
16
|
+
todos.read();
|
|
17
|
+
return db.filter((t) => (filter === "all" ? true : filter === "open" ? !t.done : t.done));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const addTodo = mutation(async (title: string) => {
|
|
21
|
+
title = title.trim();
|
|
22
|
+
if (!title) throw new HttpError(422, "Title is required");
|
|
23
|
+
const todo: Todo = { id: nextId++, title, done: false, createdAt: new Date() };
|
|
24
|
+
db.push(todo);
|
|
25
|
+
todos.write();
|
|
26
|
+
return todo;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const toggleTodo = mutation(async (id: number) => {
|
|
30
|
+
const t = db.find((x) => x.id === id);
|
|
31
|
+
if (!t) throw new HttpError(404, "No such todo");
|
|
32
|
+
t.done = !t.done;
|
|
33
|
+
todos.write(id);
|
|
34
|
+
todos.write();
|
|
35
|
+
return t;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const removeTodo = mutation(async (id: number) => {
|
|
39
|
+
const i = db.findIndex((x) => x.id === id);
|
|
40
|
+
if (i >= 0) db.splice(i, 1);
|
|
41
|
+
todos.write();
|
|
42
|
+
return id;
|
|
43
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# {{name}}
|
|
2
|
+
|
|
3
|
+
A Volt client app.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install
|
|
7
|
+
npm run dev # http://localhost:5173
|
|
8
|
+
npm run build # production build in dist/
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
- `src/main.tsx` is the whole app. Components run once; signals drive updates.
|
|
12
|
+
- `For`, `Show` and friends come from `@prouzbek/dom`; `signal`/`computed` from `@prouzbek/signals`.
|
|
13
|
+
- Need a server, routing or live data? Scaffold the `fullstack` template: `npm create voltapp@latest -- --template fullstack`.
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"check": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@prouzbek/dom": "{{version}}",
|
|
14
|
+
"@prouzbek/signals": "{{version}}"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@prouzbek/vite": "{{version}}",
|
|
18
|
+
"typescript": "^5.9.0",
|
|
19
|
+
"vite": "^8.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { signal, computed } from "@prouzbek/signals";
|
|
2
|
+
import { render, For, Show } from "@prouzbek/dom";
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
|
|
5
|
+
type Item = { id: number; text: string; done: boolean };
|
|
6
|
+
|
|
7
|
+
function App() {
|
|
8
|
+
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
|
+
|
|
26
|
+
return (
|
|
27
|
+
<main>
|
|
28
|
+
<h1>⚡ {{name}}</h1>
|
|
29
|
+
<p class="muted">
|
|
30
|
+
This component ran once. Every value below is a signal bound to exactly one DOM node.
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<section class="card">
|
|
34
|
+
<h2>Counter</h2>
|
|
35
|
+
<div class="row">
|
|
36
|
+
<button onClick={() => count.value--}>−</button>
|
|
37
|
+
<output>{count}</output>
|
|
38
|
+
<button onClick={() => count.value++}>+</button>
|
|
39
|
+
</div>
|
|
40
|
+
<p class="muted">double = {double}</p>
|
|
41
|
+
</section>
|
|
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>
|
|
72
|
+
</main>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(() => <App />, document.getElementById("app")!);
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
margin: 0;
|
|
8
|
+
}
|
|
9
|
+
main {
|
|
10
|
+
max-width: 560px;
|
|
11
|
+
margin: 3rem auto;
|
|
12
|
+
padding: 0 1rem;
|
|
13
|
+
display: grid;
|
|
14
|
+
gap: 1rem;
|
|
15
|
+
}
|
|
16
|
+
h1,
|
|
17
|
+
h2,
|
|
18
|
+
p {
|
|
19
|
+
margin: 0;
|
|
20
|
+
}
|
|
21
|
+
.muted {
|
|
22
|
+
opacity: 0.65;
|
|
23
|
+
font-size: 0.9rem;
|
|
24
|
+
}
|
|
25
|
+
.card {
|
|
26
|
+
border: 1px solid var(--border);
|
|
27
|
+
border-radius: 10px;
|
|
28
|
+
padding: 1rem;
|
|
29
|
+
display: grid;
|
|
30
|
+
gap: 0.75rem;
|
|
31
|
+
}
|
|
32
|
+
.row {
|
|
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;
|
|
51
|
+
}
|
|
52
|
+
button {
|
|
53
|
+
padding: 0.45rem 0.9rem;
|
|
54
|
+
border: 1px solid var(--border);
|
|
55
|
+
border-radius: 999px;
|
|
56
|
+
background: transparent;
|
|
57
|
+
color: inherit;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
button:disabled {
|
|
61
|
+
opacity: 0.4;
|
|
62
|
+
cursor: not-allowed;
|
|
63
|
+
}
|
|
64
|
+
button.link {
|
|
65
|
+
border: 0;
|
|
66
|
+
opacity: 0.6;
|
|
67
|
+
font-size: 0.85rem;
|
|
68
|
+
}
|
|
69
|
+
ul {
|
|
70
|
+
list-style: none;
|
|
71
|
+
margin: 0;
|
|
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;
|
|
84
|
+
}
|
|
@@ -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": ["vite/client"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"]
|
|
17
|
+
}
|