@mideind/netskrafl-react 1.0.0-beta.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.
Files changed (68) hide show
  1. package/.eslintignore +8 -0
  2. package/.eslintrc.json +13 -0
  3. package/README.md +63 -0
  4. package/dist/cjs/index.css +6837 -0
  5. package/dist/cjs/index.js +3046 -0
  6. package/dist/cjs/index.js.map +1 -0
  7. package/dist/esm/index.css +6837 -0
  8. package/dist/esm/index.js +3046 -0
  9. package/dist/esm/index.js.map +1 -0
  10. package/dist/types.d.ts +41 -0
  11. package/package.json +63 -0
  12. package/rollup.config.js +60 -0
  13. package/src/components/index.ts +2 -0
  14. package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
  15. package/src/components/netskrafl/Netskrafl.tsx +135 -0
  16. package/src/components/netskrafl/Netskrafl.types.ts +7 -0
  17. package/src/components/netskrafl/index.ts +2 -0
  18. package/src/css/fonts.css +4 -0
  19. package/src/css/glyphs.css +224 -0
  20. package/src/css/skrafl-explo.css +6616 -0
  21. package/src/fonts/glyphicons-regular.eot +0 -0
  22. package/src/fonts/glyphicons-regular.ttf +0 -0
  23. package/src/fonts/glyphicons-regular.woff +0 -0
  24. package/src/index.ts +2 -0
  25. package/src/messages/messages.json +1576 -0
  26. package/src/mithril/actions.ts +319 -0
  27. package/src/mithril/bag.ts +65 -0
  28. package/src/mithril/bestdisplay.ts +74 -0
  29. package/src/mithril/blankdialog.ts +94 -0
  30. package/src/mithril/board.ts +336 -0
  31. package/src/mithril/buttons.ts +303 -0
  32. package/src/mithril/challengedialog.ts +186 -0
  33. package/src/mithril/channel.ts +162 -0
  34. package/src/mithril/chat.ts +228 -0
  35. package/src/mithril/components.ts +496 -0
  36. package/src/mithril/dragdrop.ts +219 -0
  37. package/src/mithril/elopage.ts +180 -0
  38. package/src/mithril/friend.ts +227 -0
  39. package/src/mithril/game.ts +1378 -0
  40. package/src/mithril/gameview.ts +111 -0
  41. package/src/mithril/globalstate.ts +33 -0
  42. package/src/mithril/i18n.ts +186 -0
  43. package/src/mithril/localstorage.ts +133 -0
  44. package/src/mithril/login.ts +122 -0
  45. package/src/mithril/logo.ts +270 -0
  46. package/src/mithril/main.ts +737 -0
  47. package/src/mithril/mithril.ts +29 -0
  48. package/src/mithril/model.ts +817 -0
  49. package/src/mithril/movelistitem.ts +226 -0
  50. package/src/mithril/page.ts +852 -0
  51. package/src/mithril/playername.ts +91 -0
  52. package/src/mithril/promodialog.ts +82 -0
  53. package/src/mithril/recentlist.ts +148 -0
  54. package/src/mithril/request.ts +52 -0
  55. package/src/mithril/review.ts +634 -0
  56. package/src/mithril/rightcolumn.ts +398 -0
  57. package/src/mithril/searchbutton.ts +118 -0
  58. package/src/mithril/statsdisplay.ts +109 -0
  59. package/src/mithril/tabs.ts +169 -0
  60. package/src/mithril/tile.ts +145 -0
  61. package/src/mithril/twoletter.ts +76 -0
  62. package/src/mithril/types.ts +379 -0
  63. package/src/mithril/userinfodialog.ts +171 -0
  64. package/src/mithril/util.ts +304 -0
  65. package/src/mithril/wait.ts +246 -0
  66. package/src/mithril/wordcheck.ts +102 -0
  67. package/tsconfig.json +28 -0
  68. package/vite.config.ts +12 -0
@@ -0,0 +1,304 @@
1
+ /*
2
+
3
+ Util.ts
4
+
5
+ Utility functions for the Explo/Netskrafl user interface
6
+
7
+ Copyright (C) 2024 Miðeind ehf.
8
+ Author: Vilhjálmur Þorsteinsson
9
+
10
+ The Creative Commons Attribution-NonCommercial 4.0
11
+ International Public License (CC-BY-NC 4.0) applies to this software.
12
+ For further information, see https://github.com/mideind/Netskrafl
13
+
14
+ The following code is based on
15
+ https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
16
+
17
+ */
18
+
19
+ import { m, MithrilDragEvent, Vnode } from "./mithril";
20
+ import { BOARD_SIZE, ROWIDS } from "./types";
21
+
22
+ type UrlParams = "uuid" | "move" | "tab" | "faq" | "zombie";
23
+
24
+ // Possible URL parameters that are passed to routes
25
+ export type Params = Partial<Record<UrlParams, string>>;
26
+
27
+ type ZoomFunc = () => void;
28
+
29
+ // Global vars to cache event state
30
+ var evCache: PointerEvent[] = [];
31
+ var origDistance = -1;
32
+ const PINCH_THRESHOLD = 10; // Minimum pinch movement
33
+ var hasZoomed = false;
34
+
35
+ // Old-style (non-single-page) game URL prefix
36
+ const BOARD_PREFIX = "/board?game=";
37
+ const BOARD_PREFIX_LEN = BOARD_PREFIX.length;
38
+
39
+ export function addPinchZoom(attrs: any, funcZoomIn: ZoomFunc, funcZoomOut: ZoomFunc) {
40
+ // Install event handlers for the pointer target
41
+ attrs.onpointerdown = pointerdown_handler;
42
+ attrs.onpointermove = pointermove_handler.bind(null, funcZoomIn, funcZoomOut);
43
+ // Use same handler for pointer{up,cancel,out,leave} events since
44
+ // the semantics for these events - in this app - are the same.
45
+ attrs.onpointerup = pointerup_handler;
46
+ attrs.onpointercancel = pointerup_handler;
47
+ attrs.onpointerout = pointerup_handler;
48
+ attrs.onpointerleave = pointerup_handler;
49
+ }
50
+
51
+ function pointerdown_handler(ev: PointerEvent) {
52
+ // The pointerdown event signals the start of a touch interaction.
53
+ // This event is cached to support 2-finger gestures
54
+ evCache.push(ev);
55
+ }
56
+
57
+ function pointermove_handler(funcZoomIn: ZoomFunc, funcZoomOut: ZoomFunc, ev: PointerEvent) {
58
+ // This function implements a 2-pointer horizontal pinch/zoom gesture.
59
+ //
60
+ // If the distance between the two pointers has increased (zoom in),
61
+ // the target element's background is changed to "pink" and if the
62
+ // distance is decreasing (zoom out), the color is changed to "lightblue".
63
+ //
64
+ // Find this event in the cache and update its record with this event
65
+ for (let i = 0; i < evCache.length; i++) {
66
+ if (ev.pointerId === evCache[i].pointerId) {
67
+ evCache[i] = ev;
68
+ break;
69
+ }
70
+ }
71
+
72
+ // If two pointers are down, check for pinch gestures
73
+ if (evCache.length == 2) {
74
+ // Calculate the distance between the two pointers
75
+ const curDistance = Math.sqrt(
76
+ Math.pow(evCache[0].clientX - evCache[1].clientX, 2) +
77
+ Math.pow(evCache[0].clientY - evCache[1].clientY, 2)
78
+ );
79
+
80
+ if (origDistance > 0) {
81
+ if (curDistance - origDistance >= PINCH_THRESHOLD) {
82
+ // The distance between the two pointers has increased
83
+ if (!hasZoomed)
84
+ funcZoomIn();
85
+ hasZoomed = true;
86
+ }
87
+ else
88
+ if (origDistance - curDistance >= PINCH_THRESHOLD) {
89
+ // The distance between the two pointers has decreased
90
+ if (!hasZoomed)
91
+ funcZoomOut();
92
+ hasZoomed = true;
93
+ }
94
+ }
95
+ else
96
+ if (origDistance < 0) {
97
+ // Note the original difference between two pointers
98
+ origDistance = curDistance;
99
+ hasZoomed = false;
100
+ }
101
+ }
102
+ }
103
+
104
+ function pointerup_handler(ev: PointerEvent) {
105
+ // Remove this pointer from the cache and reset the target's
106
+ // background and border
107
+ remove_event(ev);
108
+ // If the number of pointers down is less than two then reset diff tracker
109
+ if (evCache.length < 2) {
110
+ origDistance = -1;
111
+ }
112
+ }
113
+
114
+ function remove_event(ev: PointerEvent) {
115
+ // Remove this event from the target's cache
116
+ for (let i = 0; i < evCache.length; i++) {
117
+ if (evCache[i].pointerId === ev.pointerId) {
118
+ evCache.splice(i, 1);
119
+ break;
120
+ }
121
+ }
122
+ }
123
+
124
+ export function buttonOver(ev: Event) {
125
+ const clist = (ev.currentTarget as HTMLElement).classList;
126
+ if (clist !== undefined && !clist.contains("disabled"))
127
+ clist.add("over");
128
+ (ev as MithrilDragEvent).redraw = false;
129
+ }
130
+
131
+ export function buttonOut(ev: Event) {
132
+ const clist = (ev.currentTarget as HTMLElement).classList;
133
+ if (clist !== undefined)
134
+ clist.remove("over");
135
+ (ev as MithrilDragEvent).redraw = false;
136
+ }
137
+
138
+ // Glyphicon utility function: inserts a glyphicon span
139
+ export function glyph(icon: string, attrs?: any, grayed?: boolean): Vnode {
140
+ return m("span.glyphicon.glyphicon-" + icon + (grayed ? ".grayed" : ""), attrs);
141
+ }
142
+
143
+ export function glyphGrayed(icon: string, attrs?: any): Vnode {
144
+ return m("span.glyphicon.glyphicon-" + icon + ".grayed", attrs);
145
+ }
146
+
147
+ // Utility function: inserts non-breaking space
148
+ export function nbsp(n?: number): Vnode {
149
+ if (!n || n === 1)
150
+ return m.trust("&nbsp;");
151
+ let r: Vnode[] = [];
152
+ for (let i = 0; i < n; i++)
153
+ r.push(m.trust("&nbsp;"));
154
+ return m.fragment({}, r);
155
+ }
156
+
157
+ // Utility functions
158
+
159
+ export function escapeHtml(string: string): string {
160
+ /* Utility function to properly encode a string into HTML */
161
+ const entityMap: Record<string, string> = {
162
+ "&": "&amp;",
163
+ "<": "&lt;",
164
+ ">": "&gt;",
165
+ '"': '&quot;',
166
+ "'": '&#39;',
167
+ "/": '&#x2F;'
168
+ };
169
+ return String(string).replace(/[&<>"'/]/g, (s) => entityMap[s] ?? "");
170
+ }
171
+
172
+ export function getUrlVars(url: string): Params {
173
+ // Get values from a URL query string
174
+ const hashes = url.split('&');
175
+ const vars: Params = {};
176
+ for (let i = 0; i < hashes.length; i++) {
177
+ const hash = hashes[i].split('=');
178
+ if (hash.length == 2)
179
+ vars[hash[0] as keyof Params] = decodeURIComponent(hash[1]);
180
+ }
181
+ return vars;
182
+ }
183
+
184
+ export function getInput(id: string): string {
185
+ // Return the current value of a text input field
186
+ const elem = document.getElementById(id) as HTMLInputElement;
187
+ return elem.value;
188
+ }
189
+
190
+ export function setInput(id: string, val: string) {
191
+ // Set the current value of a text input field
192
+ const elem = document.getElementById(id) as HTMLInputElement;
193
+ elem.value = val;
194
+ }
195
+
196
+ export function playAudio(elemId: string) {
197
+ // Play an audio file
198
+ const sound = document.getElementById(elemId) as HTMLMediaElement;
199
+ if (sound)
200
+ sound.play();
201
+ }
202
+
203
+ export function arrayEqual(a: any[], b: any[]): boolean {
204
+ // Return true if arrays a and b are equal
205
+ if (a.length != b.length)
206
+ return false;
207
+ for (let i = 0; i < a.length; i++)
208
+ if (a[i] != b[i])
209
+ return false;
210
+ return true;
211
+ }
212
+
213
+ export function gameUrl(url: string): string {
214
+ // Convert old-style game URL to new-style single-page URL
215
+ // The URL format is "/board?game=ed27b9f0-d429-11eb-8bc7-d43d7ee303b2&zombie=1"
216
+ if (url.slice(0, BOARD_PREFIX_LEN) == BOARD_PREFIX)
217
+ // Cut off "/board?game="
218
+ url = url.slice(BOARD_PREFIX_LEN);
219
+ // Isolate the game UUID
220
+ const uuid = url.slice(0, 36);
221
+ // Isolate the other parameters, if any
222
+ let params = url.slice(36);
223
+ // Start parameter section of URL with a ? sign
224
+ if (params.length > 0 && params.charAt(0) == "&")
225
+ params = "?" + params.slice(1);
226
+ // Return the single-page URL, to be consumed by m.route.Link()
227
+ return "/game/" + uuid + params;
228
+ }
229
+
230
+ export function scrollMovelistToBottom(): void {
231
+ // If the length of the move list has changed,
232
+ // scroll the last move into view
233
+ let movelist = document.querySelectorAll("div.movelist .move");
234
+ if (!movelist || !movelist.length)
235
+ return;
236
+ let target = movelist[movelist.length - 1] as HTMLElement;
237
+ let parent = target.parentNode as HTMLElement;
238
+ let len = parent.getAttribute("data-len");
239
+ let intLen = (!len) ? 0 : parseInt(len);
240
+ if (movelist.length > intLen) {
241
+ // The list has grown since we last updated it:
242
+ // scroll to the bottom and mark its length
243
+ parent.scrollTop = target.offsetTop;
244
+ }
245
+ parent.setAttribute("data-len", movelist.length.toString());
246
+ }
247
+
248
+ export function coord(row: number, col: number): string | null {
249
+ // Return the co-ordinate string for the given 0-based row and col
250
+ if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE)
251
+ return null;
252
+ return ROWIDS[row] + (col + 1);
253
+ }
254
+
255
+ export function toVector(co: string): { col: number, row: number, dx: number, dy: number } {
256
+ // Convert a co-ordinate string to a 0-based row, col and direction vector
257
+ var dx = 0, dy = 0;
258
+ var col = 0;
259
+ var row = ROWIDS.indexOf(co[0]);
260
+ if (row >= 0) {
261
+ /* Horizontal move */
262
+ col = parseInt(co.slice(1)) - 1;
263
+ dx = 1;
264
+ }
265
+ else {
266
+ /* Vertical move */
267
+ row = ROWIDS.indexOf(co.slice(-1));
268
+ col = parseInt(co) - 1;
269
+ dy = 1;
270
+ }
271
+ return { col: col, row: row, dx: dx, dy: dy };
272
+ }
273
+
274
+ export function valueOrK(value: number, breakpoint: number = 10000): string {
275
+ // Return a numeric value as a string, but in kilos (thousands)
276
+ // if it exceeds a breakpoint, in that case suffixed by "K"
277
+ const sign = value < 0 ? "-" : "";
278
+ value = Math.abs(value);
279
+ if (value < breakpoint)
280
+ return `${sign}${value}`;
281
+ value = Math.round(value / 1000);
282
+ return `${sign}${value}K`;
283
+ }
284
+
285
+ // SalesCloud stuff
286
+ function doRegisterSalesCloud(i: any,s: any,o: any,g: any,r: any,a?:any,m?:any) {
287
+ i.SalesCloudObject=r;
288
+ i[r]=i[r]||function(){(i[r].q=i[r].q||[]).push(arguments);};
289
+ i[r].l=1*(new Date() as any);
290
+ a=s.createElement(o);
291
+ m=s.getElementsByTagName(o)[0];
292
+ a.src=g;
293
+ m.parentNode.insertBefore(a,m);
294
+ }
295
+
296
+ export function registerSalesCloud() {
297
+ doRegisterSalesCloud(
298
+ window,
299
+ document,
300
+ 'script',
301
+ 'https://cdn.salescloud.is/js/salescloud.min.js',
302
+ 'salescloud'
303
+ );
304
+ }
@@ -0,0 +1,246 @@
1
+ /*
2
+
3
+ Wait.ts
4
+
5
+ Code for the WaitDialog and AcceptDialog components,
6
+ used in the UI flow for timed games
7
+
8
+ Copyright (C) 2024 Miðeind ehf.
9
+ Original author: Vilhjálmur Þorsteinsson
10
+
11
+ The Creative Commons Attribution-NonCommercial 4.0
12
+ International Public License (CC-BY-NC 4.0) applies to this software.
13
+ For further information, see https://github.com/mideind/Netskrafl
14
+
15
+ */
16
+
17
+ import { IView } from "./types";
18
+ import { m, ComponentFunc } from "./mithril";
19
+ import { mt, ts } from "./i18n";
20
+ import { request } from "./request";
21
+ import { DialogButton, OnlinePresence } from "./components";
22
+ import { glyph } from "./util";
23
+ import { attachFirebaseListener, detachFirebaseListener } from "./channel";
24
+
25
+ export const WaitDialog: ComponentFunc<{
26
+ view: IView;
27
+ oppId: string;
28
+ oppNick: string;
29
+ oppName: string;
30
+ duration: number;
31
+ challengeKey: string;
32
+ }> = (initialVnode) => {
33
+
34
+ // A dialog that is shown while the user waits for the opponent,
35
+ // who issued a timed game challenge, to be ready
36
+
37
+ const attrs = initialVnode.attrs;
38
+ const view = attrs.view;
39
+ const model = view.model;
40
+ const duration = attrs.duration;
41
+ const oppId = attrs.oppId;
42
+ const key = attrs.challengeKey;
43
+ let oppNick = attrs.oppNick;
44
+ let oppName = attrs.oppName;
45
+ let oppOnline = false;
46
+ const userId = model.state?.userId ?? "";
47
+ // Firebase path
48
+ const path = 'user/' + userId + "/wait/" + oppId;
49
+ // Flag set when the new game has been initiated
50
+ let pointOfNoReturn = false;
51
+
52
+ async function updateOnline() {
53
+ // Initiate an online check on the opponent
54
+ try {
55
+ if (!oppId || !key) return;
56
+ const json: { online: boolean; waiting: boolean; } = await request({
57
+ method: "POST",
58
+ url: "/initwait",
59
+ body: { opp: oppId, key }
60
+ });
61
+ // If json.waiting is false, the initiation failed
62
+ // and there is really no point in continuing to wait
63
+ if (json && json.online && json.waiting)
64
+ // The user is online
65
+ oppOnline = true;
66
+ }
67
+ catch(e) {
68
+ }
69
+ }
70
+
71
+ async function cancelWait() {
72
+ // Cancel a pending wait for a timed game
73
+ try {
74
+ await request({
75
+ method: "POST",
76
+ url: "/cancelwait",
77
+ body: {
78
+ user: userId,
79
+ opp: oppId,
80
+ key
81
+ }
82
+ });
83
+ }
84
+ catch(e) {
85
+ }
86
+ }
87
+
88
+ return {
89
+ oninit: () => {
90
+ if (!userId || !oppId) return; // Should not happen
91
+ updateOnline();
92
+ // Attach a Firebase listener to the wait path
93
+ attachFirebaseListener(path, (json: true | { game: string }) => {
94
+ if (json !== true && json.game) {
95
+ // A new game has been created and initiated by the server
96
+ pointOfNoReturn = true;
97
+ detachFirebaseListener(path);
98
+ // We don't need to pop the dialog; that is done automatically
99
+ // by the route resolver upon m.route.set()
100
+ // Navigate to the newly initiated game
101
+ m.route.set("/game/" + json.game);
102
+ }
103
+ });
104
+ },
105
+ view: () => {
106
+ return m(".modal-dialog",
107
+ { id: "wait-dialog", style: { visibility: "visible" } },
108
+ m(".ui-widget.ui-widget-content.ui-corner-all", { "id": "wait-form" },
109
+ [
110
+ m(".chall-hdr",
111
+ m("table",
112
+ m("tbody",
113
+ m("tr", [
114
+ m("td", m("h1.chall-icon", glyph("time"))),
115
+ m("td.l-border", [
116
+ m(OnlinePresence, { id: "chall-online", userId: oppId, online: oppOnline }),
117
+ m("h1", oppNick),
118
+ m("h2", oppName)
119
+ ])
120
+ ])
121
+ )
122
+ )
123
+ ),
124
+ m(".wait-explain", [
125
+ mt("p", [
126
+ "Þú ert reiðubúin(n) að taka áskorun um viðureign með klukku, ",
127
+ m("strong", [ "2 x ", duration.toString(), ts(" mínútur.") ])
128
+ ]),
129
+ mt("p", [
130
+ "Beðið er eftir að áskorandinn ", m("strong", oppNick),
131
+ " sé ", oppOnline ? "" : mt("span#chall-is-online", "álínis og "), "til í tuskið."
132
+ ]),
133
+ mt("p", "Leikur hefst um leið og áskorandinn bregst við. Handahóf ræður hvor byrjar."),
134
+ mt("p", "Ef þér leiðist biðin geturðu hætt við og reynt aftur síðar.")
135
+ ]),
136
+ m(DialogButton,
137
+ {
138
+ id: "wait-cancel",
139
+ title: ts("Hætta við"),
140
+ onclick: (ev: Event) => {
141
+ // Cancel the wait status and navigate back to the main page
142
+ if (pointOfNoReturn) {
143
+ // Actually, it's too late to cancel
144
+ ev.preventDefault();
145
+ return;
146
+ }
147
+ detachFirebaseListener(path);
148
+ cancelWait();
149
+ view.popDialog();
150
+ ev.preventDefault();
151
+ }
152
+ },
153
+ glyph("remove")
154
+ )
155
+ ]
156
+ )
157
+ );
158
+ }
159
+ }
160
+ };
161
+
162
+ export const AcceptDialog: ComponentFunc<{
163
+ view: IView; oppId: string; oppNick: string; challengeKey: string;
164
+ }> = (initialVnode) => {
165
+
166
+ // A dialog that is shown (usually briefly) while
167
+ // the user who originated a timed game challenge
168
+ // is linked up with her opponent and a new game is started
169
+
170
+ const attrs = initialVnode.attrs;
171
+ const view = attrs.view;
172
+ const model = view.model;
173
+ const oppId = attrs.oppId;
174
+ const key = attrs.challengeKey;
175
+ let oppNick = attrs.oppNick;
176
+ let oppReady = true;
177
+
178
+ async function waitCheck() {
179
+ // Initiate a wait status check on the opponent
180
+ try {
181
+ const json: { waiting: boolean; } = await request({
182
+ method: "POST",
183
+ url: "/waitcheck",
184
+ body: { user: oppId, key }
185
+ });
186
+ if (json?.waiting) {
187
+ // Both players are now ready: Start the timed game.
188
+ // The newGame() call switches to a new route (/game),
189
+ // and all open dialogs are thereby closed automatically.
190
+ model.newGame(oppId, true);
191
+ }
192
+ else
193
+ // Something didn't check out: keep the dialog open
194
+ // until the user manually closes it
195
+ oppReady = false;
196
+ }
197
+ catch(e) {
198
+ }
199
+ }
200
+
201
+ return {
202
+ oninit: () => waitCheck(),
203
+ view: () => {
204
+ return m(".modal-dialog",
205
+ { id: "accept-dialog", style: { visibility: "visible" } },
206
+ m(".ui-widget.ui-widget-content.ui-corner-all", { id: "accept-form" },
207
+ [
208
+ m(".chall-hdr",
209
+ m("table",
210
+ m("tbody",
211
+ m("tr",
212
+ [
213
+ m("td", m("h1.chall-icon", glyph("time"))),
214
+ m("td.l-border", m("h1", oppNick))
215
+ ]
216
+ )
217
+ )
218
+ )
219
+ ),
220
+ m("div", { "style": { "text-align": "center", "padding-top": "32px" }},
221
+ [
222
+ m("p", mt("strong", "Viðureign með klukku")),
223
+ mt("p",
224
+ oppReady ? "Athuga hvort andstæðingur er reiðubúinn..."
225
+ : ["Andstæðingurinn ", m("strong", oppNick), " er ekki reiðubúinn"]
226
+ )
227
+ ]
228
+ ),
229
+ m(DialogButton,
230
+ {
231
+ id: 'accept-cancel',
232
+ title: ts('Reyna síðar'),
233
+ onclick: (ev: Event) => {
234
+ // Abort mission
235
+ view.popDialog();
236
+ ev.preventDefault();
237
+ }
238
+ },
239
+ glyph("remove")
240
+ )
241
+ ]
242
+ )
243
+ );
244
+ }
245
+ }
246
+ };
@@ -0,0 +1,102 @@
1
+ /*
2
+
3
+ Wordcheck.ts
4
+
5
+ Wrapper for word checking and caching
6
+
7
+ Copyright (C) 2024 Miðeind ehf.
8
+ Author: Vilhjalmur Thorsteinsson
9
+
10
+ The Creative Commons Attribution-NonCommercial 4.0
11
+ International Public License (CC-BY-NC 4.0) applies to this software.
12
+ For further information, see https://github.com/mideind/Netskrafl
13
+
14
+ */
15
+
16
+ import { requestMoves } from "./request";
17
+
18
+ interface WordCheckResult {
19
+ word: string;
20
+ ok: boolean;
21
+ valid: [string, boolean][];
22
+ }
23
+
24
+ class WordChecker {
25
+
26
+ // Global cache for word check results. The cache is first indexed
27
+ // by locale, and then by word.
28
+
29
+ private wordCheckCache: Record<string, Record<string, boolean>> = {};
30
+
31
+ constructor() {}
32
+
33
+ ingestTwoLetterWords(locale: string, twoLetterWords: string[][]) {
34
+ // Initialize the cache with a list of known two-letter words
35
+ // The two-letter word list contains a list of entries where each
36
+ // entry is an initial letter followed by a list of two-letter words
37
+ // starting with that letter.
38
+ const cache = this.wordCheckCache[locale] ?? (this.wordCheckCache[locale] = {});
39
+ for (const [/* firstLetter */, wordList] of twoLetterWords) {
40
+ for (const word of wordList) {
41
+ cache[word] = true;
42
+ }
43
+ }
44
+ }
45
+
46
+ async checkWords(locale: string, words: string[]): Promise<boolean> {
47
+ // Return true if all words are valid in the given locale,
48
+ // or false otherwise. Lookups are cached for efficiency.
49
+ let cache = this.wordCheckCache[locale];
50
+ if (cache) {
51
+ let allValid = true;
52
+ for (const word of words) {
53
+ if (cache[word] === undefined) {
54
+ if (word.length === 2)
55
+ // Special case for two-letter words: if they are not
56
+ // in the cache, they are not valid - no roundtrip needed
57
+ return false;
58
+ // Word not found in cache; we need a server roundtrip
59
+ allValid = false;
60
+ break;
61
+ } else if (cache[word] === false) {
62
+ // Word is known to be invalid
63
+ return false;
64
+ }
65
+ }
66
+ if (allValid) {
67
+ // All words are known to be valid
68
+ return true;
69
+ }
70
+ }
71
+
72
+ // We need a server roundtrip
73
+ try {
74
+ const response = await requestMoves<WordCheckResult>({
75
+ url: "/wordcheck",
76
+ body: {
77
+ locale,
78
+ word: words[0],
79
+ words,
80
+ }
81
+ });
82
+ if (response?.word === words[0] && response?.valid) {
83
+ // Looks like a valid response
84
+ if (!cache) {
85
+ // We didn't already have a cache for this locale
86
+ cache = this.wordCheckCache[locale] = {};
87
+ }
88
+ // Harvest the returned data into the cache
89
+ for (const [word, result] of response.valid) {
90
+ cache[word] = result;
91
+ }
92
+ }
93
+ return !!response.ok;
94
+ }
95
+ catch (e) {
96
+ }
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // Global word checker cache
102
+ export const wordChecker = new WordChecker();
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "include": ["src"],
3
+ "exclude": ["node_modules", "dist", "rollup.config.js"],
4
+ "compilerOptions": {
5
+ "target": "ES2018",
6
+ "allowJs": true,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": true,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "noFallthroughCasesInSwitch": true,
13
+ "module": "esnext",
14
+ "moduleResolution": "node",
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true,
17
+ "noEmit": true,
18
+ "jsx": "react-jsx",
19
+ "lib": [
20
+ "dom",
21
+ "dom.iterable",
22
+ "esnext"
23
+ ],
24
+ "paths": {
25
+ "@/*": ["./src/*"]
26
+ }
27
+ }
28
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,12 @@
1
+
2
+ import path from 'path'
3
+ import { defineConfig } from 'vite'
4
+
5
+ export default defineConfig(
6
+ {
7
+ resolve: {
8
+ alias: [
9
+ { find: '@', replacement: path.resolve(__dirname, 'src') }
10
+ ]
11
+ }
12
+ });