create-tinyjoin 0.0.5
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 +53 -0
- package/cli.js +15 -0
- package/package.json +39 -0
- package/templates/AGENTS.md.hbs +15 -0
- package/templates/README.md.hbs +29 -0
- package/templates/client/.gitignore.hbs +4 -0
- package/templates/client/index.html.hbs +51 -0
- package/templates/client/package.json.hbs +21 -0
- package/templates/client/src/database.ts.hbs +29 -0
- package/templates/client/src/main.ts.hbs +159 -0
- package/templates/client/src/style.css.hbs +199 -0
- package/templates/client/tsconfig.json.hbs +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Pearce
|
|
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
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# create-tinyjoin
|
|
2
|
+
|
|
3
|
+
Scaffold a small local todo app with TinyJoin, TypeScript, and Vite:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm create tinyjoin@latest
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The generated project is ready to run at its root and needs no database server,
|
|
10
|
+
account, or credentials. Choose whether its todos should remain after reloads or
|
|
11
|
+
start fresh each time.
|
|
12
|
+
|
|
13
|
+
For agents and CI, every option can be supplied non-interactively:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm create tinyjoin@latest -- --non-interactive \
|
|
17
|
+
--projectName my-tinyjoin-app \
|
|
18
|
+
--storage opfs \
|
|
19
|
+
--installAndRun false
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use `--list-options` for the machine-readable option catalog or `--help` for
|
|
23
|
+
usage.
|
|
24
|
+
|
|
25
|
+
`npm run build` assembles the publishable `create-tinyjoin` package under
|
|
26
|
+
`dist/`.
|
|
27
|
+
|
|
28
|
+
## Development
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npm run typecheck # generator and test TypeScript
|
|
32
|
+
npm test # CLI, template, and package-boundary tests
|
|
33
|
+
npm run test:generated # generated apps against published TinyJoin
|
|
34
|
+
npm run test:e2e # real generated-app behavior in Chromium
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Run the local generator from this repository's parent directory with:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
npm --prefix create-tinyjoin run local
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
This builds only the generator. The generated app installs the published
|
|
44
|
+
TinyJoin package, so its native build toolchain is not required.
|
|
45
|
+
|
|
46
|
+
To test unpublished changes from a neighboring TinyJoin source repository, use:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npm --prefix create-tinyjoin run local:source
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That command builds and packs the sibling TinyJoin repository before starting
|
|
53
|
+
the generator.
|
package/cli.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{existsSync as c}from"node:fs";import{dirname as l,join as r}from"node:path";import{fileURLToPath as p}from"node:url";import{createCLI as u,detectPackageManager as m}from"tinycreate";const g=r(l(p(import.meta.url)),"templates"),s=process.argv.slice(2),i=[{title:"Save data across reloads (recommended)",value:"opfs"},{title:"Start fresh each time",value:"memory"}],d={command:"npm create tinyjoin@latest --",nonInteractiveFlag:"--non-interactive",options:{projectName:{type:"string",required:!0},storage:{values:i.map(({value:e})=>e),required:!0,default:"opfs"},installAndRun:{values:[!0,!1],required:!0,recommendedForAgents:!1}}};(s.includes("--help")||s.includes("-h"))&&(console.log(`create-tinyjoin
|
|
3
|
+
|
|
4
|
+
Interactively scaffold a TinyJoin application:
|
|
5
|
+
npm create tinyjoin@latest
|
|
6
|
+
|
|
7
|
+
Run non-interactively:
|
|
8
|
+
npm create tinyjoin@latest -- --non-interactive \\
|
|
9
|
+
--projectName my-tinyjoin-app --storage opfs \\
|
|
10
|
+
--installAndRun false
|
|
11
|
+
|
|
12
|
+
Agent and automation commands:
|
|
13
|
+
--list-options Print the current option catalog as JSON
|
|
14
|
+
--help Show this help`),process.exit(0)),s.includes("--list-options")&&(console.log(JSON.stringify(d,null,2)),process.exit(0));const f={welcomeMessage:`\u{1F389} Welcome to TinyJoin!
|
|
15
|
+
`,questions:[{type:"text",name:"projectName",message:"Project name:",initial:"my-tinyjoin-app",validate:a},{type:"select",name:"storage",message:"Todo data:",choices:[...i],initial:0},{type:"confirm",name:"installAndRun",message:"Install dependencies and start the app?",initial:!0}],createContext:e=>{const t=String(e.projectName??"").trim(),n=a(t);if(n!==!0)throw new TypeError(n);const o=h(e.storage===0?"opfs":e.storage??"opfs","storage",["opfs","memory"]);return{projectName:t,installAndRun:e.installAndRun===!0||e.installAndRun==="true",storage:o,usesOpfs:o==="opfs",storageName:y(t),tinyjoinDependency:process.env.CREATE_TINYJOIN_DEPENDENCY??"^0.0.5"}},getFiles:()=>[{template:"README.md.hbs",output:"README.md",prettier:!0},{template:"AGENTS.md.hbs",output:"AGENTS.md",prettier:!0},{template:"client/package.json.hbs",output:"package.json",prettier:!0},{template:"client/.gitignore.hbs",output:".gitignore"},{template:"client/index.html.hbs",output:"index.html",prettier:!0},{template:"client/tsconfig.json.hbs",output:"tsconfig.json",prettier:!0},{template:"client/src/database.ts.hbs",output:"src/database.ts",prettier:!0},{template:"client/src/main.ts.hbs",output:"src/main.ts",prettier:!0},{template:"client/src/style.css.hbs",output:"src/style.css",prettier:!0}],templateRoot:g,installCommand:"{pm} install",devCommand:"{pm} run dev",workingDirectory:".",onSuccess:e=>{const t=m();console.log("Next steps:"),console.log(` cd ${e}`),console.log(` ${t} install`),console.log(` ${t} run dev`)}};u(f).catch(e=>{console.error(e instanceof Error?e.message:String(e)),process.exit(1)});function a(e){return!/^[a-z0-9][a-z0-9._-]*$/i.test(e)||e==="."||e===".."?"Use letters, numbers, dots, dashes, or underscores without path separators.":c(r(process.cwd(),e))?`Directory "${e}" already exists. Choose a different name.`:!0}function h(e,t,n){const o=typeof e=="string"?e:"";if(!n.includes(o))throw new TypeError(`${j(t)} must be one of: ${n.join(", ")}.`);return o}function y(e){const t=e.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"app",n="tinyjoin-",o="-db-v1";return`${n}${t.slice(0,64-n.length-o.length)}${o}`}function j(e){return`${e.charAt(0).toUpperCase()}${e.slice(1)}`}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-tinyjoin",
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"author": "jamesgpearce",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/tinyplex/create-tinyjoin.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://tinyjoin.org",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/tinyplex/create-tinyjoin/issues"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"description": "Scaffold a standalone TinyJoin browser database application.",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"postgresql",
|
|
17
|
+
"local-first",
|
|
18
|
+
"webassembly",
|
|
19
|
+
"web-worker",
|
|
20
|
+
"app generator",
|
|
21
|
+
"scaffolding"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"create-tinyjoin": "cli.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"tinycreate": "^1.0.8"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"cli.js",
|
|
35
|
+
"templates",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
This is a generated TinyJoin TypeScript/Vite starter.
|
|
4
|
+
|
|
5
|
+
- Run the app from this directory with `npm run dev`.
|
|
6
|
+
- Keep database setup in `src/database.ts` and application UI in `src/main.ts`.
|
|
7
|
+
- Keep schema setup safe to run again when the app reloads.
|
|
8
|
+
- Use TinyJoin's `sql` tagged template or parameter arrays for application
|
|
9
|
+
values. Do not concatenate user input into SQL.
|
|
10
|
+
- Generate stable row identifiers in application code.
|
|
11
|
+
- Keep the database client open while the app is running and close it during
|
|
12
|
+
page teardown.
|
|
13
|
+
- Validate changes with `npm run build`.
|
|
14
|
+
|
|
15
|
+
Current TinyJoin documentation: https://tinyjoin.org
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# {{projectName}}
|
|
2
|
+
|
|
3
|
+
A small local todo app built with TinyJoin, TypeScript, and Vite.
|
|
4
|
+
|
|
5
|
+
## Start it
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Your app will open at the address Vite prints in the terminal. It needs no
|
|
13
|
+
database server, account, or credentials.
|
|
14
|
+
|
|
15
|
+
{{#if usesOpfs}}
|
|
16
|
+
Todos are saved locally in this browser and remain after a reload.
|
|
17
|
+
{{else}}
|
|
18
|
+
Todos are kept for the current visit and start fresh after a reload.
|
|
19
|
+
{{/if}}
|
|
20
|
+
|
|
21
|
+
## Start editing
|
|
22
|
+
|
|
23
|
+
- `src/database.ts` opens the database and defines the `todos` table.
|
|
24
|
+
- `src/main.ts` contains the todo UI and its queries.
|
|
25
|
+
- `src/style.css` contains the app styles.
|
|
26
|
+
|
|
27
|
+
Run `npm run build` to check the TypeScript and create a production build.
|
|
28
|
+
|
|
29
|
+
Learn more at [tinyjoin.org](https://tinyjoin.org).
|
|
@@ -0,0 +1,51 @@
|
|
|
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.0" />
|
|
6
|
+
<meta
|
|
7
|
+
name="description"
|
|
8
|
+
content="A small local todo app built with TinyJoin"
|
|
9
|
+
/>
|
|
10
|
+
<title>{{projectName}}</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main>
|
|
14
|
+
<header>
|
|
15
|
+
<p class="eyebrow">TinyJoin starter</p>
|
|
16
|
+
<h1>Todos</h1>
|
|
17
|
+
<p class="intro">
|
|
18
|
+
{{#if usesOpfs}}Your list is saved locally in this browser.{{else}}Your list starts fresh each time you load the app.{{/if}}
|
|
19
|
+
</p>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<form data-testid="todo-form">
|
|
23
|
+
<label for="todo-title">What needs doing?</label>
|
|
24
|
+
<div class="composer">
|
|
25
|
+
<input
|
|
26
|
+
id="todo-title"
|
|
27
|
+
data-testid="todo-title"
|
|
28
|
+
name="title"
|
|
29
|
+
type="text"
|
|
30
|
+
maxlength="120"
|
|
31
|
+
placeholder="Add a todo"
|
|
32
|
+
autocomplete="off"
|
|
33
|
+
required
|
|
34
|
+
/>
|
|
35
|
+
<button type="submit">Add</button>
|
|
36
|
+
</div>
|
|
37
|
+
</form>
|
|
38
|
+
|
|
39
|
+
<p class="status" data-testid="status" role="status" aria-live="polite">
|
|
40
|
+
Opening your todos…
|
|
41
|
+
</p>
|
|
42
|
+
<p class="error" data-testid="error" role="alert" hidden></p>
|
|
43
|
+
<ul data-testid="todos" aria-live="polite" aria-busy="true"></ul>
|
|
44
|
+
|
|
45
|
+
<footer>
|
|
46
|
+
Built with <a href="https://tinyjoin.org">TinyJoin</a>.
|
|
47
|
+
</footer>
|
|
48
|
+
</main>
|
|
49
|
+
<script type="module" src="/src/main.ts"></script>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "vite",
|
|
11
|
+
"build": "tsc --noEmit && vite build",
|
|
12
|
+
"preview": "vite preview"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"tinyjoin": "{{tinyjoinDependency}}"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^7.0.2",
|
|
19
|
+
"vite": "^8.2.1"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {create} from 'tinyjoin';
|
|
2
|
+
|
|
3
|
+
export type Todo = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
done: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type Database = Awaited<ReturnType<typeof create>>;
|
|
10
|
+
|
|
11
|
+
export async function openDatabase(): Promise<Database> {
|
|
12
|
+
const database = await create(
|
|
13
|
+
{{#if usesOpfs}}
|
|
14
|
+
'opfs://{{storageName}}',
|
|
15
|
+
{{else}}
|
|
16
|
+
'memory://',
|
|
17
|
+
{{/if}}
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
await database.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
title TEXT NOT NULL,
|
|
24
|
+
done BOOLEAN NOT NULL DEFAULT false
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
|
|
28
|
+
return database;
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import './style.css';
|
|
2
|
+
|
|
3
|
+
import {openDatabase, type Database, type Todo} from './database';
|
|
4
|
+
|
|
5
|
+
const form = requiredElement<HTMLFormElement>('[data-testid="todo-form"]');
|
|
6
|
+
const titleInput = requiredElement<HTMLInputElement>(
|
|
7
|
+
'[data-testid="todo-title"]',
|
|
8
|
+
);
|
|
9
|
+
const todosElement = requiredElement<HTMLUListElement>('[data-testid="todos"]');
|
|
10
|
+
const statusElement = requiredElement<HTMLElement>('[data-testid="status"]');
|
|
11
|
+
const errorElement = requiredElement<HTMLElement>('[data-testid="error"]');
|
|
12
|
+
|
|
13
|
+
async function start(): Promise<void> {
|
|
14
|
+
const database = await openDatabase();
|
|
15
|
+
const unsubscribe = database.subscribe({tables: ['todos']}, () => {
|
|
16
|
+
runAction(() => renderTodos(database));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
addEventListener(
|
|
20
|
+
'pagehide',
|
|
21
|
+
() => {
|
|
22
|
+
unsubscribe();
|
|
23
|
+
void database.close();
|
|
24
|
+
},
|
|
25
|
+
{once: true},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
form.addEventListener('submit', (event) => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
const title = titleInput.value.trim();
|
|
31
|
+
if (!title) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
runAction(async () => {
|
|
35
|
+
await database.sql`
|
|
36
|
+
INSERT INTO todos (id, title, done)
|
|
37
|
+
VALUES (${crypto.randomUUID()}, ${title}, ${false})
|
|
38
|
+
`;
|
|
39
|
+
form.reset();
|
|
40
|
+
titleInput.focus();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
todosElement.addEventListener('change', (event) => {
|
|
45
|
+
const checkbox = event.target;
|
|
46
|
+
const id =
|
|
47
|
+
checkbox instanceof HTMLInputElement
|
|
48
|
+
? checkbox.dataset.todoId
|
|
49
|
+
: undefined;
|
|
50
|
+
if (
|
|
51
|
+
!(checkbox instanceof HTMLInputElement) ||
|
|
52
|
+
checkbox.dataset.action !== 'toggle' ||
|
|
53
|
+
!id
|
|
54
|
+
) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
runAction(async () => {
|
|
58
|
+
await database.sql`
|
|
59
|
+
UPDATE todos SET done = ${checkbox.checked}
|
|
60
|
+
WHERE id = ${id}
|
|
61
|
+
`;
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
todosElement.addEventListener('click', (event) => {
|
|
66
|
+
const target = event.target;
|
|
67
|
+
const button =
|
|
68
|
+
target instanceof Element
|
|
69
|
+
? target.closest<HTMLButtonElement>('button[data-action="delete"]')
|
|
70
|
+
: null;
|
|
71
|
+
const id = button?.dataset.todoId;
|
|
72
|
+
if (!id) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
runAction(async () => {
|
|
76
|
+
await database.sql`DELETE FROM todos WHERE id = ${id}`;
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await renderTodos(database);
|
|
81
|
+
titleInput.focus();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function renderTodos(database: Database): Promise<void> {
|
|
85
|
+
todosElement.setAttribute('aria-busy', 'true');
|
|
86
|
+
const {rows} = await database.query<Todo>(
|
|
87
|
+
'SELECT id, title, done FROM todos ORDER BY title, id',
|
|
88
|
+
);
|
|
89
|
+
todosElement.replaceChildren(
|
|
90
|
+
...(rows.length === 0 ? [createEmptyState()] : rows.map(createTodo)),
|
|
91
|
+
);
|
|
92
|
+
todosElement.setAttribute('aria-busy', 'false');
|
|
93
|
+
statusElement.textContent = 'Ready';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createTodo(todo: Todo): HTMLLIElement {
|
|
97
|
+
const item = document.createElement('li');
|
|
98
|
+
item.className = `todo${todo.done ? ' is-done' : ''}`;
|
|
99
|
+
item.dataset.todoId = todo.id;
|
|
100
|
+
|
|
101
|
+
const label = document.createElement('label');
|
|
102
|
+
const checkbox = document.createElement('input');
|
|
103
|
+
checkbox.type = 'checkbox';
|
|
104
|
+
checkbox.checked = todo.done;
|
|
105
|
+
checkbox.dataset.action = 'toggle';
|
|
106
|
+
checkbox.dataset.todoId = todo.id;
|
|
107
|
+
checkbox.setAttribute(
|
|
108
|
+
'aria-label',
|
|
109
|
+
`Mark ${todo.title} as ${todo.done ? 'not done' : 'done'}`,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const title = document.createElement('span');
|
|
113
|
+
title.textContent = todo.title;
|
|
114
|
+
label.append(checkbox, title);
|
|
115
|
+
|
|
116
|
+
const deleteButton = document.createElement('button');
|
|
117
|
+
deleteButton.type = 'button';
|
|
118
|
+
deleteButton.className = 'delete';
|
|
119
|
+
deleteButton.dataset.action = 'delete';
|
|
120
|
+
deleteButton.dataset.todoId = todo.id;
|
|
121
|
+
deleteButton.setAttribute('aria-label', `Delete ${todo.title}`);
|
|
122
|
+
deleteButton.textContent = 'Delete';
|
|
123
|
+
|
|
124
|
+
item.append(label, deleteButton);
|
|
125
|
+
return item;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createEmptyState(): HTMLLIElement {
|
|
129
|
+
const item = document.createElement('li');
|
|
130
|
+
item.className = 'empty';
|
|
131
|
+
item.textContent = 'No todos yet. Add your first one above.';
|
|
132
|
+
return item;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runAction(action: () => Promise<unknown>): void {
|
|
136
|
+
clearError();
|
|
137
|
+
void action().catch(showError);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function clearError(): void {
|
|
141
|
+
errorElement.hidden = true;
|
|
142
|
+
errorElement.textContent = '';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function showError(error: unknown): void {
|
|
146
|
+
statusElement.textContent = 'Something went wrong.';
|
|
147
|
+
errorElement.hidden = false;
|
|
148
|
+
errorElement.textContent = error instanceof Error ? error.message : String(error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function requiredElement<ElementType extends Element>(selector: string): ElementType {
|
|
152
|
+
const element = document.querySelector<ElementType>(selector);
|
|
153
|
+
if (!element) {
|
|
154
|
+
throw new Error(`Missing starter element: ${selector}`);
|
|
155
|
+
}
|
|
156
|
+
return element;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
void start().catch(showError);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color: #20251f;
|
|
3
|
+
background: #f4f6f1;
|
|
4
|
+
font-family:
|
|
5
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
6
|
+
sans-serif;
|
|
7
|
+
font-synthesis: none;
|
|
8
|
+
text-rendering: optimizeLegibility;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
* {
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
min-width: 20rem;
|
|
17
|
+
min-height: 100vh;
|
|
18
|
+
margin: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
button,
|
|
22
|
+
input {
|
|
23
|
+
font: inherit;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
button {
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
main {
|
|
31
|
+
width: min(36rem, calc(100% - 2rem));
|
|
32
|
+
margin: 0 auto;
|
|
33
|
+
padding: 5rem 0 3rem;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
header {
|
|
37
|
+
margin-bottom: 2rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.eyebrow {
|
|
41
|
+
margin: 0 0 0.5rem;
|
|
42
|
+
color: #397253;
|
|
43
|
+
font-size: 0.78rem;
|
|
44
|
+
font-weight: 800;
|
|
45
|
+
letter-spacing: 0.08em;
|
|
46
|
+
text-transform: uppercase;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
h1 {
|
|
50
|
+
margin: 0;
|
|
51
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
52
|
+
font-size: clamp(3rem, 12vw, 5rem);
|
|
53
|
+
line-height: 0.95;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.intro {
|
|
57
|
+
margin: 1rem 0 0;
|
|
58
|
+
color: #697066;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
form {
|
|
62
|
+
padding: 1rem;
|
|
63
|
+
border: 1px solid #d7ddd3;
|
|
64
|
+
border-radius: 1rem;
|
|
65
|
+
background: #fff;
|
|
66
|
+
box-shadow: 0 1rem 3rem rgb(32 49 38 / 8%);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
form label {
|
|
70
|
+
display: block;
|
|
71
|
+
margin-bottom: 0.65rem;
|
|
72
|
+
font-size: 0.85rem;
|
|
73
|
+
font-weight: 750;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.composer {
|
|
77
|
+
display: flex;
|
|
78
|
+
gap: 0.6rem;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.composer input {
|
|
82
|
+
min-width: 0;
|
|
83
|
+
flex: 1;
|
|
84
|
+
padding: 0.8rem 0.9rem;
|
|
85
|
+
border: 1px solid #cbd3c7;
|
|
86
|
+
border-radius: 0.7rem;
|
|
87
|
+
color: inherit;
|
|
88
|
+
background: #fbfcfa;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.composer input:focus {
|
|
92
|
+
border-color: #397253;
|
|
93
|
+
outline: 3px solid rgb(57 114 83 / 15%);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.composer button,
|
|
97
|
+
.delete {
|
|
98
|
+
border: 0;
|
|
99
|
+
border-radius: 0.7rem;
|
|
100
|
+
font-weight: 750;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.composer button {
|
|
104
|
+
padding: 0.8rem 1.15rem;
|
|
105
|
+
color: #fff;
|
|
106
|
+
background: #397253;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.status,
|
|
110
|
+
.error {
|
|
111
|
+
min-height: 1.4rem;
|
|
112
|
+
margin: 1rem 0 0.5rem;
|
|
113
|
+
color: #697066;
|
|
114
|
+
font-size: 0.85rem;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.error {
|
|
118
|
+
color: #a0382e;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
ul {
|
|
122
|
+
display: grid;
|
|
123
|
+
gap: 0.65rem;
|
|
124
|
+
margin: 0;
|
|
125
|
+
padding: 0;
|
|
126
|
+
list-style: none;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.todo,
|
|
130
|
+
.empty {
|
|
131
|
+
border: 1px solid #d7ddd3;
|
|
132
|
+
border-radius: 0.85rem;
|
|
133
|
+
background: #fff;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.todo {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
justify-content: space-between;
|
|
140
|
+
gap: 1rem;
|
|
141
|
+
padding: 0.8rem 0.9rem;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.todo label {
|
|
145
|
+
display: flex;
|
|
146
|
+
min-width: 0;
|
|
147
|
+
flex: 1;
|
|
148
|
+
align-items: center;
|
|
149
|
+
gap: 0.75rem;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.todo input {
|
|
153
|
+
width: 1.15rem;
|
|
154
|
+
height: 1.15rem;
|
|
155
|
+
accent-color: #397253;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.todo span {
|
|
159
|
+
overflow-wrap: anywhere;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.todo.is-done span {
|
|
163
|
+
color: #8a9088;
|
|
164
|
+
text-decoration: line-through;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.delete {
|
|
168
|
+
padding: 0.45rem 0.65rem;
|
|
169
|
+
color: #7b423c;
|
|
170
|
+
background: #f7ecea;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.empty {
|
|
174
|
+
padding: 2rem;
|
|
175
|
+
color: #7b8278;
|
|
176
|
+
text-align: center;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
footer {
|
|
180
|
+
margin-top: 2rem;
|
|
181
|
+
color: #7b8278;
|
|
182
|
+
font-size: 0.82rem;
|
|
183
|
+
text-align: center;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
a {
|
|
187
|
+
color: #397253;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@media (max-width: 32rem) {
|
|
191
|
+
main {
|
|
192
|
+
padding-top: 2.5rem;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.composer {
|
|
196
|
+
align-items: stretch;
|
|
197
|
+
flex-direction: column;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"exactOptionalPropertyTypes": true,
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"target": "ES2022",
|
|
12
|
+
"types": ["vite/client"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|