@mideind/netskrafl-react 1.6.1 → 1.7.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.
@@ -1898,7 +1898,7 @@ div.netskrafl-tile.dragging div.letterscore {
1898
1898
  opacity: 1;
1899
1899
  }
1900
1900
 
1901
- .netskrafl-container div.right-tab.alert > span.glyphicon {
1901
+ .netskrafl-container div.right-tab.chat-alert > span.glyphicon {
1902
1902
  color: var(--cancel-button);
1903
1903
  animation: redBlink 1s infinite;
1904
1904
  -webkit-animation: redBlink 1s infinite;
@@ -5325,7 +5325,7 @@ div.highlight1.netskrafl-blanktile {
5325
5325
  }
5326
5326
 
5327
5327
  .netskrafl-container div#user-unfriend {
5328
- left: 180px;
5328
+ left: 166px;
5329
5329
  width: 280px;
5330
5330
  /* Override */
5331
5331
  border-style: solid;
package/dist/esm/index.js CHANGED
@@ -1,8 +1,63 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import React, { useEffect } from 'react';
3
3
 
4
+ const DEFAULT_STATE = {
5
+ projectId: "netskrafl",
6
+ firebaseApiKey: "",
7
+ databaseUrl: "",
8
+ firebaseSenderId: "",
9
+ firebaseAppId: "",
10
+ measurementId: "",
11
+ account: "",
12
+ userEmail: "",
13
+ userId: "",
14
+ userNick: "",
15
+ userFullname: "",
16
+ locale: "is_IS",
17
+ isExplo: false,
18
+ serverUrl: "",
19
+ movesUrl: "",
20
+ movesAccessKey: "",
21
+ token: "",
22
+ loginMethod: "",
23
+ subscriptionUrl: "",
24
+ newUser: false,
25
+ beginner: true,
26
+ fairPlay: false,
27
+ plan: "", // Not a friend
28
+ hasPaid: false,
29
+ ready: true,
30
+ readyTimed: true,
31
+ uiFullscreen: true,
32
+ uiLandscape: false,
33
+ runningLocal: false,
34
+ };
35
+
4
36
  // Key for storing auth settings in sessionStorage
5
37
  const AUTH_SETTINGS_KEY = "netskrafl_auth_settings";
38
+ const makeServerUrls = (backendUrl, movesUrl) => {
39
+ // If the last character of the url is a slash, cut it off,
40
+ // since path URLs always start with a slash
41
+ const cleanupUrl = (url) => {
42
+ if (url.length > 0 && url[url.length - 1] === "/") {
43
+ url = url.slice(0, -1);
44
+ }
45
+ return url;
46
+ };
47
+ return {
48
+ serverUrl: cleanupUrl(backendUrl),
49
+ movesUrl: cleanupUrl(movesUrl),
50
+ };
51
+ };
52
+ const makeGlobalState = (overrides) => {
53
+ const state = {
54
+ ...DEFAULT_STATE,
55
+ ...overrides,
56
+ };
57
+ const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
58
+ // Apply any persisted authentication settings from sessionStorage
59
+ return applyPersistedSettings(stateWithUrls);
60
+ };
6
61
  // Save authentication settings to sessionStorage
7
62
  const saveAuthSettings = (settings) => {
8
63
  if (!settings) {
@@ -98,61 +153,6 @@ const applyPersistedSettings = (state) => {
98
153
  };
99
154
  };
100
155
 
101
- const DEFAULT_STATE = {
102
- projectId: "netskrafl",
103
- firebaseApiKey: "",
104
- databaseUrl: "",
105
- firebaseSenderId: "",
106
- firebaseAppId: "",
107
- measurementId: "",
108
- account: "",
109
- userEmail: "",
110
- userId: "",
111
- userNick: "",
112
- userFullname: "",
113
- locale: "is_IS",
114
- isExplo: false,
115
- serverUrl: "",
116
- movesUrl: "",
117
- movesAccessKey: "",
118
- token: "",
119
- loginMethod: "",
120
- subscriptionUrl: "",
121
- newUser: false,
122
- beginner: true,
123
- fairPlay: false,
124
- plan: "", // Not a friend
125
- hasPaid: false,
126
- ready: true,
127
- readyTimed: true,
128
- uiFullscreen: true,
129
- uiLandscape: false,
130
- runningLocal: false,
131
- };
132
- const makeServerUrls = (backendUrl, movesUrl) => {
133
- // If the last character of the url is a slash, cut it off,
134
- // since path URLs always start with a slash
135
- const cleanupUrl = (url) => {
136
- if (url.length > 0 && url[url.length - 1] === "/") {
137
- url = url.slice(0, -1);
138
- }
139
- return url;
140
- };
141
- return {
142
- serverUrl: cleanupUrl(backendUrl),
143
- movesUrl: cleanupUrl(movesUrl),
144
- };
145
- };
146
- const makeGlobalState = (overrides) => {
147
- const state = {
148
- ...DEFAULT_STATE,
149
- ...overrides,
150
- };
151
- const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
152
- // Apply any persisted authentication settings from sessionStorage
153
- return applyPersistedSettings(stateWithUrls);
154
- };
155
-
156
156
  function getDefaultExportFromCjs (x) {
157
157
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
158
158
  }
@@ -2533,6 +2533,133 @@ const ERROR_MESSAGES = {
2533
2533
  "server": "Netþjónn gat ekki tekið við leiknum - reyndu aftur"
2534
2534
  };
2535
2535
 
2536
+ /*
2537
+
2538
+ Audio.ts
2539
+
2540
+ Audio management service for Netskrafl/Explo
2541
+
2542
+ Copyright (C) 2025 Miðeind ehf.
2543
+ Author: Vilhjálmur Þorsteinsson
2544
+
2545
+ The Creative Commons Attribution-NonCommercial 4.0
2546
+ International Public License (CC-BY-NC 4.0) applies to this software.
2547
+ For further information, see https://github.com/mideind/Netskrafl
2548
+
2549
+ */
2550
+ /**
2551
+ * AudioManager handles preloading and playback of sound effects.
2552
+ * It creates HTMLAudioElement instances for each sound and manages their lifecycle.
2553
+ */
2554
+ class AudioManager {
2555
+ constructor(state, soundUrls) {
2556
+ this.sounds = new Map();
2557
+ this.initialized = false;
2558
+ // By default, sound URLs are based on /static on the backend server
2559
+ const DEFAULT_SOUND_BASE = serverUrl(state, "/static");
2560
+ const DEFAULT_SOUND_URLS = {
2561
+ "your-turn": `${DEFAULT_SOUND_BASE}/your-turn.mp3`,
2562
+ "you-win": `${DEFAULT_SOUND_BASE}/you-win.mp3`,
2563
+ "new-msg": `${DEFAULT_SOUND_BASE}/new-msg.mp3`,
2564
+ };
2565
+ // Merge provided URLs with defaults
2566
+ this.soundUrls = {
2567
+ ...DEFAULT_SOUND_URLS,
2568
+ ...(soundUrls || {}),
2569
+ };
2570
+ }
2571
+ /**
2572
+ * Initialize the audio manager by creating and preloading audio elements.
2573
+ * This should be called once when the application starts.
2574
+ */
2575
+ initialize() {
2576
+ if (this.initialized) {
2577
+ return;
2578
+ }
2579
+ // Create audio elements for each sound
2580
+ Object.entries(this.soundUrls).forEach(([soundId, url]) => {
2581
+ const audio = new Audio(url);
2582
+ audio.preload = "auto";
2583
+ // Handle load errors gracefully - don't let them crash the app
2584
+ audio.addEventListener("error", () => {
2585
+ console.warn(`Failed to load audio: ${soundId} from ${url}`);
2586
+ });
2587
+ this.sounds.set(soundId, audio);
2588
+ });
2589
+ this.initialized = true;
2590
+ }
2591
+ /**
2592
+ * Play a sound by its ID.
2593
+ * If the sound is not loaded or fails to play, the error is logged but doesn't throw.
2594
+ *
2595
+ * @param soundId The identifier of the sound to play
2596
+ */
2597
+ play(soundId) {
2598
+ if (!this.initialized) {
2599
+ this.initialize();
2600
+ }
2601
+ const audio = this.sounds.get(soundId);
2602
+ if (!audio) {
2603
+ console.warn(`Audio not found: ${soundId}`);
2604
+ return;
2605
+ }
2606
+ // Reset to start in case it's already playing
2607
+ audio.currentTime = 0;
2608
+ // Play the audio - catch any errors (e.g., user hasn't interacted with page yet)
2609
+ audio.play().catch((err) => {
2610
+ // This is expected in some cases (e.g., autoplay restrictions)
2611
+ // so we just log it at debug level
2612
+ if (err.name !== "NotAllowedError") {
2613
+ console.warn(`Failed to play audio ${soundId}:`, err);
2614
+ }
2615
+ });
2616
+ }
2617
+ /**
2618
+ * Update the URL for a specific sound.
2619
+ * This will recreate the audio element with the new URL.
2620
+ *
2621
+ * @param soundId The identifier of the sound to update
2622
+ * @param url The new URL for the sound
2623
+ */
2624
+ updateSoundUrl(soundId, url) {
2625
+ this.soundUrls[soundId] = url;
2626
+ // If already initialized, recreate this audio element
2627
+ if (this.initialized) {
2628
+ const audio = new Audio(url);
2629
+ audio.preload = "auto";
2630
+ audio.addEventListener("error", () => {
2631
+ console.warn(`Failed to load audio: ${soundId} from ${url}`);
2632
+ });
2633
+ this.sounds.set(soundId, audio);
2634
+ }
2635
+ }
2636
+ /**
2637
+ * Dispose of all audio elements and clean up resources.
2638
+ */
2639
+ dispose() {
2640
+ this.sounds.forEach((audio) => {
2641
+ audio.pause();
2642
+ audio.src = "";
2643
+ });
2644
+ this.sounds.clear();
2645
+ this.initialized = false;
2646
+ }
2647
+ }
2648
+ // Global singleton instance
2649
+ let audioManager = null;
2650
+ /**
2651
+ * Get or create the global AudioManager instance.
2652
+ *
2653
+ * @param soundUrls Optional custom sound URLs (only used on first call)
2654
+ * @returns The global AudioManager instance
2655
+ */
2656
+ function getAudioManager(state, soundUrls) {
2657
+ if (!audioManager) {
2658
+ audioManager = new AudioManager(state, soundUrls);
2659
+ }
2660
+ return audioManager;
2661
+ }
2662
+
2536
2663
  /*
2537
2664
 
2538
2665
  Util.ts
@@ -2689,11 +2816,10 @@ function setInput(id, val) {
2689
2816
  const elem = document.getElementById(id);
2690
2817
  elem.value = val;
2691
2818
  }
2692
- function playAudio(elemId) {
2693
- // Play an audio file
2694
- const sound = document.getElementById(elemId);
2695
- if (sound)
2696
- sound.play();
2819
+ function playAudio(state, soundId) {
2820
+ // Play an audio file using the AudioManager
2821
+ const audioManager = getAudioManager(state);
2822
+ audioManager.play(soundId);
2697
2823
  }
2698
2824
  function arrayEqual(a, b) {
2699
2825
  // Return true if arrays a and b are equal
@@ -27905,11 +28031,12 @@ const TogglerReadyTimed = (initialVnode) => {
27905
28031
  }
27906
28032
  };
27907
28033
  };
27908
- const TogglerAudio = () => {
28034
+ const TogglerAudio = (initialVnode) => {
27909
28035
  // Toggle for audio on/off
28036
+ const { model } = initialVnode.attrs.view;
27910
28037
  function toggleFunc(state) {
27911
- if (state)
27912
- playAudio("your-turn");
28038
+ if (state && model.state !== null)
28039
+ playAudio(model.state, "your-turn");
27913
28040
  }
27914
28041
  return {
27915
28042
  view: ({ attrs: { state, tabindex } }) => m(Toggler, {
@@ -27924,11 +28051,12 @@ const TogglerAudio = () => {
27924
28051
  })
27925
28052
  };
27926
28053
  };
27927
- const TogglerFanfare = () => {
28054
+ const TogglerFanfare = (initialVnode) => {
27928
28055
  // Toggle for fanfare on/off
28056
+ const { model } = initialVnode.attrs.view;
27929
28057
  function toggleFunc(state) {
27930
- if (state)
27931
- playAudio("you-win");
28058
+ if (state && model.state !== null)
28059
+ playAudio(model.state, "you-win");
27932
28060
  }
27933
28061
  return {
27934
28062
  view: ({ attrs: { state, tabindex } }) => m(Toggler, {
@@ -29075,8 +29203,7 @@ class Game extends BaseGame {
29075
29203
  // Ongoing timed game: start the clock
29076
29204
  this.startClock();
29077
29205
  // Kick off loading of chat messages, if this is not a robot game
29078
- const isHumanGame = !this.autoplayer[0] && !this.autoplayer[1];
29079
- if (isHumanGame)
29206
+ if (!this.isRobotGame())
29080
29207
  this.loadMessages();
29081
29208
  }
29082
29209
  init(srvGame) {
@@ -29234,6 +29361,8 @@ class Game extends BaseGame {
29234
29361
  // Update the srvGame state with data from the server,
29235
29362
  // either after submitting a move to the server or
29236
29363
  // after receiving a move notification via the Firebase listener
29364
+ // Remember if the game was already won before this update
29365
+ const wasWon = this.congratulate;
29237
29366
  // Stop highlighting the previous opponent move, if any
29238
29367
  for (let sq in this.tiles)
29239
29368
  if (this.tiles.hasOwnProperty(sq))
@@ -29254,6 +29383,10 @@ class Game extends BaseGame {
29254
29383
  // The call to resetClock() clears any outstanding interval timers
29255
29384
  // if the srvGame is now over
29256
29385
  this.resetClock();
29386
+ // Notify the move listener if the game just transitioned to won
29387
+ if (!wasWon && this.congratulate && this.moveListener) {
29388
+ this.moveListener.notifyGameWon();
29389
+ }
29257
29390
  }
29258
29391
  ;
29259
29392
  async refresh() {
@@ -29458,6 +29591,10 @@ class Game extends BaseGame {
29458
29591
  // actual game score minus accrued time penalty, if any, in a timed game
29459
29592
  return Math.max(this.scores[player] + (player === 0 ? this.penalty0 : this.penalty1), 0);
29460
29593
  }
29594
+ isRobotGame() {
29595
+ // Return true if any player in the game is a robot
29596
+ return this.autoplayer[0] || this.autoplayer[1];
29597
+ }
29461
29598
  async loadMessages() {
29462
29599
  // Load chat messages for this game
29463
29600
  if (this.chatLoading)
@@ -30993,9 +31130,17 @@ class Model {
30993
31130
  m.redraw();
30994
31131
  }
30995
31132
  handleMoveMessage(json, firstAttach) {
31133
+ var _a;
30996
31134
  // Handle an incoming Firebase move message
30997
31135
  if (!firstAttach && this.game) {
30998
31136
  this.game.update(json);
31137
+ // Play "your turn" audio notification if:
31138
+ // - User has audio enabled
31139
+ // - User is a participant in the game
31140
+ // - This is not a robot game (robots reply instantly anyway)
31141
+ if (((_a = this.user) === null || _a === void 0 ? void 0 : _a.audio) && this.game.player !== null && !this.game.isRobotGame()) {
31142
+ playAudio(this.state, "your-turn");
31143
+ }
30999
31144
  m.redraw();
31000
31145
  }
31001
31146
  }
@@ -31006,6 +31151,14 @@ class Model {
31006
31151
  this.gameList = null;
31007
31152
  }
31008
31153
  }
31154
+ notifyGameWon() {
31155
+ var _a;
31156
+ // The user just won a game:
31157
+ // play the "you-win" audio if fanfare is enabled
31158
+ if ((_a = this.user) === null || _a === void 0 ? void 0 : _a.fanfare) {
31159
+ playAudio(this.state, "you-win");
31160
+ }
31161
+ }
31009
31162
  moreGamesAllowed() {
31010
31163
  // Return true if the user is allowed to have more games ongoing
31011
31164
  if (!this.state)
@@ -32717,7 +32870,7 @@ const GamePromptDialogs = (initialVnode) => {
32717
32870
  // they can be invoked while the last_chall dialog is being
32718
32871
  // displayed. We therefore allow them to cover the last_chall
32719
32872
  // dialog. On mobile, both dialogs are displayed simultaneously.
32720
- if (game.last_chall) {
32873
+ if (game.last_chall && game.localturn) {
32721
32874
  r.push(m(".chall-info", [
32722
32875
  glyph("info-sign"), nbsp(),
32723
32876
  // "Your opponent emptied the rack - you can challenge or pass"
@@ -34334,7 +34487,7 @@ const Tab = {
34334
34487
  const game = view.model.game;
34335
34488
  return m(".right-tab" + (sel === tabid ? ".selected" : ""), {
34336
34489
  id: "tab-" + tabid,
34337
- className: alert ? "alert" : "",
34490
+ className: alert ? "chat-alert" : "",
34338
34491
  title: title,
34339
34492
  onclick: (ev) => {
34340
34493
  // Select this tab
@@ -34356,7 +34509,7 @@ const TabGroup = {
34356
34509
  // A group of clickable tabs for the right-side area content
34357
34510
  const { view } = vnode.attrs;
34358
34511
  const { game } = view.model;
34359
- const showChat = game && !(game.autoplayer[0] || game.autoplayer[1]);
34512
+ const showChat = game && !game.isRobotGame();
34360
34513
  const r = [
34361
34514
  m(Tab, { view, tabid: "board", title: ts("Borðið"), icon: "grid" }),
34362
34515
  m(Tab, { view, tabid: "movelist", title: ts("Leikir"), icon: "show-lines" }),
@@ -34378,7 +34531,7 @@ const TabGroup = {
34378
34531
  },
34379
34532
  // Show chat icon in red if any chat messages have not been seen
34380
34533
  // and the chat tab is not already selected
34381
- alert: !game.chatSeen && view.selectedTab != "chat"
34534
+ alert: !game.chatSeen && view.selectedTab !== "chat"
34382
34535
  }));
34383
34536
  }
34384
34537
  return m.fragment({}, r);
@@ -35080,6 +35233,9 @@ class View {
35080
35233
  this.actions = actions;
35081
35234
  // Initialize media listeners now that we have the view reference
35082
35235
  this.actions.initMediaListener(this);
35236
+ // Load user preferences early so audio settings are available
35237
+ // Use false to not show spinner on initial load
35238
+ this.model.loadUser(false);
35083
35239
  }
35084
35240
  appView(routeName) {
35085
35241
  // Returns a view based on the current route.
@@ -35512,6 +35668,7 @@ class Actions {
35512
35668
  this.model.handleUserMessage(json, firstAttach);
35513
35669
  }
35514
35670
  onChatMessage(json, firstAttach, view) {
35671
+ var _a, _b, _c;
35515
35672
  // Handle an incoming chat message
35516
35673
  if (firstAttach)
35517
35674
  console.log("First attach of chat: " + JSON.stringify(json));
@@ -35520,6 +35677,13 @@ class Actions {
35520
35677
  if (this.model.addChatMessage(json.game, json.from_userid, json.msg, json.ts)) {
35521
35678
  // A chat message was successfully added
35522
35679
  view.notifyChatMessage();
35680
+ // Play audio notification if:
35681
+ // - User has audio enabled
35682
+ // - Message is from opponent (not from current user)
35683
+ const userId = (_b = (_a = this.model.state) === null || _a === void 0 ? void 0 : _a.userId) !== null && _b !== void 0 ? _b : "";
35684
+ if (((_c = this.model.user) === null || _c === void 0 ? void 0 : _c.audio) && json.from_userid !== userId) {
35685
+ playAudio(this.model.state, "new-msg");
35686
+ }
35523
35687
  }
35524
35688
  }
35525
35689
  }