@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,336 @@
1
+ /*
2
+
3
+ Board.ts
4
+
5
+ Board, Tile and Rack components
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 { ERROR_MESSAGES, IGame, IView, RACK_SIZE } from "./types";
17
+ import { addPinchZoom, glyph } from "./util";
18
+ import { Component, ComponentFunc, m, VnodeAttrs, VnodeChildren } from "./mithril";
19
+ import { Buttons, ButtonsLeftOfRack } from "./buttons";
20
+ import { Tile } from "./tile";
21
+ import { mt, ts } from "./i18n";
22
+
23
+ const GameOver: Component<{ game: IGame }> = {
24
+ view: (vnode) => {
25
+ // Show message at end of game, either congratulating a win or
26
+ // solemnly informing the player that the game is over
27
+ const { game } = vnode.attrs;
28
+ if (game.congratulate) {
29
+ return m("div", { id: "congrats" },
30
+ [
31
+ glyph("bookmark"),
32
+ " ",
33
+ mt("strong", "Til hamingju með sigurinn!")
34
+ ]
35
+ );
36
+ }
37
+ if (game.over) {
38
+ return m("div", { id: "gameover" },
39
+ [
40
+ glyph("info-sign"),
41
+ " ",
42
+ mt("strong", "Viðureigninni er lokið")
43
+ ]
44
+ );
45
+ }
46
+ return undefined;
47
+ }
48
+ };
49
+
50
+ const Errors: Component<{ game: IGame }> = {
51
+ view: (vnode) => {
52
+ // Error messages, selectively displayed
53
+ const { game } = vnode.attrs;
54
+ const err = game.currentError || "";
55
+ if (err && err in ERROR_MESSAGES) {
56
+ const msg: string = game.currentMessage || "";
57
+ const txt: string = ts(ERROR_MESSAGES[err]);
58
+ const wix = txt.indexOf("{word}");
59
+ let children: VnodeChildren[];
60
+ if (wix >= 0) {
61
+ // Found {word} macro: create three child nodes
62
+ children = [ txt.slice(0, wix), m("span.errword", msg), txt.slice(wix + 6) ];
63
+ }
64
+ else {
65
+ // No {word} macro: just return the message as-is
66
+ children = [ txt ];
67
+ }
68
+ return m(".error",
69
+ {
70
+ style: { visibility: "visible" },
71
+ onclick: (ev: Event) => { game.resetError(); ev.preventDefault(); }
72
+ },
73
+ [ glyph("exclamation-sign"), ...children ]
74
+ );
75
+ }
76
+ return undefined;
77
+ }
78
+ };
79
+
80
+ interface IAttributes {
81
+ view: IView;
82
+ }
83
+
84
+ export const BoardArea: ComponentFunc<IAttributes> = (initialVnode) => {
85
+ // Collection of components in the board (left-side) area
86
+ const view = initialVnode.attrs.view;
87
+ const model = view.model;
88
+ return {
89
+ view: () => {
90
+ const game = model.game;
91
+ let r: VnodeChildren = [];
92
+ if (game) {
93
+ r = [
94
+ m(Board, { view, review: false }),
95
+ m(Rack, { view, review: false }),
96
+ m(Buttons, { view }),
97
+ m(Errors, { game }),
98
+ m(GameOver, { game }),
99
+ ];
100
+ r = r.concat(view.vwDialogs());
101
+ }
102
+ return m(".board-area", r);
103
+ }
104
+ };
105
+ };
106
+
107
+ interface ITileSquareAttributes {
108
+ view: IView;
109
+ coord: string;
110
+ opponent: boolean;
111
+ }
112
+
113
+ export const TileSquare: ComponentFunc<ITileSquareAttributes> = (initialVnode) => {
114
+ // Return a td element that wraps a tile on the board.
115
+ // If the opponent flag is true, we put an '.opp' class on the td
116
+ const view = initialVnode.attrs.view;
117
+ const model = view.model;
118
+ return {
119
+ view: (vnode) => {
120
+ const coord = vnode.attrs.coord;
121
+ const game = model.game;
122
+ if (!game) return undefined;
123
+ // The square contains a tile, so we don't allow dropping a tile on it
124
+ // and don't wish to allow a parent element to accept a drop either.
125
+ // Indicate this by including the not-target class.
126
+ return m("td.not-target",
127
+ {
128
+ id: "sq_" + coord,
129
+ class: game.squareClass(coord),
130
+ },
131
+ m(Tile, { view, coord: coord, opponent: false })
132
+ );
133
+ }
134
+ };
135
+ };
136
+
137
+ export const ReviewTileSquare: ComponentFunc<ITileSquareAttributes> = (initialVnode) => {
138
+ // Return a td element that wraps an 'inert' tile in a review screen.
139
+ // If the opponent flag is true, we put an '.opp' class on the td
140
+ const view = initialVnode.attrs.view;
141
+ const model = view.model;
142
+ return {
143
+ view: (vnode) => {
144
+ const game = model.game;
145
+ if (!game) return undefined;
146
+ const coord = vnode.attrs.coord;
147
+ let cls = game.squareClass(coord) || "";
148
+ if (cls)
149
+ cls = "." + cls;
150
+ if (vnode.attrs.opponent)
151
+ cls += ".opp";
152
+ return m("td" + cls, { id: "sq_" + coord }, vnode.children);
153
+ }
154
+ };
155
+ };
156
+
157
+ interface IDropTargetSquareAttributes {
158
+ view: IView;
159
+ coord: string;
160
+ }
161
+
162
+ export const DropTargetSquare: ComponentFunc<IDropTargetSquareAttributes> = (initialVnode) => {
163
+ // Return a td element that is a target for dropping tiles
164
+ const view = initialVnode.attrs.view;
165
+ const model = view.model;
166
+ return {
167
+ view: (vnode) => {
168
+ const game = model.game;
169
+ if (!game) return undefined;
170
+ const coord = vnode.attrs.coord;
171
+ let cls = game.squareClass(coord) || "";
172
+ if (cls)
173
+ cls = "." + cls;
174
+ // Mark the cell with the 'blinking' class if it is the drop
175
+ // target of a pending blank tile dialog
176
+ if (game.askingForBlank !== null && game.askingForBlank.to == coord)
177
+ cls += ".blinking";
178
+ if (coord == game.startSquare && game.localturn)
179
+ // Unoccupied start square, first move
180
+ cls += ".center";
181
+ return m("td.drop-target" + cls,
182
+ {
183
+ id: "sq_" + coord,
184
+ onclick: (ev: Event) => {
185
+ // If a square is selected (blinking red) and
186
+ // we click on an empty square, move the selected tile
187
+ // to the clicked square
188
+ if (game.selectedSq !== null) {
189
+ ev.stopPropagation();
190
+ game.attemptMove(game.selectedSq, coord);
191
+ game.selectedSq = null;
192
+ (ev.currentTarget as HTMLElement).classList.remove("sel");
193
+ view.updateScale();
194
+ return false;
195
+ }
196
+ },
197
+ onmouseover: (ev: Event) => {
198
+ // If a tile is selected, show a red selection square
199
+ // around this square when the mouse is over it
200
+ if (game.selectedSq !== null)
201
+ (ev.currentTarget as HTMLElement).classList.add("sel");
202
+ },
203
+ onmouseout: (ev: Event) => {
204
+ (ev.currentTarget as HTMLElement).classList.remove("sel");
205
+ }
206
+ },
207
+ vnode.children
208
+ );
209
+ }
210
+ };
211
+ }
212
+
213
+ interface IBoardAttributes {
214
+ view: IView;
215
+ review: boolean;
216
+ }
217
+
218
+ export const Board: ComponentFunc<IBoardAttributes> = (initialVnode) => {
219
+ // The game board, a 15x15 table plus row (A-O) and column (1-15) identifiers
220
+
221
+ const { view, review } = initialVnode.attrs;
222
+ const model = view.model;
223
+
224
+ function colid(): VnodeChildren {
225
+ // The column identifier row
226
+ let r: VnodeChildren = [];
227
+ r.push(m("td"));
228
+ for (let col = 1; col <= 15; col++)
229
+ r.push(m("td", col.toString()));
230
+ return m("tr.colid", r);
231
+ }
232
+
233
+ function row(rowid: string): VnodeChildren {
234
+ // Each row of the board
235
+ let r: VnodeChildren = [];
236
+ const game = model.game;
237
+ r.push(m("td.rowid", { key: "R" + rowid }, rowid));
238
+ for (let col = 1; col <= 15; col++) {
239
+ const coord = rowid + col.toString();
240
+ if (game && (coord in game.tiles))
241
+ // There is a tile in this square: render it
242
+ r.push(m(TileSquare, { view, key: coord, coord: coord, opponent: false }));
243
+ else if (review)
244
+ // Empty, inert square
245
+ r.push(m(ReviewTileSquare, { view, key: coord, coord: coord, opponent: false }));
246
+ else
247
+ // Empty square which is a drop target
248
+ r.push(m(DropTargetSquare, { view, key: coord, coord: coord }));
249
+ }
250
+ return m("tr", r);
251
+ }
252
+
253
+ function allrows(): VnodeChildren {
254
+ // Return a list of all rows on the board
255
+ let r: VnodeChildren = [];
256
+ r.push(colid());
257
+ const rows = "ABCDEFGHIJKLMNO";
258
+ for (const rw of rows)
259
+ r.push(row(rw));
260
+ return r;
261
+ }
262
+
263
+ function zoomIn() {
264
+ view.boardScale = 1.5;
265
+ }
266
+
267
+ function zoomOut() {
268
+ if (view.boardScale != 1.0) {
269
+ view.boardScale = 1.0;
270
+ setTimeout(view.resetScale);
271
+ }
272
+ }
273
+
274
+ return {
275
+ view: (vnode) => {
276
+ const scale = view.boardScale || 1.0;
277
+ let attrs: VnodeAttrs = {};
278
+ // Add handlers for pinch zoom functionality
279
+ addPinchZoom(attrs, zoomIn, zoomOut);
280
+ if (scale != 1.0)
281
+ attrs.style = `transform: scale(${scale})`;
282
+ return m(".board",
283
+ { id: "board-parent" },
284
+ m("table.board", attrs, m("tbody", allrows()))
285
+ );
286
+ }
287
+ };
288
+ }
289
+
290
+ export const Rack: ComponentFunc<IBoardAttributes> = (initialVnode) => {
291
+ // A rack of 7 tiles
292
+ const { view, review } = initialVnode.attrs;
293
+ const model = view.model;
294
+ return {
295
+ view: () => {
296
+ const game = model.game;
297
+ if (!game) return undefined;
298
+ let r: VnodeChildren = [];
299
+ // If review==true, this is a review rack
300
+ // that is not a drop target and whose color reflects the
301
+ // currently shown move.
302
+ // If opponent==true, we're showing the opponent's rack
303
+ const reviewMove = model.reviewMove ?? 0;
304
+ const opponent = review && (reviewMove > 0) && (reviewMove % 2 === game.player);
305
+ for (let i = 1; i <= RACK_SIZE; i++) {
306
+ const coord = 'R' + i.toString();
307
+ if (game && (coord in game.tiles)) {
308
+ // We have a tile in this rack slot, but it is a drop target anyway
309
+ if (review) {
310
+ r.push(
311
+ m(ReviewTileSquare, { view, coord: coord, opponent: opponent },
312
+ m(Tile, { view, coord: coord, opponent: opponent })
313
+ )
314
+ );
315
+ } else {
316
+ r.push(
317
+ m(DropTargetSquare, { view, coord: coord },
318
+ m(Tile, { view, coord: coord, opponent: false })
319
+ )
320
+ );
321
+ }
322
+ }
323
+ else if (review) {
324
+ r.push(m(ReviewTileSquare, { view, coord: coord, opponent: false }));
325
+ }
326
+ else {
327
+ r.push(m(DropTargetSquare, { view, coord: coord }));
328
+ }
329
+ }
330
+ return m(".rack-row", [
331
+ m(".rack-left", m(ButtonsLeftOfRack, { view })),
332
+ m(".rack", m("table.board", m("tbody", m("tr", r))))
333
+ ]);
334
+ }
335
+ };
336
+ };
@@ -0,0 +1,303 @@
1
+ /*
2
+
3
+ Buttons.ts
4
+
5
+ Buttons below rack 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 { AnimatedExploLogo } from "./logo";
18
+ import { Component, ComponentFunc, m, VnodeAttrs, VnodeChildren } from "./mithril";
19
+ import { IGame, IView } from "./types";
20
+ import { buttonOut, buttonOver, glyph, nbsp } from "./util";
21
+
22
+ export function makeButton(
23
+ cls: string, disabled: boolean, onclick: () => void,
24
+ title?: string, children?: VnodeChildren, id?: string
25
+ ): VnodeChildren {
26
+ // Create a button element, wrapping the disabling logic
27
+ // and other boilerplate
28
+ const attr: VnodeAttrs = {
29
+ onmouseout: buttonOut,
30
+ onmouseover: buttonOver,
31
+ };
32
+ if (title)
33
+ attr.title = title;
34
+ if (id)
35
+ attr.id = id;
36
+ if (disabled)
37
+ attr.onclick = (ev: Event) => ev.preventDefault();
38
+ else
39
+ attr.onclick = (ev: Event) => {
40
+ onclick && onclick();
41
+ ev.preventDefault();
42
+ };
43
+ return m(
44
+ "." + cls + (disabled ? ".disabled" : ""),
45
+ attr, children // children may be omitted
46
+ );
47
+ }
48
+
49
+ const Score: Component<{ game: IGame }> = {
50
+ view: (vnode) => {
51
+ // Shows the score of the current word
52
+ const { game } = vnode.attrs;
53
+ let sc = [".score"];
54
+ if (game.manual)
55
+ sc.push("manual");
56
+ else
57
+ if (game.wordGood) {
58
+ sc.push("word-good");
59
+ if (game.currentScore !== undefined && game.currentScore >= 50)
60
+ sc.push("word-great");
61
+ }
62
+ let txt = (game.currentScore === undefined ? "?" : game.currentScore.toString())
63
+ return m(sc.join("."), { title: txt }, txt);
64
+ }
65
+ };
66
+
67
+ const RecallButton: Component<{ view: IView; disabled?: boolean; }> = {
68
+ view: (vnode) => {
69
+ // Create a tile recall button
70
+ const { view, disabled } = vnode.attrs;
71
+ const game = view.model.game;
72
+ if (!game) return undefined;
73
+ return makeButton(
74
+ "recallbtn", !!disabled,
75
+ () => { game.resetRack(); view.updateScale(); },
76
+ ts("Færa stafi aftur í rekka"), glyph("down-arrow")
77
+ );
78
+ }
79
+ };
80
+
81
+ const ScrambleButton: Component<{ view: IView; disabled?: boolean; }> = {
82
+ view: (vnode) => {
83
+ // Create a tile scramble button
84
+ const { view, disabled } = vnode.attrs;
85
+ const game = view.model.game;
86
+ if (!game) return undefined;
87
+ return makeButton(
88
+ "scramblebtn", !!disabled,
89
+ () => game.rescrambleRack(), // Note: plain game.rescrambleRack doesn't work here
90
+ ts("Stokka upp rekka"), glyph("random")
91
+ );
92
+ }
93
+ };
94
+
95
+ export const ButtonsLeftOfRack: Component<{ view: IView }> = {
96
+ view: (vnode) => {
97
+ // The button to the left of the rack in the mobile UI
98
+ const { view } = vnode.attrs;
99
+ const game = view.model.game;
100
+ if (!game) return undefined;
101
+ const s = game.buttonState();
102
+ if (s.showRecall && !s.showingDialog)
103
+ // Show a 'Recall tiles' button
104
+ return m(RecallButton, { view });
105
+ if (s.showScramble && !s.showingDialog)
106
+ return m(ScrambleButton, { view });
107
+ return undefined;
108
+ }
109
+ };
110
+
111
+ interface IAttributes {
112
+ view: IView;
113
+ }
114
+
115
+ export const Buttons: ComponentFunc<IAttributes> = (initialVnode) => {
116
+
117
+ const view = initialVnode.attrs.view;
118
+ const model = view.model;
119
+
120
+ return {
121
+ view: () => {
122
+ // The set of buttons below the game board, alongside the rack (fullscreen view)
123
+ // or below the rack (mobile view)
124
+ const game = model.game;
125
+ let r: VnodeChildren = [];
126
+ if (!game) return undefined;
127
+ const s = game.buttonState();
128
+ r.push(m(".word-check" +
129
+ (s.wordGood ? ".word-good" : "") +
130
+ (s.wordBad ? ".word-bad" : "")));
131
+ if (s.showChallenge) {
132
+ // Show a button that allows the player to challenge the opponent's
133
+ // last move
134
+ const disabled = (s.tilesPlaced || s.showingDialog) && !s.lastChallenge;
135
+ r.push(
136
+ makeButton(
137
+ "challenge", disabled,
138
+ () => game.submitChallenge(), // Note: plain game.submitChallenge doesn't work here
139
+ 'Véfenging (röng kostar 10 stig)', glyph("ban-circle")
140
+ )
141
+ );
142
+ }
143
+ if (s.showRecall) {
144
+ // Show button to recall tiles from the board into the rack
145
+ r.push(m(RecallButton, { view, disabled: s.showingDialog }));
146
+ }
147
+ if (s.showScramble) {
148
+ // Show button to scramble (randomly reorder) the rack tiles
149
+ r.push(m(ScrambleButton, { view, disabled: s.showingDialog }));
150
+ }
151
+ if (s.showMove) {
152
+ // "Plain" move button for fullscreen
153
+ const submit_move = ts("submit_move"); // 'Move' or 'Leika'
154
+ r.push(
155
+ makeButton(
156
+ "submitmove", !s.tilesPlaced || s.showingDialog,
157
+ () => { game.submitMove(); view.updateScale(); },
158
+ submit_move, [submit_move, glyph("play")]
159
+ )
160
+ );
161
+ }
162
+ if (s.showMoveMobile) {
163
+ // Submit-Move button on mobile, which also shows the score
164
+ // and whether the move is good or bad
165
+ let classes: string[] = ["submitmove"];
166
+ let wordIsPlayable = game.currentScore !== undefined;
167
+ if (game.manual) {
168
+ classes.push("manual")
169
+ } else if (s.wordGood) {
170
+ classes.push("word-good");
171
+ if (game.currentScore !== undefined && game.currentScore >= 50)
172
+ classes.push("word-great");
173
+ } else if (s.wordBad) {
174
+ classes.push("word-bad");
175
+ wordIsPlayable = false;
176
+ }
177
+ const text = (game.currentScore === undefined) ? "?" : game.currentScore.toString();
178
+ let legend: VnodeChildren[] = [m("span.score-mobile", text)];
179
+ if (s.canPlay && wordIsPlayable)
180
+ legend.push(glyph("play"));
181
+ else
182
+ legend.push(glyph("remove"));
183
+ let action: () => void;
184
+ if (s.canPlay) {
185
+ if (wordIsPlayable)
186
+ action = () => { game.submitMove(); view.updateScale(); };
187
+ else
188
+ action = () => { /* TODO: Add some kind of feedback? */ };
189
+ }
190
+ else {
191
+ action = () => {
192
+ // Make the 'opp-turn' flash, to remind the user that it's not her turn
193
+ const el = document.querySelector("div.opp-turn") as HTMLElement;
194
+ if (el) {
195
+ el.classList.toggle("flashing", true);
196
+ setTimeout(() => el.classList.toggle("flashing", false), 1200);
197
+ }
198
+ };
199
+ }
200
+ r.push(
201
+ makeButton(
202
+ classes.join("."), s.showingDialog, action, text, legend, "move-mobile"
203
+ )
204
+ );
205
+ }
206
+ if (s.showForceResignMobile) {
207
+ // Force resignation button (only shown on mobile,
208
+ // and only if submit move button is not shown)
209
+ const txt = ts("Þvinga til uppgjafar");
210
+ r.push(
211
+ makeButton(
212
+ "force-resign",
213
+ s.showingDialog,
214
+ () => game.forceResign(), // Note: plain game.forceResign doesn't work here
215
+ txt,
216
+ txt
217
+ )
218
+ );
219
+ }
220
+ if (s.showPass) {
221
+ // Pass move: shown if no tiles have been placed
222
+ // and we're not showing a dialog, or if this is
223
+ // the last move by the opponent in a manual game
224
+ r.push(
225
+ makeButton(
226
+ "submitpass",
227
+ (s.tilesPlaced || s.showingDialog) && !s.lastChallenge,
228
+ () => game.submitPass(), // Note: plain game.submitPass doesn't work here
229
+ ts("Pass"), glyph("forward")
230
+ )
231
+ );
232
+ }
233
+ if (s.showExchange) {
234
+ // Exchange tiles from the rack
235
+ r.push(
236
+ makeButton(
237
+ "submitexchange",
238
+ s.tilesPlaced || s.showingDialog || !s.exchangeAllowed,
239
+ () => game.submitExchange(), // Note: plain game.submitExchange doesn't work here
240
+ ts("Skipta stöfum"), glyph("refresh")
241
+ )
242
+ );
243
+ }
244
+ if (s.showResign) {
245
+ // Resign the game
246
+ r.push(
247
+ makeButton(
248
+ "submitresign", s.showingDialog,
249
+ () => game.submitResign(), // Note: plain game.submitResign doesn't work here
250
+ ts("Gefa viðureign"), glyph("fire")
251
+ )
252
+ );
253
+ }
254
+ if (!s.gameOver && !s.localTurn && !game.moveInProgress && game.player !== null) {
255
+ // Indicate that it is the opponent's turn; offer to force a resignation
256
+ // if the opponent hasn't moved for 14 days
257
+ r.push(
258
+ m(".opp-turn",
259
+ { style: { visibility: "visible" } },
260
+ [
261
+ m("span.move-indicator"),
262
+ nbsp(),
263
+ m("strong", game.nickname[1 - game.player]),
264
+ ts(" á leik"),
265
+ nbsp(),
266
+ // The following inline button is only
267
+ // displayed in the fullscreen UI
268
+ s.tardyOpponent ? m("span.yesnobutton",
269
+ {
270
+ id: 'force-resign',
271
+ onclick: (ev: Event) => {
272
+ ev.preventDefault();
273
+ game.forceResign();
274
+ },
275
+ onmouseout: buttonOut,
276
+ onmouseover: buttonOver,
277
+ title: ts("14 dagar liðnir án leiks")
278
+ },
279
+ ts("Þvinga til uppgjafar")
280
+ ) : ""
281
+ ]
282
+ )
283
+ );
284
+ }
285
+ if (s.tilesPlaced) {
286
+ // Show the score of the current move (not visible on mobile)
287
+ const sc = m(Score, { game });
288
+ sc && r.push(sc);
289
+ }
290
+ // Is the server processing a move?
291
+ if (game?.moveInProgress) {
292
+ r.push(
293
+ m(".waitmove",
294
+ m(".animated-waitmove",
295
+ m(AnimatedExploLogo, { msStepTime: 100, width: 38, withCircle: false })
296
+ )
297
+ )
298
+ );
299
+ }
300
+ return m(".buttons", r);
301
+ }
302
+ };
303
+ };