@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,319 @@
1
+ /*
2
+
3
+ Actions.ts
4
+
5
+ Single page UI for Explo using the Mithril library
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
+ This file implements the Actions class.
15
+
16
+ */
17
+
18
+ import { ServerGame } from "./types";
19
+ import { Params, scrollMovelistToBottom } from "./util";
20
+ import { m } from "./mithril";
21
+ import {
22
+ attachFirebaseListener, detachFirebaseListener, logEvent
23
+ } from "./channel";
24
+ import { Model } from "./model";
25
+ import { View } from "./page";
26
+
27
+ export class Actions {
28
+
29
+ model: Model;
30
+ view: View;
31
+
32
+ constructor(model: Model, view: View) {
33
+ this.model = model;
34
+ this.view = view;
35
+ this.initMediaListener();
36
+ // this.attachListenerToUser();
37
+ }
38
+
39
+ onNavigateTo(routeName: string, params: Params) {
40
+ // We have navigated to a new route
41
+ // If navigating to something other than help,
42
+ // we need to have a logged-in user
43
+ const model = this.model;
44
+ this.view.boardScale = 1.0;
45
+ model.routeName = routeName;
46
+ model.params = params;
47
+ const uuid = params.uuid ?? "";
48
+ if (routeName == "game") {
49
+ // New game route: initiate loading of the game into the model
50
+ if (model.game !== null) {
51
+ this.detachListenerFromGame(model.game.uuid);
52
+ }
53
+ // If opening this game as a zombie, remove zombie status
54
+ const deleteZombie = params.zombie === "1";
55
+ // Load the game, and attach it to the Firebase listener once it's loaded
56
+ model.loadGame(
57
+ uuid,
58
+ () => {
59
+ this.attachListenerToGame(uuid);
60
+ setTimeout(scrollMovelistToBottom);
61
+ },
62
+ deleteZombie
63
+ );
64
+ if (model.game !== null && model.game !== undefined) {
65
+ logEvent("game_open",
66
+ {
67
+ locale: model.game.locale,
68
+ uuid: params.uuid,
69
+ }
70
+ );
71
+ }
72
+ } else if (routeName == "review") {
73
+ // A game review: detach listener, if any, and load
74
+ // new game if necessary
75
+ if (model.game !== null) {
76
+ // !!! This may cause an extra detach - we assume that's OK
77
+ this.detachListenerFromGame(model.game.uuid);
78
+ }
79
+ // Find out which move we should show in the review
80
+ let moveParam: string = params.move || "0";
81
+ // Start with move number 0 by default
82
+ let move = parseInt(moveParam);
83
+ if (isNaN(move) || !move || move < 0)
84
+ move = 0;
85
+ if (model.game === null || model.game.uuid != params.uuid) {
86
+ // Different game than we had before: load it, and then
87
+ // fetch the best moves
88
+ model.loadGame(uuid, () => {
89
+ model.loadBestMoves(move);
90
+ setTimeout(scrollMovelistToBottom);
91
+ });
92
+ } else if (model.game !== null) {
93
+ // Already have the right game loaded:
94
+ // Fetch the best moves and show them once they're available
95
+ model.loadBestMoves(move);
96
+ }
97
+ } else {
98
+ // Not a game route: delete the previously loaded game, if any
99
+ if (model.game !== null) {
100
+ this.detachListenerFromGame(model.game.uuid);
101
+ model.game.cleanup();
102
+ model.game = null;
103
+ }
104
+ const locale = model.state?.locale || "is_IS";
105
+ if (routeName == "help") {
106
+ // Make sure that the help HTML is loaded upon first use
107
+ model.loadHelp();
108
+ logEvent("help", { locale });
109
+ } else if (routeName == "thanks") {
110
+ // Log a conversion event
111
+ if (model.state?.userId) {
112
+ logEvent("init_plan",
113
+ {
114
+ userid: model.state.userId,
115
+ locale,
116
+ // TODO: Add plan identifiers here
117
+ plan: "friend"
118
+ }
119
+ );
120
+ }
121
+ } else if (routeName == "main") {
122
+ // Force reload of lists
123
+ // TODO: This may not be necessary,
124
+ // if all Firebase notifications are acted upon
125
+ model.gameList = null;
126
+ model.userListCriteria = null;
127
+ model.userList = null;
128
+ model.challengeList = null;
129
+ model.recentList = null;
130
+ }
131
+ }
132
+ }
133
+
134
+ onMoveMessage(json: ServerGame, firstAttach: boolean) {
135
+ // Handle a move message from Firebase
136
+ console.log("Move message received: " + JSON.stringify(json));
137
+ this.model.handleMoveMessage(json, firstAttach);
138
+ }
139
+
140
+ onUserMessage(json: any, firstAttach: boolean) {
141
+ // Handle a user message from Firebase
142
+ console.log("User message received: " + JSON.stringify(json));
143
+ this.model.handleUserMessage(json, firstAttach);
144
+ }
145
+
146
+ onChatMessage(
147
+ json: { from_userid: string; game: string; msg: string; ts: string; },
148
+ firstAttach: boolean
149
+ ) {
150
+ // Handle an incoming chat message
151
+ if (firstAttach)
152
+ console.log("First attach of chat: " + JSON.stringify(json));
153
+ else {
154
+ console.log("Chat message received: " + JSON.stringify(json));
155
+ if (this.model.addChatMessage(json.game, json.from_userid, json.msg, json.ts)) {
156
+ // A chat message was successfully added
157
+ this.view.notifyChatMessage();
158
+ }
159
+ }
160
+ }
161
+
162
+ onFullScreen() {
163
+ // Take action when min-width exceeds 768
164
+ const state = this.model.state;
165
+ if (state && !state.uiFullscreen) {
166
+ state.uiFullscreen = true;
167
+ this.view.notifyMediaChange();
168
+ m.redraw();
169
+ }
170
+ }
171
+
172
+ onMobileScreen() {
173
+ const state = this.model.state;
174
+ if (state && state.uiFullscreen !== false) {
175
+ state.uiFullscreen = false;
176
+ this.view.notifyMediaChange();
177
+ m.redraw();
178
+ }
179
+ }
180
+
181
+ onLandscapeScreen() {
182
+ const state = this.model.state;
183
+ if (state && !state.uiLandscape) {
184
+ state.uiLandscape = true;
185
+ this.view.notifyMediaChange();
186
+ m.redraw();
187
+ }
188
+ }
189
+
190
+ onPortraitScreen() {
191
+ const state = this.model.state;
192
+ if (state && state.uiLandscape !== false) {
193
+ state.uiLandscape = false;
194
+ this.view.notifyMediaChange();
195
+ m.redraw();
196
+ }
197
+ }
198
+
199
+ mediaMinWidth667(mql: MediaQueryList) {
200
+ if (mql.matches) {
201
+ // Take action when min-width exceeds 667
202
+ // (usually because of rotation from portrait to landscape)
203
+ // The board tab is not visible, so the movelist is default
204
+ this.onLandscapeScreen();
205
+ }
206
+ else {
207
+ // min-width is below 667
208
+ // (usually because of rotation from landscape to portrait)
209
+ // Make sure the board tab is selected
210
+ this.onPortraitScreen();
211
+ }
212
+ }
213
+
214
+ mediaMinWidth768(mql: MediaQueryList) {
215
+ if (mql.matches) {
216
+ this.onFullScreen();
217
+ }
218
+ else {
219
+ this.onMobileScreen();
220
+ }
221
+ }
222
+
223
+ initMediaListener() {
224
+ // Install listener functions for media changes
225
+
226
+ function addEventListener(mql: MediaQueryList, func: (ev: MediaQueryListEvent) => void) {
227
+ // Hack to make addEventListener work on older Safari platforms
228
+ try {
229
+ // Chrome & Firefox
230
+ mql.addEventListener('change', func, { passive: true });
231
+ } catch (e1) {
232
+ try {
233
+ // Safari
234
+ mql.addListener(func);
235
+ } catch (e2) {
236
+ console.error(e2);
237
+ }
238
+ }
239
+ }
240
+
241
+ let mql: MediaQueryList = window.matchMedia("(min-width: 667px)");
242
+ let view = this;
243
+ if (mql) {
244
+ this.mediaMinWidth667(mql);
245
+ addEventListener(mql, () => view.mediaMinWidth667(mql));
246
+ }
247
+ mql = window.matchMedia("(min-width: 768px)");
248
+ if (mql) {
249
+ this.mediaMinWidth768(mql);
250
+ addEventListener(mql, () => view.mediaMinWidth768(mql));
251
+ }
252
+ }
253
+
254
+ attachListenerToUser() {
255
+ const state = this.model.state;
256
+ if (state && state.userId)
257
+ attachFirebaseListener('user/' + state.userId,
258
+ (json, firstAttach) => this.onUserMessage(json, firstAttach)
259
+ );
260
+ }
261
+
262
+ detachListenerFromUser() {
263
+ // Stop listening to Firebase notifications for the current user
264
+ const state = this.model.state;
265
+ if (state && state.userId)
266
+ detachFirebaseListener('user/' + state.userId);
267
+ }
268
+
269
+ attachListenerToGame(uuid: string) {
270
+ // Listen to Firebase events on the /game/[gameId]/[userId] path
271
+ const state = this.model.state;
272
+ if (!uuid || !state) return;
273
+ const basepath = 'game/' + uuid + "/" + state.userId + "/";
274
+ // New moves
275
+ attachFirebaseListener(basepath + "move",
276
+ (json, firstAttach) => this.onMoveMessage(json, firstAttach)
277
+ );
278
+ // New chat messages
279
+ attachFirebaseListener(basepath + "chat",
280
+ (json, firstAttach) => this.onChatMessage(json, firstAttach)
281
+ );
282
+ }
283
+
284
+ detachListenerFromGame(uuid: string) {
285
+ // Stop listening to Firebase events on the /game/[gameId]/[userId] path
286
+ const state = this.model.state;
287
+ if (!uuid || !state) return;
288
+ const basepath = 'game/' + uuid + "/" + state.userId + "/";
289
+ detachFirebaseListener(basepath + "move");
290
+ detachFirebaseListener(basepath + "chat");
291
+ }
292
+
293
+ } // class Actions
294
+
295
+ export function createRouteResolver(actions: Actions): m.RouteDefs {
296
+
297
+ // Return a map of routes to onmatch and render functions
298
+
299
+ let model = actions.model;
300
+ let view = actions.view;
301
+ // let state = model.state;
302
+
303
+ return model.paths.reduce((acc, item) => {
304
+ acc[item.route] = {
305
+
306
+ // Navigating to a new route (passed in the second parameter)
307
+ onmatch: (args: Params) => {
308
+ // Automatically close all dialogs
309
+ view.popAllDialogs();
310
+ actions.onNavigateTo(item.name, args);
311
+ },
312
+
313
+ // Render a view on a model
314
+ render: () => { return view.appView(item.name); }
315
+
316
+ };
317
+ return acc;
318
+ }, {} as m.RouteDefs);
319
+ }
@@ -0,0 +1,65 @@
1
+ /*
2
+
3
+ Bag.ts
4
+
5
+ Bag of tiles component
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 { ts } from "./i18n";
17
+ import { ComponentFunc, m, VnodeChildren } from "./mithril";
18
+ import { RACK_SIZE } from "./types";
19
+
20
+ const BAG_TILES_PER_LINE = 19;
21
+
22
+ interface IAttributes {
23
+ bag: string;
24
+ newbag: boolean;
25
+ }
26
+
27
+ export const Bag: ComponentFunc<IAttributes> = () => {
28
+ // The bag of tiles
29
+
30
+ function tiles(bag: string): VnodeChildren {
31
+ let r: VnodeChildren = [];
32
+ let ix = 0;
33
+ let count = bag.length;
34
+ while (count > 0) {
35
+ // Rows
36
+ let cols: VnodeChildren = [];
37
+ // Columns: max BAG_TILES_PER_LINE tiles per row
38
+ for (let i = 0; i < BAG_TILES_PER_LINE && count > 0; i++) {
39
+ let tile = bag[ix++];
40
+ if (tile == "?")
41
+ // Show wildcard tiles '?' as blanks
42
+ tile = "&nbsp;";
43
+ cols.push(m("td", m.trust(tile)));
44
+ count--;
45
+ }
46
+ r.push(m("tr", cols));
47
+ }
48
+ return r;
49
+ }
50
+
51
+ return {
52
+ view: (vnode) => {
53
+ const { bag, newbag } = vnode.attrs;
54
+ let cls = "";
55
+ if (bag.length <= RACK_SIZE)
56
+ cls += ".empty";
57
+ else if (newbag)
58
+ cls += ".new";
59
+ return m(".bag",
60
+ { title: ts("Flísar sem eftir eru") },
61
+ m("table.bag-content" + cls, tiles(bag))
62
+ );
63
+ }
64
+ };
65
+ };
@@ -0,0 +1,74 @@
1
+ /*
2
+
3
+ Bestdisplay.ts
4
+
5
+ Best words and games display component
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 { ts } from "./i18n";
17
+ import { ComponentFunc, VnodeChildren, m } from "./mithril";
18
+
19
+ interface IAttributes {
20
+ ownStats: Record<string, any>;
21
+ myself: boolean;
22
+ id: string;
23
+ }
24
+
25
+ export const BestDisplay: ComponentFunc<IAttributes> = () => {
26
+ // Display the best words and best games played for a given user
27
+
28
+ return {
29
+
30
+ view: (vnode) => {
31
+ // Populate the highest score/best word field
32
+ const json = vnode.attrs.ownStats || {};
33
+ const best: VnodeChildren = [];
34
+ if (json.highest_score) {
35
+ best.push(ts("Hæsta skor "));
36
+ best.push(m("b",
37
+ m(m.route.Link,
38
+ { href: "/game/" + json.highest_score_game },
39
+ json.highest_score
40
+ )
41
+ ));
42
+ }
43
+ if (json.best_word) {
44
+ if (best.length)
45
+ if (vnode.attrs.myself)
46
+ best.push(m("br")); // Own stats: Line break between parts
47
+ else
48
+ best.push(" | "); // Opponent stats: Divider bar between parts
49
+ let bw = json.best_word;
50
+ let s = [];
51
+ // Make sure blank tiles get a different color
52
+ for (let i = 0; i < bw.length; i++)
53
+ if (bw[i] == '?') {
54
+ s.push(m("span.blanktile", bw[i + 1]));
55
+ i += 1;
56
+ }
57
+ else
58
+ s.push(bw[i]);
59
+ best.push(ts("Besta orð "));
60
+ best.push(m("span.best-word", s));
61
+ best.push(", ");
62
+ best.push(m("b",
63
+ m(m.route.Link,
64
+ { href: "/game/" + json.best_word_game },
65
+ json.best_word_score
66
+ )
67
+ ));
68
+ best.push(ts(" stig"));
69
+ }
70
+ return m("p", { id: vnode.attrs.id }, best);
71
+ }
72
+
73
+ };
74
+ }
@@ -0,0 +1,94 @@
1
+ /*
2
+
3
+ BlankDialog.ts
4
+
5
+ Blank tile dialog component
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 { IView, IGame } from "./types";
17
+ import { ComponentFunc, m, VnodeChildren } from "./mithril";
18
+ import { buttonOut, buttonOver, glyph } from "./util";
19
+ import { mt, ts } from "./i18n";
20
+ import { DialogButton } from "./components";
21
+
22
+ const BLANK_TILES_PER_LINE = 6;
23
+
24
+ interface IAttributes {
25
+ view: IView;
26
+ }
27
+
28
+ export const BlankDialog: ComponentFunc<IAttributes> = (initialVnode) => {
29
+ // A dialog for choosing the meaning of a blank tile
30
+
31
+ const view = initialVnode.attrs.view;
32
+ const model = view.model;
33
+
34
+ function blankLetters(game: IGame): VnodeChildren {
35
+ let legalLetters = game.alphabet;
36
+ let len = legalLetters.length;
37
+ let ix = 0;
38
+ let r: VnodeChildren = [];
39
+
40
+ while (len > 0) {
41
+ /* Rows */
42
+ let c = [];
43
+ /* Columns: max BLANK_TILES_PER_LINE tiles per row */
44
+ for (let i = 0; i < BLANK_TILES_PER_LINE && len > 0; i++) {
45
+ let letter = legalLetters[ix++];
46
+ c.push(
47
+ m("td",
48
+ {
49
+ onclick: (ev: Event) => { game.placeBlank(letter); ev.preventDefault(); },
50
+ onmouseover: buttonOver,
51
+ onmouseout: buttonOut
52
+ },
53
+ m(".blank-choice.tile.racktile", letter)
54
+ )
55
+ );
56
+ len--;
57
+ }
58
+ r.push(m("tr", c));
59
+ }
60
+ return r;
61
+ }
62
+
63
+ return {
64
+ view: () => {
65
+ const game = model.game;
66
+ if (!game) return undefined;
67
+ return m(".modal-dialog",
68
+ {
69
+ id: 'blank-dialog',
70
+ style: { visibility: "visible" }
71
+ },
72
+ m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'blank-form' },
73
+ [
74
+ mt("p", "Hvaða staf táknar auða flísin?"),
75
+ m(".rack.blank-rack",
76
+ m("table.board", { id: 'blank-meaning' }, blankLetters(game))
77
+ ),
78
+ m(DialogButton,
79
+ {
80
+ id: 'blank-close',
81
+ title: ts("Hætta við"),
82
+ onclick: (ev: Event) => {
83
+ ev.preventDefault();
84
+ game.cancelBlankDialog();
85
+ }
86
+ },
87
+ glyph("remove")
88
+ )
89
+ ]
90
+ )
91
+ );
92
+ }
93
+ };
94
+ };