@mideind/netskrafl-react 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/esm/index.js CHANGED
@@ -15,6 +15,8 @@ const saveAuthSettings = (settings) => {
15
15
  userEmail: settings.userEmail, // Required field
16
16
  };
17
17
  // Only add optional fields if they are defined
18
+ if (settings.account !== undefined)
19
+ filteredSettings.account = settings.account;
18
20
  if (settings.userId !== undefined)
19
21
  filteredSettings.userId = settings.userId;
20
22
  if (settings.userNick !== undefined)
@@ -83,6 +85,7 @@ const applyPersistedSettings = (state) => {
83
85
  return {
84
86
  ...state,
85
87
  // Only apply persisted values if current values are defaults
88
+ account: state.account || persisted.account || state.userId, // Use userId as fallback
86
89
  userId: state.userId || persisted.userId || state.userId,
87
90
  userNick: state.userNick || persisted.userNick || state.userNick,
88
91
  firebaseAPIKey: state.firebaseAPIKey || persisted.firebaseAPIKey || state.firebaseAPIKey,
@@ -100,6 +103,7 @@ const DEFAULT_STATE = {
100
103
  firebaseSenderId: "",
101
104
  firebaseAppId: "",
102
105
  measurementId: "",
106
+ account: "",
103
107
  userEmail: "",
104
108
  userId: "",
105
109
  userNick: "",
@@ -23529,6 +23533,9 @@ let View$1 = class View {
23529
23533
  function viewGetServerCache(view) {
23530
23534
  return view.viewCache_.serverCache.getNode();
23531
23535
  }
23536
+ function viewGetCompleteNode(view) {
23537
+ return viewCacheGetCompleteEventSnap(view.viewCache_);
23538
+ }
23532
23539
  function viewGetCompleteServerCache(view, path) {
23533
23540
  const cache = viewCacheGetCompleteServerSnap(view.viewCache_);
23534
23541
  if (cache) {
@@ -24190,6 +24197,33 @@ function syncTreeCalcCompleteEventCache(syncTree, path, writeIdsToExclude) {
24190
24197
  });
24191
24198
  return writeTreeCalcCompleteEventCache(writeTree, path, serverCache, writeIdsToExclude, includeHiddenSets);
24192
24199
  }
24200
+ function syncTreeGetServerValue(syncTree, query) {
24201
+ const path = query._path;
24202
+ let serverCache = null;
24203
+ // Any covering writes will necessarily be at the root, so really all we need to find is the server cache.
24204
+ // Consider optimizing this once there's a better understanding of what actual behavior will be.
24205
+ syncTree.syncPointTree_.foreachOnPath(path, (pathToSyncPoint, sp) => {
24206
+ const relativePath = newRelativePath(pathToSyncPoint, path);
24207
+ serverCache =
24208
+ serverCache || syncPointGetCompleteServerCache(sp, relativePath);
24209
+ });
24210
+ let syncPoint = syncTree.syncPointTree_.get(path);
24211
+ if (!syncPoint) {
24212
+ syncPoint = new SyncPoint();
24213
+ syncTree.syncPointTree_ = syncTree.syncPointTree_.set(path, syncPoint);
24214
+ }
24215
+ else {
24216
+ serverCache =
24217
+ serverCache || syncPointGetCompleteServerCache(syncPoint, newEmptyPath());
24218
+ }
24219
+ const serverCacheComplete = serverCache != null;
24220
+ const serverCacheNode = serverCacheComplete
24221
+ ? new CacheNode(serverCache, true, false)
24222
+ : null;
24223
+ const writesCache = writeTreeChildWrites(syncTree.pendingWriteTree_, query._path);
24224
+ const view = syncPointGetView(syncPoint, query, writesCache, serverCacheComplete ? serverCacheNode.getNode() : ChildrenNode.EMPTY_NODE, serverCacheComplete);
24225
+ return viewGetCompleteNode(view);
24226
+ }
24193
24227
  /**
24194
24228
  * A helper method that visits all descendant and ancestor SyncPoints, applying the operation.
24195
24229
  *
@@ -25319,6 +25353,63 @@ function repoUpdateInfo(repo, pathString, value) {
25319
25353
  function repoGetNextWriteId(repo) {
25320
25354
  return repo.nextWriteId_++;
25321
25355
  }
25356
+ /**
25357
+ * The purpose of `getValue` is to return the latest known value
25358
+ * satisfying `query`.
25359
+ *
25360
+ * This method will first check for in-memory cached values
25361
+ * belonging to active listeners. If they are found, such values
25362
+ * are considered to be the most up-to-date.
25363
+ *
25364
+ * If the client is not connected, this method will wait until the
25365
+ * repo has established a connection and then request the value for `query`.
25366
+ * If the client is not able to retrieve the query result for another reason,
25367
+ * it reports an error.
25368
+ *
25369
+ * @param query - The query to surface a value for.
25370
+ */
25371
+ function repoGetValue(repo, query, eventRegistration) {
25372
+ // Only active queries are cached. There is no persisted cache.
25373
+ const cached = syncTreeGetServerValue(repo.serverSyncTree_, query);
25374
+ if (cached != null) {
25375
+ return Promise.resolve(cached);
25376
+ }
25377
+ return repo.server_.get(query).then(payload => {
25378
+ const node = nodeFromJSON(payload).withIndex(query._queryParams.getIndex());
25379
+ /**
25380
+ * Below we simulate the actions of an `onlyOnce` `onValue()` event where:
25381
+ * Add an event registration,
25382
+ * Update data at the path,
25383
+ * Raise any events,
25384
+ * Cleanup the SyncTree
25385
+ */
25386
+ syncTreeAddEventRegistration(repo.serverSyncTree_, query, eventRegistration, true);
25387
+ let events;
25388
+ if (query._queryParams.loadsAllData()) {
25389
+ events = syncTreeApplyServerOverwrite(repo.serverSyncTree_, query._path, node);
25390
+ }
25391
+ else {
25392
+ const tag = syncTreeTagForQuery(repo.serverSyncTree_, query);
25393
+ events = syncTreeApplyTaggedQueryOverwrite(repo.serverSyncTree_, query._path, node, tag);
25394
+ }
25395
+ /*
25396
+ * We need to raise events in the scenario where `get()` is called at a parent path, and
25397
+ * while the `get()` is pending, `onValue` is called at a child location. While get() is waiting
25398
+ * for the data, `onValue` will register a new event. Then, get() will come back, and update the syncTree
25399
+ * and its corresponding serverCache, including the child location where `onValue` is called. Then,
25400
+ * `onValue` will receive the event from the server, but look at the syncTree and see that the data received
25401
+ * from the server is already at the SyncPoint, and so the `onValue` callback will never get fired.
25402
+ * Calling `eventQueueRaiseEventsForChangedPath()` is the correct way to propagate the events and
25403
+ * ensure the corresponding child events will get fired.
25404
+ */
25405
+ eventQueueRaiseEventsForChangedPath(repo.eventQueue_, query._path, events);
25406
+ syncTreeRemoveEventRegistration(repo.serverSyncTree_, query, eventRegistration, null, true);
25407
+ return node;
25408
+ }, err => {
25409
+ repoLog(repo, 'get for query ' + stringify(query) + ' failed: ' + err);
25410
+ return Promise.reject(new Error(err));
25411
+ });
25412
+ }
25322
25413
  function repoSetWithPriority(repo, path, newVal, newPriority, onComplete) {
25323
25414
  repoLog(repo, 'set', {
25324
25415
  path: path.toString(),
@@ -26735,6 +26826,22 @@ function set(ref, value) {
26735
26826
  /*priority=*/ null, deferred.wrapCallback(() => { }));
26736
26827
  return deferred.promise;
26737
26828
  }
26829
+ /**
26830
+ * Gets the most up-to-date result for this query.
26831
+ *
26832
+ * @param query - The query to run.
26833
+ * @returns A `Promise` which resolves to the resulting DataSnapshot if a value is
26834
+ * available, or rejects if the client is unable to return a value (e.g., if the
26835
+ * server is unreachable and there is nothing cached).
26836
+ */
26837
+ function get(query) {
26838
+ query = getModularInstance(query);
26839
+ const callbackContext = new CallbackContext(() => { });
26840
+ const container = new ValueEventRegistration(callbackContext);
26841
+ return repoGetValue(query._repo, query, container).then(node => {
26842
+ return new DataSnapshot(node, new ReferenceImpl(query._repo, query._path), query._queryParams.getIndex());
26843
+ });
26844
+ }
26738
26845
  /**
26739
26846
  * Represents registration for 'value' events.
26740
26847
  */
@@ -27238,6 +27345,14 @@ function logEvent(ev, params) {
27238
27345
  return;
27239
27346
  logEvent$2(analytics, ev, params);
27240
27347
  }
27348
+ async function getFirebaseData(path) {
27349
+ // Get data from a Firebase path
27350
+ if (!database)
27351
+ return null;
27352
+ const pathRef = ref(database, path);
27353
+ const snapshot = await get(pathRef);
27354
+ return snapshot.val();
27355
+ }
27241
27356
 
27242
27357
  /*
27243
27358
 
@@ -27503,50 +27618,61 @@ const ensureAuthenticated = async (state) => {
27503
27618
  await authPromise;
27504
27619
  return;
27505
27620
  }
27506
- // Start new login attempt (either forced by 401 or needed for Firebase)
27507
- authPromise = loginUserByEmail(state);
27508
- try {
27509
- const result = await authPromise;
27510
- if (result.status === "expired") {
27511
- // Token has expired, notify the React component if callback is set
27512
- state.tokenExpired && state.tokenExpired();
27513
- // Clear any persisted settings since they're no longer valid
27514
- clearAuthSettings();
27515
- throw new Error("Token expired");
27621
+ let continueTrying = true;
27622
+ while (continueTrying) {
27623
+ continueTrying = false;
27624
+ // Start new login attempt (either forced by 401 or needed for Firebase)
27625
+ authPromise = loginUserByEmail(state);
27626
+ try {
27627
+ const result = await authPromise;
27628
+ if (result.status === "expired") {
27629
+ // Token has expired, notify the React component if callback is set
27630
+ if (state.tokenExpired) {
27631
+ // We have a callback to renew the token: do it and try again
27632
+ state.tokenExpired();
27633
+ continueTrying = true; // Try logging in again
27634
+ clearAuthSettings();
27635
+ continue;
27636
+ }
27637
+ // Clear any persisted settings since they're no longer valid
27638
+ clearAuthSettings();
27639
+ throw new Error("Authentication token has expired");
27640
+ }
27641
+ else if (result.status !== "success") {
27642
+ // Clear any persisted settings on auth failure
27643
+ clearAuthSettings();
27644
+ throw new Error(`Authentication failed: ${result.message || result.status}`);
27645
+ }
27646
+ // Update the user's ID to the internal one used by the backend and Firebase
27647
+ state.userId = result.user_id || state.userId;
27648
+ state.account = result.account || state.userId;
27649
+ // Update the user's nickname
27650
+ state.userNick = result.nickname || state.userNick;
27651
+ // Use the server's Firebase API key, if provided
27652
+ state.firebaseAPIKey = result.firebase_api_key || state.firebaseAPIKey;
27653
+ // Load state flags and preferences
27654
+ state.beginner = (_b = (_a = result.prefs) === null || _a === void 0 ? void 0 : _a.beginner) !== null && _b !== void 0 ? _b : true;
27655
+ state.fairPlay = (_d = (_c = result.prefs) === null || _c === void 0 ? void 0 : _c.fairplay) !== null && _d !== void 0 ? _d : false;
27656
+ state.ready = (_f = (_e = result.prefs) === null || _e === void 0 ? void 0 : _e.ready) !== null && _f !== void 0 ? _f : true;
27657
+ state.readyTimed = (_h = (_g = result.prefs) === null || _g === void 0 ? void 0 : _g.ready_timed) !== null && _h !== void 0 ? _h : true;
27658
+ // Save the authentication settings to sessionStorage for persistence
27659
+ saveAuthSettings({
27660
+ userEmail: state.userEmail, // CRITICAL: Include email to validate ownership
27661
+ userId: state.userId,
27662
+ userNick: state.userNick,
27663
+ firebaseAPIKey: state.firebaseAPIKey,
27664
+ beginner: state.beginner,
27665
+ fairPlay: state.fairPlay,
27666
+ ready: state.ready,
27667
+ readyTimed: state.readyTimed,
27668
+ });
27669
+ // Success: Log in to Firebase with the token passed from the server
27670
+ await loginFirebase(state, result.firebase_token);
27671
+ }
27672
+ finally {
27673
+ // Reset the promise so future 401s can trigger a new login
27674
+ authPromise = null;
27516
27675
  }
27517
- else if (result.status !== "success") {
27518
- // Clear any persisted settings on auth failure
27519
- clearAuthSettings();
27520
- throw new Error(`Authentication failed: ${result.message || result.status}`);
27521
- }
27522
- // Update the user's ID to the internal one used by the backend and Firebase
27523
- state.userId = result.user_id || state.userId;
27524
- // Update the user's nickname
27525
- state.userNick = result.nickname || state.userNick;
27526
- // Use the server's Firebase API key, if provided
27527
- state.firebaseAPIKey = result.firebase_api_key || state.firebaseAPIKey;
27528
- // Load state flags and preferences
27529
- state.beginner = (_b = (_a = result.prefs) === null || _a === void 0 ? void 0 : _a.beginner) !== null && _b !== void 0 ? _b : true;
27530
- state.fairPlay = (_d = (_c = result.prefs) === null || _c === void 0 ? void 0 : _c.fairplay) !== null && _d !== void 0 ? _d : false;
27531
- state.ready = (_f = (_e = result.prefs) === null || _e === void 0 ? void 0 : _e.ready) !== null && _f !== void 0 ? _f : true;
27532
- state.readyTimed = (_h = (_g = result.prefs) === null || _g === void 0 ? void 0 : _g.ready_timed) !== null && _h !== void 0 ? _h : true;
27533
- // Save the authentication settings to sessionStorage for persistence
27534
- saveAuthSettings({
27535
- userEmail: state.userEmail, // CRITICAL: Include email to validate ownership
27536
- userId: state.userId,
27537
- userNick: state.userNick,
27538
- firebaseAPIKey: state.firebaseAPIKey,
27539
- beginner: state.beginner,
27540
- fairPlay: state.fairPlay,
27541
- ready: state.ready,
27542
- readyTimed: state.readyTimed,
27543
- });
27544
- // Success: Log in to Firebase with the token passed from the server
27545
- await loginFirebase(state, result.firebase_token);
27546
- }
27547
- finally {
27548
- // Reset the promise so future 401s can trigger a new login
27549
- authPromise = null;
27550
27676
  }
27551
27677
  };
27552
27678
  // Internal authenticated request function
@@ -29078,7 +29204,7 @@ const BestDisplay = () => {
29078
29204
  // Make sure blank tiles get a different color
29079
29205
  for (let i = 0; i < bw.length; i++)
29080
29206
  if (bw[i] == '?') {
29081
- s.push(m("span.blanktile", bw[i + 1]));
29207
+ s.push(m("span.netskrafl-blanktile", bw[i + 1]));
29082
29208
  i += 1;
29083
29209
  }
29084
29210
  else
@@ -29874,7 +30000,7 @@ const BlankDialog = () => {
29874
30000
  onclick: (ev) => { game.placeBlank(letter); ev.preventDefault(); },
29875
30001
  onmouseover: buttonOver,
29876
30002
  onmouseout: buttonOut
29877
- }, m(".blank-choice.tile.racktile", letter)));
30003
+ }, m(".blank-choice.netskrafl-tile.netskrafl-racktile", letter)));
29878
30004
  len--;
29879
30005
  }
29880
30006
  r.push(m("tr", c));
@@ -29969,25 +30095,26 @@ const Bag = () => {
29969
30095
  For further information, see https://github.com/mideind/Netskrafl
29970
30096
 
29971
30097
  */
29972
- function makeButton(cls, disabled, onclick, title, children, id) {
30098
+ function makeButton(cls, disabled, onclick, title, children, visible, id) {
29973
30099
  // Create a button element, wrapping the disabling logic
29974
30100
  // and other boilerplate
29975
- const attr = {
30101
+ const attrs = {
29976
30102
  onmouseout: buttonOut,
29977
30103
  onmouseover: buttonOver,
30104
+ style: { visibility: visible === false ? "hidden" : "visible" },
29978
30105
  };
29979
30106
  if (title)
29980
- attr.title = title;
30107
+ attrs.title = title;
29981
30108
  if (id)
29982
- attr.id = id;
30109
+ attrs.id = id;
29983
30110
  if (disabled)
29984
- attr.onclick = (ev) => ev.preventDefault();
30111
+ attrs.onclick = (ev) => ev.preventDefault();
29985
30112
  else
29986
- attr.onclick = (ev) => {
30113
+ attrs.onclick = (ev) => {
29987
30114
  onclick && onclick();
29988
30115
  ev.preventDefault();
29989
30116
  };
29990
- return m("." + cls + (disabled ? ".disabled" : ""), attr, children // children may be omitted
30117
+ return m("." + cls + (disabled ? ".disabled" : ""), attrs, children // children may be omitted
29991
30118
  );
29992
30119
  }
29993
30120
  const Score = {
@@ -30009,20 +30136,20 @@ const Score = {
30009
30136
  const RecallButton = {
30010
30137
  view: (vnode) => {
30011
30138
  // Create a tile recall button
30012
- const { view, game, disabled } = vnode.attrs;
30139
+ const { view, game, disabled, visible = true } = vnode.attrs;
30013
30140
  if (!game)
30014
30141
  return undefined;
30015
- return makeButton("recallbtn", !!disabled, () => { game.resetRack(); view.updateScale(); }, ts("Færa stafi aftur í rekka"), glyph("down-arrow"));
30142
+ return makeButton("recallbtn", !!disabled, () => { game.resetRack(); view.updateScale(); }, ts("Færa stafi aftur í rekka"), glyph("down-arrow"), visible);
30016
30143
  }
30017
30144
  };
30018
30145
  const ScrambleButton = {
30019
30146
  view: (vnode) => {
30020
30147
  // Create a tile scramble button
30021
- const { game, disabled } = vnode.attrs;
30148
+ const { game, disabled, visible = true } = vnode.attrs;
30022
30149
  if (!game)
30023
30150
  return undefined;
30024
30151
  return makeButton("scramblebtn", !!disabled, () => game.rescrambleRack(), // Note: plain game.rescrambleRack doesn't work here
30025
- ts("Stokka upp rekka"), glyph("random"));
30152
+ ts("Stokka upp rekka"), glyph("random"), visible);
30026
30153
  }
30027
30154
  };
30028
30155
  const ButtonsRecallScramble = {
@@ -30038,9 +30165,9 @@ const ButtonsRecallScramble = {
30038
30165
  if (s.showRecall && !s.showingDialog)
30039
30166
  // Show a 'Recall tiles' button
30040
30167
  return m(RecallButton, { view, game });
30041
- if (s.showScramble && !s.showingDialog)
30042
- return m(ScrambleButton, { view, game });
30043
- return undefined;
30168
+ const visible = s.showScramble && !s.tilesPlaced;
30169
+ const disabled = s.showingDialog;
30170
+ return m(ScrambleButton, { view, game, visible, disabled });
30044
30171
  }
30045
30172
  };
30046
30173
  const Buttons = {
@@ -30117,7 +30244,7 @@ const Buttons = {
30117
30244
  }
30118
30245
  };
30119
30246
  }
30120
- r.push(makeButton(classes.join("."), s.showingDialog, action, text, legend, "move-mobile"));
30247
+ r.push(makeButton(classes.join("."), s.showingDialog, action, text, legend, true, "move-mobile"));
30121
30248
  }
30122
30249
  if (s.showForceResignMobile) {
30123
30250
  // Force resignation button (only shown on mobile,
@@ -30136,7 +30263,7 @@ const Buttons = {
30136
30263
  }
30137
30264
  if (s.showExchange) {
30138
30265
  // Exchange tiles from the rack
30139
- const disabled = (s.tilesPlaced || s.showingDialog) && !s.exchangeAllowed;
30266
+ const disabled = !s.exchangeAllowed || s.tilesPlaced || s.showingDialog;
30140
30267
  r.push(makeButton("submitexchange", disabled, () => game.submitExchange(), // Note: plain game.submitExchange doesn't work here
30141
30268
  ts("Skipta stöfum"), glyph("refresh")));
30142
30269
  }
@@ -30433,24 +30560,24 @@ const Tile = (initialVnode) => {
30433
30560
  const isRackTile = coord[0] === 'R';
30434
30561
  // A single tile, on the board or in the rack
30435
30562
  const t = game.tiles[coord];
30436
- let classes = [".tile"];
30563
+ let classes = [".netskrafl-tile"];
30437
30564
  let attrs = {};
30438
30565
  if (t.tile === '?')
30439
- classes.push("blanktile");
30566
+ classes.push("netskrafl-blanktile");
30440
30567
  if (EXTRA_WIDE_LETTERS.includes(t.letter))
30441
30568
  // Extra wide letter: handle specially
30442
- classes.push("extra-wide");
30569
+ classes.push("netskrafl-extra-wide");
30443
30570
  else if (WIDE_LETTERS.includes(t.letter))
30444
30571
  // Wide letter: handle specially
30445
- classes.push("wide");
30572
+ classes.push("netskrafl-wide");
30446
30573
  if (isRackTile || t.draggable) {
30447
30574
  // Rack tile, or at least a draggable one
30448
- classes.push(opponent ? "freshtile" : "racktile");
30575
+ classes.push(opponent ? "netskrafl-freshtile" : "netskrafl-racktile");
30449
30576
  if (isRackTile && game.showingDialog === "exchange") {
30450
30577
  // Rack tile, and we're showing the exchange dialog
30451
30578
  if (t.xchg)
30452
30579
  // Chosen as an exchange tile
30453
- classes.push("xchgsel");
30580
+ classes.push("netskrafl-xchgsel");
30454
30581
  // Exchange dialog is live: add a click handler for the
30455
30582
  // exchange state
30456
30583
  attrs.onclick = (ev) => {
@@ -30463,7 +30590,7 @@ const Tile = (initialVnode) => {
30463
30590
  else if (t.freshtile) {
30464
30591
  // A fresh tile on the board that has
30465
30592
  // just been played by the opponent
30466
- classes.push("freshtile");
30593
+ classes.push("netskrafl-freshtile");
30467
30594
  }
30468
30595
  if (t.index) {
30469
30596
  // Make fresh or highlighted tiles appear sequentally by animation
@@ -32069,11 +32196,11 @@ const vwButtonsReview = (view, moveIndex) => {
32069
32196
  () => {
32070
32197
  // Navigate to previous moveIndex
32071
32198
  model.loadBestMoves(moveIndex ? moveIndex - 1 : 0);
32072
- }, "Sjá fyrri leik", m("span", { id: "nav-prev-visible" }, [glyph("chevron-left"), " Fyrri"]), "navprev"));
32199
+ }, "Sjá fyrri leik", m("span", { id: "nav-prev-visible" }, [glyph("chevron-left"), " Fyrri"]), true, "navprev"));
32073
32200
  r.push(makeButton("navbtn", (!moveIndex) || (moveIndex >= numMoves), () => {
32074
32201
  // Navigate to next moveIndex
32075
32202
  model.loadBestMoves(moveIndex + 1);
32076
- }, "Sjá næsta leik", m("span", { id: "nav-next-visible" }, ["Næsti ", glyph("chevron-right")]), "navnext"));
32203
+ }, "Sjá næsta leik", m("span", { id: "nav-next-visible" }, ["Næsti ", glyph("chevron-right")]), true, "navnext"));
32077
32204
  // Show the score difference between an actual moveIndex and
32078
32205
  // a particular moveIndex on the best moveIndex list
32079
32206
  if (model.highlightedMove !== null) {
@@ -32283,39 +32410,68 @@ class View {
32283
32410
  this.boardScale = 1.0;
32284
32411
  const boardParent = document.getElementById("board-parent");
32285
32412
  const board = boardParent === null || boardParent === void 0 ? void 0 : boardParent.children[0];
32286
- if (board)
32287
- board.setAttribute("style", "transform: scale(1.0)");
32413
+ if (board) {
32414
+ board.style.transition = 'none';
32415
+ board.style.transformOrigin = 'top left';
32416
+ board.style.transform = `translate(0, 0) scale(1.0)`;
32417
+ }
32288
32418
  if (boardParent)
32289
32419
  boardParent.scrollTo(0, 0);
32290
32420
  }
32291
32421
  updateScale() {
32292
32422
  var _a;
32293
32423
  const model = this.model;
32294
- const game = model.game;
32424
+ // Use either the regular game or the riddle (Gáta Dagsins)
32425
+ const game = model.game || model.riddle;
32295
32426
  // Update the board scale (zoom)
32296
32427
  function scrollIntoView(sq) {
32297
- // Scroll a square above and to the left of the placed tile into view
32428
+ // Scroll a square above and to the left of the placed tile into view,
32429
+ // with a smooth concurrent zoom and pan animation,
32430
+ // taking clamping into account to ensure that the board always fills
32431
+ // the viewport.
32432
+ const boardParent = document.getElementById("board-parent");
32433
+ const board = boardParent === null || boardParent === void 0 ? void 0 : boardParent.children[0];
32434
+ if (!board || !boardParent)
32435
+ return;
32298
32436
  const offset = 3;
32299
32437
  const vec = toVector(sq);
32300
32438
  const row = Math.max(0, vec.row - offset);
32301
32439
  const col = Math.max(0, vec.col - offset);
32302
32440
  const c = coord(row, col);
32303
- const boardParent = document.getElementById("board-parent");
32304
- const board = boardParent === null || boardParent === void 0 ? void 0 : boardParent.children[0];
32305
- // The following seems to be needed to ensure that
32306
- // the transform and hence the size of the board has been
32307
- // updated in the browser, before calculating the client rects
32308
- if (board)
32309
- board.setAttribute("style", `transform: scale(${ZOOM_FACTOR})`);
32441
+ // Temporarily set scale to calculate target scroll position
32442
+ board.style.transformOrigin = 'top left';
32443
+ board.style.transform = `translate(0, 0) scale(1.0)`;
32310
32444
  const el = document.getElementById("sq_" + c);
32311
32445
  const elRect = el === null || el === void 0 ? void 0 : el.getBoundingClientRect();
32312
- const boardRect = boardParent === null || boardParent === void 0 ? void 0 : boardParent.getBoundingClientRect();
32313
- if (boardParent && elRect && boardRect) {
32314
- boardParent.scrollTo({
32315
- left: elRect.left - boardRect.left,
32316
- top: elRect.top - boardRect.top,
32317
- behavior: "smooth"
32318
- });
32446
+ const boardRect = boardParent.getBoundingClientRect();
32447
+ if (elRect && boardRect) {
32448
+ // Get the dimensions of the scrollable area
32449
+ const viewportWidth = boardParent.clientWidth;
32450
+ const viewportHeight = boardParent.clientHeight;
32451
+ // The offsetWidth/Height include borders, so we use those
32452
+ const scaledBoardWidth = board.offsetWidth * ZOOM_FACTOR;
32453
+ const scaledBoardHeight = board.offsetHeight * ZOOM_FACTOR;
32454
+ // Calculate maximum scroll positions (board edges)
32455
+ const maxScrollLeft = scaledBoardWidth - viewportWidth;
32456
+ const maxScrollTop = scaledBoardHeight - viewportHeight;
32457
+ // Calculate desired scroll position to put the target into
32458
+ // the top left corner of the viewport, or as close as possible
32459
+ let targetScrollLeft = Math.min(elRect.left - boardRect.left, maxScrollLeft);
32460
+ let targetScrollTop = Math.min(elRect.top - boardRect.top, maxScrollTop);
32461
+ // Now animate both translate (for pan) and scale (for zoom) concurrently
32462
+ board.style.transition = 'transform 0.3s ease-in-out';
32463
+ // Note: transforms are applied right to left
32464
+ board.style.transform =
32465
+ `translate(${-targetScrollLeft}px, ${-targetScrollTop}px) scale(${ZOOM_FACTOR})`;
32466
+ // When animation completes, commit to actual scroll position and reset translate
32467
+ board.addEventListener('transitionend', function handler() {
32468
+ // First reset the transform (remove translate, keep scale)
32469
+ board.style.transition = 'none';
32470
+ board.style.transform = `translate(0, 0) scale(${ZOOM_FACTOR})`;
32471
+ // Now set the actual scroll position (already clamped)
32472
+ boardParent.scrollTo(targetScrollLeft, targetScrollTop);
32473
+ board.removeEventListener('transitionend', handler);
32474
+ }, { once: true });
32319
32475
  }
32320
32476
  }
32321
32477
  if (!game || ((_a = model.state) === null || _a === void 0 ? void 0 : _a.uiFullscreen) || game.moveInProgress) {
@@ -32602,14 +32758,14 @@ class View {
32602
32758
  // displayed. We therefore allow them to cover the last_chall
32603
32759
  // dialog. On mobile, both dialogs are displayed simultaneously.
32604
32760
  if (game.last_chall) {
32605
- r.push(m(".chall-info", { style: { visibility: "visible" } }, [
32761
+ r.push(m(".chall-info", [
32606
32762
  glyph("info-sign"), nbsp(),
32607
32763
  // "Your opponent emptied the rack - you can challenge or pass"
32608
32764
  mt("span.pass-explain", "opponent_emptied_rack")
32609
32765
  ]));
32610
32766
  }
32611
32767
  if (game.showingDialog == "resign") {
32612
- r.push(m(".resign", { style: { visibility: "visible" } }, [
32768
+ r.push(m(".resign", [
32613
32769
  glyph("exclamation-sign"), nbsp(), ts("Viltu gefa leikinn?"), nbsp(),
32614
32770
  m("span.mobile-break", m("br")),
32615
32771
  m("span.yesnobutton", { onclick: () => game.confirmResign(true) }, [glyph("ok"), ts(" Já")]),
@@ -32619,7 +32775,7 @@ class View {
32619
32775
  }
32620
32776
  if (game.showingDialog == "pass") {
32621
32777
  if (game.last_chall) {
32622
- r.push(m(".pass-last", { style: { visibility: "visible" } }, [
32778
+ r.push(m(".pass-last", [
32623
32779
  glyph("forward"), nbsp(), ts("Segja pass?"),
32624
32780
  mt("span.pass-explain", "Viðureign lýkur þar með"),
32625
32781
  nbsp(),
@@ -32630,7 +32786,7 @@ class View {
32630
32786
  ]));
32631
32787
  }
32632
32788
  else {
32633
- r.push(m(".pass", { style: { visibility: "visible" } }, [
32789
+ r.push(m(".pass", [
32634
32790
  glyph("forward"), nbsp(), ts("Segja pass?"),
32635
32791
  mt("span.pass-explain", "2x3 pöss í röð ljúka viðureign"),
32636
32792
  nbsp(), m("span.mobile-break", m("br")),
@@ -32641,7 +32797,7 @@ class View {
32641
32797
  }
32642
32798
  }
32643
32799
  if (game.showingDialog == "exchange") {
32644
- r.push(m(".exchange", { style: { visibility: "visible" } }, [
32800
+ r.push(m(".exchange", [
32645
32801
  glyph("refresh"), nbsp(),
32646
32802
  ts("Smelltu á flísarnar sem þú vilt skipta"), nbsp(),
32647
32803
  m("span.mobile-break", m("br")),
@@ -32651,7 +32807,7 @@ class View {
32651
32807
  ]));
32652
32808
  }
32653
32809
  if (game.showingDialog == "chall") {
32654
- r.push(m(".chall", { style: { visibility: "visible" } }, [
32810
+ r.push(m(".chall", [
32655
32811
  glyph("ban-circle"), nbsp(), ts("Véfengja lögn?"),
32656
32812
  mt("span.pass-explain", "Röng véfenging kostar 10 stig"), nbsp(),
32657
32813
  m("span.mobile-break", m("br")),
@@ -32990,6 +33146,7 @@ class BaseGame {
32990
33146
  this.currentError = null;
32991
33147
  this.currentMessage = null;
32992
33148
  this.localturn = true;
33149
+ this.moveInProgress = false;
32993
33150
  // UI state
32994
33151
  this.showingDialog = null;
32995
33152
  this.selectedSq = null;
@@ -34352,6 +34509,161 @@ class Game extends BaseGame {
34352
34509
  ;
34353
34510
  } // class Game
34354
34511
 
34512
+ /*
34513
+
34514
+ riddlePersistence.ts
34515
+
34516
+ Local persistence for Gáta Dagsins using localStorage
34517
+
34518
+ Copyright (C) 2025 Miðeind ehf.
34519
+
34520
+ The Creative Commons Attribution-NonCommercial 4.0
34521
+ International Public License (CC-BY-NC 4.0) applies to this software.
34522
+ For further information, see https://github.com/mideind/Netskrafl
34523
+
34524
+ */
34525
+ class RiddlePersistence {
34526
+ // Generate storage key for a specific user and date
34527
+ static getStorageKey(userId, date) {
34528
+ return `${this.STORAGE_KEY_PREFIX}${date}_${userId}`;
34529
+ }
34530
+ // Save complete move list to localStorage
34531
+ static saveLocalMoves(userId, date, moves) {
34532
+ if (!userId || !date) {
34533
+ return;
34534
+ }
34535
+ const data = {
34536
+ date,
34537
+ moves,
34538
+ timestamp: new Date().toISOString(),
34539
+ userId,
34540
+ };
34541
+ try {
34542
+ const key = this.getStorageKey(userId, date);
34543
+ localStorage.setItem(key, JSON.stringify(data));
34544
+ // Clean up old entries while we're here
34545
+ this.cleanupOldEntries();
34546
+ }
34547
+ catch (e) {
34548
+ // Handle localStorage quota errors silently
34549
+ console.error('Failed to save moves to localStorage:', e);
34550
+ }
34551
+ }
34552
+ // Load move history from localStorage
34553
+ static loadLocalMoves(userId, date) {
34554
+ if (!userId || !date) {
34555
+ return [];
34556
+ }
34557
+ try {
34558
+ const key = this.getStorageKey(userId, date);
34559
+ const stored = localStorage.getItem(key);
34560
+ if (!stored) {
34561
+ return [];
34562
+ }
34563
+ const data = JSON.parse(stored);
34564
+ // Verify that this data belongs to the correct (current) user
34565
+ if (!data.userId || data.userId !== userId) {
34566
+ return [];
34567
+ }
34568
+ return data.moves || [];
34569
+ }
34570
+ catch (e) {
34571
+ console.error('Failed to load moves from localStorage:', e);
34572
+ return [];
34573
+ }
34574
+ }
34575
+ // Check if user has achieved top score (local check)
34576
+ static hasAchievedTopScore(userId, date, topScore) {
34577
+ const moves = this.loadLocalMoves(userId, date);
34578
+ return moves.some(move => move.score >= topScore);
34579
+ }
34580
+ // Get the best move from localStorage
34581
+ static getBestLocalMove(userId, date) {
34582
+ const moves = this.loadLocalMoves(userId, date);
34583
+ if (moves.length === 0) {
34584
+ return null;
34585
+ }
34586
+ // Find the move with the highest score
34587
+ return moves.reduce((best, current) => current.score > best.score ? current : best);
34588
+ }
34589
+ // Clean up entries older than MAX_AGE_DAYS
34590
+ static cleanupOldEntries() {
34591
+ try {
34592
+ const now = new Date();
34593
+ const cutoffDate = new Date(now);
34594
+ cutoffDate.setDate(cutoffDate.getDate() - this.MAX_AGE_DAYS);
34595
+ const keysToRemove = [];
34596
+ // Iterate through localStorage keys
34597
+ for (let i = 0; i < localStorage.length; i++) {
34598
+ const key = localStorage.key(i);
34599
+ if (key && key.startsWith(this.STORAGE_KEY_PREFIX)) {
34600
+ // Extract date from key: "gata_YYYY-MM-DD_userId"
34601
+ const parts = key.split('_');
34602
+ if (parts.length >= 2) {
34603
+ const dateStr = parts[1];
34604
+ const entryDate = new Date(dateStr);
34605
+ if (!isNaN(entryDate.getTime()) && entryDate < cutoffDate) {
34606
+ keysToRemove.push(key);
34607
+ }
34608
+ }
34609
+ }
34610
+ }
34611
+ // Remove old entries
34612
+ keysToRemove.forEach(key => localStorage.removeItem(key));
34613
+ }
34614
+ catch (e) {
34615
+ console.error('Failed to cleanup old entries:', e);
34616
+ }
34617
+ }
34618
+ // Clear all persistence for a specific user
34619
+ static clearUserData(userId) {
34620
+ const keysToRemove = [];
34621
+ for (let i = 0; i < localStorage.length; i++) {
34622
+ const key = localStorage.key(i);
34623
+ if (key && key.startsWith(this.STORAGE_KEY_PREFIX) && key.endsWith(`_${userId}`)) {
34624
+ keysToRemove.push(key);
34625
+ }
34626
+ }
34627
+ keysToRemove.forEach(key => localStorage.removeItem(key));
34628
+ }
34629
+ // === Firebase Read Methods ===
34630
+ // Note: All Firebase write operations (achievements, stats, global best, leaderboard)
34631
+ // are now handled by the backend server in the /gatadagsins/submit endpoint.
34632
+ // The client only handles localStorage persistence and Firebase reads for display.
34633
+ // Get leaderboard for a specific date
34634
+ static async getLeaderboard(date, locale, limit = 10) {
34635
+ try {
34636
+ const leadersPath = `gatadagsins/${date}/${locale}/leaders`;
34637
+ const leaders = await getFirebaseData(leadersPath);
34638
+ if (!leaders) {
34639
+ return [];
34640
+ }
34641
+ // Convert object to array and sort by score
34642
+ const entries = Object.values(leaders);
34643
+ entries.sort((a, b) => b.score - a.score);
34644
+ return entries.slice(0, limit);
34645
+ }
34646
+ catch (error) {
34647
+ console.error('Failed to get leaderboard:', error);
34648
+ return [];
34649
+ }
34650
+ }
34651
+ // Get user's streak statistics
34652
+ static async getUserStats(userId, locale) {
34653
+ try {
34654
+ const statsPath = `gatadagsins/users/${locale}/${userId}/stats`;
34655
+ const stats = await getFirebaseData(statsPath);
34656
+ return stats;
34657
+ }
34658
+ catch (error) {
34659
+ console.error('Failed to get user stats:', error);
34660
+ return null;
34661
+ }
34662
+ }
34663
+ }
34664
+ RiddlePersistence.STORAGE_KEY_PREFIX = 'gata_';
34665
+ RiddlePersistence.MAX_AGE_DAYS = 30;
34666
+
34355
34667
  /*
34356
34668
 
34357
34669
  Riddle.ts
@@ -34366,8 +34678,8 @@ class Game extends BaseGame {
34366
34678
  For further information, see https://github.com/mideind/Netskrafl
34367
34679
 
34368
34680
  */
34369
- const HOT_WARM_BOUNDARY_RATIO = 0.6;
34370
- const WARM_COLD_BOUNDARY_RATIO = 0.3;
34681
+ const HOT_WARM_BOUNDARY_RATIO = 0.5;
34682
+ const WARM_COLD_BOUNDARY_RATIO = 0.25;
34371
34683
  class Riddle extends BaseGame {
34372
34684
  constructor(uuid, date, model) {
34373
34685
  if (!model.state) {
@@ -34439,6 +34751,22 @@ class Riddle extends BaseGame {
34439
34751
  this.hotBoundary = this.bestPossibleScore * HOT_WARM_BOUNDARY_RATIO;
34440
34752
  // Initialize word checker
34441
34753
  wordChecker.ingestTwoLetterWords(this.locale, this.two_letter_words[0]);
34754
+ // Load persisted player moves from localStorage
34755
+ if (state.userId) {
34756
+ const persistedMoves = RiddlePersistence.loadLocalMoves(state.userId, date);
34757
+ if (persistedMoves.length > 0) {
34758
+ // Convert from IPlayerMove to RiddleWord format, preserving timestamps
34759
+ this.playerMoves = persistedMoves.map(move => ({
34760
+ word: move.word,
34761
+ score: move.score,
34762
+ coord: move.coord,
34763
+ timestamp: move.timestamp || new Date().toISOString() // Use stored timestamp or fallback
34764
+ }));
34765
+ // Update personal best score from persisted moves
34766
+ const bestMove = persistedMoves.reduce((best, current) => current.score > best.score ? current : best);
34767
+ this.personalBestScore = bestMove.score;
34768
+ }
34769
+ }
34442
34770
  }
34443
34771
  }
34444
34772
  catch (error) {
@@ -34459,6 +34787,7 @@ class Riddle extends BaseGame {
34459
34787
  locale: this.locale,
34460
34788
  userId: state.userId,
34461
34789
  groupId: state.userGroupId || null,
34790
+ userDisplayName: state.userFullname || state.userNick || state.userId,
34462
34791
  move,
34463
34792
  }
34464
34793
  });
@@ -34507,13 +34836,26 @@ class Riddle extends BaseGame {
34507
34836
  // If the move is not valid or was already played, return
34508
34837
  if (!move)
34509
34838
  return;
34510
- if (move.score > this.personalBestScore) {
34511
- // This is the best score we've seen yet
34512
- this.personalBestScore = move.score;
34513
- // This might affect the global state,
34514
- // so notify the server
34515
- this.submitRiddleWord(move);
34516
- }
34839
+ const { state } = this;
34840
+ if (!state || !state.userId)
34841
+ return;
34842
+ // Save all moves to localStorage (local backup/cache)
34843
+ // Convert RiddleWord[] to IPlayerMove[] for persistence
34844
+ const movesToSave = this.playerMoves.map(m => ({
34845
+ score: m.score,
34846
+ word: m.word,
34847
+ coord: m.coord,
34848
+ timestamp: m.timestamp
34849
+ }));
34850
+ RiddlePersistence.saveLocalMoves(state.userId, this.date, movesToSave);
34851
+ // If the move does not improve the personal best, we're done
34852
+ if (move.score <= this.personalBestScore)
34853
+ return;
34854
+ // This is the best score we've seen yet
34855
+ this.personalBestScore = move.score;
34856
+ // Submit to server; the server handles all Firebase updates
34857
+ // (achievements, stats, global best, leaderboard)
34858
+ this.submitRiddleWord(move);
34517
34859
  }
34518
34860
  updateGlobalBestScore(best) {
34519
34861
  // Update the global best score, typically as a result
@@ -34740,6 +35082,9 @@ class Model {
34740
35082
  this.game = null;
34741
35083
  // The current Gáta Dagsins riddle, if any
34742
35084
  this.riddle = null;
35085
+ // Gáta Dagsins-specific properties
35086
+ this.userStats = null;
35087
+ this.leaderboard = [];
34743
35088
  // The current Netskrafl game list
34744
35089
  this.gameList = null;
34745
35090
  // Number of games where it's the player's turn, plus count of zombie games
@@ -35847,6 +36192,12 @@ class Actions {
35847
36192
  if (state === null || state === void 0 ? void 0 : state.userGroupId) {
35848
36193
  attachFirebaseListener(basePath + `group/${state.userGroupId}/best`, (json, firstAttach) => this.onRiddleGroupScoreUpdate(json, firstAttach));
35849
36194
  }
36195
+ // Listen to global leaderboard
36196
+ attachFirebaseListener(basePath + "leaders", (json, firstAttach) => this.onLeaderboardUpdate(json, firstAttach));
36197
+ // Listen to user stats (if user is logged in)
36198
+ if (state === null || state === void 0 ? void 0 : state.userId) {
36199
+ attachFirebaseListener(`gatadagsins/users/${locale}/${state.userId}/stats`, (json, firstAttach) => this.onUserStatsUpdate(json, firstAttach));
36200
+ }
35850
36201
  }
35851
36202
  detachListenerFromRiddle(date, locale) {
35852
36203
  const { state } = this.model;
@@ -35855,6 +36206,10 @@ class Actions {
35855
36206
  if (state === null || state === void 0 ? void 0 : state.userGroupId) {
35856
36207
  detachFirebaseListener(basePath + `group/${state.userGroupId}/best`);
35857
36208
  }
36209
+ detachFirebaseListener(basePath + "leaders");
36210
+ if (state === null || state === void 0 ? void 0 : state.userId) {
36211
+ detachFirebaseListener(`gatadagsins/users/${locale}/${state.userId}/stats`);
36212
+ }
35858
36213
  }
35859
36214
  onRiddleGlobalScoreUpdate(json, firstAttach) {
35860
36215
  const { riddle } = this.model;
@@ -35872,6 +36227,38 @@ class Actions {
35872
36227
  riddle.updateGroupBestScore(json);
35873
36228
  m.redraw();
35874
36229
  }
36230
+ onLeaderboardUpdate(json, firstAttach) {
36231
+ if (!json || typeof json !== 'object') {
36232
+ this.model.leaderboard = [];
36233
+ }
36234
+ else {
36235
+ // Convert dictionary to array and sort by score (desc), then timestamp (desc)
36236
+ const entries = Object.keys(json).map(userId => ({
36237
+ userId: json[userId].userId || userId,
36238
+ displayName: json[userId].displayName || '',
36239
+ score: json[userId].score || 0,
36240
+ timestamp: json[userId].timestamp || ''
36241
+ }));
36242
+ // Sort by score descending, then by timestamp ascending (earlier first)
36243
+ entries.sort((a, b) => {
36244
+ if (b.score !== a.score) {
36245
+ return b.score - a.score;
36246
+ }
36247
+ return a.timestamp.localeCompare(b.timestamp);
36248
+ });
36249
+ this.model.leaderboard = entries;
36250
+ }
36251
+ m.redraw();
36252
+ }
36253
+ onUserStatsUpdate(json, firstAttach) {
36254
+ if (!json) {
36255
+ this.model.userStats = null;
36256
+ }
36257
+ else {
36258
+ this.model.userStats = json;
36259
+ }
36260
+ m.redraw();
36261
+ }
35875
36262
  async fetchRiddle(date, locale) {
35876
36263
  // Create the game via model
35877
36264
  if (!this.model)
@@ -36062,13 +36449,14 @@ const Netskrafl = React.memo(NetskraflImpl);
36062
36449
  */
36063
36450
  const RiddleScore = {
36064
36451
  view: (vnode) => {
36065
- const { riddle } = vnode.attrs;
36452
+ const { riddle, mode = "desktop" } = vnode.attrs;
36066
36453
  if (!riddle)
36067
36454
  return m("div");
36068
36455
  const score = riddle.currentScore;
36069
36456
  const hasValidMove = score !== undefined;
36070
36457
  const hasTiles = riddle.tilesPlaced().length > 0;
36071
- let classes = [".gata-dagsins-score"];
36458
+ const baseClass = (mode === "mobile" ? ".mobile-score" : ".gata-dagsins-score");
36459
+ let classes = [baseClass];
36072
36460
  let displayText = "0";
36073
36461
  if (!hasTiles) {
36074
36462
  // State 1: No tiles on board - grayed/disabled, showing zero
@@ -36102,7 +36490,8 @@ const RiddleScore = {
36102
36490
  classes.push(".celebrate");
36103
36491
  }
36104
36492
  }
36105
- return m("div" + classes.join(""), m("span.gata-dagsins-legend", displayText));
36493
+ const legendClass = mode === "mobile" ? ".mobile-score-legend" : ".gata-dagsins-legend";
36494
+ return m("div" + classes.join(""), m("span" + legendClass, displayText));
36106
36495
  }
36107
36496
  };
36108
36497
 
@@ -36162,35 +36551,40 @@ const GataDagsinsBoardAndRack = {
36162
36551
  */
36163
36552
  const SunCorona = {
36164
36553
  view: (vnode) => {
36165
- const { animate = false } = vnode.attrs;
36554
+ const { animate = false, size = 80 } = vnode.attrs;
36555
+ // Calculate ray positions based on size
36556
+ // For 80px: rays from -40 to -20 (inner radius 20px, outer radius 40px)
36557
+ // For 90px: rays from -45 to -30 (inner radius 30px, outer radius 45px) to fit 60px circle
36558
+ const outerRadius = size / 2;
36559
+ const innerRadius = size / 3;
36166
36560
  return m("div.sun-corona" + (animate ? ".rotating" : ""), [
36167
36561
  m.trust(`
36168
- <svg width="80" height="80" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
36562
+ <svg width="${size}" height="${size}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
36169
36563
  <g transform="translate(50,50)">
36170
36564
  <!-- Ray at 0° (12 o'clock) -->
36171
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(0)"/>
36565
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(0)"/>
36172
36566
  <!-- Ray at 30° -->
36173
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(30)"/>
36567
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(30)"/>
36174
36568
  <!-- Ray at 60° -->
36175
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(60)"/>
36569
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(60)"/>
36176
36570
  <!-- Ray at 90° (3 o'clock) -->
36177
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(90)"/>
36571
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(90)"/>
36178
36572
  <!-- Ray at 120° -->
36179
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(120)"/>
36573
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(120)"/>
36180
36574
  <!-- Ray at 150° -->
36181
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(150)"/>
36575
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(150)"/>
36182
36576
  <!-- Ray at 180° (6 o'clock) -->
36183
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(180)"/>
36577
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(180)"/>
36184
36578
  <!-- Ray at 210° -->
36185
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(210)"/>
36579
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(210)"/>
36186
36580
  <!-- Ray at 240° -->
36187
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(240)"/>
36581
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(240)"/>
36188
36582
  <!-- Ray at 270° (9 o'clock) -->
36189
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(270)"/>
36583
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(270)"/>
36190
36584
  <!-- Ray at 300° -->
36191
- <polygon points="0,-40 -3,-20 3,-20" fill="#f1c40f" opacity="0.8" transform="rotate(300)"/>
36585
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(300)"/>
36192
36586
  <!-- Ray at 330° -->
36193
- <polygon points="0,-40 -3,-20 3,-20" fill="#f39c12" opacity="0.7" transform="rotate(330)"/>
36587
+ <polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(330)"/>
36194
36588
  </g>
36195
36589
  </svg>
36196
36590
  `)
@@ -36198,6 +36592,112 @@ const SunCorona = {
36198
36592
  }
36199
36593
  };
36200
36594
 
36595
+ /*
36596
+
36597
+ Mobile-Status.ts
36598
+
36599
+ Mobile-only horizontal status component for Gáta Dagsins
36600
+
36601
+ Copyright (C) 2025 Miðeind ehf.
36602
+ Author: Vilhjálmur Þorsteinsson
36603
+
36604
+ The Creative Commons Attribution-NonCommercial 4.0
36605
+ International Public License (CC-BY-NC 4.0) applies to this software.
36606
+ For further information, see https://github.com/mideind/Netskrafl
36607
+
36608
+ */
36609
+ // Mobile-only horizontal status display
36610
+ const MobileStatus = () => {
36611
+ return {
36612
+ view: (vnode) => {
36613
+ const { riddle, selectedMoves, bestMove, onMoveClick, onStatsClick } = vnode.attrs;
36614
+ const { bestPossibleScore, globalBestScore } = riddle;
36615
+ // Determine if player achieved best possible score
36616
+ const achieved = bestMove !== undefined;
36617
+ const celebrate = bestMove && bestMove.word !== "";
36618
+ // Get player's current best score
36619
+ // If the player achieved the best possible score, it's in bestMove (not selectedMoves)
36620
+ const playerBestScore = (bestMove && bestMove.word !== "")
36621
+ ? bestMove.score
36622
+ : (selectedMoves.length > 0 ? selectedMoves[0].score : 0);
36623
+ // Determine current leader score (may be this player or another)
36624
+ let leaderScore = 0;
36625
+ let isPlayerLeading = false;
36626
+ if (globalBestScore && globalBestScore.score > 0) {
36627
+ leaderScore = globalBestScore.score;
36628
+ // Check if player is leading
36629
+ isPlayerLeading = playerBestScore >= globalBestScore.score;
36630
+ }
36631
+ else {
36632
+ leaderScore = playerBestScore;
36633
+ isPlayerLeading = playerBestScore > 0;
36634
+ }
36635
+ return m(".mobile-status-container", [
36636
+ // Current word score (leftmost) - uses RiddleScore component in mobile mode
36637
+ m(".mobile-status-item.left", m(RiddleScore, { riddle, mode: "mobile" })),
36638
+ // Interactive card containing player best and leader scores
36639
+ m(".mobile-status-card", {
36640
+ onclick: onStatsClick,
36641
+ title: ts("Tölfræði og stigatafla")
36642
+ }, [
36643
+ // Player's best score
36644
+ m(".mobile-status-card-item.player-best", [
36645
+ m(".mobile-status-label", ts("Þín besta:")),
36646
+ m(".mobile-status-score", playerBestScore.toString())
36647
+ ]),
36648
+ // Current leader score
36649
+ m(".mobile-status-card-item.leader" + (isPlayerLeading ? ".is-player" : ""), [
36650
+ m(".mobile-status-label", isPlayerLeading ? ts("Þú leiðir!") : ts("Leiðandi:")),
36651
+ m(".mobile-status-score", leaderScore.toString())
36652
+ ]),
36653
+ // Chevron indicator (overlaid at bottom center)
36654
+ m(".mobile-status-card-icon", glyph("chevron-down"))
36655
+ ]),
36656
+ // Best possible score
36657
+ m(".mobile-status-item.right.best-possible"
36658
+ + (celebrate ? ".celebrate" : "")
36659
+ + (achieved ? ".achieved" : ""), {
36660
+ onclick: () => celebrate && onMoveClick(bestMove.word, bestMove.coord)
36661
+ }, [
36662
+ // Wrapper for score and corona to position them together
36663
+ m(".mobile-best-score-wrapper", [
36664
+ celebrate ? m(SunCorona, { animate: true, size: 100 }) : null,
36665
+ m(".mobile-status-score", bestPossibleScore.toString())
36666
+ ])
36667
+ ])
36668
+ ]);
36669
+ }
36670
+ };
36671
+ };
36672
+
36673
+ /*
36674
+
36675
+ TabBar.ts
36676
+
36677
+ Reusable tab navigation component
36678
+
36679
+ Copyright (C) 2025 Miðeind ehf.
36680
+ Author: Vilhjálmur Þorsteinsson
36681
+
36682
+ The Creative Commons Attribution-NonCommercial 4.0
36683
+ International Public License (CC-BY-NC 4.0) applies to this software.
36684
+ For further information, see https://github.com/mideind/Netskrafl
36685
+
36686
+ */
36687
+ const TabBar = {
36688
+ view: (vnode) => {
36689
+ const { tabs, activeTab, onTabChange } = vnode.attrs;
36690
+ return m(".tab-bar", tabs.map(tab => m(".tab-item" + (activeTab === tab.id ? ".active" : ""), {
36691
+ key: tab.id,
36692
+ onclick: () => onTabChange(tab.id)
36693
+ }, [
36694
+ tab.iconGlyph ? m("span.tab-icon", glyph(tab.iconGlyph)) :
36695
+ tab.icon ? m("span.tab-icon", tab.icon) : null,
36696
+ m("span.tab-label", tab.label)
36697
+ ])));
36698
+ }
36699
+ };
36700
+
36201
36701
  /*
36202
36702
 
36203
36703
  Thermometer.ts
@@ -36272,12 +36772,27 @@ const BestPossibleScore = () => {
36272
36772
  return {
36273
36773
  view: (vnode) => {
36274
36774
  const { score, bestMove, onMoveClick } = vnode.attrs;
36275
- const topLabel = bestMove
36276
- ? removeBlankMarkers(bestMove.word)
36277
- : ts("Besta mögulega lögn");
36775
+ // Determine the label based on achievement status
36776
+ let topLabel;
36777
+ if (bestMove !== undefined) {
36778
+ if (bestMove.word) {
36779
+ // Current player achieved it - show their word
36780
+ topLabel = removeBlankMarkers(bestMove.word);
36781
+ }
36782
+ else {
36783
+ // Someone else achieved it - indicate this
36784
+ topLabel = ts("Bestu lögn náð!");
36785
+ }
36786
+ }
36787
+ else {
36788
+ // Not achieved yet - show default label
36789
+ topLabel = ts("Besta mögulega lögn");
36790
+ }
36791
+ const achieved = bestMove !== undefined;
36278
36792
  const celebrate = bestMove && bestMove.word !== "";
36279
36793
  return m(".thermometer-best-score"
36280
- + (celebrate ? ".celebrate" : ""), m(".thermometer-best-score-container", {
36794
+ + (celebrate ? ".celebrate" : "")
36795
+ + (achieved ? ".achieved" : ""), m(".thermometer-best-score-container", {
36281
36796
  onclick: () => celebrate && onMoveClick(bestMove.word, bestMove.coord)
36282
36797
  }, [
36283
36798
  // Sun corona behind the circle when celebrating
@@ -36405,6 +36920,196 @@ const Thermometer = () => {
36405
36920
  };
36406
36921
  };
36407
36922
 
36923
+ /*
36924
+
36925
+ StatsView.ts
36926
+
36927
+ User statistics display component for Gáta Dagsins
36928
+
36929
+ Copyright (C) 2025 Miðeind ehf.
36930
+ Author: Vilhjálmur Þorsteinsson
36931
+
36932
+ The Creative Commons Attribution-NonCommercial 4.0
36933
+ International Public License (CC-BY-NC 4.0) applies to this software.
36934
+ For further information, see https://github.com/mideind/Netskrafl
36935
+
36936
+ */
36937
+ const StatsView = {
36938
+ view: (vnode) => {
36939
+ const { stats, loading = false } = vnode.attrs;
36940
+ if (loading) {
36941
+ return m(".stats-view.loading", m(".loading-message", ts("Sæki tölfræði...")));
36942
+ }
36943
+ if (!stats) {
36944
+ return m(".stats-view.empty", m(".empty-message", ts("Engin tölfræði til að sýna")));
36945
+ }
36946
+ const statItems = [
36947
+ {
36948
+ iconGlyph: "fire",
36949
+ label: ts("Núverandi striklota"),
36950
+ value: stats.currentStreak,
36951
+ highlight: stats.currentStreak > 0
36952
+ },
36953
+ {
36954
+ iconGlyph: "star",
36955
+ label: ts("Lengsta striklota"),
36956
+ value: stats.longestStreak
36957
+ },
36958
+ {
36959
+ iconGlyph: "tower",
36960
+ label: ts("Hæsta skori náð"),
36961
+ value: stats.totalTopScores
36962
+ },
36963
+ {
36964
+ iconGlyph: "certificate",
36965
+ label: ts("Striklota hæsta skors"),
36966
+ value: stats.topScoreStreak,
36967
+ highlight: stats.topScoreStreak > 0
36968
+ },
36969
+ {
36970
+ iconGlyph: "calendar",
36971
+ label: ts("Heildarfjöldi daga"),
36972
+ value: stats.totalDaysPlayed
36973
+ },
36974
+ ];
36975
+ return m(".stats-view", m(".stats-grid", statItems.map((item, index) => m(".stat-item" + (item.highlight ? ".highlight" : ""), { key: index }, [
36976
+ m(".stat-icon", glyph(item.iconGlyph)),
36977
+ m(".stat-info", [
36978
+ m(".stat-label", item.label),
36979
+ m(".stat-value", item.value.toString())
36980
+ ])
36981
+ ]))));
36982
+ }
36983
+ };
36984
+
36985
+ /*
36986
+
36987
+ LeaderboardView.ts
36988
+
36989
+ Daily leaderboard display component for Gáta Dagsins
36990
+
36991
+ Copyright (C) 2025 Miðeind ehf.
36992
+ Author: Vilhjálmur Þorsteinsson
36993
+
36994
+ The Creative Commons Attribution-NonCommercial 4.0
36995
+ International Public License (CC-BY-NC 4.0) applies to this software.
36996
+ For further information, see https://github.com/mideind/Netskrafl
36997
+
36998
+ */
36999
+ function getMedalIcon(rank) {
37000
+ switch (rank) {
37001
+ case 1: return "🥇";
37002
+ case 2: return "🥈";
37003
+ case 3: return "🥉";
37004
+ default: return null;
37005
+ }
37006
+ }
37007
+ function formatDate(dateStr) {
37008
+ // Format YYYY-MM-DD to Icelandic date (e.g., "2. okt.")
37009
+ const date = new Date(dateStr + "T00:00:00");
37010
+ const day = date.getDate();
37011
+ const months = [
37012
+ "janúar", "febrúar", "mars", "apríl", "maí", "júní",
37013
+ "júlí", "ágúst", "september", "október", "nóvember", "desember",
37014
+ ];
37015
+ const month = months[date.getMonth()];
37016
+ return `${day}. ${month}`;
37017
+ }
37018
+ const LeaderboardView = {
37019
+ view: (vnode) => {
37020
+ const { leaderboard, currentUserId, date, loading = false } = vnode.attrs;
37021
+ if (loading) {
37022
+ return m(".leaderboard-view.loading", m(".loading-message", ts("Hleð stigatöflu...")));
37023
+ }
37024
+ if (!leaderboard || leaderboard.length === 0) {
37025
+ return m(".leaderboard-view.empty", m(".empty-message", ts("Engin stig skráð enn")));
37026
+ }
37027
+ return m(".leaderboard-view", [
37028
+ m(".leaderboard-header", [
37029
+ m(".leaderboard-title", formatDate(date)),
37030
+ ]),
37031
+ m(".leaderboard-list", leaderboard.map((entry, index) => {
37032
+ const rank = index + 1;
37033
+ const isCurrentUser = entry.userId === currentUserId;
37034
+ const medal = getMedalIcon(rank);
37035
+ return m(".leaderboard-entry" + (isCurrentUser ? ".current-user" : ""), { key: entry.userId }, [
37036
+ m(".entry-rank", [
37037
+ medal ? m("span.medal", medal) : m("span.rank-number", rank.toString())
37038
+ ]),
37039
+ m(".entry-name", isCurrentUser ? ts("Þú") : entry.displayName),
37040
+ m(".entry-score", entry.score.toString())
37041
+ ]);
37042
+ }))
37043
+ ]);
37044
+ }
37045
+ };
37046
+
37047
+ /*
37048
+
37049
+ RightSideTabs.ts
37050
+
37051
+ Desktop tabbed container for Performance/Stats/Leaderboard in Gáta Dagsins
37052
+
37053
+ Copyright (C) 2025 Miðeind ehf.
37054
+ Author: Vilhjálmur Þorsteinsson
37055
+
37056
+ The Creative Commons Attribution-NonCommercial 4.0
37057
+ International Public License (CC-BY-NC 4.0) applies to this software.
37058
+ For further information, see https://github.com/mideind/Netskrafl
37059
+
37060
+ */
37061
+ const RightSideTabs = () => {
37062
+ // Component-local state for active tab (defaults to performance)
37063
+ let activeTab = "performance";
37064
+ const tabs = [
37065
+ { id: "performance", label: ts("Frammistaða"), iconGlyph: "dashboard" },
37066
+ { id: "stats", label: ts("Tölfræði"), iconGlyph: "stats" },
37067
+ { id: "leaderboard", label: ts("Stigatafla"), iconGlyph: "tower" }
37068
+ ];
37069
+ return {
37070
+ view: (vnode) => {
37071
+ const { view, selectedMoves, bestMove, onMoveClick } = vnode.attrs;
37072
+ const { riddle, state } = view.model;
37073
+ if (!riddle) {
37074
+ return m(".gatadagsins-right-side-tabs", "");
37075
+ }
37076
+ const handleTabChange = (tabId) => {
37077
+ activeTab = tabId;
37078
+ };
37079
+ return m(".gatadagsins-right-side-tabs", [
37080
+ // Tab navigation
37081
+ m(TabBar, {
37082
+ tabs,
37083
+ activeTab,
37084
+ onTabChange: handleTabChange
37085
+ }),
37086
+ // Tab content
37087
+ m(".tab-content", [
37088
+ // Performance tab (thermometer)
37089
+ activeTab === "performance" ? m(Thermometer, {
37090
+ riddle,
37091
+ selectedMoves,
37092
+ bestMove,
37093
+ onMoveClick
37094
+ }) : null,
37095
+ // Stats tab
37096
+ activeTab === "stats" ? m(StatsView, {
37097
+ stats: view.model.userStats || null,
37098
+ loading: false
37099
+ }) : null,
37100
+ // Leaderboard tab
37101
+ activeTab === "leaderboard" ? m(LeaderboardView, {
37102
+ leaderboard: view.model.leaderboard || [],
37103
+ currentUserId: (state === null || state === void 0 ? void 0 : state.userId) || "",
37104
+ date: riddle.date,
37105
+ loading: false
37106
+ }) : null
37107
+ ])
37108
+ ]);
37109
+ }
37110
+ };
37111
+ };
37112
+
36408
37113
  /*
36409
37114
 
36410
37115
  GataDagsins-Right-Side.ts
@@ -36420,9 +37125,9 @@ const Thermometer = () => {
36420
37125
 
36421
37126
  */
36422
37127
  const GataDagsinsRightSide = {
36423
- // Component containing a single vertical thermometer-style performance display
37128
+ // Component containing both mobile status bar and desktop tabbed view
36424
37129
  view: (vnode) => {
36425
- const { view, selectedMoves, bestMove } = vnode.attrs;
37130
+ const { view, selectedMoves, bestMove, onStatsClick } = vnode.attrs;
36426
37131
  const { riddle } = view.model;
36427
37132
  const handleMoveClick = (word, coord) => {
36428
37133
  if (riddle && word && coord) {
@@ -36431,11 +37136,19 @@ const GataDagsinsRightSide = {
36431
37136
  }
36432
37137
  };
36433
37138
  return m(".gatadagsins-right-side-wrapper", riddle ? [
36434
- // Single thermometer column containing all performance levels
36435
- m(".gatadagsins-thermometer-column", m(Thermometer, {
37139
+ // Mobile-only status bar (visible on mobile, hidden on desktop)
37140
+ m(".gatadagsins-mobile-status", m(MobileStatus, {
36436
37141
  riddle,
36437
37142
  selectedMoves,
36438
37143
  bestMove,
37144
+ onMoveClick: handleMoveClick,
37145
+ onStatsClick
37146
+ })),
37147
+ // Desktop-only tabbed view (hidden on mobile, visible on desktop)
37148
+ m(".gatadagsins-thermometer-column", m(RightSideTabs, {
37149
+ view,
37150
+ selectedMoves,
37151
+ bestMove,
36439
37152
  onMoveClick: handleMoveClick
36440
37153
  })),
36441
37154
  ] : null);
@@ -36459,67 +37172,150 @@ const GataDagsinsRightSide = {
36459
37172
  const GataDagsinsHelp = {
36460
37173
  view: (vnode) => {
36461
37174
  const closeHelp = vnode.attrs.onClose;
36462
- return m(".modal-dialog.gatadagsins-help", m(".modal-content", [
36463
- // Header with close button
36464
- m(".modal-header", [
36465
- m("h2", "Um Gátu dagsins"),
36466
- m("button.close", {
36467
- onclick: closeHelp,
36468
- "aria-label": "Loka"
36469
- }, m("span", { "aria-hidden": "true" }, "×"))
36470
- ]),
36471
- // Body with help content
36472
- m(".modal-body", [
36473
- m("p", "Gáta dagsins er dagleg krossgátuþraut, svipuð skrafli, þar sem þú reynir að finna " +
36474
- "stigahæsta orðið sem hægt er að mynda með gefnum stöfum."),
36475
- m("h3", "Hvernig á að spila"),
36476
- m("ul", [
36477
- m("li", "Þú færð borð með allmörgum stöfum sem þegar hafa verið lagðir."),
36478
- m("li", "Neðst á skjánum eru stafaflísar sem þú getur notað til að mynda orð."),
36479
- m("li", "Dragðu flísar á borðið til að mynda orð, annaðhvort lárétt eða lóðrétt."),
36480
- m("li", "Orðin verða að tengjast við stafi sem fyrir eru á borðinu."),
36481
- m("li", "Þú sérð jafnóðum hvort lögnin á borðinu er gild og hversu mörg stig hún gefur."),
36482
- m("li", "Þú getur prófað eins mörg orð og þú vilt - besta skorið þitt er vistað."),
36483
- ]),
36484
- m("h3", "Stigagjöf"),
36485
- m("p", "Þú færð stig fyrir hvern staf í orðinu, auk bónusstiga fyrir lengri orð:"),
36486
- m("ul", [
36487
- m("li", "Hver stafur gefur 1-10 stig eftir gildi hans"),
36488
- m("li", "Orð sem nota allar 7 stafaflísarnar gefa 50 stiga bónus"),
36489
- m("li", "Sumir reitir á borðinu tvöfalda eða þrefalda stafagildið"),
36490
- m("li", "Sumir reitir tvöfalda eða þrefalda heildarorðagildið"),
36491
- ]),
36492
- m("h3", "Hitamælir"),
36493
- m("p", "Hitamælirinn hægra megin (eða efst á farsímum) sýnir:"),
36494
- m("ul", [
36495
- m("li", m("strong", "Besta mögulega skor:"), " Hæstu stig sem hægt er að ná á þessu borði."),
36496
- m("li", m("strong", "Besta skor dagsins:"), " Hæstu stig sem einhver leikmaður hefur náð í dag."),
36497
- m("li", m("strong", "Þín bestu orð:"), " Orðin sem þú hefur lagt og stigin fyrir þau."),
36498
- m("li", "Þú getur smellt á orð á hitamælinum til að fá þá lögn aftur á borðið."),
36499
- ]),
36500
- m("h3", "Ábendingar"),
36501
- m("ul", [
36502
- m("li", "Reyndu að nota dýra stafi (eins og X, Ý, Þ) á tvöföldunar- eða þreföldunarreitum."),
36503
- m("li", "Lengri orð gefa mun fleiri stig vegna bónussins."),
36504
- m("li", "Þú getur dregið allar flísar til baka með bláa endurkalls-hnappnum."),
36505
- m("li", "Ný gáta birtist á hverjum nýjum degi - klukkan 00:00!"),
37175
+ return [
37176
+ // Backdrop
37177
+ m(".modal-backdrop-netskrafl", {
37178
+ onclick: (e) => { e.preventDefault(); },
37179
+ onwheel: (e) => { e.preventDefault(); e.stopPropagation(); },
37180
+ ontouchmove: (e) => { e.preventDefault(); e.stopPropagation(); }
37181
+ }),
37182
+ m(".modal-dialog.gatadagsins-help", m(".modal-content", [
37183
+ // Header with close button
37184
+ m(".modal-header", [
37185
+ m("h2", "Um Gátu dagsins"),
37186
+ m("button.close", {
37187
+ onclick: closeHelp,
37188
+ "aria-label": "Loka"
37189
+ }, m("span", { "aria-hidden": "true" }, "×"))
36506
37190
  ]),
36507
- m("h3", "Um leikinn"),
36508
- m("p", [
36509
- "Gáta dagsins er systkini ",
36510
- m("a", { href: "https://netskrafl.is", target: "_blank" }, "Netskrafls"),
36511
- ", hins sívinsæla íslenska krossgátuleiks á netinu. ",
36512
- "Leikurinn er þróaður af Miðeind ehf."
37191
+ // Body with help content
37192
+ m(".modal-body", [
37193
+ m("p", "Gáta dagsins er dagleg krossgátuþraut, svipuð skrafli, þar sem þú reynir að finna " +
37194
+ "stigahæsta orðið sem hægt er mynda með gefnum stöfum."),
37195
+ m("h3", "Hvernig á spila"),
37196
+ m("ul", [
37197
+ m("li", "Þú færð borð með allmörgum stöfum sem þegar hafa verið lagðir."),
37198
+ m("li", "Neðst á skjánum eru stafaflísar sem þú getur notað til að mynda orð."),
37199
+ m("li", "Dragðu flísar á borðið til að mynda orð, annaðhvort lárétt eða lóðrétt."),
37200
+ m("li", "Orðin verða að tengjast við stafi sem fyrir eru á borðinu."),
37201
+ m("li", "Þú sérð jafnóðum hvort lögnin á borðinu er gild og hversu mörg stig hún gefur."),
37202
+ m("li", "Þú getur prófað eins mörg orð og þú vilt - besta skorið þitt er vistað."),
37203
+ ]),
37204
+ m("h3", "Stigagjöf"),
37205
+ m("p", "Þú færð stig fyrir hvern staf í orðinu, auk bónusstiga fyrir lengri orð:"),
37206
+ m("ul", [
37207
+ m("li", "Hver stafur gefur 1-10 stig eftir gildi hans"),
37208
+ m("li", "Orð sem nota allar 7 stafaflísarnar gefa 50 stiga bónus"),
37209
+ m("li", "Sumir reitir á borðinu tvöfalda eða þrefalda stafagildið"),
37210
+ m("li", "Sumir reitir tvöfalda eða þrefalda heildarorðagildið"),
37211
+ ]),
37212
+ m("h3", "Hitamælir"),
37213
+ m("p", "Hitamælirinn hægra megin (eða efst á farsímum) sýnir:"),
37214
+ m("ul", [
37215
+ m("li", m("strong", "Besta mögulega skor:"), " Hæstu stig sem hægt er að ná á þessu borði."),
37216
+ m("li", m("strong", "Besta skor dagsins:"), " Hæstu stig sem einhver leikmaður hefur náð í dag."),
37217
+ m("li", m("strong", "Þín bestu orð:"), " Orðin sem þú hefur lagt og stigin fyrir þau."),
37218
+ m("li", "Þú getur smellt á orð á hitamælinum til að fá þá lögn aftur á borðið."),
37219
+ ]),
37220
+ m("h3", "Ábendingar"),
37221
+ m("ul", [
37222
+ m("li", "Reyndu að nota dýra stafi (eins og X, Ý, Þ) á tvöföldunar- eða þreföldunarreitum."),
37223
+ m("li", "Lengri orð gefa mun fleiri stig vegna bónussins."),
37224
+ m("li", "Þú getur dregið allar flísar til baka með bláa endurkalls-hnappnum."),
37225
+ m("li", "Ný gáta birtist á hverjum nýjum degi - klukkan 00:00!"),
37226
+ ]),
37227
+ m("h3", "Um leikinn"),
37228
+ m("p", [
37229
+ "Gáta dagsins er systkini ",
37230
+ m("a", { href: "https://netskrafl.is", target: "_blank" }, "Netskrafls"),
37231
+ ", hins sívinsæla íslenska krossgátuleiks á netinu. ",
37232
+ "Leikurinn er þróaður af Miðeind ehf."
37233
+ ]),
36513
37234
  ]),
36514
- ]),
36515
- // Footer with close button
36516
- m(".modal-footer", m("button.btn.btn-primary", {
36517
- onclick: closeHelp
36518
- }, "Loka"))
36519
- ]));
37235
+ // Footer with close button
37236
+ m(".modal-footer", m("button.btn.btn-primary", {
37237
+ onclick: closeHelp
37238
+ }, "Loka"))
37239
+ ])),
37240
+ ];
36520
37241
  }
36521
37242
  };
36522
37243
 
37244
+ /*
37245
+
37246
+ StatsModal.ts
37247
+
37248
+ Mobile modal for stats and leaderboard
37249
+
37250
+ Copyright (C) 2025 Miðeind ehf.
37251
+ Author: Vilhjálmur Þorsteinsson
37252
+
37253
+ The Creative Commons Attribution-NonCommercial 4.0
37254
+ International Public License (CC-BY-NC 4.0) applies to this software.
37255
+ For further information, see https://github.com/mideind/Netskrafl
37256
+
37257
+ */
37258
+ const StatsModal = () => {
37259
+ // Component-local state for active tab (defaults to stats)
37260
+ let activeTab = "stats";
37261
+ const tabs = [
37262
+ { id: "stats", label: ts("Tölfræði"), iconGlyph: "stats" },
37263
+ { id: "leaderboard", label: ts("Stigatafla"), iconGlyph: "tower" }
37264
+ ];
37265
+ return {
37266
+ view: (vnode) => {
37267
+ const { view, onClose } = vnode.attrs;
37268
+ const { riddle, state } = view.model;
37269
+ if (!riddle) {
37270
+ return null;
37271
+ }
37272
+ const handleTabChange = (tabId) => {
37273
+ activeTab = tabId;
37274
+ };
37275
+ return [
37276
+ // Backdrop
37277
+ m(".modal-backdrop-netskrafl", {
37278
+ onclick: (e) => { e.preventDefault(); },
37279
+ onwheel: (e) => { e.preventDefault(); e.stopPropagation(); },
37280
+ ontouchmove: (e) => { e.preventDefault(); e.stopPropagation(); }
37281
+ }),
37282
+ // Modal dialog
37283
+ m(".modal-dialog.stats-modal", {
37284
+ onwheel: (e) => { e.stopPropagation(); },
37285
+ ontouchmove: (e) => { e.stopPropagation(); }
37286
+ }, [
37287
+ m(".modal-content", [
37288
+ // Close button in top right
37289
+ m("button.close", {
37290
+ onclick: onClose,
37291
+ "aria-label": "Loka"
37292
+ }, glyph("remove")),
37293
+ // Tab navigation
37294
+ m(TabBar, {
37295
+ tabs,
37296
+ activeTab,
37297
+ onTabChange: handleTabChange
37298
+ }),
37299
+ // Modal body with tab content
37300
+ m(".modal-body", [
37301
+ activeTab === "stats" ? m(StatsView, {
37302
+ stats: view.model.userStats || null,
37303
+ loading: false
37304
+ }) : null,
37305
+ activeTab === "leaderboard" ? m(LeaderboardView, {
37306
+ leaderboard: view.model.leaderboard || [],
37307
+ currentUserId: (state === null || state === void 0 ? void 0 : state.userId) || "",
37308
+ date: riddle.date,
37309
+ loading: false
37310
+ }) : null
37311
+ ])
37312
+ ])
37313
+ ])
37314
+ ];
37315
+ }
37316
+ };
37317
+ };
37318
+
36523
37319
  /*
36524
37320
 
36525
37321
  GataDagsins.ts
@@ -36535,11 +37331,19 @@ const GataDagsinsHelp = {
36535
37331
 
36536
37332
  */
36537
37333
  const MAX_MOVES_TO_DISPLAY = 10;
36538
- const selectTopMoves = (thisPlayer, moves, globalBestScore, groupBestScore) => {
36539
- var _a;
37334
+ const currentMoveState = (riddle) => {
37335
+ var _a, _b, _c;
37336
+ const thisPlayer = ((_a = riddle.model.state) === null || _a === void 0 ? void 0 : _a.userId) || "";
37337
+ const { bestPossibleScore, // The highest score achievable for this riddle
37338
+ globalBestScore, // The best score achieved by any player
37339
+ groupBestScore, // The best score achieved within the player's group
37340
+ playerMoves, } = riddle;
37341
+ // If the player has equaled the best possible score,
37342
+ // the winning word is stored here and displayed at the top
37343
+ let bestMove = undefined;
36540
37344
  // Sort moves by score in descending order and
36541
37345
  // cut the tail off the list to only include the top moves
36542
- const movesToDisplay = moves
37346
+ const selectedMoves = playerMoves
36543
37347
  .sort((a, b) => b.score - a.score)
36544
37348
  .slice(0, MAX_MOVES_TO_DISPLAY)
36545
37349
  .map(move => ({
@@ -36547,61 +37351,49 @@ const selectTopMoves = (thisPlayer, moves, globalBestScore, groupBestScore) => {
36547
37351
  word: move.word,
36548
37352
  coord: move.coord,
36549
37353
  }));
36550
- // If there is a global best score, and it is different from
36551
- // the player's own top score, we include it as the first move
37354
+ // Check whether we need to add or annotate the global best score
36552
37355
  if (globalBestScore && globalBestScore.score > 0) {
36553
37356
  const { score, word, coord } = globalBestScore;
36554
- if (((_a = movesToDisplay[0]) === null || _a === void 0 ? void 0 : _a.score) >= score) {
37357
+ if (((_c = (_b = selectedMoves[0]) === null || _b === void 0 ? void 0 : _b.score) !== null && _c !== void 0 ? _c : 0) >= score) {
36555
37358
  // This player has made a move that scores the same
36556
37359
  // or better as the top score: mark the move
36557
- movesToDisplay[0].isGlobalBestScore = true;
37360
+ selectedMoves[0].isGlobalBestScore = true;
36558
37361
  }
36559
37362
  else if (globalBestScore.player === thisPlayer) {
36560
37363
  // This player holds the global best score, probably
36561
- // from a previous session: add it as a move
36562
- movesToDisplay.unshift({ score, isGlobalBestScore: true, word, coord });
37364
+ // from a previous session, so it's not already
37365
+ // in the selectedMoves list: add it as a move
37366
+ selectedMoves.unshift({ score, isGlobalBestScore: true, word, coord });
36563
37367
  }
36564
37368
  else {
36565
- // This is a global best score from another player: add it as a special move
36566
- movesToDisplay.unshift({ score, isGlobalBestScore: true, word: "", coord: "" });
37369
+ // This is a global best score from another player
37370
+ selectedMoves.unshift({ score, isGlobalBestScore: true, word: "", coord: "" });
36567
37371
  }
36568
37372
  }
36569
- // TODO: Add handling for group best score
36570
- return movesToDisplay;
36571
- };
36572
- const currentMoveState = (riddle) => {
36573
- var _a;
36574
- const thisPlayer = ((_a = riddle.model.state) === null || _a === void 0 ? void 0 : _a.userId) || "";
36575
- const { bestPossibleScore, globalBestScore, groupBestScore, playerMoves, } = riddle;
36576
- // If the player has equaled the best possible score,
36577
- // the winning word is stored here and displayed at the top
36578
- let bestMove = undefined;
36579
- // Apply the move selection and allocation algorithm
36580
- const selectedMoves = selectTopMoves(thisPlayer, playerMoves, globalBestScore);
36581
- // If the top-scoring move has the bestPossibleScore,
36582
- // extract it from the move list
37373
+ // Check if the best possible score has been achieved, by this player
37374
+ // or another player. If so, we remove it from the move list, since we
37375
+ // only display it at the top of the thermometer.
36583
37376
  if (selectedMoves.length > 0 && selectedMoves[0].score === bestPossibleScore) {
36584
- if (selectedMoves[0].word) {
36585
- // The word was played by this player
36586
- bestMove = selectedMoves.shift();
36587
- }
37377
+ bestMove = selectedMoves.shift();
36588
37378
  }
36589
37379
  return { selectedMoves, bestMove };
36590
37380
  };
36591
37381
  const GataDagsins$1 = () => {
36592
37382
  // A view of the Gáta Dagsins page
36593
37383
  let showHelp = false;
37384
+ let showStatsModal = false;
36594
37385
  return {
36595
- oninit: (vnode) => {
37386
+ oninit: async (vnode) => {
36596
37387
  const { model, actions } = vnode.attrs.view;
36597
37388
  const { riddle } = model;
36598
37389
  if (!riddle) {
36599
37390
  const { date, locale } = vnode.attrs;
36600
37391
  // Initialize a fresh riddle object if it doesn't exist
36601
- actions.fetchRiddle(date, locale);
37392
+ await actions.fetchRiddle(date, locale);
36602
37393
  }
36603
- // Initialize help dialog state
37394
+ // Initialize dialog states
36604
37395
  showHelp = false;
37396
+ showStatsModal = false;
36605
37397
  },
36606
37398
  view: (vnode) => {
36607
37399
  var _a;
@@ -36615,6 +37407,10 @@ const GataDagsins$1 = () => {
36615
37407
  showHelp = !showHelp;
36616
37408
  m.redraw();
36617
37409
  };
37410
+ const toggleStatsModal = () => {
37411
+ showStatsModal = !showStatsModal;
37412
+ m.redraw();
37413
+ };
36618
37414
  return m("div.drop-target", {
36619
37415
  id: "gatadagsins-background",
36620
37416
  }, [
@@ -36625,7 +37421,7 @@ const GataDagsins$1 = () => {
36625
37421
  // Board and rack component (left side)
36626
37422
  m(GataDagsinsBoardAndRack, { view }),
36627
37423
  // Right-side component with scores and comparisons
36628
- m(GataDagsinsRightSide, { view, selectedMoves, bestMove }),
37424
+ m(GataDagsinsRightSide, { view, selectedMoves, bestMove, onStatsClick: toggleStatsModal }),
36629
37425
  // Blank dialog
36630
37426
  riddle.askingForBlank
36631
37427
  ? m(BlankDialog, { game: riddle })
@@ -36640,10 +37436,9 @@ const GataDagsins$1 = () => {
36640
37436
  // Custom Info button for GataDagsins that shows help dialog
36641
37437
  m(".info", { title: ts("Upplýsingar og hjálp") }, m("a.iconlink", { href: "#", onclick: (e) => { e.preventDefault(); toggleHelp(); } }, glyph("info-sign"))),
36642
37438
  // Help dialog and backdrop
36643
- showHelp ? [
36644
- m(".modal-backdrop", { onclick: (e) => { e.preventDefault(); } }),
36645
- m(GataDagsinsHelp, { onClose: toggleHelp })
36646
- ] : "",
37439
+ showHelp ? m(GataDagsinsHelp, { onClose: toggleHelp }) : "",
37440
+ // Stats modal and backdrop (mobile only)
37441
+ showStatsModal ? m(StatsModal, { view, onClose: toggleStatsModal }) : "",
36647
37442
  ]);
36648
37443
  }
36649
37444
  };
@@ -36682,11 +37477,22 @@ async function main(state, container) {
36682
37477
  const model = new Model(settings, state);
36683
37478
  const actions = new Actions(model);
36684
37479
  const view = new View(actions);
37480
+ // Get date from URL parameter, fallback to today
37481
+ const urlParams = new URLSearchParams(window.location.search);
37482
+ const dateParam = urlParams.get('date');
36685
37483
  const today = new Date().toISOString().split("T")[0];
37484
+ const riddleDate = dateParam || today;
37485
+ // Validate date format (YYYY-MM-DD)
37486
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
37487
+ const validDate = dateRegex.test(riddleDate) ? riddleDate : today;
36686
37488
  const locale = state.locale || "is_IS";
37489
+ // Log the date being used (helpful for debugging)
37490
+ if (dateParam) {
37491
+ console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
37492
+ }
36687
37493
  // Mount the Gáta Dagsins UI using an anonymous closure component
36688
37494
  m.mount(container, {
36689
- view: () => m(GataDagsins$1, { view, date: today, locale }),
37495
+ view: () => m(GataDagsins$1, { view, date: validDate, locale }),
36690
37496
  });
36691
37497
  }
36692
37498
  catch (e) {
@@ -36696,6 +37502,8 @@ async function main(state, container) {
36696
37502
  return "success";
36697
37503
  }
36698
37504
 
37505
+ // Note: To load a specific date for debugging, use URL parameter: ?date=YYYY-MM-DD
37506
+ // Example: http://localhost:6006/?date=2025-01-25
36699
37507
  const mountForUser = async (state) => {
36700
37508
  // Return a DOM tree containing a mounted Gáta Dagsins UI
36701
37509
  // for the user specified in the state object