create-tinyjoin 0.0.5 → 0.0.6

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 (33) hide show
  1. package/README.md +52 -4
  2. package/cli.js +4 -4
  3. package/package.json +1 -1
  4. package/templates/AGENTS.md.hbs +49 -12
  5. package/templates/README.md.hbs +40 -15
  6. package/templates/client/index.html.hbs +51 -45
  7. package/templates/client/package.json.hbs +3 -1
  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 +28 -0
  12. package/templates/client/src/button.css.hbs +25 -0
  13. package/templates/client/src/button.ts.hbs +19 -0
  14. package/templates/client/src/database.ts.hbs +50 -23
  15. package/templates/client/src/index.ts.hbs +7 -0
  16. package/templates/client/src/info.css.hbs +93 -0
  17. package/templates/client/src/info.ts.hbs +42 -0
  18. package/templates/client/src/input.css.hbs +22 -0
  19. package/templates/client/src/input.ts.hbs +16 -0
  20. package/templates/client/src/loading.css.hbs +19 -0
  21. package/templates/client/src/loading.ts.hbs +12 -0
  22. package/templates/client/src/title.css.hbs +16 -0
  23. package/templates/client/src/title.ts.hbs +16 -0
  24. package/templates/client/src/todoInput.css.hbs +9 -0
  25. package/templates/client/src/todoInput.ts.hbs +36 -0
  26. package/templates/client/src/todoItem.css.hbs +33 -0
  27. package/templates/client/src/todoItem.ts.hbs +31 -0
  28. package/templates/client/src/todoList.css.hbs +15 -0
  29. package/templates/client/src/todoList.ts.hbs +38 -0
  30. package/templates/client/src/topBar.css.hbs +13 -0
  31. package/templates/client/src/topBar.ts.hbs +13 -0
  32. package/templates/client/src/main.ts.hbs +0 -159
  33. package/templates/client/src/style.css.hbs +0 -199
@@ -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,9 @@
1
+ #todoInput {
2
+ display: flex;
3
+ gap: 0.5rem;
4
+ margin-bottom: 1.5rem;
5
+ }
6
+
7
+ #todoInput input {
8
+ flex: 1;
9
+ }
@@ -0,0 +1,36 @@
1
+ import './todoInput.css';
2
+ import {type TodosDatabase} from './database';
3
+ import {createButton} from './button';
4
+ import {createInput} from './input';
5
+
6
+ export const createTodoInput = (database: TodosDatabase): HTMLFormElement => {
7
+ const container = document.createElement('form');
8
+ container.id = 'todoInput';
9
+
10
+ const input = createInput('What needs to be done?');
11
+
12
+ const addTodo = () => {
13
+ const text = input.value.trim();
14
+ if (text) {
15
+ void database.query(
16
+ `INSERT INTO todos (id, text, completed, created)
17
+ VALUES ($1, $2, $3, $4)`,
18
+ [crypto.randomUUID(), text, false, Date.now()],
19
+ );
20
+ input.value = '';
21
+ input.focus();
22
+ }
23
+ };
24
+
25
+ container.addEventListener('submit', (event) => {
26
+ event.preventDefault();
27
+ addTodo();
28
+ });
29
+
30
+ const addButton = createButton('Add', null, 'primary', 'submit');
31
+
32
+ container.appendChild(input);
33
+ container.appendChild(addButton);
34
+
35
+ return container;
36
+ };
@@ -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: () => 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);
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,38 @@
1
+ import './todoList.css';
2
+ import {type TodosDatabase, type TodoRow} from './database';
3
+ import {createTodoItem} from './todoItem';
4
+
5
+ export const createTodoList = (database: TodosDatabase): HTMLDivElement => {
6
+ const list = document.createElement('div');
7
+ list.id = 'todoList';
8
+
9
+ const render = async () => {
10
+ const {rows} = await database.query<TodoRow>(
11
+ 'SELECT id, text, completed, created FROM todos ORDER BY created, id',
12
+ );
13
+
14
+ list.innerHTML = '';
15
+ rows.forEach((todo) => {
16
+ const item = createTodoItem(
17
+ todo.id,
18
+ todo.text,
19
+ todo.completed,
20
+ () => {
21
+ void database.query('UPDATE todos SET completed = $1 WHERE id = $2', [
22
+ !todo.completed,
23
+ todo.id,
24
+ ]);
25
+ },
26
+ () => {
27
+ void database.query('DELETE FROM todos WHERE id = $1', [todo.id]);
28
+ },
29
+ );
30
+ list.appendChild(item);
31
+ });
32
+ };
33
+
34
+ database.subscribe({tables: ['todos']}, () => void render());
35
+ void render();
36
+
37
+ return list;
38
+ };
@@ -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
+ };
@@ -1,159 +0,0 @@
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);