@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.
- package/.eslintignore +8 -0
- package/.eslintrc.json +13 -0
- package/README.md +63 -0
- package/dist/cjs/index.css +6837 -0
- package/dist/cjs/index.js +3046 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/esm/index.css +6837 -0
- package/dist/esm/index.js +3046 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/package.json +63 -0
- package/rollup.config.js +60 -0
- package/src/components/index.ts +2 -0
- package/src/components/netskrafl/Netskrafl.stories.tsx +66 -0
- package/src/components/netskrafl/Netskrafl.tsx +135 -0
- package/src/components/netskrafl/Netskrafl.types.ts +7 -0
- package/src/components/netskrafl/index.ts +2 -0
- package/src/css/fonts.css +4 -0
- package/src/css/glyphs.css +224 -0
- package/src/css/skrafl-explo.css +6616 -0
- package/src/fonts/glyphicons-regular.eot +0 -0
- package/src/fonts/glyphicons-regular.ttf +0 -0
- package/src/fonts/glyphicons-regular.woff +0 -0
- package/src/index.ts +2 -0
- package/src/messages/messages.json +1576 -0
- package/src/mithril/actions.ts +319 -0
- package/src/mithril/bag.ts +65 -0
- package/src/mithril/bestdisplay.ts +74 -0
- package/src/mithril/blankdialog.ts +94 -0
- package/src/mithril/board.ts +336 -0
- package/src/mithril/buttons.ts +303 -0
- package/src/mithril/challengedialog.ts +186 -0
- package/src/mithril/channel.ts +162 -0
- package/src/mithril/chat.ts +228 -0
- package/src/mithril/components.ts +496 -0
- package/src/mithril/dragdrop.ts +219 -0
- package/src/mithril/elopage.ts +180 -0
- package/src/mithril/friend.ts +227 -0
- package/src/mithril/game.ts +1378 -0
- package/src/mithril/gameview.ts +111 -0
- package/src/mithril/globalstate.ts +33 -0
- package/src/mithril/i18n.ts +186 -0
- package/src/mithril/localstorage.ts +133 -0
- package/src/mithril/login.ts +122 -0
- package/src/mithril/logo.ts +270 -0
- package/src/mithril/main.ts +737 -0
- package/src/mithril/mithril.ts +29 -0
- package/src/mithril/model.ts +817 -0
- package/src/mithril/movelistitem.ts +226 -0
- package/src/mithril/page.ts +852 -0
- package/src/mithril/playername.ts +91 -0
- package/src/mithril/promodialog.ts +82 -0
- package/src/mithril/recentlist.ts +148 -0
- package/src/mithril/request.ts +52 -0
- package/src/mithril/review.ts +634 -0
- package/src/mithril/rightcolumn.ts +398 -0
- package/src/mithril/searchbutton.ts +118 -0
- package/src/mithril/statsdisplay.ts +109 -0
- package/src/mithril/tabs.ts +169 -0
- package/src/mithril/tile.ts +145 -0
- package/src/mithril/twoletter.ts +76 -0
- package/src/mithril/types.ts +379 -0
- package/src/mithril/userinfodialog.ts +171 -0
- package/src/mithril/util.ts +304 -0
- package/src/mithril/wait.ts +246 -0
- package/src/mithril/wordcheck.ts +102 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +12 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
/*
|
|
2
|
+
|
|
3
|
+
Page.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 UI is built on top of Mithril (https://mithril.js.org), a lightweight,
|
|
15
|
+
straightforward JavaScript single-page reactive UI library.
|
|
16
|
+
|
|
17
|
+
The page is structured into models, actions and views,
|
|
18
|
+
cf. https://github.com/pakx/the-mithril-diaries/wiki/Basic-App-Structure
|
|
19
|
+
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { mt, t, ts } from "./i18n";
|
|
23
|
+
import { GlobalState } from "./globalstate";
|
|
24
|
+
import { IView, DialogViewEnum } from "./types";
|
|
25
|
+
import { setServerUrl } from "./request";
|
|
26
|
+
import {
|
|
27
|
+
glyph, nbsp, getInput,
|
|
28
|
+
scrollMovelistToBottom, coord, toVector,
|
|
29
|
+
} from "./util";
|
|
30
|
+
import {
|
|
31
|
+
m, Vnode, VnodeDOM, VnodeChildren, EventHandler,
|
|
32
|
+
} from "./mithril";
|
|
33
|
+
import { logEvent, loginFirebase } from "./channel";
|
|
34
|
+
import { Model, getSettings } from "./model";
|
|
35
|
+
import { Actions, createRouteResolver } from "./actions";
|
|
36
|
+
import { WaitDialog, AcceptDialog } from "./wait";
|
|
37
|
+
import {
|
|
38
|
+
FriendPromoteDialog, FriendThanksDialog,
|
|
39
|
+
FriendCancelDialog, FriendCancelConfirmDialog
|
|
40
|
+
} from "./friend";
|
|
41
|
+
import { LoginError, LoginForm, loginUserByEmail } from "./login";
|
|
42
|
+
import {
|
|
43
|
+
DialogButton, Spinner,
|
|
44
|
+
UserId,
|
|
45
|
+
LeftLogo,
|
|
46
|
+
TogglerAudio,
|
|
47
|
+
TogglerFanfare,
|
|
48
|
+
TogglerBeginner,
|
|
49
|
+
TogglerFairplay,
|
|
50
|
+
TextInput
|
|
51
|
+
} from "./components";
|
|
52
|
+
import { ChallengeDialog } from "./challengedialog";
|
|
53
|
+
import { Main } from "./main";
|
|
54
|
+
import { makeTabs, selectTab, TabVnode } from "./tabs";
|
|
55
|
+
import { PromoDialog } from "./promodialog";
|
|
56
|
+
import { UserInfoDialog } from "./userinfodialog";
|
|
57
|
+
import { GameView } from "./gameview";
|
|
58
|
+
import { vwReview } from "./review";
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
// EXPERIMENTAL
|
|
62
|
+
function insertStyleSheet(url: string) {
|
|
63
|
+
// Insert a link rel="stylesheet" element into the document head,
|
|
64
|
+
// if it isn't there already
|
|
65
|
+
const head = document.head;
|
|
66
|
+
if (!head) return;
|
|
67
|
+
const links = head.getElementsByTagName("link");
|
|
68
|
+
for (const link of links) {
|
|
69
|
+
if (link.href === url) return;
|
|
70
|
+
}
|
|
71
|
+
const link = document.createElement("link");
|
|
72
|
+
link.rel = "stylesheet";
|
|
73
|
+
link.href = url;
|
|
74
|
+
head.appendChild(link);
|
|
75
|
+
}
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
function updateFontFaceUrls(serverUrl: string) {
|
|
79
|
+
// Get all stylesheets in the document
|
|
80
|
+
const styleSheets = document.styleSheets;
|
|
81
|
+
// Iterate through each stylesheet
|
|
82
|
+
for (const styleSheet of styleSheets) {
|
|
83
|
+
// Iterate through each CSS rule in the stylesheet
|
|
84
|
+
for (const rule of styleSheet.cssRules) {
|
|
85
|
+
// Check if the rule is a @font-face rule
|
|
86
|
+
if (rule instanceof CSSFontFaceRule) {
|
|
87
|
+
// Update the src property with new URLs
|
|
88
|
+
const src = rule.style.getPropertyValue("src");
|
|
89
|
+
if (src.includes("glyphicons-")) {
|
|
90
|
+
rule.style.setProperty("src", `
|
|
91
|
+
url('${serverUrl}/static/glyphicons-regular.eot') format('embedded-opentype'),
|
|
92
|
+
url('${serverUrl}/static/glyphicons-regular.woff') format('woff'),
|
|
93
|
+
url('${serverUrl}/static/glyphicons-regular.ttf') format('truetype')
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type LoginResult = "success" | "expired" | "error";
|
|
102
|
+
|
|
103
|
+
export async function main(state: GlobalState, container: HTMLElement): Promise<LoginResult> {
|
|
104
|
+
// The main UI entry point, called from page.html
|
|
105
|
+
|
|
106
|
+
if (!container) {
|
|
107
|
+
console.error("No container element found");
|
|
108
|
+
return "error";
|
|
109
|
+
}
|
|
110
|
+
// Set up Netskrafl backend server URLs
|
|
111
|
+
setServerUrl(state.serverUrl, state.movesUrl, state.movesAccessKey);
|
|
112
|
+
|
|
113
|
+
// Insert the Explo CSS stylesheet
|
|
114
|
+
// insertStyleSheet("./index.css");
|
|
115
|
+
|
|
116
|
+
// Update font URLs to point to the backend server
|
|
117
|
+
updateFontFaceUrls(state.serverUrl);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const loginData = await loginUserByEmail(
|
|
121
|
+
state.userEmail,
|
|
122
|
+
state.userNick,
|
|
123
|
+
state.userFullname,
|
|
124
|
+
state.token,
|
|
125
|
+
);
|
|
126
|
+
if (loginData.status === "expired") {
|
|
127
|
+
// The current Málstaður JWT has expired;
|
|
128
|
+
// we need to obtain a new one
|
|
129
|
+
return "expired";
|
|
130
|
+
}
|
|
131
|
+
if (loginData.status === "success") {
|
|
132
|
+
state.userId = loginData.user_id;
|
|
133
|
+
// Log in to Firebase with the token passed from the server
|
|
134
|
+
await loginFirebase(state, loginData.firebase_token);
|
|
135
|
+
// Everything looks OK:
|
|
136
|
+
// Create the model, view and actions objects
|
|
137
|
+
const settings = getSettings();
|
|
138
|
+
const model = new Model(settings, state);
|
|
139
|
+
const view = new View(model);
|
|
140
|
+
const actions = new Actions(model, view);
|
|
141
|
+
// Run the Mithril router
|
|
142
|
+
const routeResolver = createRouteResolver(actions);
|
|
143
|
+
m.route(container, settings.defaultRoute, routeResolver);
|
|
144
|
+
return "success";
|
|
145
|
+
}
|
|
146
|
+
} catch(e) {
|
|
147
|
+
console.error("Exception during login: ", e);
|
|
148
|
+
}
|
|
149
|
+
m.mount(container, LoginError);
|
|
150
|
+
return "error";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function unmount(container: HTMLElement) {
|
|
154
|
+
// Unmount the Mithril UI
|
|
155
|
+
m.mount(container, null);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type DialogFunc = (view: View, args: any) => VnodeChildren;
|
|
159
|
+
|
|
160
|
+
type DialogViews = Record<DialogViewEnum, DialogFunc>;
|
|
161
|
+
|
|
162
|
+
interface Dialog {
|
|
163
|
+
name: DialogViewEnum;
|
|
164
|
+
args: any;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class View implements IView {
|
|
168
|
+
|
|
169
|
+
// The View class exposes the vwApp view function.
|
|
170
|
+
// Each instance maintains a current dialog window stack.
|
|
171
|
+
|
|
172
|
+
// The model that the view is attached to
|
|
173
|
+
model: Model;
|
|
174
|
+
|
|
175
|
+
// The currently displayed dialogs
|
|
176
|
+
private dialogStack: Dialog[] = [];
|
|
177
|
+
|
|
178
|
+
// Map of available dialogs
|
|
179
|
+
private static dialogViews: DialogViews = {
|
|
180
|
+
userprefs:
|
|
181
|
+
(view) => view.vwUserPrefs(),
|
|
182
|
+
userinfo:
|
|
183
|
+
(view, args) => view.vwUserInfo(args),
|
|
184
|
+
challenge:
|
|
185
|
+
(view, args) => m(ChallengeDialog, { view, item: args }),
|
|
186
|
+
promo:
|
|
187
|
+
(view, args) => view.vwPromo(args),
|
|
188
|
+
friend:
|
|
189
|
+
(view) => m(FriendPromoteDialog, { view }),
|
|
190
|
+
thanks:
|
|
191
|
+
(view) => m(FriendThanksDialog, { view }),
|
|
192
|
+
cancel:
|
|
193
|
+
(view) => m(FriendCancelDialog, { view }),
|
|
194
|
+
confirm:
|
|
195
|
+
(view) => m(FriendCancelConfirmDialog, { view }),
|
|
196
|
+
wait:
|
|
197
|
+
(view, args) => view.vwWait(args),
|
|
198
|
+
accept:
|
|
199
|
+
(view, args) => view.vwAccept(args)
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// The current scaling of the board
|
|
203
|
+
boardScale: number = 1.0;
|
|
204
|
+
|
|
205
|
+
constructor(model: Model) {
|
|
206
|
+
|
|
207
|
+
this.model = model;
|
|
208
|
+
|
|
209
|
+
// Start a blinker interval function
|
|
210
|
+
window.setInterval(this.blinker, 500);
|
|
211
|
+
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
appView(routeName: string): VnodeChildren {
|
|
215
|
+
// Returns a view based on the current route.
|
|
216
|
+
// Displays the appropriate content for the route,
|
|
217
|
+
// also considering active dialogs.
|
|
218
|
+
const model = this.model;
|
|
219
|
+
let views: VnodeChildren = [];
|
|
220
|
+
switch (routeName) {
|
|
221
|
+
case "login":
|
|
222
|
+
// The login screen is displayed by default
|
|
223
|
+
views.push(this.vwLogin());
|
|
224
|
+
break;
|
|
225
|
+
case "loginerror":
|
|
226
|
+
views.push(m(LoginError));
|
|
227
|
+
break;
|
|
228
|
+
case "main":
|
|
229
|
+
views.push(m(Main, { view: this }));
|
|
230
|
+
break;
|
|
231
|
+
case "game":
|
|
232
|
+
views.push(m(GameView, { view: this }));
|
|
233
|
+
break;
|
|
234
|
+
case "review":
|
|
235
|
+
const n = vwReview(this);
|
|
236
|
+
n && views.push(n);
|
|
237
|
+
break;
|
|
238
|
+
case "thanks":
|
|
239
|
+
// Display a thank-you dialog on top of the normal main screen
|
|
240
|
+
views.push(m(Main, { view: this }));
|
|
241
|
+
// Be careful to add the Thanks dialog only once to the stack
|
|
242
|
+
if (!this.dialogStack.length)
|
|
243
|
+
this.showThanks();
|
|
244
|
+
break;
|
|
245
|
+
case "help":
|
|
246
|
+
// A route parameter of ?q=N goes directly to the FAQ number N
|
|
247
|
+
// A route parameter of ?tab=N goes directly to tab N (0-based)
|
|
248
|
+
views.push(
|
|
249
|
+
this.vwHelp(
|
|
250
|
+
parseInt(m.route.param("tab") || ""),
|
|
251
|
+
parseInt(m.route.param("faq") || "")
|
|
252
|
+
)
|
|
253
|
+
);
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
// console.log("Unknown route name: " + model.routeName);
|
|
257
|
+
return [ m("div", t("Þessi vefslóð er ekki rétt")) ];
|
|
258
|
+
}
|
|
259
|
+
// Push any open dialogs
|
|
260
|
+
for (const dialog of this.dialogStack) {
|
|
261
|
+
const v = View.dialogViews[dialog.name];
|
|
262
|
+
if (v === undefined) {
|
|
263
|
+
console.error("Unknown dialog name: " + dialog.name);
|
|
264
|
+
} else {
|
|
265
|
+
const n = v(this, dialog.args);
|
|
266
|
+
n && views.push(n);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Overlay a spinner, if active
|
|
270
|
+
if (model.spinners)
|
|
271
|
+
views.push(m(Spinner));
|
|
272
|
+
return views;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Dialog support
|
|
276
|
+
|
|
277
|
+
pushDialog(dialogName: DialogViewEnum, dialogArgs?: any) {
|
|
278
|
+
this.dialogStack.push({ name: dialogName, args: dialogArgs });
|
|
279
|
+
m.redraw(); // Ensure that the dialog is shown
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
popDialog() {
|
|
283
|
+
if (this.dialogStack.length > 0) {
|
|
284
|
+
this.dialogStack.pop();
|
|
285
|
+
m.redraw();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
popAllDialogs() {
|
|
290
|
+
if (this.dialogStack.length > 0) {
|
|
291
|
+
this.dialogStack = [];
|
|
292
|
+
m.redraw();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
isDialogShown() {
|
|
297
|
+
return this.dialogStack.length > 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
startSpinner() {
|
|
301
|
+
this.model.spinners++;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
stopSpinner() {
|
|
305
|
+
if (this.model.spinners) {
|
|
306
|
+
this.model.spinners--;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async cancelFriendship() {
|
|
311
|
+
// Initiate cancellation of the user's friendship
|
|
312
|
+
let spinner = true;
|
|
313
|
+
try {
|
|
314
|
+
this.startSpinner();
|
|
315
|
+
if (await this.model.cancelFriendship()) {
|
|
316
|
+
// Successfully cancelled the friendship
|
|
317
|
+
this.stopSpinner();
|
|
318
|
+
spinner = false;
|
|
319
|
+
// Show a confirmation of the cancellation
|
|
320
|
+
this.pushDialog("confirm", {});
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {
|
|
323
|
+
// Simply display no confirmation in this case
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
if (spinner)
|
|
327
|
+
this.stopSpinner();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
notifyMediaChange() {
|
|
332
|
+
// The view is changing, between mobile and fullscreen
|
|
333
|
+
// and/or between portrait and landscape: ensure that
|
|
334
|
+
// we don't end up with a selected game tab that is not visible
|
|
335
|
+
const model = this.model;
|
|
336
|
+
if (model.game) {
|
|
337
|
+
if (model.state?.uiFullscreen || model.state?.uiLandscape) {
|
|
338
|
+
// In this case, there is no board tab:
|
|
339
|
+
// show the movelist
|
|
340
|
+
if (model.game.setSelectedTab("movelist"))
|
|
341
|
+
setTimeout(scrollMovelistToBottom);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// Mobile: we default to the board tab
|
|
345
|
+
model.game.setSelectedTab("board");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
// When switching between landscape and portrait,
|
|
349
|
+
// close all current dialogs
|
|
350
|
+
this.popAllDialogs();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
notifyChatMessage() {
|
|
354
|
+
// A fresh chat message has arrived
|
|
355
|
+
// and has been added to the chat message list
|
|
356
|
+
m.redraw();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
resetScale() {
|
|
360
|
+
// Reset the board scale (zoom) to 100% and the scroll origin to (0, 0)
|
|
361
|
+
this.boardScale = 1.0;
|
|
362
|
+
const boardParent = document.getElementById("board-parent");
|
|
363
|
+
const board = boardParent?.children[0];
|
|
364
|
+
if (board)
|
|
365
|
+
board.setAttribute("style", "transform: scale(1.0)");
|
|
366
|
+
if (boardParent)
|
|
367
|
+
boardParent.scrollTo(0, 0);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
updateScale() {
|
|
371
|
+
|
|
372
|
+
const model = this.model;
|
|
373
|
+
const game = model.game;
|
|
374
|
+
|
|
375
|
+
// Update the board scale (zoom)
|
|
376
|
+
|
|
377
|
+
function scrollIntoView(sq: string) {
|
|
378
|
+
// Scroll a square above and to the left of the placed tile into view
|
|
379
|
+
const offset = 3;
|
|
380
|
+
const vec = toVector(sq);
|
|
381
|
+
const row = Math.max(0, vec.row - offset);
|
|
382
|
+
const col = Math.max(0, vec.col - offset);
|
|
383
|
+
const c = coord(row, col);
|
|
384
|
+
const boardParent = document.getElementById("board-parent");
|
|
385
|
+
const board = boardParent?.children[0];
|
|
386
|
+
// The following seems to be needed to ensure that
|
|
387
|
+
// the transform and hence the size of the board has been
|
|
388
|
+
// updated in the browser, before calculating the client rects
|
|
389
|
+
if (board)
|
|
390
|
+
board.setAttribute("style", "transform: scale(1.5)");
|
|
391
|
+
const el = document.getElementById("sq_" + c);
|
|
392
|
+
const elRect = el?.getBoundingClientRect();
|
|
393
|
+
const boardRect = boardParent?.getBoundingClientRect();
|
|
394
|
+
if (boardParent && elRect && boardRect) {
|
|
395
|
+
boardParent.scrollTo(
|
|
396
|
+
{
|
|
397
|
+
left: elRect.left - boardRect.left,
|
|
398
|
+
top: elRect.top - boardRect.top,
|
|
399
|
+
behavior: "smooth"
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!game || model.state?.uiFullscreen || game.moveInProgress) {
|
|
406
|
+
// No game or we're in full screen mode: always 100% scale
|
|
407
|
+
// Also, as soon as a move is being processed by the server, we zoom out
|
|
408
|
+
this.boardScale = 1.0; // Needs to be done before setTimeout() call
|
|
409
|
+
setTimeout(this.resetScale);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const tp = game.tilesPlaced();
|
|
413
|
+
const numTiles = tp.length;
|
|
414
|
+
if (numTiles === 1 && this.boardScale === 1.0) {
|
|
415
|
+
// Laying down first tile: zoom in & position
|
|
416
|
+
this.boardScale = 1.5;
|
|
417
|
+
setTimeout(() => scrollIntoView(tp[0]));
|
|
418
|
+
}
|
|
419
|
+
else if (numTiles === 0 && this.boardScale > 1.0) {
|
|
420
|
+
// Removing only remaining tile: zoom out
|
|
421
|
+
this.boardScale = 1.0; // Needs to be done before setTimeout() call
|
|
422
|
+
setTimeout(() => this.resetScale());
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
showUserInfo(userid: string, nick: string, fullname: string) {
|
|
427
|
+
// Show a user info dialog
|
|
428
|
+
this.pushDialog("userinfo", { userid: userid, nick: nick, fullname: fullname });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
showFriendPromo() {
|
|
432
|
+
// Show a friendship promotion
|
|
433
|
+
this.pushDialog("friend", { });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
showThanks() {
|
|
437
|
+
// Show thanks for becoming a friend
|
|
438
|
+
this.pushDialog("thanks", { });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
showFriendCancel() {
|
|
442
|
+
// Show a friendship cancellation dialog
|
|
443
|
+
this.pushDialog("cancel", { });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
showAcceptDialog(oppId: string, oppNick: string, challengeKey: string) {
|
|
447
|
+
this.pushDialog("accept", { oppId, oppNick, challengeKey });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Globally available view functions
|
|
451
|
+
|
|
452
|
+
vwDialogButton(
|
|
453
|
+
id: string, title: string, func: EventHandler,
|
|
454
|
+
content: VnodeChildren, tabindex: number
|
|
455
|
+
): VnodeChildren {
|
|
456
|
+
// Create a .modal-close dialog button
|
|
457
|
+
const attrs = {
|
|
458
|
+
id: id,
|
|
459
|
+
onclick: func,
|
|
460
|
+
title,
|
|
461
|
+
tabindex
|
|
462
|
+
};
|
|
463
|
+
return m(DialogButton, attrs, content);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
blinker() {
|
|
467
|
+
// Toggle the 'over' class on all elements having the 'blinking' class
|
|
468
|
+
const blinkers = document.getElementsByClassName('blinking');
|
|
469
|
+
for (let b of blinkers)
|
|
470
|
+
b.classList.toggle("over");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// A control that rigs up a tabbed view of raw HTML
|
|
474
|
+
|
|
475
|
+
vwTabsFromHtml(
|
|
476
|
+
html: string, id: string, tabNumber: number, createFunc: (vnode: TabVnode) => void
|
|
477
|
+
): VnodeChildren {
|
|
478
|
+
// The function assumes that 'this' is the current view object
|
|
479
|
+
if (!html)
|
|
480
|
+
return "";
|
|
481
|
+
return m("div",
|
|
482
|
+
{
|
|
483
|
+
oninit: (vnode) => { vnode.state.selected = tabNumber || 1; },
|
|
484
|
+
oncreate: (vnode) => { makeTabs(this, id, createFunc, true, vnode); }
|
|
485
|
+
/* onupdate: updateSelection */
|
|
486
|
+
},
|
|
487
|
+
m.trust(html)
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Help screen
|
|
492
|
+
|
|
493
|
+
vwHelp(tabNumber: number, faqNumber: number): Vnode {
|
|
494
|
+
|
|
495
|
+
const model = this.model;
|
|
496
|
+
|
|
497
|
+
function wireQuestions(vnode: TabVnode) {
|
|
498
|
+
// Clicking on a question brings the corresponding answer into view
|
|
499
|
+
// This is achieved by wiring up all contained a[href="#faq-*"] links
|
|
500
|
+
|
|
501
|
+
function showAnswer(ev: Event, href: string) {
|
|
502
|
+
// this points to the vnode
|
|
503
|
+
vnode.state.selected = 1; // FAQ tab
|
|
504
|
+
vnode.dom.querySelector(href)?.scrollIntoView();
|
|
505
|
+
ev.preventDefault();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const anchors = vnode.dom.querySelectorAll("a");
|
|
509
|
+
for (const anchor of anchors) {
|
|
510
|
+
const href = anchor.getAttribute("href");
|
|
511
|
+
if (href && href.slice(0, 5) == "#faq-")
|
|
512
|
+
// This is a direct link to a question: wire it up
|
|
513
|
+
anchor.onclick = (ev) => { showAnswer(ev, href); };
|
|
514
|
+
}
|
|
515
|
+
if (faqNumber !== undefined && !isNaN(faqNumber)) {
|
|
516
|
+
// Go to the FAQ tab and scroll the requested question into view
|
|
517
|
+
selectTab(vnode, 1);
|
|
518
|
+
vnode.state.selected = 1; // FAQ tab
|
|
519
|
+
vnode.dom.querySelector("#faq-" + faqNumber.toString())?.scrollIntoView();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Output literal HTML obtained from rawhelp.html on the server
|
|
524
|
+
return m.fragment({}, [
|
|
525
|
+
m(LeftLogo),
|
|
526
|
+
m(UserId, { view: this }),
|
|
527
|
+
this.vwTabsFromHtml(model.helpHTML || "", "tabs", tabNumber, wireQuestions),
|
|
528
|
+
]);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// User preferences screen
|
|
532
|
+
|
|
533
|
+
vwUserPrefsDialog(): VnodeChildren {
|
|
534
|
+
|
|
535
|
+
const model = this.model;
|
|
536
|
+
if (!model.user) return undefined;
|
|
537
|
+
const user = model.user;
|
|
538
|
+
if (!model.state) return undefined;
|
|
539
|
+
const state = model.state;
|
|
540
|
+
const err = model.userErrors || {};
|
|
541
|
+
const view = this;
|
|
542
|
+
|
|
543
|
+
function vwErrMsg(propname: keyof typeof err) {
|
|
544
|
+
// Show a validation error message returned from the server
|
|
545
|
+
return err.hasOwnProperty(propname) ?
|
|
546
|
+
m(".errinput", [glyph("arrow-up"), nbsp(), err[propname] || ""]) : "";
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function getToggle(elemId: string): boolean {
|
|
550
|
+
const cls2 = document.querySelector("#" + elemId + "-toggler #opt2")?.classList;
|
|
551
|
+
if (!cls2) return false;
|
|
552
|
+
return cls2.contains("selected");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function validate() {
|
|
556
|
+
// Move data back to the model.user object
|
|
557
|
+
// before sending it to the server
|
|
558
|
+
user.nickname = getInput("nickname");
|
|
559
|
+
user.full_name = getInput("full_name");
|
|
560
|
+
user.audio = getToggle("audio");
|
|
561
|
+
user.fanfare = getToggle("fanfare");
|
|
562
|
+
user.beginner = getToggle("beginner");
|
|
563
|
+
user.fairplay = getToggle("fairplay");
|
|
564
|
+
// When done, pop the current dialog
|
|
565
|
+
model.saveUser(() => { view.popDialog(); });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function initFocus(vnode: VnodeDOM) {
|
|
569
|
+
// Set the focus on the nickname field when the dialog is displayed
|
|
570
|
+
(vnode.dom.querySelector("#nickname") as HTMLElement).focus();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return m(".modal-dialog",
|
|
574
|
+
{
|
|
575
|
+
id: "user-dialog",
|
|
576
|
+
oncreate: initFocus
|
|
577
|
+
// onupdate: initFocus
|
|
578
|
+
},
|
|
579
|
+
m(".ui-widget.ui-widget-content.ui-corner-all", { id: 'user-form' },
|
|
580
|
+
[
|
|
581
|
+
m(".loginhdr", [glyph("address-book"), " " + ts("player_info")]), // "Player information"
|
|
582
|
+
m("div",
|
|
583
|
+
m("form", { action: '', id: 'frm1', method: 'post', name: 'frm1' },
|
|
584
|
+
[
|
|
585
|
+
m(".dialog-spacer",
|
|
586
|
+
[
|
|
587
|
+
m("span.caption", t("Einkenni:")),
|
|
588
|
+
m(TextInput,
|
|
589
|
+
{
|
|
590
|
+
initialValue: user.nickname || "",
|
|
591
|
+
class: "username",
|
|
592
|
+
maxlength: 15,
|
|
593
|
+
id: "nickname",
|
|
594
|
+
// autocomplete: "nickname", // Chrome doesn't like this
|
|
595
|
+
}
|
|
596
|
+
),
|
|
597
|
+
nbsp(), m("span.asterisk", "*")
|
|
598
|
+
]
|
|
599
|
+
),
|
|
600
|
+
m(".explain", t("Verður að vera útfyllt")),
|
|
601
|
+
vwErrMsg("nickname"),
|
|
602
|
+
m(".dialog-spacer",
|
|
603
|
+
[
|
|
604
|
+
m("span.caption", t("Fullt nafn:")),
|
|
605
|
+
m(TextInput,
|
|
606
|
+
{
|
|
607
|
+
initialValue: user.full_name || "",
|
|
608
|
+
class: "fullname",
|
|
609
|
+
maxlength: 32,
|
|
610
|
+
id: "full_name",
|
|
611
|
+
autocomplete: "name",
|
|
612
|
+
}
|
|
613
|
+
)
|
|
614
|
+
]
|
|
615
|
+
),
|
|
616
|
+
m(".explain", t("Valfrjálst - sýnt í notendalistum Netskrafls")),
|
|
617
|
+
vwErrMsg("full_name"),
|
|
618
|
+
m(".dialog-spacer",
|
|
619
|
+
[
|
|
620
|
+
m("span.caption.sub", t("Hljóðmerki:")),
|
|
621
|
+
m(TogglerAudio, { view, state: user.audio, tabindex: 4 }),
|
|
622
|
+
m("span.subcaption", t("Lúðraþytur eftir sigur:")),
|
|
623
|
+
m(TogglerFanfare, { view, state: user.fanfare, tabindex: 5 }),
|
|
624
|
+
]
|
|
625
|
+
),
|
|
626
|
+
m(".explain", t("explain_sound")),
|
|
627
|
+
m(".dialog-spacer",
|
|
628
|
+
[
|
|
629
|
+
m("span.caption.sub", t("Sýna reitagildi:")),
|
|
630
|
+
m(TogglerBeginner, { view, state: user.beginner, tabindex: 6 }),
|
|
631
|
+
mt(".subexplain",
|
|
632
|
+
[
|
|
633
|
+
"Stillir hvort ",
|
|
634
|
+
mt("strong", "minnismiði"),
|
|
635
|
+
" um margföldunargildi reita er sýndur við borðið"
|
|
636
|
+
]
|
|
637
|
+
)
|
|
638
|
+
]
|
|
639
|
+
),
|
|
640
|
+
m(".dialog-spacer",
|
|
641
|
+
[
|
|
642
|
+
m("span.caption.sub", t("Án hjálpartækja:")),
|
|
643
|
+
m(TogglerFairplay, { view, state: user.fairplay, tabindex: 7 }),
|
|
644
|
+
mt(".subexplain",
|
|
645
|
+
[
|
|
646
|
+
"no_helpers",
|
|
647
|
+
mt("strong", "án stafrænna hjálpartækja"),
|
|
648
|
+
" af nokkru tagi"
|
|
649
|
+
]
|
|
650
|
+
)
|
|
651
|
+
]
|
|
652
|
+
)
|
|
653
|
+
]
|
|
654
|
+
)
|
|
655
|
+
),
|
|
656
|
+
this.vwDialogButton("user-ok", ts("Vista"), validate, glyph("ok"), 8),
|
|
657
|
+
this.vwDialogButton("user-cancel", ts("Hætta við"),
|
|
658
|
+
(ev) => { this.popDialog(); ev.preventDefault(); },
|
|
659
|
+
glyph("remove"), 9),
|
|
660
|
+
user.friend ?
|
|
661
|
+
this.vwDialogButton("user-unfriend", ts("Hætta sem vinur"),
|
|
662
|
+
(ev) => {
|
|
663
|
+
ev.preventDefault();
|
|
664
|
+
view.showFriendCancel()
|
|
665
|
+
},
|
|
666
|
+
[glyph("coffee-cup"), nbsp(), nbsp(), ts("Þú ert vinur Netskrafls!")], 10
|
|
667
|
+
)
|
|
668
|
+
:
|
|
669
|
+
this.vwDialogButton("user-friend", ts("Gerast vinur"),
|
|
670
|
+
(ev) => {
|
|
671
|
+
// Invoke the friend promo dialog
|
|
672
|
+
ev.preventDefault();
|
|
673
|
+
logEvent("click_friend",
|
|
674
|
+
{
|
|
675
|
+
userid: state.userId, locale: state.locale
|
|
676
|
+
}
|
|
677
|
+
);
|
|
678
|
+
view.showFriendPromo();
|
|
679
|
+
},
|
|
680
|
+
[glyph("coffee-cup"), nbsp(), nbsp(), ts("Gerast vinur Netskrafls")], 11
|
|
681
|
+
)
|
|
682
|
+
]
|
|
683
|
+
)
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
vwUserPrefs(): VnodeChildren {
|
|
688
|
+
const model = this.model;
|
|
689
|
+
if (model.user === null && !model.userLoadError)
|
|
690
|
+
model.loadUser(true); // Activate spinner while loading
|
|
691
|
+
if (!model.user)
|
|
692
|
+
// Nothing to edit (the spinner should be showing in this case)
|
|
693
|
+
return m.fragment({}, []);
|
|
694
|
+
return this.vwUserPrefsDialog();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
vwUserInfo(args: { userid: string; nick: string; fullname: string; }): VnodeChildren {
|
|
698
|
+
return m(UserInfoDialog,
|
|
699
|
+
{
|
|
700
|
+
view: this,
|
|
701
|
+
userid: args.userid,
|
|
702
|
+
nick: args.nick,
|
|
703
|
+
fullname: args.fullname
|
|
704
|
+
}
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
vwPromo(args: { kind: string; initFunc: () => void; }): VnodeChildren {
|
|
709
|
+
return m(PromoDialog,
|
|
710
|
+
{
|
|
711
|
+
view: this,
|
|
712
|
+
kind: args.kind,
|
|
713
|
+
initFunc: args.initFunc
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
vwWait(args: {
|
|
719
|
+
oppId: string;
|
|
720
|
+
oppNick: string;
|
|
721
|
+
oppName: string;
|
|
722
|
+
duration: number;
|
|
723
|
+
challengeKey: string;
|
|
724
|
+
}): VnodeChildren {
|
|
725
|
+
return m(WaitDialog, {
|
|
726
|
+
view: this,
|
|
727
|
+
oppId: args.oppId,
|
|
728
|
+
oppNick: args.oppNick,
|
|
729
|
+
oppName: args.oppName,
|
|
730
|
+
duration: args.duration,
|
|
731
|
+
challengeKey: args.challengeKey,
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
vwAccept(args: { oppId: string; oppNick: string; challengeKey: string; }): VnodeChildren {
|
|
736
|
+
return m(AcceptDialog, {
|
|
737
|
+
view: this,
|
|
738
|
+
oppId: args.oppId,
|
|
739
|
+
oppNick: args.oppNick,
|
|
740
|
+
challengeKey: args.challengeKey,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
vwLogin(): VnodeChildren {
|
|
745
|
+
const model = this.model;
|
|
746
|
+
const loginUrl = model.state?.loginUrl || "";
|
|
747
|
+
return m(LoginForm, { loginUrl });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
vwDialogs(): VnodeChildren {
|
|
751
|
+
// Show prompt dialogs below game board, if any
|
|
752
|
+
const game = this.model.game;
|
|
753
|
+
let r: Vnode[] = [];
|
|
754
|
+
if (!game || game.showingDialog === null && !game.last_chall)
|
|
755
|
+
return r;
|
|
756
|
+
// The dialogs below, specifically the challenge and pass
|
|
757
|
+
// dialogs, have priority over the last_chall dialog - since
|
|
758
|
+
// they can be invoked while the last_chall dialog is being
|
|
759
|
+
// displayed. We therefore allow them to cover the last_chall
|
|
760
|
+
// dialog. On mobile, both dialogs are displayed simultaneously.
|
|
761
|
+
if (game.last_chall)
|
|
762
|
+
r.push(m(".chall-info", { style: { visibility: "visible" } },
|
|
763
|
+
[
|
|
764
|
+
glyph("info-sign"), nbsp(),
|
|
765
|
+
// "Your opponent emptied the rack - you can challenge or pass"
|
|
766
|
+
mt("span.pass-explain", "opponent_emptied_rack")
|
|
767
|
+
]
|
|
768
|
+
));
|
|
769
|
+
if (game.showingDialog == "resign")
|
|
770
|
+
r.push(m(".resign", { style: { visibility: "visible" } },
|
|
771
|
+
[
|
|
772
|
+
glyph("exclamation-sign"), nbsp(), ts("Viltu gefa leikinn?"), nbsp(),
|
|
773
|
+
m("span.mobile-break", m("br")),
|
|
774
|
+
m("span.yesnobutton", { onclick: () => game.confirmResign(true) },
|
|
775
|
+
[glyph("ok"), ts(" Já")]
|
|
776
|
+
),
|
|
777
|
+
m("span.mobile-space"),
|
|
778
|
+
m("span.yesnobutton", { onclick: () => game.confirmResign(false) },
|
|
779
|
+
[glyph("remove"), ts(" Nei")]
|
|
780
|
+
)
|
|
781
|
+
]
|
|
782
|
+
));
|
|
783
|
+
if (game.showingDialog == "pass") {
|
|
784
|
+
if (game.last_chall)
|
|
785
|
+
r.push(m(".pass-last", { style: { visibility: "visible" } },
|
|
786
|
+
[
|
|
787
|
+
glyph("forward"), nbsp(), ts("Segja pass?"),
|
|
788
|
+
mt("span.pass-explain", "Viðureign lýkur þar með"),
|
|
789
|
+
nbsp(),
|
|
790
|
+
m("span.mobile-break", m("br")),
|
|
791
|
+
m("span.yesnobutton", { onclick: () => game.confirmPass(true) },
|
|
792
|
+
[glyph("ok"), ts(" Já")]
|
|
793
|
+
),
|
|
794
|
+
m("span.mobile-space"),
|
|
795
|
+
m("span.yesnobutton", { onclick: () => game.confirmPass(false) },
|
|
796
|
+
[glyph("remove"), ts(" Nei")]
|
|
797
|
+
)
|
|
798
|
+
]
|
|
799
|
+
));
|
|
800
|
+
else
|
|
801
|
+
r.push(m(".pass", { style: { visibility: "visible" } },
|
|
802
|
+
[
|
|
803
|
+
glyph("forward"), nbsp(), ts("Segja pass?"),
|
|
804
|
+
mt("span.pass-explain", "2x3 pöss í röð ljúka viðureign"),
|
|
805
|
+
nbsp(), m("span.mobile-break", m("br")),
|
|
806
|
+
m("span.yesnobutton", { onclick: () => game.confirmPass(true) },
|
|
807
|
+
[glyph("ok"), ts(" Já")]
|
|
808
|
+
),
|
|
809
|
+
m("span.mobile-space"),
|
|
810
|
+
m("span.yesnobutton", { onclick: () => game.confirmPass(false) },
|
|
811
|
+
[glyph("remove"), ts(" Nei")]
|
|
812
|
+
)
|
|
813
|
+
]
|
|
814
|
+
));
|
|
815
|
+
}
|
|
816
|
+
if (game.showingDialog == "exchange")
|
|
817
|
+
r.push(m(".exchange", { style: { visibility: "visible" } },
|
|
818
|
+
[
|
|
819
|
+
glyph("refresh"), nbsp(),
|
|
820
|
+
ts("Smelltu á flísarnar sem þú vilt skipta"), nbsp(),
|
|
821
|
+
m("span.mobile-break", m("br")),
|
|
822
|
+
m("span.yesnobutton",
|
|
823
|
+
{ title: ts('Skipta'), onclick: () => game.confirmExchange(true) },
|
|
824
|
+
glyph("ok")
|
|
825
|
+
),
|
|
826
|
+
m("span.mobile-space"),
|
|
827
|
+
m("span.yesnobutton",
|
|
828
|
+
{ title: ts('Hætta við'), onclick: () => game.confirmExchange(false) },
|
|
829
|
+
glyph("remove"))
|
|
830
|
+
]
|
|
831
|
+
));
|
|
832
|
+
if (game.showingDialog == "chall")
|
|
833
|
+
r.push(m(".chall", { style: { visibility: "visible" } },
|
|
834
|
+
[
|
|
835
|
+
glyph("ban-circle"), nbsp(), ts("Véfengja lögn?"),
|
|
836
|
+
mt("span.pass-explain", "Röng véfenging kostar 10 stig"), nbsp(),
|
|
837
|
+
m("span.mobile-break", m("br")),
|
|
838
|
+
m("span.yesnobutton",
|
|
839
|
+
{ onclick: () => game.confirmChallenge(true) },
|
|
840
|
+
[glyph("ok"), ts(" Já")]
|
|
841
|
+
),
|
|
842
|
+
m("span.mobile-space"),
|
|
843
|
+
m("span.yesnobutton",
|
|
844
|
+
{ onclick: () => game.confirmChallenge(false) },
|
|
845
|
+
[glyph("remove"), ts(" Nei")]
|
|
846
|
+
)
|
|
847
|
+
]
|
|
848
|
+
));
|
|
849
|
+
return r;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
} // class View
|