create-tinyjoin 0.0.5 → 0.1.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.
Files changed (36) hide show
  1. package/README.md +70 -4
  2. package/cli.js +4 -4
  3. package/package.json +1 -1
  4. package/templates/AGENTS.md.hbs +67 -12
  5. package/templates/README.md.hbs +74 -13
  6. package/templates/client/index.html.hbs +48 -45
  7. package/templates/client/package.json.hbs +4 -2
  8. package/templates/client/public/favicon.svg +6 -0
  9. package/templates/client/public/js.svg +5 -0
  10. package/templates/client/public/ts.svg +6 -0
  11. package/templates/client/src/app.ts.hbs +37 -0
  12. package/templates/client/src/button.css.hbs +30 -0
  13. package/templates/client/src/button.ts.hbs +19 -0
  14. package/templates/client/src/database.ts.hbs +62 -25
  15. package/templates/client/src/error.css.hbs +6 -0
  16. package/templates/client/src/error.ts.hbs +39 -0
  17. package/templates/client/src/index.ts.hbs +7 -0
  18. package/templates/client/src/info.css.hbs +93 -0
  19. package/templates/client/src/info.ts.hbs +42 -0
  20. package/templates/client/src/input.css.hbs +22 -0
  21. package/templates/client/src/input.ts.hbs +16 -0
  22. package/templates/client/src/loading.css.hbs +19 -0
  23. package/templates/client/src/loading.ts.hbs +12 -0
  24. package/templates/client/src/title.css.hbs +16 -0
  25. package/templates/client/src/title.ts.hbs +16 -0
  26. package/templates/client/src/todoInput.css.hbs +16 -0
  27. package/templates/client/src/todoInput.ts.hbs +50 -0
  28. package/templates/client/src/todoItem.css.hbs +33 -0
  29. package/templates/client/src/todoItem.ts.hbs +31 -0
  30. package/templates/client/src/todoList.css.hbs +15 -0
  31. package/templates/client/src/todoList.ts.hbs +89 -0
  32. package/templates/client/src/topBar.css.hbs +13 -0
  33. package/templates/client/src/topBar.ts.hbs +13 -0
  34. package/templates/client/vite.config.ts.hbs +6 -0
  35. package/templates/client/src/main.ts.hbs +0 -159
  36. package/templates/client/src/style.css.hbs +0 -199
@@ -1,29 +1,66 @@
1
- import {create} from 'tinyjoin';
1
+ import {create, ClientError, type Client} from 'tinyjoin';
2
2
 
3
- export type Todo = {
3
+ export type TodoRow = {
4
4
  id: string;
5
- title: string;
6
- done: boolean;
5
+ text: string;
6
+ completed: boolean;
7
+ created: number;
7
8
  };
8
9
 
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
- }
10
+ export type TodosDatabase = Client;
11
+
12
+ {{#if usesOpfs}}
13
+ export const DATA_DIR = 'opfs://{{storageName}}';
14
+ {{else}}
15
+ export const DATA_DIR = 'memory://';
16
+ {{/if}}
17
+
18
+ export const createTodosDatabase = async (): Promise<TodosDatabase> => {
19
+ const database = await create(DATA_DIR);
20
+
21
+ try {
22
+ await initializeTodos(database);
23
+ return database;
24
+ } catch (error) {
25
+ await database.close().catch(() => undefined);
26
+ throw error;
27
+ }
28
+ };
29
+
30
+ const initializeTodos = async (database: TodosDatabase): Promise<void> => {
31
+ try {
32
+ // One atomic script creates and seeds the table. Concurrent tabs can race
33
+ // to initialize it, but only the tab that creates the table adds examples.
34
+ await database.exec(`
35
+ CREATE TABLE todos (
36
+ id TEXT PRIMARY KEY,
37
+ text TEXT NOT NULL,
38
+ completed BOOLEAN NOT NULL DEFAULT false,
39
+ created INTEGER NOT NULL DEFAULT 0
40
+ );
41
+ INSERT INTO todos (id, text, completed, created)
42
+ VALUES ('1', 'Learn TinyJoin', false, 1), ('2', 'Build an app', false, 2)
43
+ `);
44
+ } catch (error) {
45
+ if (!(error instanceof ClientError && error.code === 'TABLE_ALREADY_EXISTS')) {
46
+ throw error;
47
+ }
48
+ }
49
+ };
50
+
51
+ export const databaseReady = createTodosDatabase();
52
+ // Startup can reject before the page's load event. app() presents the failure.
53
+ void databaseReady.catch(() => undefined);
54
+
55
+ addEventListener(
56
+ 'pagehide',
57
+ () => void databaseReady.then((database) => database.close()).catch(() => undefined),
58
+ {once: true},
59
+ );
60
+
61
+ // A restored page must not reuse the client closed during pagehide.
62
+ addEventListener('pageshow', (event) => {
63
+ if (event.persisted) {
64
+ location.reload();
65
+ }
66
+ });
@@ -0,0 +1,6 @@
1
+ .error {
2
+ color: #fca5a5;
3
+ line-height: 1.5;
4
+ margin: 0 0 1rem;
5
+ user-select: text;
6
+ }
@@ -0,0 +1,39 @@
1
+ import {ClientError} from 'tinyjoin';
2
+ import './error.css';
3
+
4
+ export const createError = (): HTMLParagraphElement => {
5
+ const element = document.createElement('p');
6
+ element.className = 'error';
7
+ element.setAttribute('role', 'alert');
8
+ element.hidden = true;
9
+ return element;
10
+ };
11
+
12
+ export const clearError = (element: HTMLElement): void => {
13
+ element.hidden = true;
14
+ element.textContent = '';
15
+ };
16
+
17
+ export const showError = (
18
+ element: HTMLElement,
19
+ error: unknown,
20
+ fallback: string,
21
+ ): void => {
22
+ let message = error instanceof Error ? `${fallback} ${error.message}` : fallback;
23
+ if (error instanceof ClientError) {
24
+ if (error.code === 'STORAGE_LOCKED') {
25
+ message = 'The saved database is busy. Close older versions of this app, then select Retry.';
26
+ } else if ([
27
+ 'RECOVERY_REQUIRED',
28
+ 'STORAGE_COMMIT_OUTCOME_UNKNOWN',
29
+ 'STORAGE_ENGINE_POISONED',
30
+ 'WORKER_ERROR',
31
+ 'WORKER_MESSAGE_ERROR',
32
+ 'WORKER_TERMINATED',
33
+ ].includes(error.code)) {
34
+ message = 'The change may have been saved. Reload this page and check your todos before trying again.';
35
+ }
36
+ }
37
+ element.textContent = message;
38
+ element.hidden = false;
39
+ };
@@ -0,0 +1,7 @@
1
+ import {app} from './app';
2
+
3
+ const root = document.getElementById('root')!;
4
+
5
+ addEventListener('load', () => {
6
+ void app(root);
7
+ });
@@ -0,0 +1,93 @@
1
+ #info {
2
+ --info-fg: var(--fg, oklch(85% 0.01 270));
3
+ --info-fg2: var(--fg2, oklch(60% 0.01 270));
4
+ --info-bg2: var(--bg2, oklch(25% 0.01 270));
5
+ --info-border: var(--border, oklch(30% 0.01 270));
6
+ --info-accent: var(--accent, #7c3aed);
7
+ --info-icon: #8a8f98;
8
+ position: relative;
9
+ display: flex;
10
+ align-items: center;
11
+ gap: 0.5rem;
12
+ }
13
+
14
+ #infoTech {
15
+ display: flex;
16
+ align-items: center;
17
+ gap: 0.375rem;
18
+ }
19
+
20
+ .infoTechIcon {
21
+ width: 1.25rem;
22
+ height: 1.25rem;
23
+ display: block;
24
+ flex-shrink: 0;
25
+ }
26
+
27
+ #infoIcon {
28
+ width: 1.25rem;
29
+ height: 1.25rem;
30
+ border: 2px solid var(--info-icon);
31
+ border-radius: 50%;
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ font-size: 0.75rem;
36
+ font-weight: 800;
37
+ color: var(--info-icon);
38
+ cursor: help;
39
+ transition: all 0.2s;
40
+ user-select: none;
41
+ }
42
+
43
+ #infoIcon:hover {
44
+ border-color: var(--info-accent);
45
+ color: var(--info-accent);
46
+ }
47
+
48
+ #infoIcon:hover #infoTooltip {
49
+ opacity: 1;
50
+ visibility: visible;
51
+ transform: translateY(0);
52
+ }
53
+
54
+ #infoTooltip {
55
+ position: absolute;
56
+ right: 0;
57
+ top: calc(100% + 0.5rem);
58
+ background: var(--info-bg2);
59
+ border: 1px solid var(--info-border);
60
+ border-radius: 0.375rem;
61
+ padding: 0.75rem 1rem;
62
+ font-size: 0.85rem;
63
+ font-weight: 400;
64
+ width: 20rem;
65
+ white-space: normal;
66
+ color: var(--info-fg);
67
+ line-height: 1.5;
68
+ opacity: 0;
69
+ visibility: hidden;
70
+ transform: translateY(-0.25rem);
71
+ transition: all 0.2s;
72
+ pointer-events: none;
73
+ box-shadow: 0 1px 2px 0 #0007;
74
+ z-index: 1;
75
+ }
76
+
77
+ #infoTooltip a {
78
+ color: var(--info-fg);
79
+ text-decoration: underline;
80
+ }
81
+
82
+ #infoTooltip::before {
83
+ content: '';
84
+ position: absolute;
85
+ right: 0.75rem;
86
+ top: -0.375rem;
87
+ width: 0.75rem;
88
+ height: 0.75rem;
89
+ background: var(--info-bg2);
90
+ border-left: 1px solid var(--info-border);
91
+ border-top: 1px solid var(--info-border);
92
+ transform: rotate(45deg);
93
+ }
@@ -0,0 +1,42 @@
1
+ import './info.css';
2
+
3
+ export const createInfo = (): HTMLDivElement => {
4
+ const container = document.createElement('div');
5
+ container.id = 'info';
6
+
7
+ const tech = document.createElement('div');
8
+ tech.id = 'infoTech';
9
+ container.appendChild(tech);
10
+
11
+ {{#if typescript}}
12
+ addIcon(tech, '/ts.svg', 'Written in TypeScript');
13
+ {{else}}
14
+ addIcon(tech, '/js.svg', 'Written in JavaScript');
15
+ {{/if}}
16
+
17
+ const infoIcon = document.createElement('div');
18
+ infoIcon.id = 'infoIcon';
19
+ infoIcon.textContent = 'i';
20
+
21
+ const tooltip = document.createElement('div');
22
+ tooltip.id = 'infoTooltip';
23
+ tooltip.textContent = `A simple todo list application demonstrating TinyJoin's relational queries with CRUD operations.`;
24
+
25
+ infoIcon.appendChild(tooltip);
26
+ container.appendChild(infoIcon);
27
+
28
+ return container;
29
+ };
30
+
31
+ const addIcon = (
32
+ container: HTMLElement,
33
+ src: string,
34
+ title: string,
35
+ ): HTMLImageElement => {
36
+ const icon = document.createElement('img');
37
+ icon.src = src;
38
+ icon.className = 'infoTechIcon';
39
+ icon.title = title;
40
+ container.appendChild(icon);
41
+ return icon;
42
+ };
@@ -0,0 +1,22 @@
1
+ input[type="text"] {
2
+ padding: 0.5rem 0.75rem;
3
+ background: var(--bg);
4
+ border: 1px solid var(--border);
5
+ border-radius: 0.375rem;
6
+ color: var(--fg);
7
+ font-family: inherit;
8
+ font-size: 1rem;
9
+ line-height: 1.5;
10
+ width: 100%;
11
+ box-sizing: border-box;
12
+ align-self: center;
13
+ }
14
+
15
+ input[type="text"]:focus {
16
+ outline: none;
17
+ border-color: var(--accent);
18
+ }
19
+
20
+ input[type="text"]::placeholder {
21
+ color: var(--fg2);
22
+ }
@@ -0,0 +1,16 @@
1
+ import './input.css';
2
+
3
+ export const createInput = (
4
+ placeholder: string = '',
5
+ value: string = '',
6
+ onInput?: (value: string) => void,
7
+ ): HTMLInputElement => {
8
+ const input = document.createElement('input');
9
+ input.type = 'text';
10
+ input.placeholder = placeholder;
11
+ input.value = value;
12
+ if (onInput) {
13
+ input.addEventListener('input', () => onInput(input.value));
14
+ }
15
+ return input;
16
+ };
@@ -0,0 +1,19 @@
1
+ #loading {
2
+ animation: spin 1s infinite linear;
3
+ height: 2rem;
4
+ margin: 40vh auto;
5
+ width: 2rem;
6
+ }
7
+
8
+ #loading::before {
9
+ content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="2rem" viewBox="0 0 100 100"><path d="M50 10A40 40 0 1 1 10 50" stroke="%237c3aed" fill="none" stroke-width="4" /></svg>');
10
+ }
11
+
12
+ @keyframes spin {
13
+ from {
14
+ transform: rotate(0);
15
+ }
16
+ to {
17
+ transform: rotate(360deg);
18
+ }
19
+ }
@@ -0,0 +1,12 @@
1
+ import './loading.css';
2
+
3
+ export const showLoading = (container: HTMLElement): HTMLElement => {
4
+ const loadingDiv = document.createElement('div');
5
+ loadingDiv.id = 'loading';
6
+ container.appendChild(loadingDiv);
7
+ return loadingDiv;
8
+ };
9
+
10
+ export const hideLoading = (loadingDiv: HTMLElement): void => {
11
+ loadingDiv.remove();
12
+ };
@@ -0,0 +1,16 @@
1
+ #topBarLogo {
2
+ height: 2rem;
3
+ width: 2rem;
4
+ flex-shrink: 0;
5
+ }
6
+
7
+ #topBarTitle {
8
+ font-size: 1.1rem;
9
+ font-weight: 600;
10
+ color: inherit;
11
+ flex: 1;
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 1rem;
15
+ margin: 0;
16
+ }
@@ -0,0 +1,16 @@
1
+ import './title.css';
2
+
3
+ export const createTitle = (): HTMLHeadingElement => {
4
+ const title = document.createElement('h1');
5
+ title.id = 'topBarTitle';
6
+
7
+ const logo = document.createElement('img');
8
+ logo.src = '/favicon.svg';
9
+ logo.id = 'topBarLogo';
10
+ logo.alt = 'TinyJoin';
11
+ title.appendChild(logo);
12
+
13
+ title.append(`TinyJoin Todos`);
14
+
15
+ return title;
16
+ };
@@ -0,0 +1,16 @@
1
+ #todoInput {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ gap: 0.5rem;
5
+ margin-bottom: 1.5rem;
6
+ }
7
+
8
+ #todoInput input {
9
+ flex: 1;
10
+ min-width: 0;
11
+ }
12
+
13
+ #todoInput .error {
14
+ flex-basis: 100%;
15
+ margin: 0;
16
+ }
@@ -0,0 +1,50 @@
1
+ import './todoInput.css';
2
+ import {type TodosDatabase} from './database';
3
+ import {createButton} from './button';
4
+ import {createInput} from './input';
5
+ import {createError, clearError, showError} from './error';
6
+
7
+ export const createTodoInput = (database: TodosDatabase): HTMLFormElement => {
8
+ const container = document.createElement('form');
9
+ container.id = 'todoInput';
10
+
11
+ const input = createInput('What needs to be done?');
12
+ const addButton = createButton('Add', null, 'primary', 'submit');
13
+ const error = createError();
14
+ let pending = false;
15
+
16
+ const addTodo = async () => {
17
+ const text = input.value.trim();
18
+ if (!text || pending) {
19
+ return;
20
+ }
21
+ pending = true;
22
+ input.disabled = true;
23
+ addButton.disabled = true;
24
+ clearError(error);
25
+ try {
26
+ await database.query(
27
+ `INSERT INTO todos (id, text, completed, created)
28
+ VALUES ($1, $2, $3, $4)`,
29
+ [crypto.randomUUID(), text, false, Date.now()],
30
+ );
31
+ input.value = '';
32
+ } catch (failure) {
33
+ showError(error, failure, 'Could not add this todo.');
34
+ } finally {
35
+ pending = false;
36
+ input.disabled = false;
37
+ addButton.disabled = false;
38
+ input.focus();
39
+ }
40
+ };
41
+
42
+ container.addEventListener('submit', (event) => {
43
+ event.preventDefault();
44
+ void addTodo();
45
+ });
46
+
47
+ container.append(input, addButton, error);
48
+
49
+ return container;
50
+ };
@@ -0,0 +1,33 @@
1
+ .todoItem {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 0.75rem;
5
+ padding: 0.75rem;
6
+ border-bottom: 1px solid var(--border);
7
+ }
8
+
9
+ .todoItem:last-child {
10
+ border-bottom: none;
11
+ }
12
+
13
+ .todoItem input[type="checkbox"] {
14
+ width: 1.25rem;
15
+ height: 1.25rem;
16
+ cursor: pointer;
17
+ flex-shrink: 0;
18
+ }
19
+
20
+ .todoItem label {
21
+ flex: 1;
22
+ cursor: pointer;
23
+ user-select: none;
24
+ }
25
+
26
+ .todoItem.completed label {
27
+ text-decoration: line-through;
28
+ opacity: 0.6;
29
+ }
30
+
31
+ .todoItem button {
32
+ flex-shrink: 0;
33
+ }
@@ -0,0 +1,31 @@
1
+ import './todoItem.css';
2
+ import {createButton} from './button';
3
+
4
+ export const createTodoItem = (
5
+ id: string,
6
+ text: string,
7
+ completed: boolean,
8
+ onToggle: (checkbox: HTMLInputElement) => void,
9
+ onDelete: () => void,
10
+ ): HTMLDivElement => {
11
+ const item = document.createElement('div');
12
+ item.className = `todoItem${completed ? ' completed' : ''}`;
13
+
14
+ const checkbox = document.createElement('input');
15
+ checkbox.type = 'checkbox';
16
+ checkbox.checked = completed;
17
+ checkbox.id = `todo-${id}`;
18
+ checkbox.addEventListener('change', () => onToggle(checkbox));
19
+
20
+ const label = document.createElement('label');
21
+ label.textContent = text;
22
+ label.htmlFor = `todo-${id}`;
23
+
24
+ const deleteBtn = createButton('Delete', onDelete);
25
+
26
+ item.appendChild(checkbox);
27
+ item.appendChild(label);
28
+ item.appendChild(deleteBtn);
29
+
30
+ return item;
31
+ };
@@ -0,0 +1,15 @@
1
+ #todoList {
2
+ border: 1px solid var(--border);
3
+ border-radius: 0.375rem;
4
+ overflow: hidden;
5
+ background: var(--bg2);
6
+ width: 100%;
7
+ }
8
+
9
+ #todoList:empty::before {
10
+ content: 'No todos yet. Add one above!';
11
+ display: block;
12
+ padding: 2rem;
13
+ text-align: center;
14
+ color: var(--fg2);
15
+ }
@@ -0,0 +1,89 @@
1
+ import './todoList.css';
2
+ import {type TodosDatabase, type TodoRow} from './database';
3
+ import {createTodoItem} from './todoItem';
4
+ import {createError, clearError, showError} from './error';
5
+
6
+ export const createTodoList = (database: TodosDatabase): HTMLDivElement => {
7
+ const container = document.createElement('div');
8
+ const error = createError();
9
+ const list = document.createElement('div');
10
+ list.id = 'todoList';
11
+ container.append(error, list);
12
+ let pending = false;
13
+ let mutationFailed = false;
14
+ let renderGeneration = 0;
15
+
16
+ const setDisabled = (disabled: boolean) => {
17
+ list.querySelectorAll<HTMLInputElement | HTMLButtonElement>('input, button')
18
+ .forEach((control) => { control.disabled = disabled; });
19
+ };
20
+
21
+ const mutate = async (
22
+ operation: () => Promise<unknown>,
23
+ restore?: () => void,
24
+ ) => {
25
+ if (pending) {
26
+ return;
27
+ }
28
+ pending = true;
29
+ mutationFailed = false;
30
+ setDisabled(true);
31
+ clearError(error);
32
+ try {
33
+ await operation();
34
+ } catch (failure) {
35
+ restore?.();
36
+ mutationFailed = true;
37
+ showError(error, failure, 'Could not save this change.');
38
+ } finally {
39
+ // Refresh reads only: a rejected write may already have committed.
40
+ await render();
41
+ pending = false;
42
+ setDisabled(false);
43
+ }
44
+ };
45
+
46
+ const render = async () => {
47
+ const generation = ++renderGeneration;
48
+ try {
49
+ const {rows} = await database.query<TodoRow>(
50
+ 'SELECT id, text, completed, created FROM todos ORDER BY created, id',
51
+ );
52
+ if (generation !== renderGeneration) {
53
+ return;
54
+ }
55
+ if (!mutationFailed) {
56
+ clearError(error);
57
+ }
58
+
59
+ const items = rows.map((todo) => createTodoItem(
60
+ todo.id,
61
+ todo.text,
62
+ todo.completed,
63
+ (checkbox) => {
64
+ void mutate(
65
+ () => database.query('UPDATE todos SET completed = $1 WHERE id = $2', [
66
+ !todo.completed,
67
+ todo.id,
68
+ ]),
69
+ () => { checkbox.checked = todo.completed; },
70
+ );
71
+ },
72
+ () => {
73
+ void mutate(() => database.query('DELETE FROM todos WHERE id = $1', [todo.id]));
74
+ },
75
+ ));
76
+ list.replaceChildren(...items);
77
+ setDisabled(pending);
78
+ } catch (failure) {
79
+ if (generation === renderGeneration && !mutationFailed) {
80
+ showError(error, failure, 'Could not refresh your todos. Reload to try again.');
81
+ }
82
+ }
83
+ };
84
+
85
+ database.subscribe({tables: ['todos']}, () => void render());
86
+ void render();
87
+
88
+ return container;
89
+ };
@@ -0,0 +1,13 @@
1
+ #topBar {
2
+ background: var(--bg-header);
3
+ border-bottom: 1px solid var(--border);
4
+ box-shadow: 0 1px 2px 0 #0007;
5
+ padding: 0.75rem 1.5rem;
6
+ display: flex;
7
+ align-items: center;
8
+ gap: 1rem;
9
+ position: sticky;
10
+ top: 0;
11
+ z-index: 100;
12
+ backdrop-filter: blur(4px);
13
+ }
@@ -0,0 +1,13 @@
1
+ import './topBar.css';
2
+ import {createTitle} from './title';
3
+ import {createInfo} from './info';
4
+
5
+ export const createTopBar = (): HTMLDivElement => {
6
+ const topBar = document.createElement('div');
7
+ topBar.id = 'topBar';
8
+
9
+ topBar.appendChild(createTitle());
10
+ topBar.appendChild(createInfo());
11
+
12
+ return topBar;
13
+ };
@@ -0,0 +1,6 @@
1
+ import {tinyjoinOffline} from 'tinyjoin/vite';
2
+ import {defineConfig} from 'vite';
3
+
4
+ export default defineConfig({
5
+ plugins: [tinyjoinOffline()],
6
+ });