create-tinybase 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/cli.js +2 -2
  2. package/package.json +2 -2
  3. package/templates/{base/README.md.hbs → README.md.hbs} +1 -0
  4. package/templates/{base → client}/eslint.config.js.hbs +5 -0
  5. package/templates/client/index.html.hbs +182 -0
  6. package/templates/{base → client}/package.json.hbs +18 -2
  7. package/templates/client/public/favicon.svg +8 -0
  8. package/templates/client/src/chat/App.tsx.hbs +40 -0
  9. package/templates/client/src/chat/ChatStore.tsx.hbs +70 -0
  10. package/templates/client/src/chat/Message.tsx.hbs +21 -0
  11. package/templates/client/src/chat/MessageInput.tsx.hbs +42 -0
  12. package/templates/client/src/chat/Messages.tsx.hbs +29 -0
  13. package/templates/client/src/chat/SettingsStore.tsx.hbs +34 -0
  14. package/templates/client/src/chat/UsernameInput.tsx.hbs +22 -0
  15. package/templates/client/src/chat/app.ts.hbs +39 -0
  16. package/templates/client/src/chat/chatStore.ts.hbs +50 -0
  17. package/templates/client/src/chat/message.css.hbs +20 -0
  18. package/templates/client/src/chat/message.ts.hbs +27 -0
  19. package/templates/client/src/chat/messageInput.css.hbs +6 -0
  20. package/templates/client/src/chat/messageInput.ts.hbs +42 -0
  21. package/templates/client/src/chat/messages.css.hbs +6 -0
  22. package/templates/client/src/chat/messages.ts.hbs +33 -0
  23. package/templates/client/src/chat/settingsStore.ts.hbs +19 -0
  24. package/templates/client/src/chat/usernameInput.css.hbs +14 -0
  25. package/templates/client/src/chat/usernameInput.ts.hbs +30 -0
  26. package/templates/client/src/drawing/App.tsx.hbs +36 -0
  27. package/templates/client/src/drawing/BrushSize.tsx.hbs +22 -0
  28. package/templates/client/src/drawing/Canvas.tsx.hbs +100 -0
  29. package/templates/client/src/drawing/CanvasStore.tsx.hbs +62 -0
  30. package/templates/client/src/drawing/ColorPicker.tsx.hbs +24 -0
  31. package/templates/client/src/drawing/DrawingControls.tsx.hbs +24 -0
  32. package/templates/client/src/drawing/SettingsStore.tsx.hbs +36 -0
  33. package/templates/client/src/drawing/app.ts.hbs +20 -0
  34. package/templates/client/src/drawing/brushSize.css.hbs +21 -0
  35. package/templates/client/src/drawing/brushSize.ts.hbs +33 -0
  36. package/templates/client/src/drawing/canvas.css.hbs +8 -0
  37. package/templates/client/src/drawing/canvas.ts.hbs +103 -0
  38. package/templates/client/src/drawing/canvasStore.ts.hbs +42 -0
  39. package/templates/client/src/drawing/colorPicker.css.hbs +21 -0
  40. package/templates/client/src/drawing/colorPicker.ts.hbs +34 -0
  41. package/templates/client/src/drawing/drawingControls.css.hbs +12 -0
  42. package/templates/client/src/drawing/drawingControls.ts.hbs +26 -0
  43. package/templates/client/src/drawing/settingsStore.ts.hbs +21 -0
  44. package/templates/client/src/game/App.tsx.hbs +28 -0
  45. package/templates/client/src/game/Board.tsx.hbs +27 -0
  46. package/templates/client/src/game/Game.tsx.hbs +78 -0
  47. package/templates/client/src/game/GameStatus.tsx.hbs +21 -0
  48. package/templates/client/src/game/Square.tsx.hbs +23 -0
  49. package/templates/client/src/game/Store.tsx.hbs +67 -0
  50. package/templates/client/src/game/app.ts.hbs +12 -0
  51. package/templates/client/src/game/board.css.hbs +13 -0
  52. package/templates/client/src/game/board.ts.hbs +39 -0
  53. package/templates/client/src/game/game.ts.hbs +74 -0
  54. package/templates/client/src/game/gameStatus.css.hbs +21 -0
  55. package/templates/client/src/game/gameStatus.ts.hbs +27 -0
  56. package/templates/client/src/game/square.css.hbs +38 -0
  57. package/templates/client/src/game/square.ts.hbs +11 -0
  58. package/templates/client/src/game/store.ts.hbs +47 -0
  59. package/templates/client/src/index.tsx.hbs +24 -0
  60. package/templates/client/src/shared/Button.tsx.hbs +16 -0
  61. package/templates/client/src/shared/Input.tsx.hbs +16 -0
  62. package/templates/client/src/shared/button.css.hbs +25 -0
  63. package/templates/client/src/shared/button.ts.hbs +16 -0
  64. package/templates/client/src/shared/config.ts.hbs +9 -0
  65. package/templates/client/src/shared/input.css.hbs +22 -0
  66. package/templates/client/src/shared/input.ts.hbs +17 -0
  67. package/templates/client/src/todos/App.tsx.hbs +32 -0
  68. package/templates/client/src/todos/Store.tsx.hbs +70 -0
  69. package/templates/client/src/todos/TodoInput.tsx.hbs +30 -0
  70. package/templates/client/src/todos/TodoItem.tsx.hbs +20 -0
  71. package/templates/client/src/todos/TodoList.tsx.hbs +18 -0
  72. package/templates/client/src/todos/app.ts.hbs +23 -0
  73. package/templates/client/src/todos/store.ts.hbs +49 -0
  74. package/templates/client/src/todos/todoInput.css.hbs +9 -0
  75. package/templates/client/src/todos/todoInput.ts.hbs +38 -0
  76. package/templates/client/src/todos/todoItem.css.hbs +33 -0
  77. package/templates/client/src/todos/todoItem.ts.hbs +28 -0
  78. package/templates/client/src/todos/todoList.css.hbs +14 -0
  79. package/templates/client/src/todos/todoList.ts.hbs +38 -0
  80. package/templates/{base → client}/vite.config.js.hbs +5 -3
  81. package/templates/package.json.hbs +38 -0
  82. package/templates/server/index-do.ts.hbs +22 -0
  83. package/templates/server/index-node.ts.hbs +8 -0
  84. package/templates/server/package.json.hbs +51 -0
  85. package/templates/server/tsconfig.json.hbs +13 -0
  86. package/templates/server/wrangler.toml.hbs +12 -0
  87. package/templates/base/index.html.hbs +0 -17
  88. package/templates/base/tsconfig.node.json.hbs +0 -9
  89. package/templates/src/App.tsx.hbs +0 -24
  90. package/templates/src/index.css.hbs +0 -110
  91. package/templates/src/index.tsx.hbs +0 -48
  92. /package/templates/{base → client}/.prettierrc.hbs +0 -0
  93. /package/templates/{base → client}/tsconfig.json.hbs +0 -0
@@ -0,0 +1,103 @@
1
+ {{includeFile template="client/src/drawing/canvas.css.hbs" output="client/src/canvas.css"}}
2
+ import './canvas.css';
3
+ import {getUniqueId} from 'tinybase';
4
+ import type {Store as SettingsStore} from './settingsStore';
5
+ import type {Store as CanvasStore} from './canvasStore';
6
+
7
+ export const createCanvas = (settingsStore: SettingsStore, canvasStore: CanvasStore): HTMLCanvasElement => {
8
+ const canvas = document.createElement('canvas');
9
+ canvas.id = 'drawingCanvas';
10
+ canvas.width = 600;
11
+ canvas.height = 400;
12
+
13
+ const ctx = canvas.getContext('2d')!;
14
+ let isDrawing = false;
15
+ let currentStrokeId: string | null = null;
16
+ let pointIndex = 0;
17
+
18
+ const draw = () => {
19
+ ctx.fillStyle = '#111';
20
+ ctx.fillRect(0, 0, canvas!.width, canvas!.height);
21
+
22
+ const strokes = canvasStore.getTable('strokes');
23
+ Object.entries(strokes).forEach(([id, stroke]: [string, any]) => {
24
+ const points: Array<{x: number; y: number}> = [];
25
+ let i = 0;
26
+ while (stroke[`x${i}`] !== undefined && stroke[`y${i}`] !== undefined) {
27
+ points.push({x: stroke[`x${i}`], y: stroke[`y${i}`]});
28
+ i++;
29
+ }
30
+ if (points.length > 0) {
31
+ ctx.strokeStyle = stroke.color;
32
+ ctx.lineWidth = stroke.size * 2;
33
+ ctx.lineCap = 'round';
34
+ ctx.lineJoin = 'round';
35
+ ctx.beginPath();
36
+ ctx.moveTo(points[0].x, points[0].y);
37
+ for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y); } ctx.stroke(); } }); }; const addPoint=(e: MouseEvent | TouchEvent)=> {
38
+ if (!currentStrokeId) return;
39
+
40
+ const rect = canvas!.getBoundingClientRect();
41
+ const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
42
+ const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
43
+
44
+ canvasStore.setCell('strokes', currentStrokeId, `x${pointIndex}`, clientX - rect.left);
45
+ canvasStore.setCell('strokes', currentStrokeId, `y${pointIndex}`, clientY - rect.top);
46
+ pointIndex++;
47
+ };
48
+
49
+ const startStroke = (e: MouseEvent | TouchEvent) => {
50
+ const brush = settingsStore.getValues() as any;
51
+ currentStrokeId = getUniqueId();
52
+ pointIndex = 0;
53
+
54
+ canvasStore.setRow('strokes', currentStrokeId, {
55
+ color: brush.brushColor,
56
+ size: brush.brushSize,
57
+ });
58
+
59
+ addPoint(e);
60
+ };
61
+
62
+ canvas.addEventListener('mousedown', (e) => {
63
+ isDrawing = true;
64
+ startStroke(e);
65
+ });
66
+
67
+ canvas.addEventListener('mouseup', () => {
68
+ isDrawing = false;
69
+ currentStrokeId = null;
70
+ });
71
+
72
+ canvas.addEventListener('mouseleave', () => {
73
+ isDrawing = false;
74
+ currentStrokeId = null;
75
+ });
76
+
77
+ canvas.addEventListener('mousemove', (e) => {
78
+ if (isDrawing) addPoint(e);
79
+ });
80
+
81
+ canvas.addEventListener('touchstart', (e) => {
82
+ isDrawing = true;
83
+ startStroke(e);
84
+ e.preventDefault();
85
+ });
86
+
87
+ canvas.addEventListener('touchmove', (e) => {
88
+ if (isDrawing) {
89
+ addPoint(e);
90
+ e.preventDefault();
91
+ }
92
+ });
93
+
94
+ canvas.addEventListener('touchend', () => {
95
+ isDrawing = false;
96
+ currentStrokeId = null;
97
+ });
98
+
99
+ canvasStore.addTablesListener(draw);
100
+ draw();
101
+
102
+ return canvas;
103
+ };
@@ -0,0 +1,42 @@
1
+ {{#if schemas}}
2
+ import {createMergeableStore} from 'tinybase/with-schemas';
3
+ {{else}}
4
+ import {createMergeableStore} from 'tinybase';
5
+ {{/if}}
6
+ import {getUniqueId} from 'tinybase';
7
+
8
+ const STORE_ID = 'canvas';
9
+
10
+ {{#if schemas}}
11
+ const TABLES_SCHEMA = {
12
+ strokes: {
13
+ color: {type: 'string'},
14
+ size: {type: 'number'},
15
+ },
16
+ } as const;
17
+
18
+ {{/if}}
19
+ export const canvasStore = createMergeableStore(STORE_ID){{#if schemas}}
20
+ .setTablesSchema(TABLES_SCHEMA){{/if}}
21
+ .setDefaultContent([{strokes: {}}, {}]);
22
+
23
+ {{#if sync}}
24
+ {{includeFile template="client/src/shared/config.ts.hbs" output="client/src/config.ts"}}
25
+ {{addImport "import {SERVER} from './config';"}}
26
+ {{addImport "import ReconnectingWebSocket from 'reconnecting-websocket';"}}
27
+ {{addImport "import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client';"}}
28
+
29
+ const serverPathId = location.pathname;
30
+ createWsSynchronizer(
31
+ canvasStore,
32
+ new ReconnectingWebSocket(SERVER + serverPathId),
33
+ ).then(async (synchronizer) => {
34
+ await synchronizer.startSync();
35
+
36
+ synchronizer.getWebSocket().addEventListener('open', () => {
37
+ synchronizer.load().then(() => synchronizer.save());
38
+ });
39
+ });
40
+ {{/if}}
41
+
42
+ export type CanvasStore = typeof canvasStore;
@@ -0,0 +1,21 @@
1
+ #colorPicker {
2
+ display: flex;
3
+ gap: 0;
4
+ align-items: center;
5
+ }
6
+
7
+ .colorBtn {
8
+ width: 2rem;
9
+ height: 2rem;
10
+ border: 2px solid var(--border);
11
+ border-radius: 0.25rem;
12
+ cursor: pointer;
13
+ transition: border-color 0.2s;
14
+ margin: 0 0 0 0.5rem;
15
+ padding: 0;
16
+ }
17
+
18
+ .colorBtn:hover,
19
+ .colorBtn.active {
20
+ border-color: var(--accent);
21
+ }
@@ -0,0 +1,34 @@
1
+ {{includeFile template="client/src/drawing/colorPicker.css.hbs" output="client/src/colorPicker.css"}}
2
+ import './colorPicker.css';
3
+ import type {Store as SettingsStore} from './settingsStore';
4
+
5
+ export const createColorPicker = (store: SettingsStore): HTMLDivElement => {
6
+ const colors = ['#d81b60', '#1976d2', '#388e3c', '#f57c00', '#7b1fa2', '#fff'];
7
+ const container = document.createElement('div');
8
+ container.id = 'colorPicker';
9
+
10
+ const buttons: HTMLButtonElement[] = [];
11
+
12
+ colors.forEach((color) => {
13
+ const btn = document.createElement('button');
14
+ btn.className = 'colorBtn';
15
+ btn.style.background = color;
16
+ btn.addEventListener('click', () => {
17
+ store.setValue('brushColor', color);
18
+ });
19
+ container.appendChild(btn);
20
+ buttons.push(btn);
21
+ });
22
+
23
+ const updateActive = () => {
24
+ const currentColor = store.getValue('brushColor');
25
+ buttons.forEach((btn, i) => {
26
+ btn.classList.toggle('active', colors[i] === currentColor);
27
+ });
28
+ };
29
+
30
+ store.addValueListener('brushColor', updateActive);
31
+ updateActive();
32
+
33
+ return container;
34
+ };
@@ -0,0 +1,12 @@
1
+ #drawingControls {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+ gap: 0.75rem;
5
+ align-items: center;
6
+ margin: 0 auto 1rem;
7
+ padding: 0.75rem;
8
+ border: 1px solid var(--border);
9
+ border-radius: 0.375rem;
10
+ justify-content: space-between;
11
+ max-width: 600px;
12
+ }
@@ -0,0 +1,26 @@
1
+ {{includeFile template="client/src/drawing/drawingControls.css.hbs" output="client/src/drawingControls.css"}}
2
+ import './drawingControls.css';
3
+ import type {Store as SettingsStore} from './settingsStore';
4
+ import type {Store as CanvasStore} from './canvasStore';
5
+
6
+ {{includeFile template="client/src/shared/button.ts.hbs" output="client/src/button.{{ext}}"}}
7
+ import {createButton} from './button';
8
+
9
+ {{includeFile template="client/src/drawing/colorPicker.ts.hbs" output="client/src/colorPicker.{{ext}}"}}
10
+ import {createColorPicker} from './colorPicker';
11
+
12
+ {{includeFile template="client/src/drawing/brushSize.ts.hbs" output="client/src/brushSize.{{ext}}"}}
13
+ import {createBrushSize} from './brushSize';
14
+
15
+ export const createDrawingControls = (settingsStore: SettingsStore, canvasStore: CanvasStore): HTMLDivElement => {
16
+ const controls = document.createElement('div');
17
+ controls.id = 'drawingControls';
18
+
19
+ controls.appendChild(createColorPicker(settingsStore));
20
+ controls.appendChild(createBrushSize(settingsStore));
21
+
22
+ const clearButton = createButton('Clear', () => canvasStore.delTable('strokes'), 'primary');
23
+ controls.appendChild(clearButton);
24
+
25
+ return controls;
26
+ };
@@ -0,0 +1,21 @@
1
+ {{#if schemas}}
2
+ import {createStore} from 'tinybase/with-schemas';
3
+ {{else}}
4
+ import {createStore} from 'tinybase';
5
+ {{/if}}
6
+
7
+ const STORE_ID = 'settings';
8
+
9
+ {{#if schemas}}
10
+ const VALUES_SCHEMA = {
11
+ brushColor: {type: 'string'},
12
+ brushSize: {type: 'number'},
13
+ } as const;
14
+
15
+ {{/if}}
16
+ export const settingsStore = createStore(){{#if schemas}}
17
+ .setValuesSchema(VALUES_SCHEMA){{/if}}
18
+ .setValue('brushColor', '#d81b60')
19
+ .setValue('brushSize', 5);
20
+
21
+ export type SettingsStore = typeof settingsStore;
@@ -0,0 +1,28 @@
1
+ import {StrictMode} from 'react';
2
+
3
+ {{#if schemas}}
4
+ import {Provider} from 'tinybase/ui-react/with-schemas';
5
+ {{else}}
6
+ import {Provider} from 'tinybase/ui-react';
7
+ {{/if}}
8
+ import {Inspector} from 'tinybase/ui-react-inspector';
9
+
10
+ {{includeFile template="client/src/game/Store.tsx.hbs" output="client/src/Store.{{ext}}"}}
11
+ import {Store} from './Store';
12
+
13
+ {{includeFile template="client/src/game/Game.tsx.hbs" output="client/src/Game.{{ext}}"}}
14
+ import {Game} from './Game';
15
+
16
+ const App = () => {
17
+ return (
18
+ <StrictMode>
19
+ <Provider>
20
+ <Store />
21
+ <Game />
22
+ <Inspector />
23
+ </Provider>
24
+ </StrictMode>
25
+ );
26
+ };
27
+
28
+ export {App};
@@ -0,0 +1,27 @@
1
+ {{includeFile template="client/src/game/board.css.hbs" output="client/src/board.css"}}
2
+ import './board.css';
3
+ import {useMemo} from 'react';
4
+ import {useValue, STORE_ID} from './Store';
5
+
6
+ {{includeFile template="client/src/game/Square.tsx.hbs" output="client/src/Square.{{ext}}"}}
7
+ import {Square} from './Square';
8
+
9
+ export const Board = () => {
10
+ const gameStatus = useValue('gameStatus', STORE_ID);
11
+ const winningLine = useValue('winningLine', STORE_ID);
12
+
13
+ const winningPositions = useMemo(() => {
14
+ if (!winningLine) return new Set();
15
+ return new Set(winningLine.split(',').map(Number));
16
+ }, [winningLine]);
17
+
18
+ const disabled = gameStatus !== 'playing';
19
+
20
+ return (
21
+ <div id="board">
22
+ {Array.from({length: 9}, (_, i) => (
23
+ <Square key={i} position={i} disabled={disabled} winning={winningPositions.has(i)} />
24
+ ))}
25
+ </div>
26
+ );
27
+ };
@@ -0,0 +1,78 @@
1
+ import {useStore, useSetValuesCallback, useTableListener, STORE_ID} from './Store';
2
+
3
+ {{includeFile template="client/src/shared/Button.tsx.hbs" output="client/src/Button.{{ext}}"}}
4
+ import {Button} from './Button';
5
+
6
+ {{includeFile template="client/src/game/Board.tsx.hbs" output="client/src/Board.{{ext}}"}}
7
+ import {Board} from './Board';
8
+
9
+ {{includeFile template="client/src/game/GameStatus.tsx.hbs" output="client/src/GameStatus.{{ext}}"}}
10
+ import {GameStatus} from './GameStatus';
11
+
12
+ const WINNING_LINES = [
13
+ [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
14
+ [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
15
+ [0, 4, 8], [2, 4, 6], // diagonals
16
+ ];
17
+
18
+ export const Game = () => {
19
+ const store = useStore(STORE_ID);
20
+
21
+ useTableListener(
22
+ 'board',
23
+ () => {
24
+ const gameStatus = store.getValue('gameStatus');
25
+ if (gameStatus !== 'playing') return;
26
+
27
+ const board = store.getTable('board');
28
+
29
+ // Check for winner
30
+ for (const line of WINNING_LINES) {
31
+ const [a, b, c] = line;
32
+ const cellA = board[a]?.value;
33
+ const cellB = board[b]?.value;
34
+ const cellC = board[c]?.value;
35
+
36
+ if (cellA && cellA === cellB && cellA === cellC) {
37
+ store.setValue('gameStatus', 'won');
38
+ store.setValue('winner', cellA);
39
+ store.setValue('winningLine', line.join(','));
40
+ return;
41
+ }
42
+ }
43
+
44
+ // Check for draw
45
+ const filledCells = Object.values(board).filter((cell: any) => cell.value).length;
46
+ if (filledCells === 9) {
47
+ store.setValue('gameStatus', 'draw');
48
+ return;
49
+ }
50
+
51
+ // Switch player after each move
52
+ const currentPlayer = store.getValue('currentPlayer');
53
+ store.setValue('currentPlayer', currentPlayer === 'X' ? 'O' : 'X');
54
+ },
55
+ [],
56
+ true,
57
+ STORE_ID,);
58
+
59
+ const resetGame = useSetValuesCallback(
60
+ () => {
61
+ store.delTable('board');
62
+ return {currentPlayer: 'X',
63
+ gameStatus: 'playing',
64
+ };
65
+ },
66
+ [],
67
+ );
68
+
69
+ return (
70
+ <>
71
+ <GameStatus />
72
+ <Board />
73
+ <div style=\{{textAlign: 'center', marginTop: '2rem'}}>
74
+ <Button onClick={resetGame} variant="primary">New Game</Button>
75
+ </div>
76
+ </>
77
+ );
78
+ };
@@ -0,0 +1,21 @@
1
+ {{includeFile template="client/src/game/gameStatus.css.hbs" output="client/src/gameStatus.css"}}
2
+ import './gameStatus.css';
3
+ import {useValue, STORE_ID} from './Store';
4
+
5
+ export const GameStatus = () => {
6
+ const gameStatus = useValue('gameStatus', STORE_ID);
7
+ const currentPlayer = useValue('currentPlayer', STORE_ID);
8
+ const winner = useValue('winner', STORE_ID);
9
+
10
+ return (
11
+ <div id="gameStatus">
12
+ {gameStatus === 'playing' && (
13
+ <>Player <span className="player">{currentPlayer}</span>'s turn</>
14
+ )}
15
+ {gameStatus === 'won' && (
16
+ <>Player <span className="winner">{winner}</span> wins!</>
17
+ )}
18
+ {gameStatus === 'draw' && <>It's a draw!</>}
19
+ </div>
20
+ );
21
+ };
@@ -0,0 +1,23 @@
1
+ {{includeFile template="client/src/game/square.css.hbs" output="client/src/square.css"}}
2
+ import './square.css';
3
+ import {useCell, useStore, useValue, STORE_ID} from './Store';
4
+
5
+ export const Square = ({position, disabled, winning}: {position: number; disabled: boolean; winning: boolean}) => {
6
+ const store = useStore(STORE_ID);
7
+ const value = useCell('board', position.toString(), 'value', STORE_ID);
8
+ const currentPlayer = useValue('currentPlayer', STORE_ID);
9
+
10
+ const isDisabled = disabled || !!value;
11
+
12
+ const handleClick = () => {
13
+ if (!isDisabled) {
14
+ store.setCell('board', position.toString(), 'value', currentPlayer);
15
+ }
16
+ };
17
+
18
+ return (
19
+ <button className={`square${isDisabled ? ' disabled' : '' }${winning ? ' winning' : '' }`} onClick={handleClick} disabled={isDisabled}>
20
+ {value || ''}
21
+ </button>
22
+ );
23
+ };
@@ -0,0 +1,67 @@
1
+ {{#if schemas}}
2
+ import {createMergeableStore} from 'tinybase/with-schemas';
3
+ import * as UiReact from 'tinybase/ui-react/with-schemas';
4
+ import {type NoTablesSchema} from 'tinybase/with-schemas';
5
+ {{else}}
6
+ import {createMergeableStore} from 'tinybase';
7
+ import {useCreateStore, useProvideStore, useStore, useSetValuesCallback, useTableListener, useCell, useValue} from 'tinybase/ui-react';
8
+ {{/if}}
9
+
10
+ export const STORE_ID = 'game';
11
+
12
+ {{#if schemas}}
13
+ const VALUES_SCHEMA = {
14
+ currentPlayer: {type: 'string'},
15
+ gameStatus: {type: 'string'},
16
+ winner: {type: 'string', default: ''},
17
+ winningLine: {type: 'string', default: ''},
18
+ } as const;
19
+
20
+ type Schemas = [NoTablesSchema, typeof VALUES_SCHEMA];
21
+
22
+ const {useCreateStore, useProvideStore, useStore, useSetValuesCallback, useTableListener, useCell, useValue} = UiReact as UiReact.WithSchemas<Schemas>;
23
+ {{/if}}
24
+
25
+ export {useStore, useSetValuesCallback, useTableListener, useCell, useValue};
26
+
27
+ export const Store = () => {
28
+ const store = useCreateStore(() =>
29
+ createMergeableStore(STORE_ID){{#if schemas}}
30
+ .setValuesSchema(VALUES_SCHEMA){{/if}}
31
+ .setDefaultContent([
32
+ {},
33
+ {
34
+ currentPlayer: 'X',
35
+ gameStatus: 'playing',
36
+ },
37
+ ]),
38
+ );
39
+
40
+ useProvideStore(STORE_ID, store);
41
+
42
+ {{#if sync}}
43
+ {{includeFile template="client/src/shared/config.ts.hbs" output="client/src/config.ts"}}
44
+ {{addImport "import {SERVER} from './config';"}}
45
+ {{addImport "import ReconnectingWebSocket from 'reconnecting-websocket';"}}
46
+ {{addImport "import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client';"}}
47
+ {{addImport "import {useCreateSynchronizer} from 'tinybase/ui-react';"}}
48
+ {{addImport "import type {MergeableStore} from 'tinybase';"}}
49
+
50
+ useCreateSynchronizer(store, async (store: MergeableStore) => {
51
+ const serverPathId = location.pathname;
52
+ const synchronizer = await createWsSynchronizer(
53
+ store,
54
+ new ReconnectingWebSocket(SERVER + serverPathId),
55
+ );
56
+ await synchronizer.startSync();
57
+
58
+ synchronizer.getWebSocket().addEventListener('open', () => {
59
+ synchronizer.load().then(() => synchronizer.save());
60
+ });
61
+
62
+ return synchronizer;
63
+ });
64
+ {{/if}}
65
+
66
+ return null;
67
+ };
@@ -0,0 +1,12 @@
1
+ {{includeFile template="client/src/game/store.ts.hbs" output="client/src/store.ts"}}
2
+ import {store} from './store';
3
+
4
+ {{includeFile template="client/src/game/game.ts.hbs" output="client/src/game.{{ext}}"}}
5
+ import {createGame} from './game';
6
+
7
+ const app = () => {
8
+ const appContainer = document.getElementById('app')!;
9
+ appContainer.appendChild(createGame(store));
10
+ };
11
+
12
+ export {app};
@@ -0,0 +1,13 @@
1
+ #board {
2
+ display: grid;
3
+ grid-template-columns: repeat(3, 100px);
4
+ grid-template-rows: repeat(3, 100px);
5
+ gap: 1px;
6
+ background: var(--border);
7
+ border: 2px solid var(--border);
8
+ border-radius: 0.5rem;
9
+ overflow: hidden;
10
+ margin: 0 auto 2rem;
11
+ width: fit-content;
12
+ padding: 1px;
13
+ }
@@ -0,0 +1,39 @@
1
+ {{includeFile template="client/src/game/board.css.hbs" output="client/src/board.css"}}
2
+ import './board.css';
3
+ import type {GameStore} from './store';
4
+
5
+ {{includeFile template="client/src/game/square.ts.hbs" output="client/src/square.{{ext}}"}}
6
+ import {createSquare} from './square';
7
+
8
+ export const createBoard = (store: GameStore): HTMLDivElement => {
9
+ const board = document.createElement('div');
10
+ board.id = 'board';
11
+
12
+ const render = () => {
13
+ const gameStatus = store.getValue('gameStatus');
14
+ const winningLine = store.getValue('winningLine');
15
+ const currentPlayer = store.getValue('currentPlayer');
16
+ const winningPositions = new Set(
17
+ winningLine ? winningLine.split(',').map(Number) : []
18
+ );
19
+ const disabled = gameStatus !== 'playing';
20
+
21
+ board.innerHTML = '';
22
+ for (let i = 0; i < 9; i++) { const cell=store.getCell('board', i.toString(), 'value' ); const square=createSquare( i, cell, ()=> {
23
+ if (gameStatus === 'playing' && !cell) {
24
+ store.setCell('board', i.toString(), 'value', currentPlayer);
25
+ }
26
+ },
27
+ disabled || !!cell,
28
+ winningPositions.has(i)
29
+ );
30
+ board.appendChild(square);
31
+ }
32
+ };
33
+
34
+ store.addValuesListener(render);
35
+ store.addTableListener('board', render);
36
+ render();
37
+
38
+ return board;
39
+ };
@@ -0,0 +1,74 @@
1
+ import type {GameStore} from './store';
2
+
3
+ {{includeFile template="client/src/shared/button.ts.hbs" output="client/src/button.{{ext}}"}}
4
+ import {createButton} from './button';
5
+
6
+ {{includeFile template="client/src/game/board.ts.hbs" output="client/src/board.{{ext}}"}}
7
+ import {createBoard} from './board';
8
+
9
+ {{includeFile template="client/src/game/gameStatus.ts.hbs" output="client/src/gameStatus.{{ext}}"}}
10
+ import {createGameStatus} from './gameStatus';
11
+
12
+ const WINNING_LINES = [
13
+ [0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
14
+ [0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
15
+ [0, 4, 8], [2, 4, 6], // diagonals
16
+ ];
17
+
18
+ export const createGame = (store: GameStore): HTMLDivElement => {
19
+ const checkGameState = () => {
20
+ const gameStatus = store.getValue('gameStatus');
21
+ if (gameStatus !== 'playing') return;
22
+
23
+ const board = store.getTable('board');
24
+
25
+ // Check for winner
26
+ for (const line of WINNING_LINES) {
27
+ const [a, b, c] = line;
28
+ const cellA = board[a]?.value;
29
+ const cellB = board[b]?.value;
30
+ const cellC = board[c]?.value;
31
+
32
+ if (cellA && cellA === cellB && cellA === cellC) {
33
+ store.setValue('gameStatus', 'won');
34
+ store.setValue('winner', cellA);
35
+ store.setValue('winningLine', line.join(','));
36
+ return;
37
+ }
38
+ }
39
+
40
+ // Check for draw
41
+ const filledCells = Object.values(board).filter((cell: any) => cell.value).length;
42
+ if (filledCells === 9) {
43
+ store.setValue('gameStatus', 'draw');
44
+ return;
45
+ }
46
+
47
+ // Switch player after each move
48
+ const currentPlayer = store.getValue('currentPlayer');
49
+ store.setValue('currentPlayer', currentPlayer === 'X' ? 'O' : 'X');
50
+ };
51
+
52
+ store.addTableListener('board', checkGameState, true);
53
+
54
+ const resetGame = () => {
55
+ store.delTable('board');
56
+ store.setValues({
57
+ currentPlayer: 'X',
58
+ gameStatus: 'playing',
59
+ });
60
+ };
61
+
62
+ const gameContainer = document.createElement('div');
63
+
64
+ gameContainer.appendChild(createGameStatus(store));
65
+ gameContainer.appendChild(createBoard(store));
66
+
67
+ const buttonContainer = document.createElement('div');
68
+ buttonContainer.style.textAlign = 'center';
69
+ buttonContainer.style.marginTop = '2rem';
70
+ buttonContainer.appendChild(createButton('New Game', resetGame, 'primary'));
71
+ gameContainer.appendChild(buttonContainer);
72
+
73
+ return gameContainer;
74
+ };
@@ -0,0 +1,21 @@
1
+ #gameStatus {
2
+ text-align: center;
3
+ font-size: 1.5rem;
4
+ font-weight: 800;
5
+ margin: 2rem 0 1rem;
6
+ color: var(--accent);
7
+ }
8
+
9
+ #gameStatus .winner {
10
+ color: var(--accent);
11
+ animation: pulse 1s ease-in-out infinite;
12
+ }
13
+
14
+ @keyframes pulse {
15
+ 0%, 100% {
16
+ opacity: 1;
17
+ }
18
+ 50% {
19
+ opacity: 0.7;
20
+ }
21
+ }