@mideind/netskrafl-react 1.0.1 → 1.1.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/cjs/css/netskrafl.css +888 -52
- package/dist/cjs/index.js +1135 -147
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/css/netskrafl.css +888 -52
- package/dist/esm/index.js +1135 -147
- package/dist/esm/index.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1,6 +1,101 @@
|
|
|
1
1
|
import { jsx } from 'react/jsx-runtime';
|
|
2
2
|
import React, { useEffect } from 'react';
|
|
3
3
|
|
|
4
|
+
// Key for storing auth settings in sessionStorage
|
|
5
|
+
const AUTH_SETTINGS_KEY = "netskrafl_auth_settings";
|
|
6
|
+
// Save authentication settings to sessionStorage
|
|
7
|
+
const saveAuthSettings = (settings) => {
|
|
8
|
+
if (!settings) {
|
|
9
|
+
clearAuthSettings();
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
// Filter to only include properties defined in PersistedAuthSettings interface
|
|
14
|
+
const filteredSettings = {
|
|
15
|
+
userEmail: settings.userEmail, // Required field
|
|
16
|
+
};
|
|
17
|
+
// Only add optional fields if they are defined
|
|
18
|
+
if (settings.account !== undefined)
|
|
19
|
+
filteredSettings.account = settings.account;
|
|
20
|
+
if (settings.userId !== undefined)
|
|
21
|
+
filteredSettings.userId = settings.userId;
|
|
22
|
+
if (settings.userNick !== undefined)
|
|
23
|
+
filteredSettings.userNick = settings.userNick;
|
|
24
|
+
if (settings.firebaseAPIKey !== undefined)
|
|
25
|
+
filteredSettings.firebaseAPIKey = settings.firebaseAPIKey;
|
|
26
|
+
if (settings.beginner !== undefined)
|
|
27
|
+
filteredSettings.beginner = settings.beginner;
|
|
28
|
+
if (settings.fairPlay !== undefined)
|
|
29
|
+
filteredSettings.fairPlay = settings.fairPlay;
|
|
30
|
+
if (settings.ready !== undefined)
|
|
31
|
+
filteredSettings.ready = settings.ready;
|
|
32
|
+
if (settings.readyTimed !== undefined)
|
|
33
|
+
filteredSettings.readyTimed = settings.readyTimed;
|
|
34
|
+
// Only save if we have actual settings to persist
|
|
35
|
+
if (Object.keys(filteredSettings).length > 1) {
|
|
36
|
+
sessionStorage.setItem(AUTH_SETTINGS_KEY, JSON.stringify(filteredSettings));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
clearAuthSettings();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// SessionStorage might be unavailable or full
|
|
44
|
+
console.warn("Could not save auth settings to sessionStorage:", error);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
// Retrieve authentication settings from sessionStorage
|
|
48
|
+
const loadAuthSettings = () => {
|
|
49
|
+
try {
|
|
50
|
+
const stored = sessionStorage.getItem(AUTH_SETTINGS_KEY);
|
|
51
|
+
if (stored) {
|
|
52
|
+
return JSON.parse(stored);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// SessionStorage might be unavailable or data might be corrupted
|
|
57
|
+
console.warn("Could not load auth settings from sessionStorage:", error);
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
// Clear authentication settings from sessionStorage
|
|
62
|
+
const clearAuthSettings = () => {
|
|
63
|
+
try {
|
|
64
|
+
sessionStorage.removeItem(AUTH_SETTINGS_KEY);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
console.warn("Could not clear auth settings from sessionStorage:", error);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
// Apply persisted settings to a GlobalState object
|
|
71
|
+
const applyPersistedSettings = (state) => {
|
|
72
|
+
var _a, _b, _c, _d;
|
|
73
|
+
const persisted = loadAuthSettings();
|
|
74
|
+
if (!persisted) {
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
// CRITICAL SECURITY CHECK: Only apply persisted settings if they belong to the current user
|
|
78
|
+
// This prevents data leakage between different users in the same browser session
|
|
79
|
+
if (persisted.userEmail !== state.userEmail) {
|
|
80
|
+
// Different user detected - clear the old user's settings
|
|
81
|
+
clearAuthSettings();
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
// Apply persisted settings, but don't override values explicitly passed in props
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
// Only apply persisted values if current values are defaults
|
|
88
|
+
account: state.account || persisted.account || state.userId, // Use userId as fallback
|
|
89
|
+
userId: state.userId || persisted.userId || state.userId,
|
|
90
|
+
userNick: state.userNick || persisted.userNick || state.userNick,
|
|
91
|
+
firebaseAPIKey: state.firebaseAPIKey || persisted.firebaseAPIKey || state.firebaseAPIKey,
|
|
92
|
+
beginner: (_a = persisted.beginner) !== null && _a !== void 0 ? _a : state.beginner,
|
|
93
|
+
fairPlay: (_b = persisted.fairPlay) !== null && _b !== void 0 ? _b : state.fairPlay,
|
|
94
|
+
ready: (_c = persisted.ready) !== null && _c !== void 0 ? _c : state.ready,
|
|
95
|
+
readyTimed: (_d = persisted.readyTimed) !== null && _d !== void 0 ? _d : state.readyTimed,
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
4
99
|
const DEFAULT_STATE = {
|
|
5
100
|
projectId: "netskrafl",
|
|
6
101
|
firebaseAPIKey: "",
|
|
@@ -8,6 +103,7 @@ const DEFAULT_STATE = {
|
|
|
8
103
|
firebaseSenderId: "",
|
|
9
104
|
firebaseAppId: "",
|
|
10
105
|
measurementId: "",
|
|
106
|
+
account: "",
|
|
11
107
|
userEmail: "",
|
|
12
108
|
userId: "",
|
|
13
109
|
userNick: "",
|
|
@@ -21,12 +117,12 @@ const DEFAULT_STATE = {
|
|
|
21
117
|
loginUrl: "",
|
|
22
118
|
loginMethod: "",
|
|
23
119
|
newUser: false,
|
|
24
|
-
beginner:
|
|
25
|
-
fairPlay:
|
|
120
|
+
beginner: true,
|
|
121
|
+
fairPlay: false,
|
|
26
122
|
plan: "", // Not a friend
|
|
27
123
|
hasPaid: false,
|
|
28
|
-
ready:
|
|
29
|
-
readyTimed:
|
|
124
|
+
ready: true,
|
|
125
|
+
readyTimed: true,
|
|
30
126
|
uiFullscreen: true,
|
|
31
127
|
uiLandscape: false,
|
|
32
128
|
runningLocal: false,
|
|
@@ -50,7 +146,9 @@ const makeGlobalState = (overrides) => {
|
|
|
50
146
|
...DEFAULT_STATE,
|
|
51
147
|
...overrides,
|
|
52
148
|
};
|
|
53
|
-
|
|
149
|
+
const stateWithUrls = { ...state, ...makeServerUrls(state.serverUrl, state.movesUrl) };
|
|
150
|
+
// Apply any persisted authentication settings from sessionStorage
|
|
151
|
+
return applyPersistedSettings(stateWithUrls);
|
|
54
152
|
};
|
|
55
153
|
|
|
56
154
|
function getDefaultExportFromCjs (x) {
|
|
@@ -23435,6 +23533,9 @@ let View$1 = class View {
|
|
|
23435
23533
|
function viewGetServerCache(view) {
|
|
23436
23534
|
return view.viewCache_.serverCache.getNode();
|
|
23437
23535
|
}
|
|
23536
|
+
function viewGetCompleteNode(view) {
|
|
23537
|
+
return viewCacheGetCompleteEventSnap(view.viewCache_);
|
|
23538
|
+
}
|
|
23438
23539
|
function viewGetCompleteServerCache(view, path) {
|
|
23439
23540
|
const cache = viewCacheGetCompleteServerSnap(view.viewCache_);
|
|
23440
23541
|
if (cache) {
|
|
@@ -24096,6 +24197,33 @@ function syncTreeCalcCompleteEventCache(syncTree, path, writeIdsToExclude) {
|
|
|
24096
24197
|
});
|
|
24097
24198
|
return writeTreeCalcCompleteEventCache(writeTree, path, serverCache, writeIdsToExclude, includeHiddenSets);
|
|
24098
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
|
+
}
|
|
24099
24227
|
/**
|
|
24100
24228
|
* A helper method that visits all descendant and ancestor SyncPoints, applying the operation.
|
|
24101
24229
|
*
|
|
@@ -25225,6 +25353,63 @@ function repoUpdateInfo(repo, pathString, value) {
|
|
|
25225
25353
|
function repoGetNextWriteId(repo) {
|
|
25226
25354
|
return repo.nextWriteId_++;
|
|
25227
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
|
+
}
|
|
25228
25413
|
function repoSetWithPriority(repo, path, newVal, newPriority, onComplete) {
|
|
25229
25414
|
repoLog(repo, 'set', {
|
|
25230
25415
|
path: path.toString(),
|
|
@@ -26641,6 +26826,22 @@ function set(ref, value) {
|
|
|
26641
26826
|
/*priority=*/ null, deferred.wrapCallback(() => { }));
|
|
26642
26827
|
return deferred.promise;
|
|
26643
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
|
+
}
|
|
26644
26845
|
/**
|
|
26645
26846
|
* Represents registration for 'value' events.
|
|
26646
26847
|
*/
|
|
@@ -27144,6 +27345,14 @@ function logEvent(ev, params) {
|
|
|
27144
27345
|
return;
|
|
27145
27346
|
logEvent$2(analytics, ev, params);
|
|
27146
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
|
+
}
|
|
27147
27356
|
|
|
27148
27357
|
/*
|
|
27149
27358
|
|
|
@@ -27403,29 +27612,67 @@ class AuthenticationError extends Error {
|
|
|
27403
27612
|
}
|
|
27404
27613
|
// Internal function to ensure authentication
|
|
27405
27614
|
const ensureAuthenticated = async (state) => {
|
|
27615
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
27406
27616
|
// If login is already in progress, wait for it to complete
|
|
27407
27617
|
if (authPromise) {
|
|
27408
27618
|
await authPromise;
|
|
27409
27619
|
return;
|
|
27410
27620
|
}
|
|
27411
|
-
|
|
27412
|
-
|
|
27413
|
-
|
|
27414
|
-
|
|
27415
|
-
|
|
27416
|
-
|
|
27417
|
-
|
|
27418
|
-
|
|
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);
|
|
27419
27671
|
}
|
|
27420
|
-
|
|
27421
|
-
|
|
27672
|
+
finally {
|
|
27673
|
+
// Reset the promise so future 401s can trigger a new login
|
|
27674
|
+
authPromise = null;
|
|
27422
27675
|
}
|
|
27423
|
-
// Success: Log in to Firebase with the token passed from the server
|
|
27424
|
-
await loginFirebase(state, result.firebase_token);
|
|
27425
|
-
}
|
|
27426
|
-
finally {
|
|
27427
|
-
// Reset the promise so future 401s can trigger a new login
|
|
27428
|
-
authPromise = null;
|
|
27429
27676
|
}
|
|
27430
27677
|
};
|
|
27431
27678
|
// Internal authenticated request function
|
|
@@ -34231,6 +34478,161 @@ class Game extends BaseGame {
|
|
|
34231
34478
|
;
|
|
34232
34479
|
} // class Game
|
|
34233
34480
|
|
|
34481
|
+
/*
|
|
34482
|
+
|
|
34483
|
+
riddlePersistence.ts
|
|
34484
|
+
|
|
34485
|
+
Local persistence for Gáta Dagsins using localStorage
|
|
34486
|
+
|
|
34487
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
34488
|
+
|
|
34489
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
34490
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
34491
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
34492
|
+
|
|
34493
|
+
*/
|
|
34494
|
+
class RiddlePersistence {
|
|
34495
|
+
// Generate storage key for a specific user and date
|
|
34496
|
+
static getStorageKey(userId, date) {
|
|
34497
|
+
return `${this.STORAGE_KEY_PREFIX}${date}_${userId}`;
|
|
34498
|
+
}
|
|
34499
|
+
// Save complete move list to localStorage
|
|
34500
|
+
static saveLocalMoves(userId, date, moves) {
|
|
34501
|
+
if (!userId || !date) {
|
|
34502
|
+
return;
|
|
34503
|
+
}
|
|
34504
|
+
const data = {
|
|
34505
|
+
date,
|
|
34506
|
+
moves,
|
|
34507
|
+
timestamp: new Date().toISOString(),
|
|
34508
|
+
userId,
|
|
34509
|
+
};
|
|
34510
|
+
try {
|
|
34511
|
+
const key = this.getStorageKey(userId, date);
|
|
34512
|
+
localStorage.setItem(key, JSON.stringify(data));
|
|
34513
|
+
// Clean up old entries while we're here
|
|
34514
|
+
this.cleanupOldEntries();
|
|
34515
|
+
}
|
|
34516
|
+
catch (e) {
|
|
34517
|
+
// Handle localStorage quota errors silently
|
|
34518
|
+
console.error('Failed to save moves to localStorage:', e);
|
|
34519
|
+
}
|
|
34520
|
+
}
|
|
34521
|
+
// Load move history from localStorage
|
|
34522
|
+
static loadLocalMoves(userId, date) {
|
|
34523
|
+
if (!userId || !date) {
|
|
34524
|
+
return [];
|
|
34525
|
+
}
|
|
34526
|
+
try {
|
|
34527
|
+
const key = this.getStorageKey(userId, date);
|
|
34528
|
+
const stored = localStorage.getItem(key);
|
|
34529
|
+
if (!stored) {
|
|
34530
|
+
return [];
|
|
34531
|
+
}
|
|
34532
|
+
const data = JSON.parse(stored);
|
|
34533
|
+
// Verify that this data belongs to the correct (current) user
|
|
34534
|
+
if (!data.userId || data.userId !== userId) {
|
|
34535
|
+
return [];
|
|
34536
|
+
}
|
|
34537
|
+
return data.moves || [];
|
|
34538
|
+
}
|
|
34539
|
+
catch (e) {
|
|
34540
|
+
console.error('Failed to load moves from localStorage:', e);
|
|
34541
|
+
return [];
|
|
34542
|
+
}
|
|
34543
|
+
}
|
|
34544
|
+
// Check if user has achieved top score (local check)
|
|
34545
|
+
static hasAchievedTopScore(userId, date, topScore) {
|
|
34546
|
+
const moves = this.loadLocalMoves(userId, date);
|
|
34547
|
+
return moves.some(move => move.score >= topScore);
|
|
34548
|
+
}
|
|
34549
|
+
// Get the best move from localStorage
|
|
34550
|
+
static getBestLocalMove(userId, date) {
|
|
34551
|
+
const moves = this.loadLocalMoves(userId, date);
|
|
34552
|
+
if (moves.length === 0) {
|
|
34553
|
+
return null;
|
|
34554
|
+
}
|
|
34555
|
+
// Find the move with the highest score
|
|
34556
|
+
return moves.reduce((best, current) => current.score > best.score ? current : best);
|
|
34557
|
+
}
|
|
34558
|
+
// Clean up entries older than MAX_AGE_DAYS
|
|
34559
|
+
static cleanupOldEntries() {
|
|
34560
|
+
try {
|
|
34561
|
+
const now = new Date();
|
|
34562
|
+
const cutoffDate = new Date(now);
|
|
34563
|
+
cutoffDate.setDate(cutoffDate.getDate() - this.MAX_AGE_DAYS);
|
|
34564
|
+
const keysToRemove = [];
|
|
34565
|
+
// Iterate through localStorage keys
|
|
34566
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
34567
|
+
const key = localStorage.key(i);
|
|
34568
|
+
if (key && key.startsWith(this.STORAGE_KEY_PREFIX)) {
|
|
34569
|
+
// Extract date from key: "gata_YYYY-MM-DD_userId"
|
|
34570
|
+
const parts = key.split('_');
|
|
34571
|
+
if (parts.length >= 2) {
|
|
34572
|
+
const dateStr = parts[1];
|
|
34573
|
+
const entryDate = new Date(dateStr);
|
|
34574
|
+
if (!isNaN(entryDate.getTime()) && entryDate < cutoffDate) {
|
|
34575
|
+
keysToRemove.push(key);
|
|
34576
|
+
}
|
|
34577
|
+
}
|
|
34578
|
+
}
|
|
34579
|
+
}
|
|
34580
|
+
// Remove old entries
|
|
34581
|
+
keysToRemove.forEach(key => localStorage.removeItem(key));
|
|
34582
|
+
}
|
|
34583
|
+
catch (e) {
|
|
34584
|
+
console.error('Failed to cleanup old entries:', e);
|
|
34585
|
+
}
|
|
34586
|
+
}
|
|
34587
|
+
// Clear all persistence for a specific user
|
|
34588
|
+
static clearUserData(userId) {
|
|
34589
|
+
const keysToRemove = [];
|
|
34590
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
34591
|
+
const key = localStorage.key(i);
|
|
34592
|
+
if (key && key.startsWith(this.STORAGE_KEY_PREFIX) && key.endsWith(`_${userId}`)) {
|
|
34593
|
+
keysToRemove.push(key);
|
|
34594
|
+
}
|
|
34595
|
+
}
|
|
34596
|
+
keysToRemove.forEach(key => localStorage.removeItem(key));
|
|
34597
|
+
}
|
|
34598
|
+
// === Firebase Read Methods ===
|
|
34599
|
+
// Note: All Firebase write operations (achievements, stats, global best, leaderboard)
|
|
34600
|
+
// are now handled by the backend server in the /gatadagsins/submit endpoint.
|
|
34601
|
+
// The client only handles localStorage persistence and Firebase reads for display.
|
|
34602
|
+
// Get leaderboard for a specific date
|
|
34603
|
+
static async getLeaderboard(date, locale, limit = 10) {
|
|
34604
|
+
try {
|
|
34605
|
+
const leadersPath = `gatadagsins/${date}/${locale}/leaders`;
|
|
34606
|
+
const leaders = await getFirebaseData(leadersPath);
|
|
34607
|
+
if (!leaders) {
|
|
34608
|
+
return [];
|
|
34609
|
+
}
|
|
34610
|
+
// Convert object to array and sort by score
|
|
34611
|
+
const entries = Object.values(leaders);
|
|
34612
|
+
entries.sort((a, b) => b.score - a.score);
|
|
34613
|
+
return entries.slice(0, limit);
|
|
34614
|
+
}
|
|
34615
|
+
catch (error) {
|
|
34616
|
+
console.error('Failed to get leaderboard:', error);
|
|
34617
|
+
return [];
|
|
34618
|
+
}
|
|
34619
|
+
}
|
|
34620
|
+
// Get user's streak statistics
|
|
34621
|
+
static async getUserStats(userId, locale) {
|
|
34622
|
+
try {
|
|
34623
|
+
const statsPath = `gatadagsins/users/${locale}/${userId}/stats`;
|
|
34624
|
+
const stats = await getFirebaseData(statsPath);
|
|
34625
|
+
return stats;
|
|
34626
|
+
}
|
|
34627
|
+
catch (error) {
|
|
34628
|
+
console.error('Failed to get user stats:', error);
|
|
34629
|
+
return null;
|
|
34630
|
+
}
|
|
34631
|
+
}
|
|
34632
|
+
}
|
|
34633
|
+
RiddlePersistence.STORAGE_KEY_PREFIX = 'gata_';
|
|
34634
|
+
RiddlePersistence.MAX_AGE_DAYS = 30;
|
|
34635
|
+
|
|
34234
34636
|
/*
|
|
34235
34637
|
|
|
34236
34638
|
Riddle.ts
|
|
@@ -34245,8 +34647,8 @@ class Game extends BaseGame {
|
|
|
34245
34647
|
For further information, see https://github.com/mideind/Netskrafl
|
|
34246
34648
|
|
|
34247
34649
|
*/
|
|
34248
|
-
const HOT_WARM_BOUNDARY_RATIO = 0.
|
|
34249
|
-
const WARM_COLD_BOUNDARY_RATIO = 0.
|
|
34650
|
+
const HOT_WARM_BOUNDARY_RATIO = 0.5;
|
|
34651
|
+
const WARM_COLD_BOUNDARY_RATIO = 0.25;
|
|
34250
34652
|
class Riddle extends BaseGame {
|
|
34251
34653
|
constructor(uuid, date, model) {
|
|
34252
34654
|
if (!model.state) {
|
|
@@ -34318,6 +34720,22 @@ class Riddle extends BaseGame {
|
|
|
34318
34720
|
this.hotBoundary = this.bestPossibleScore * HOT_WARM_BOUNDARY_RATIO;
|
|
34319
34721
|
// Initialize word checker
|
|
34320
34722
|
wordChecker.ingestTwoLetterWords(this.locale, this.two_letter_words[0]);
|
|
34723
|
+
// Load persisted player moves from localStorage
|
|
34724
|
+
if (state.userId) {
|
|
34725
|
+
const persistedMoves = RiddlePersistence.loadLocalMoves(state.userId, date);
|
|
34726
|
+
if (persistedMoves.length > 0) {
|
|
34727
|
+
// Convert from IPlayerMove to RiddleWord format, preserving timestamps
|
|
34728
|
+
this.playerMoves = persistedMoves.map(move => ({
|
|
34729
|
+
word: move.word,
|
|
34730
|
+
score: move.score,
|
|
34731
|
+
coord: move.coord,
|
|
34732
|
+
timestamp: move.timestamp || new Date().toISOString() // Use stored timestamp or fallback
|
|
34733
|
+
}));
|
|
34734
|
+
// Update personal best score from persisted moves
|
|
34735
|
+
const bestMove = persistedMoves.reduce((best, current) => current.score > best.score ? current : best);
|
|
34736
|
+
this.personalBestScore = bestMove.score;
|
|
34737
|
+
}
|
|
34738
|
+
}
|
|
34321
34739
|
}
|
|
34322
34740
|
}
|
|
34323
34741
|
catch (error) {
|
|
@@ -34338,6 +34756,7 @@ class Riddle extends BaseGame {
|
|
|
34338
34756
|
locale: this.locale,
|
|
34339
34757
|
userId: state.userId,
|
|
34340
34758
|
groupId: state.userGroupId || null,
|
|
34759
|
+
userDisplayName: state.userFullname || state.userNick || state.userId,
|
|
34341
34760
|
move,
|
|
34342
34761
|
}
|
|
34343
34762
|
});
|
|
@@ -34386,13 +34805,26 @@ class Riddle extends BaseGame {
|
|
|
34386
34805
|
// If the move is not valid or was already played, return
|
|
34387
34806
|
if (!move)
|
|
34388
34807
|
return;
|
|
34389
|
-
|
|
34390
|
-
|
|
34391
|
-
|
|
34392
|
-
|
|
34393
|
-
|
|
34394
|
-
|
|
34395
|
-
|
|
34808
|
+
const { state } = this;
|
|
34809
|
+
if (!state || !state.userId)
|
|
34810
|
+
return;
|
|
34811
|
+
// Save all moves to localStorage (local backup/cache)
|
|
34812
|
+
// Convert RiddleWord[] to IPlayerMove[] for persistence
|
|
34813
|
+
const movesToSave = this.playerMoves.map(m => ({
|
|
34814
|
+
score: m.score,
|
|
34815
|
+
word: m.word,
|
|
34816
|
+
coord: m.coord,
|
|
34817
|
+
timestamp: m.timestamp
|
|
34818
|
+
}));
|
|
34819
|
+
RiddlePersistence.saveLocalMoves(state.userId, this.date, movesToSave);
|
|
34820
|
+
// If the move does not improve the personal best, we're done
|
|
34821
|
+
if (move.score <= this.personalBestScore)
|
|
34822
|
+
return;
|
|
34823
|
+
// This is the best score we've seen yet
|
|
34824
|
+
this.personalBestScore = move.score;
|
|
34825
|
+
// Submit to server; the server handles all Firebase updates
|
|
34826
|
+
// (achievements, stats, global best, leaderboard)
|
|
34827
|
+
this.submitRiddleWord(move);
|
|
34396
34828
|
}
|
|
34397
34829
|
updateGlobalBestScore(best) {
|
|
34398
34830
|
// Update the global best score, typically as a result
|
|
@@ -34619,6 +35051,9 @@ class Model {
|
|
|
34619
35051
|
this.game = null;
|
|
34620
35052
|
// The current Gáta Dagsins riddle, if any
|
|
34621
35053
|
this.riddle = null;
|
|
35054
|
+
// Gáta Dagsins-specific properties
|
|
35055
|
+
this.userStats = null;
|
|
35056
|
+
this.leaderboard = [];
|
|
34622
35057
|
// The current Netskrafl game list
|
|
34623
35058
|
this.gameList = null;
|
|
34624
35059
|
// Number of games where it's the player's turn, plus count of zombie games
|
|
@@ -35124,6 +35559,7 @@ class Model {
|
|
|
35124
35559
|
state.userNick = user.nickname;
|
|
35125
35560
|
state.beginner = user.beginner;
|
|
35126
35561
|
state.fairPlay = user.fairplay;
|
|
35562
|
+
saveAuthSettings(state);
|
|
35127
35563
|
}
|
|
35128
35564
|
// Note that state.plan is updated via a Firebase notification
|
|
35129
35565
|
// Give the game instance a chance to update its state
|
|
@@ -35158,41 +35594,22 @@ class Model {
|
|
|
35158
35594
|
return false;
|
|
35159
35595
|
}
|
|
35160
35596
|
handleUserMessage(json, firstAttach) {
|
|
35161
|
-
var _a;
|
|
35162
35597
|
// Handle an incoming Firebase user message, i.e. a message
|
|
35163
35598
|
// on the /user/[userid] path
|
|
35164
|
-
if (firstAttach || !this.state)
|
|
35599
|
+
if (firstAttach || !this.state || !json)
|
|
35165
35600
|
return;
|
|
35166
35601
|
let redraw = false;
|
|
35167
|
-
if (json.
|
|
35168
|
-
// Potential change of user friendship status
|
|
35169
|
-
const newFriend = json.friend ? true : false;
|
|
35170
|
-
if (this.user && this.user.friend != newFriend) {
|
|
35171
|
-
this.user.friend = newFriend;
|
|
35172
|
-
redraw = true;
|
|
35173
|
-
}
|
|
35174
|
-
}
|
|
35175
|
-
if (json.plan !== undefined) {
|
|
35602
|
+
if (typeof json.plan === "string") {
|
|
35176
35603
|
// Potential change of user subscription plan
|
|
35177
|
-
if (this.state.plan
|
|
35604
|
+
if (this.state.plan !== json.plan) {
|
|
35178
35605
|
this.state.plan = json.plan;
|
|
35179
35606
|
redraw = true;
|
|
35180
35607
|
}
|
|
35181
|
-
if (this.user && !this.user.friend && this.state.plan == "friend") {
|
|
35182
|
-
// plan == "friend" implies that user.friend should be true
|
|
35183
|
-
this.user.friend = true;
|
|
35184
|
-
redraw = true;
|
|
35185
|
-
}
|
|
35186
|
-
if (this.state.plan == "" && ((_a = this.user) === null || _a === void 0 ? void 0 : _a.friend)) {
|
|
35187
|
-
// Conversely, an empty plan string means that the user is not a friend
|
|
35188
|
-
this.user.friend = false;
|
|
35189
|
-
redraw = true;
|
|
35190
|
-
}
|
|
35191
35608
|
}
|
|
35192
35609
|
if (json.hasPaid !== undefined) {
|
|
35193
35610
|
// Potential change of payment status
|
|
35194
|
-
const newHasPaid = (this.state.plan
|
|
35195
|
-
if (this.state.hasPaid
|
|
35611
|
+
const newHasPaid = (this.state.plan !== "" && json.hasPaid) ? true : false;
|
|
35612
|
+
if (this.state.hasPaid !== newHasPaid) {
|
|
35196
35613
|
this.state.hasPaid = newHasPaid;
|
|
35197
35614
|
redraw = true;
|
|
35198
35615
|
}
|
|
@@ -35696,6 +36113,8 @@ class Actions {
|
|
|
35696
36113
|
url: "/setuserpref",
|
|
35697
36114
|
body: pref
|
|
35698
36115
|
}); // No result required or expected
|
|
36116
|
+
// Update the persisted settings in sessionStorage
|
|
36117
|
+
saveAuthSettings(this.model.state);
|
|
35699
36118
|
}
|
|
35700
36119
|
catch (e) {
|
|
35701
36120
|
// A future TODO might be to signal an error in the UI
|
|
@@ -35742,6 +36161,12 @@ class Actions {
|
|
|
35742
36161
|
if (state === null || state === void 0 ? void 0 : state.userGroupId) {
|
|
35743
36162
|
attachFirebaseListener(basePath + `group/${state.userGroupId}/best`, (json, firstAttach) => this.onRiddleGroupScoreUpdate(json, firstAttach));
|
|
35744
36163
|
}
|
|
36164
|
+
// Listen to global leaderboard
|
|
36165
|
+
attachFirebaseListener(basePath + "leaders", (json, firstAttach) => this.onLeaderboardUpdate(json, firstAttach));
|
|
36166
|
+
// Listen to user stats (if user is logged in)
|
|
36167
|
+
if (state === null || state === void 0 ? void 0 : state.userId) {
|
|
36168
|
+
attachFirebaseListener(`gatadagsins/users/${locale}/${state.userId}/stats`, (json, firstAttach) => this.onUserStatsUpdate(json, firstAttach));
|
|
36169
|
+
}
|
|
35745
36170
|
}
|
|
35746
36171
|
detachListenerFromRiddle(date, locale) {
|
|
35747
36172
|
const { state } = this.model;
|
|
@@ -35750,6 +36175,10 @@ class Actions {
|
|
|
35750
36175
|
if (state === null || state === void 0 ? void 0 : state.userGroupId) {
|
|
35751
36176
|
detachFirebaseListener(basePath + `group/${state.userGroupId}/best`);
|
|
35752
36177
|
}
|
|
36178
|
+
detachFirebaseListener(basePath + "leaders");
|
|
36179
|
+
if (state === null || state === void 0 ? void 0 : state.userId) {
|
|
36180
|
+
detachFirebaseListener(`gatadagsins/users/${locale}/${state.userId}/stats`);
|
|
36181
|
+
}
|
|
35753
36182
|
}
|
|
35754
36183
|
onRiddleGlobalScoreUpdate(json, firstAttach) {
|
|
35755
36184
|
const { riddle } = this.model;
|
|
@@ -35767,6 +36196,38 @@ class Actions {
|
|
|
35767
36196
|
riddle.updateGroupBestScore(json);
|
|
35768
36197
|
m.redraw();
|
|
35769
36198
|
}
|
|
36199
|
+
onLeaderboardUpdate(json, firstAttach) {
|
|
36200
|
+
if (!json || typeof json !== 'object') {
|
|
36201
|
+
this.model.leaderboard = [];
|
|
36202
|
+
}
|
|
36203
|
+
else {
|
|
36204
|
+
// Convert dictionary to array and sort by score (desc), then timestamp (desc)
|
|
36205
|
+
const entries = Object.keys(json).map(userId => ({
|
|
36206
|
+
userId: json[userId].userId || userId,
|
|
36207
|
+
displayName: json[userId].displayName || '',
|
|
36208
|
+
score: json[userId].score || 0,
|
|
36209
|
+
timestamp: json[userId].timestamp || ''
|
|
36210
|
+
}));
|
|
36211
|
+
// Sort by score descending, then by timestamp descending (newer first)
|
|
36212
|
+
entries.sort((a, b) => {
|
|
36213
|
+
if (b.score !== a.score) {
|
|
36214
|
+
return b.score - a.score;
|
|
36215
|
+
}
|
|
36216
|
+
return b.timestamp.localeCompare(a.timestamp);
|
|
36217
|
+
});
|
|
36218
|
+
this.model.leaderboard = entries;
|
|
36219
|
+
}
|
|
36220
|
+
m.redraw();
|
|
36221
|
+
}
|
|
36222
|
+
onUserStatsUpdate(json, firstAttach) {
|
|
36223
|
+
if (!json) {
|
|
36224
|
+
this.model.userStats = null;
|
|
36225
|
+
}
|
|
36226
|
+
else {
|
|
36227
|
+
this.model.userStats = json;
|
|
36228
|
+
}
|
|
36229
|
+
m.redraw();
|
|
36230
|
+
}
|
|
35770
36231
|
async fetchRiddle(date, locale) {
|
|
35771
36232
|
// Create the game via model
|
|
35772
36233
|
if (!this.model)
|
|
@@ -35957,13 +36418,14 @@ const Netskrafl = React.memo(NetskraflImpl);
|
|
|
35957
36418
|
*/
|
|
35958
36419
|
const RiddleScore = {
|
|
35959
36420
|
view: (vnode) => {
|
|
35960
|
-
const { riddle } = vnode.attrs;
|
|
36421
|
+
const { riddle, mode = "desktop" } = vnode.attrs;
|
|
35961
36422
|
if (!riddle)
|
|
35962
36423
|
return m("div");
|
|
35963
36424
|
const score = riddle.currentScore;
|
|
35964
36425
|
const hasValidMove = score !== undefined;
|
|
35965
36426
|
const hasTiles = riddle.tilesPlaced().length > 0;
|
|
35966
|
-
|
|
36427
|
+
const baseClass = (mode === "mobile" ? ".mobile-score" : ".gata-dagsins-score");
|
|
36428
|
+
let classes = [baseClass];
|
|
35967
36429
|
let displayText = "0";
|
|
35968
36430
|
if (!hasTiles) {
|
|
35969
36431
|
// State 1: No tiles on board - grayed/disabled, showing zero
|
|
@@ -35992,8 +36454,13 @@ const RiddleScore = {
|
|
|
35992
36454
|
else {
|
|
35993
36455
|
classes.push(".hot");
|
|
35994
36456
|
}
|
|
36457
|
+
// Add celebration class if the player achieved the best possible score
|
|
36458
|
+
if (score >= riddle.bestPossibleScore) {
|
|
36459
|
+
classes.push(".celebrate");
|
|
36460
|
+
}
|
|
35995
36461
|
}
|
|
35996
|
-
|
|
36462
|
+
const legendClass = mode === "mobile" ? ".mobile-score-legend" : ".gata-dagsins-legend";
|
|
36463
|
+
return m("div" + classes.join(""), m("span" + legendClass, displayText));
|
|
35997
36464
|
}
|
|
35998
36465
|
};
|
|
35999
36466
|
|
|
@@ -36053,35 +36520,40 @@ const GataDagsinsBoardAndRack = {
|
|
|
36053
36520
|
*/
|
|
36054
36521
|
const SunCorona = {
|
|
36055
36522
|
view: (vnode) => {
|
|
36056
|
-
const { animate = false } = vnode.attrs;
|
|
36523
|
+
const { animate = false, size = 80 } = vnode.attrs;
|
|
36524
|
+
// Calculate ray positions based on size
|
|
36525
|
+
// For 80px: rays from -40 to -20 (inner radius 20px, outer radius 40px)
|
|
36526
|
+
// For 90px: rays from -45 to -30 (inner radius 30px, outer radius 45px) to fit 60px circle
|
|
36527
|
+
const outerRadius = size / 2;
|
|
36528
|
+
const innerRadius = size / 3;
|
|
36057
36529
|
return m("div.sun-corona" + (animate ? ".rotating" : ""), [
|
|
36058
36530
|
m.trust(`
|
|
36059
|
-
<svg width="
|
|
36531
|
+
<svg width="${size}" height="${size}" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
36060
36532
|
<g transform="translate(50,50)">
|
|
36061
36533
|
<!-- Ray at 0° (12 o'clock) -->
|
|
36062
|
-
<polygon points="0
|
|
36534
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(0)"/>
|
|
36063
36535
|
<!-- Ray at 30° -->
|
|
36064
|
-
<polygon points="0
|
|
36536
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(30)"/>
|
|
36065
36537
|
<!-- Ray at 60° -->
|
|
36066
|
-
<polygon points="0
|
|
36538
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(60)"/>
|
|
36067
36539
|
<!-- Ray at 90° (3 o'clock) -->
|
|
36068
|
-
<polygon points="0
|
|
36540
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(90)"/>
|
|
36069
36541
|
<!-- Ray at 120° -->
|
|
36070
|
-
<polygon points="0
|
|
36542
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(120)"/>
|
|
36071
36543
|
<!-- Ray at 150° -->
|
|
36072
|
-
<polygon points="0
|
|
36544
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(150)"/>
|
|
36073
36545
|
<!-- Ray at 180° (6 o'clock) -->
|
|
36074
|
-
<polygon points="0
|
|
36546
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(180)"/>
|
|
36075
36547
|
<!-- Ray at 210° -->
|
|
36076
|
-
<polygon points="0
|
|
36548
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(210)"/>
|
|
36077
36549
|
<!-- Ray at 240° -->
|
|
36078
|
-
<polygon points="0
|
|
36550
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(240)"/>
|
|
36079
36551
|
<!-- Ray at 270° (9 o'clock) -->
|
|
36080
|
-
<polygon points="0
|
|
36552
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(270)"/>
|
|
36081
36553
|
<!-- Ray at 300° -->
|
|
36082
|
-
<polygon points="0
|
|
36554
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f1c40f" opacity="0.8" transform="rotate(300)"/>
|
|
36083
36555
|
<!-- Ray at 330° -->
|
|
36084
|
-
<polygon points="0
|
|
36556
|
+
<polygon points="0,-${outerRadius} -3,-${innerRadius} 3,-${innerRadius}" fill="#f39c12" opacity="0.7" transform="rotate(330)"/>
|
|
36085
36557
|
</g>
|
|
36086
36558
|
</svg>
|
|
36087
36559
|
`)
|
|
@@ -36089,6 +36561,101 @@ const SunCorona = {
|
|
|
36089
36561
|
}
|
|
36090
36562
|
};
|
|
36091
36563
|
|
|
36564
|
+
/*
|
|
36565
|
+
|
|
36566
|
+
Mobile-Status.ts
|
|
36567
|
+
|
|
36568
|
+
Mobile-only horizontal status component for Gáta Dagsins
|
|
36569
|
+
|
|
36570
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
36571
|
+
Author: Vilhjálmur Þorsteinsson
|
|
36572
|
+
|
|
36573
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
36574
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
36575
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
36576
|
+
|
|
36577
|
+
*/
|
|
36578
|
+
// Mobile-only horizontal status display
|
|
36579
|
+
const MobileStatus = () => {
|
|
36580
|
+
return {
|
|
36581
|
+
view: (vnode) => {
|
|
36582
|
+
const { riddle, selectedMoves, bestMove, onMoveClick } = vnode.attrs;
|
|
36583
|
+
const { bestPossibleScore, globalBestScore } = riddle;
|
|
36584
|
+
// Determine if player achieved best possible score
|
|
36585
|
+
const achieved = bestMove !== undefined;
|
|
36586
|
+
const celebrate = bestMove && bestMove.word !== "";
|
|
36587
|
+
// Get player's current best score
|
|
36588
|
+
const playerBestScore = selectedMoves.length > 0 ? selectedMoves[0].score : 0;
|
|
36589
|
+
// Determine current leader score (may be this player or another)
|
|
36590
|
+
let leaderScore = 0;
|
|
36591
|
+
let isPlayerLeading = false;
|
|
36592
|
+
if (globalBestScore && globalBestScore.score > 0) {
|
|
36593
|
+
leaderScore = globalBestScore.score;
|
|
36594
|
+
// Check if player is leading
|
|
36595
|
+
isPlayerLeading = playerBestScore >= globalBestScore.score;
|
|
36596
|
+
}
|
|
36597
|
+
else {
|
|
36598
|
+
leaderScore = playerBestScore;
|
|
36599
|
+
isPlayerLeading = playerBestScore > 0;
|
|
36600
|
+
}
|
|
36601
|
+
return m(".mobile-status-container", [
|
|
36602
|
+
// Current word score (leftmost) - uses RiddleScore component in mobile mode
|
|
36603
|
+
m(".mobile-status-item", m(RiddleScore, { riddle, mode: "mobile" })),
|
|
36604
|
+
// Player's best score
|
|
36605
|
+
m(".mobile-status-item.player-best", [
|
|
36606
|
+
m(".mobile-status-label", ts("Þín besta:")),
|
|
36607
|
+
m(".mobile-status-score", playerBestScore.toString())
|
|
36608
|
+
]),
|
|
36609
|
+
// Current leader score
|
|
36610
|
+
m(".mobile-status-item.leader" + (isPlayerLeading ? ".is-player" : ""), [
|
|
36611
|
+
m(".mobile-status-label", isPlayerLeading ? ts("Þú leiðir!") : ts("Leiðandi:")),
|
|
36612
|
+
m(".mobile-status-score", leaderScore.toString())
|
|
36613
|
+
]),
|
|
36614
|
+
// Best possible score
|
|
36615
|
+
m(".mobile-status-item.best-possible"
|
|
36616
|
+
+ (celebrate ? ".celebrate" : "")
|
|
36617
|
+
+ (achieved ? ".achieved" : ""), {
|
|
36618
|
+
onclick: () => celebrate && onMoveClick(bestMove.word, bestMove.coord)
|
|
36619
|
+
}, [
|
|
36620
|
+
// Wrapper for score and corona to position them together
|
|
36621
|
+
m(".mobile-best-score-wrapper", [
|
|
36622
|
+
celebrate ? m(SunCorona, { animate: true, size: 100 }) : null,
|
|
36623
|
+
m(".mobile-status-score", bestPossibleScore.toString())
|
|
36624
|
+
])
|
|
36625
|
+
])
|
|
36626
|
+
]);
|
|
36627
|
+
}
|
|
36628
|
+
};
|
|
36629
|
+
};
|
|
36630
|
+
|
|
36631
|
+
/*
|
|
36632
|
+
|
|
36633
|
+
TabBar.ts
|
|
36634
|
+
|
|
36635
|
+
Reusable tab navigation component
|
|
36636
|
+
|
|
36637
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
36638
|
+
Author: Vilhjálmur Þorsteinsson
|
|
36639
|
+
|
|
36640
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
36641
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
36642
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
36643
|
+
|
|
36644
|
+
*/
|
|
36645
|
+
const TabBar = {
|
|
36646
|
+
view: (vnode) => {
|
|
36647
|
+
const { tabs, activeTab, onTabChange } = vnode.attrs;
|
|
36648
|
+
return m(".tab-bar", tabs.map(tab => m(".tab-item" + (activeTab === tab.id ? ".active" : ""), {
|
|
36649
|
+
key: tab.id,
|
|
36650
|
+
onclick: () => onTabChange(tab.id)
|
|
36651
|
+
}, [
|
|
36652
|
+
tab.iconGlyph ? m("span.tab-icon", glyph(tab.iconGlyph)) :
|
|
36653
|
+
tab.icon ? m("span.tab-icon", tab.icon) : null,
|
|
36654
|
+
m("span.tab-label", tab.label)
|
|
36655
|
+
])));
|
|
36656
|
+
}
|
|
36657
|
+
};
|
|
36658
|
+
|
|
36092
36659
|
/*
|
|
36093
36660
|
|
|
36094
36661
|
Thermometer.ts
|
|
@@ -36163,12 +36730,27 @@ const BestPossibleScore = () => {
|
|
|
36163
36730
|
return {
|
|
36164
36731
|
view: (vnode) => {
|
|
36165
36732
|
const { score, bestMove, onMoveClick } = vnode.attrs;
|
|
36166
|
-
|
|
36167
|
-
|
|
36168
|
-
|
|
36733
|
+
// Determine the label based on achievement status
|
|
36734
|
+
let topLabel;
|
|
36735
|
+
if (bestMove !== undefined) {
|
|
36736
|
+
if (bestMove.word) {
|
|
36737
|
+
// Current player achieved it - show their word
|
|
36738
|
+
topLabel = removeBlankMarkers(bestMove.word);
|
|
36739
|
+
}
|
|
36740
|
+
else {
|
|
36741
|
+
// Someone else achieved it - indicate this
|
|
36742
|
+
topLabel = ts("Bestu lögn náð!");
|
|
36743
|
+
}
|
|
36744
|
+
}
|
|
36745
|
+
else {
|
|
36746
|
+
// Not achieved yet - show default label
|
|
36747
|
+
topLabel = ts("Besta mögulega lögn");
|
|
36748
|
+
}
|
|
36749
|
+
const achieved = bestMove !== undefined;
|
|
36169
36750
|
const celebrate = bestMove && bestMove.word !== "";
|
|
36170
36751
|
return m(".thermometer-best-score"
|
|
36171
|
-
+ (celebrate ? ".celebrate" : "")
|
|
36752
|
+
+ (celebrate ? ".celebrate" : "")
|
|
36753
|
+
+ (achieved ? ".achieved" : ""), m(".thermometer-best-score-container", {
|
|
36172
36754
|
onclick: () => celebrate && onMoveClick(bestMove.word, bestMove.coord)
|
|
36173
36755
|
}, [
|
|
36174
36756
|
// Sun corona behind the circle when celebrating
|
|
@@ -36296,6 +36878,196 @@ const Thermometer = () => {
|
|
|
36296
36878
|
};
|
|
36297
36879
|
};
|
|
36298
36880
|
|
|
36881
|
+
/*
|
|
36882
|
+
|
|
36883
|
+
StatsView.ts
|
|
36884
|
+
|
|
36885
|
+
User statistics display component for Gáta Dagsins
|
|
36886
|
+
|
|
36887
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
36888
|
+
Author: Vilhjálmur Þorsteinsson
|
|
36889
|
+
|
|
36890
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
36891
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
36892
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
36893
|
+
|
|
36894
|
+
*/
|
|
36895
|
+
const StatsView = {
|
|
36896
|
+
view: (vnode) => {
|
|
36897
|
+
const { stats, loading = false } = vnode.attrs;
|
|
36898
|
+
if (loading) {
|
|
36899
|
+
return m(".stats-view.loading", m(".loading-message", ts("Sæki tölfræði...")));
|
|
36900
|
+
}
|
|
36901
|
+
if (!stats) {
|
|
36902
|
+
return m(".stats-view.empty", m(".empty-message", ts("Engin tölfræði til að sýna")));
|
|
36903
|
+
}
|
|
36904
|
+
const statItems = [
|
|
36905
|
+
{
|
|
36906
|
+
iconGlyph: "fire",
|
|
36907
|
+
label: ts("Núverandi striklota"),
|
|
36908
|
+
value: stats.currentStreak,
|
|
36909
|
+
highlight: stats.currentStreak > 0
|
|
36910
|
+
},
|
|
36911
|
+
{
|
|
36912
|
+
iconGlyph: "star",
|
|
36913
|
+
label: ts("Lengsta striklota"),
|
|
36914
|
+
value: stats.longestStreak
|
|
36915
|
+
},
|
|
36916
|
+
{
|
|
36917
|
+
iconGlyph: "tower",
|
|
36918
|
+
label: ts("Hæsta skori náð"),
|
|
36919
|
+
value: stats.totalTopScores
|
|
36920
|
+
},
|
|
36921
|
+
{
|
|
36922
|
+
iconGlyph: "certificate",
|
|
36923
|
+
label: ts("Striklota hæsta skors"),
|
|
36924
|
+
value: stats.topScoreStreak,
|
|
36925
|
+
highlight: stats.topScoreStreak > 0
|
|
36926
|
+
},
|
|
36927
|
+
{
|
|
36928
|
+
iconGlyph: "calendar",
|
|
36929
|
+
label: ts("Heildarfjöldi daga"),
|
|
36930
|
+
value: stats.totalDaysPlayed
|
|
36931
|
+
},
|
|
36932
|
+
];
|
|
36933
|
+
return m(".stats-view", m(".stats-grid", statItems.map((item, index) => m(".stat-item" + (item.highlight ? ".highlight" : ""), { key: index }, [
|
|
36934
|
+
m(".stat-icon", glyph(item.iconGlyph)),
|
|
36935
|
+
m(".stat-info", [
|
|
36936
|
+
m(".stat-label", item.label),
|
|
36937
|
+
m(".stat-value", item.value.toString())
|
|
36938
|
+
])
|
|
36939
|
+
]))));
|
|
36940
|
+
}
|
|
36941
|
+
};
|
|
36942
|
+
|
|
36943
|
+
/*
|
|
36944
|
+
|
|
36945
|
+
LeaderboardView.ts
|
|
36946
|
+
|
|
36947
|
+
Daily leaderboard display component for Gáta Dagsins
|
|
36948
|
+
|
|
36949
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
36950
|
+
Author: Vilhjálmur Þorsteinsson
|
|
36951
|
+
|
|
36952
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
36953
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
36954
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
36955
|
+
|
|
36956
|
+
*/
|
|
36957
|
+
function getMedalIcon(rank) {
|
|
36958
|
+
switch (rank) {
|
|
36959
|
+
case 1: return "🥇";
|
|
36960
|
+
case 2: return "🥈";
|
|
36961
|
+
case 3: return "🥉";
|
|
36962
|
+
default: return null;
|
|
36963
|
+
}
|
|
36964
|
+
}
|
|
36965
|
+
function formatDate(dateStr) {
|
|
36966
|
+
// Format YYYY-MM-DD to Icelandic date (e.g., "2. okt.")
|
|
36967
|
+
const date = new Date(dateStr + "T00:00:00");
|
|
36968
|
+
const day = date.getDate();
|
|
36969
|
+
const months = [
|
|
36970
|
+
"janúar", "febrúar", "mars", "apríl", "maí", "júní",
|
|
36971
|
+
"júlí", "ágúst", "september", "október", "nóvember", "desember",
|
|
36972
|
+
];
|
|
36973
|
+
const month = months[date.getMonth()];
|
|
36974
|
+
return `${day}. ${month}`;
|
|
36975
|
+
}
|
|
36976
|
+
const LeaderboardView = {
|
|
36977
|
+
view: (vnode) => {
|
|
36978
|
+
const { leaderboard, currentUserId, date, loading = false } = vnode.attrs;
|
|
36979
|
+
if (loading) {
|
|
36980
|
+
return m(".leaderboard-view.loading", m(".loading-message", ts("Hleð stigatöflu...")));
|
|
36981
|
+
}
|
|
36982
|
+
if (!leaderboard || leaderboard.length === 0) {
|
|
36983
|
+
return m(".leaderboard-view.empty", m(".empty-message", ts("Engin stig skráð enn")));
|
|
36984
|
+
}
|
|
36985
|
+
return m(".leaderboard-view", [
|
|
36986
|
+
m(".leaderboard-header", [
|
|
36987
|
+
m(".leaderboard-title", formatDate(date)),
|
|
36988
|
+
]),
|
|
36989
|
+
m(".leaderboard-list", leaderboard.map((entry, index) => {
|
|
36990
|
+
const rank = index + 1;
|
|
36991
|
+
const isCurrentUser = entry.userId === currentUserId;
|
|
36992
|
+
const medal = getMedalIcon(rank);
|
|
36993
|
+
return m(".leaderboard-entry" + (isCurrentUser ? ".current-user" : ""), { key: entry.userId }, [
|
|
36994
|
+
m(".entry-rank", [
|
|
36995
|
+
medal ? m("span.medal", medal) : m("span.rank-number", rank.toString())
|
|
36996
|
+
]),
|
|
36997
|
+
m(".entry-name", isCurrentUser ? ts("Þú") : entry.displayName),
|
|
36998
|
+
m(".entry-score", entry.score.toString())
|
|
36999
|
+
]);
|
|
37000
|
+
}))
|
|
37001
|
+
]);
|
|
37002
|
+
}
|
|
37003
|
+
};
|
|
37004
|
+
|
|
37005
|
+
/*
|
|
37006
|
+
|
|
37007
|
+
RightSideTabs.ts
|
|
37008
|
+
|
|
37009
|
+
Desktop tabbed container for Performance/Stats/Leaderboard in Gáta Dagsins
|
|
37010
|
+
|
|
37011
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
37012
|
+
Author: Vilhjálmur Þorsteinsson
|
|
37013
|
+
|
|
37014
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
37015
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
37016
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
37017
|
+
|
|
37018
|
+
*/
|
|
37019
|
+
const RightSideTabs = () => {
|
|
37020
|
+
// Component-local state for active tab (defaults to performance)
|
|
37021
|
+
let activeTab = "performance";
|
|
37022
|
+
const tabs = [
|
|
37023
|
+
{ id: "performance", label: ts("Frammistaða"), iconGlyph: "dashboard" },
|
|
37024
|
+
{ id: "stats", label: ts("Tölfræði"), iconGlyph: "stats" },
|
|
37025
|
+
{ id: "leaderboard", label: ts("Stigatafla"), iconGlyph: "tower" }
|
|
37026
|
+
];
|
|
37027
|
+
return {
|
|
37028
|
+
view: (vnode) => {
|
|
37029
|
+
const { view, selectedMoves, bestMove, onMoveClick } = vnode.attrs;
|
|
37030
|
+
const { riddle, state } = view.model;
|
|
37031
|
+
if (!riddle) {
|
|
37032
|
+
return m(".gatadagsins-right-side-tabs", "");
|
|
37033
|
+
}
|
|
37034
|
+
const handleTabChange = (tabId) => {
|
|
37035
|
+
activeTab = tabId;
|
|
37036
|
+
};
|
|
37037
|
+
return m(".gatadagsins-right-side-tabs", [
|
|
37038
|
+
// Tab navigation
|
|
37039
|
+
m(TabBar, {
|
|
37040
|
+
tabs,
|
|
37041
|
+
activeTab,
|
|
37042
|
+
onTabChange: handleTabChange
|
|
37043
|
+
}),
|
|
37044
|
+
// Tab content
|
|
37045
|
+
m(".tab-content", [
|
|
37046
|
+
// Performance tab (thermometer)
|
|
37047
|
+
activeTab === "performance" ? m(Thermometer, {
|
|
37048
|
+
riddle,
|
|
37049
|
+
selectedMoves,
|
|
37050
|
+
bestMove,
|
|
37051
|
+
onMoveClick
|
|
37052
|
+
}) : null,
|
|
37053
|
+
// Stats tab
|
|
37054
|
+
activeTab === "stats" ? m(StatsView, {
|
|
37055
|
+
stats: view.model.userStats || null,
|
|
37056
|
+
loading: false
|
|
37057
|
+
}) : null,
|
|
37058
|
+
// Leaderboard tab
|
|
37059
|
+
activeTab === "leaderboard" ? m(LeaderboardView, {
|
|
37060
|
+
leaderboard: view.model.leaderboard || [],
|
|
37061
|
+
currentUserId: (state === null || state === void 0 ? void 0 : state.userId) || "",
|
|
37062
|
+
date: riddle.date,
|
|
37063
|
+
loading: false
|
|
37064
|
+
}) : null
|
|
37065
|
+
])
|
|
37066
|
+
]);
|
|
37067
|
+
}
|
|
37068
|
+
};
|
|
37069
|
+
};
|
|
37070
|
+
|
|
36299
37071
|
/*
|
|
36300
37072
|
|
|
36301
37073
|
GataDagsins-Right-Side.ts
|
|
@@ -36311,7 +37083,7 @@ const Thermometer = () => {
|
|
|
36311
37083
|
|
|
36312
37084
|
*/
|
|
36313
37085
|
const GataDagsinsRightSide = {
|
|
36314
|
-
// Component containing
|
|
37086
|
+
// Component containing both mobile status bar and desktop tabbed view
|
|
36315
37087
|
view: (vnode) => {
|
|
36316
37088
|
const { view, selectedMoves, bestMove } = vnode.attrs;
|
|
36317
37089
|
const { riddle } = view.model;
|
|
@@ -36322,17 +37094,198 @@ const GataDagsinsRightSide = {
|
|
|
36322
37094
|
}
|
|
36323
37095
|
};
|
|
36324
37096
|
return m(".gatadagsins-right-side-wrapper", riddle ? [
|
|
36325
|
-
//
|
|
36326
|
-
m(".gatadagsins-
|
|
37097
|
+
// Mobile-only status bar (visible on mobile, hidden on desktop)
|
|
37098
|
+
m(".gatadagsins-mobile-status", m(MobileStatus, {
|
|
36327
37099
|
riddle,
|
|
36328
37100
|
selectedMoves,
|
|
36329
37101
|
bestMove,
|
|
36330
37102
|
onMoveClick: handleMoveClick
|
|
36331
37103
|
})),
|
|
37104
|
+
// Desktop-only tabbed view (hidden on mobile, visible on desktop)
|
|
37105
|
+
m(".gatadagsins-thermometer-column", m(RightSideTabs, {
|
|
37106
|
+
view,
|
|
37107
|
+
selectedMoves,
|
|
37108
|
+
bestMove,
|
|
37109
|
+
onMoveClick: handleMoveClick
|
|
37110
|
+
})),
|
|
36332
37111
|
] : null);
|
|
36333
37112
|
}
|
|
36334
37113
|
};
|
|
36335
37114
|
|
|
37115
|
+
/*
|
|
37116
|
+
|
|
37117
|
+
gatadagsins-help.ts
|
|
37118
|
+
|
|
37119
|
+
Help dialog for Gáta Dagsins
|
|
37120
|
+
|
|
37121
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
37122
|
+
Author: Vilhjálmur Þorsteinsson
|
|
37123
|
+
|
|
37124
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
37125
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
37126
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
37127
|
+
|
|
37128
|
+
*/
|
|
37129
|
+
const GataDagsinsHelp = {
|
|
37130
|
+
view: (vnode) => {
|
|
37131
|
+
const closeHelp = vnode.attrs.onClose;
|
|
37132
|
+
return m(".modal-dialog.gatadagsins-help", m(".modal-content", [
|
|
37133
|
+
// Header with close button
|
|
37134
|
+
m(".modal-header", [
|
|
37135
|
+
m("h2", "Um Gátu dagsins"),
|
|
37136
|
+
m("button.close", {
|
|
37137
|
+
onclick: closeHelp,
|
|
37138
|
+
"aria-label": "Loka"
|
|
37139
|
+
}, m("span", { "aria-hidden": "true" }, "×"))
|
|
37140
|
+
]),
|
|
37141
|
+
// Body with help content
|
|
37142
|
+
m(".modal-body", [
|
|
37143
|
+
m("p", "Gáta dagsins er dagleg krossgátuþraut, svipuð skrafli, þar sem þú reynir að finna " +
|
|
37144
|
+
"stigahæsta orðið sem hægt er að mynda með gefnum stöfum."),
|
|
37145
|
+
m("h3", "Hvernig á að spila"),
|
|
37146
|
+
m("ul", [
|
|
37147
|
+
m("li", "Þú færð borð með allmörgum stöfum sem þegar hafa verið lagðir."),
|
|
37148
|
+
m("li", "Neðst á skjánum eru stafaflísar sem þú getur notað til að mynda orð."),
|
|
37149
|
+
m("li", "Dragðu flísar á borðið til að mynda orð, annaðhvort lárétt eða lóðrétt."),
|
|
37150
|
+
m("li", "Orðin verða að tengjast við stafi sem fyrir eru á borðinu."),
|
|
37151
|
+
m("li", "Þú sérð jafnóðum hvort lögnin á borðinu er gild og hversu mörg stig hún gefur."),
|
|
37152
|
+
m("li", "Þú getur prófað eins mörg orð og þú vilt - besta skorið þitt er vistað."),
|
|
37153
|
+
]),
|
|
37154
|
+
m("h3", "Stigagjöf"),
|
|
37155
|
+
m("p", "Þú færð stig fyrir hvern staf í orðinu, auk bónusstiga fyrir lengri orð:"),
|
|
37156
|
+
m("ul", [
|
|
37157
|
+
m("li", "Hver stafur gefur 1-10 stig eftir gildi hans"),
|
|
37158
|
+
m("li", "Orð sem nota allar 7 stafaflísarnar gefa 50 stiga bónus"),
|
|
37159
|
+
m("li", "Sumir reitir á borðinu tvöfalda eða þrefalda stafagildið"),
|
|
37160
|
+
m("li", "Sumir reitir tvöfalda eða þrefalda heildarorðagildið"),
|
|
37161
|
+
]),
|
|
37162
|
+
m("h3", "Hitamælir"),
|
|
37163
|
+
m("p", "Hitamælirinn hægra megin (eða efst á farsímum) sýnir:"),
|
|
37164
|
+
m("ul", [
|
|
37165
|
+
m("li", m("strong", "Besta mögulega skor:"), " Hæstu stig sem hægt er að ná á þessu borði."),
|
|
37166
|
+
m("li", m("strong", "Besta skor dagsins:"), " Hæstu stig sem einhver leikmaður hefur náð í dag."),
|
|
37167
|
+
m("li", m("strong", "Þín bestu orð:"), " Orðin sem þú hefur lagt og stigin fyrir þau."),
|
|
37168
|
+
m("li", "Þú getur smellt á orð á hitamælinum til að fá þá lögn aftur á borðið."),
|
|
37169
|
+
]),
|
|
37170
|
+
m("h3", "Ábendingar"),
|
|
37171
|
+
m("ul", [
|
|
37172
|
+
m("li", "Reyndu að nota dýra stafi (eins og X, Ý, Þ) á tvöföldunar- eða þreföldunarreitum."),
|
|
37173
|
+
m("li", "Lengri orð gefa mun fleiri stig vegna bónussins."),
|
|
37174
|
+
m("li", "Þú getur dregið allar flísar til baka með bláa endurkalls-hnappnum."),
|
|
37175
|
+
m("li", "Ný gáta birtist á hverjum nýjum degi - klukkan 00:00!"),
|
|
37176
|
+
]),
|
|
37177
|
+
m("h3", "Um leikinn"),
|
|
37178
|
+
m("p", [
|
|
37179
|
+
"Gáta dagsins er systkini ",
|
|
37180
|
+
m("a", { href: "https://netskrafl.is", target: "_blank" }, "Netskrafls"),
|
|
37181
|
+
", hins sívinsæla íslenska krossgátuleiks á netinu. ",
|
|
37182
|
+
"Leikurinn er þróaður af Miðeind ehf."
|
|
37183
|
+
]),
|
|
37184
|
+
]),
|
|
37185
|
+
// Footer with close button
|
|
37186
|
+
m(".modal-footer", m("button.btn.btn-primary", {
|
|
37187
|
+
onclick: closeHelp
|
|
37188
|
+
}, "Loka"))
|
|
37189
|
+
]));
|
|
37190
|
+
}
|
|
37191
|
+
};
|
|
37192
|
+
|
|
37193
|
+
/*
|
|
37194
|
+
|
|
37195
|
+
StatsModal.ts
|
|
37196
|
+
|
|
37197
|
+
Mobile modal for stats and leaderboard
|
|
37198
|
+
|
|
37199
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
37200
|
+
Author: Vilhjálmur Þorsteinsson
|
|
37201
|
+
|
|
37202
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
37203
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
37204
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
37205
|
+
|
|
37206
|
+
*/
|
|
37207
|
+
const StatsModal = () => {
|
|
37208
|
+
// Component-local state for active tab (defaults to stats)
|
|
37209
|
+
let activeTab = "stats";
|
|
37210
|
+
const tabs = [
|
|
37211
|
+
{ id: "stats", label: ts("Tölfræði"), iconGlyph: "stats" },
|
|
37212
|
+
{ id: "leaderboard", label: ts("Stigatafla"), iconGlyph: "tower" }
|
|
37213
|
+
];
|
|
37214
|
+
return {
|
|
37215
|
+
view: (vnode) => {
|
|
37216
|
+
const { view, onClose } = vnode.attrs;
|
|
37217
|
+
const { riddle, state } = view.model;
|
|
37218
|
+
if (!riddle) {
|
|
37219
|
+
return null;
|
|
37220
|
+
}
|
|
37221
|
+
const handleTabChange = (tabId) => {
|
|
37222
|
+
activeTab = tabId;
|
|
37223
|
+
};
|
|
37224
|
+
return [
|
|
37225
|
+
// Backdrop
|
|
37226
|
+
m(".modal-backdrop", {
|
|
37227
|
+
onclick: onClose
|
|
37228
|
+
}),
|
|
37229
|
+
// Modal dialog
|
|
37230
|
+
m(".modal-dialog.stats-modal", [
|
|
37231
|
+
m(".modal-content", [
|
|
37232
|
+
// Header with close button
|
|
37233
|
+
m(".modal-header", [
|
|
37234
|
+
m("h2", ts("Tölfræði")),
|
|
37235
|
+
m("button.close", {
|
|
37236
|
+
onclick: onClose
|
|
37237
|
+
}, "×")
|
|
37238
|
+
]),
|
|
37239
|
+
// Tab navigation
|
|
37240
|
+
m(TabBar, {
|
|
37241
|
+
tabs,
|
|
37242
|
+
activeTab,
|
|
37243
|
+
onTabChange: handleTabChange
|
|
37244
|
+
}),
|
|
37245
|
+
// Modal body with tab content
|
|
37246
|
+
m(".modal-body", [
|
|
37247
|
+
activeTab === "stats" ? m(StatsView, {
|
|
37248
|
+
stats: view.model.userStats || null,
|
|
37249
|
+
loading: false
|
|
37250
|
+
}) : null,
|
|
37251
|
+
activeTab === "leaderboard" ? m(LeaderboardView, {
|
|
37252
|
+
leaderboard: view.model.leaderboard || [],
|
|
37253
|
+
currentUserId: (state === null || state === void 0 ? void 0 : state.userId) || "",
|
|
37254
|
+
date: riddle.date,
|
|
37255
|
+
loading: false
|
|
37256
|
+
}) : null
|
|
37257
|
+
])
|
|
37258
|
+
])
|
|
37259
|
+
])
|
|
37260
|
+
];
|
|
37261
|
+
}
|
|
37262
|
+
};
|
|
37263
|
+
};
|
|
37264
|
+
|
|
37265
|
+
/*
|
|
37266
|
+
|
|
37267
|
+
MobileStatsButton.ts
|
|
37268
|
+
|
|
37269
|
+
Button to open stats modal on mobile
|
|
37270
|
+
|
|
37271
|
+
Copyright (C) 2025 Miðeind ehf.
|
|
37272
|
+
Author: Vilhjálmur Þorsteinsson
|
|
37273
|
+
|
|
37274
|
+
The Creative Commons Attribution-NonCommercial 4.0
|
|
37275
|
+
International Public License (CC-BY-NC 4.0) applies to this software.
|
|
37276
|
+
For further information, see https://github.com/mideind/Netskrafl
|
|
37277
|
+
|
|
37278
|
+
*/
|
|
37279
|
+
const MobileStatsButton = {
|
|
37280
|
+
view: (vnode) => {
|
|
37281
|
+
const { onClick } = vnode.attrs;
|
|
37282
|
+
return m(".mobile-stats-button", {
|
|
37283
|
+
onclick: onClick,
|
|
37284
|
+
title: "Tölfræði og stigatafla"
|
|
37285
|
+
}, m(".stats-icon", glyph("stats")));
|
|
37286
|
+
}
|
|
37287
|
+
};
|
|
37288
|
+
|
|
36336
37289
|
/*
|
|
36337
37290
|
|
|
36338
37291
|
GataDagsins.ts
|
|
@@ -36348,11 +37301,19 @@ const GataDagsinsRightSide = {
|
|
|
36348
37301
|
|
|
36349
37302
|
*/
|
|
36350
37303
|
const MAX_MOVES_TO_DISPLAY = 10;
|
|
36351
|
-
const
|
|
36352
|
-
var _a;
|
|
37304
|
+
const currentMoveState = (riddle) => {
|
|
37305
|
+
var _a, _b, _c;
|
|
37306
|
+
const thisPlayer = ((_a = riddle.model.state) === null || _a === void 0 ? void 0 : _a.userId) || "";
|
|
37307
|
+
const { bestPossibleScore, // The highest score achievable for this riddle
|
|
37308
|
+
globalBestScore, // The best score achieved by any player
|
|
37309
|
+
groupBestScore, // The best score achieved within the player's group
|
|
37310
|
+
playerMoves, } = riddle;
|
|
37311
|
+
// If the player has equaled the best possible score,
|
|
37312
|
+
// the winning word is stored here and displayed at the top
|
|
37313
|
+
let bestMove = undefined;
|
|
36353
37314
|
// Sort moves by score in descending order and
|
|
36354
37315
|
// cut the tail off the list to only include the top moves
|
|
36355
|
-
const
|
|
37316
|
+
const selectedMoves = playerMoves
|
|
36356
37317
|
.sort((a, b) => b.score - a.score)
|
|
36357
37318
|
.slice(0, MAX_MOVES_TO_DISPLAY)
|
|
36358
37319
|
.map(move => ({
|
|
@@ -36360,88 +37321,102 @@ const selectTopMoves = (thisPlayer, moves, globalBestScore, groupBestScore) => {
|
|
|
36360
37321
|
word: move.word,
|
|
36361
37322
|
coord: move.coord,
|
|
36362
37323
|
}));
|
|
36363
|
-
//
|
|
36364
|
-
// the player's own top score, we include it as the first move
|
|
37324
|
+
// Check whether we need to add or annotate the global best score
|
|
36365
37325
|
if (globalBestScore && globalBestScore.score > 0) {
|
|
36366
37326
|
const { score, word, coord } = globalBestScore;
|
|
36367
|
-
if (((
|
|
37327
|
+
if (((_c = (_b = selectedMoves[0]) === null || _b === void 0 ? void 0 : _b.score) !== null && _c !== void 0 ? _c : 0) >= score) {
|
|
36368
37328
|
// This player has made a move that scores the same
|
|
36369
37329
|
// or better as the top score: mark the move
|
|
36370
|
-
|
|
37330
|
+
selectedMoves[0].isGlobalBestScore = true;
|
|
36371
37331
|
}
|
|
36372
37332
|
else if (globalBestScore.player === thisPlayer) {
|
|
36373
37333
|
// This player holds the global best score, probably
|
|
36374
|
-
// from a previous session
|
|
36375
|
-
|
|
37334
|
+
// from a previous session, so it's not already
|
|
37335
|
+
// in the selectedMoves list: add it as a move
|
|
37336
|
+
selectedMoves.unshift({ score, isGlobalBestScore: true, word, coord });
|
|
36376
37337
|
}
|
|
36377
37338
|
else {
|
|
36378
|
-
// This is a global best score from another player
|
|
36379
|
-
|
|
37339
|
+
// This is a global best score from another player
|
|
37340
|
+
selectedMoves.unshift({ score, isGlobalBestScore: true, word: "", coord: "" });
|
|
36380
37341
|
}
|
|
36381
37342
|
}
|
|
36382
|
-
//
|
|
36383
|
-
|
|
36384
|
-
|
|
36385
|
-
const currentMoveState = (riddle) => {
|
|
36386
|
-
var _a;
|
|
36387
|
-
const thisPlayer = ((_a = riddle.model.state) === null || _a === void 0 ? void 0 : _a.userId) || "";
|
|
36388
|
-
const { bestPossibleScore, globalBestScore, groupBestScore, playerMoves, } = riddle;
|
|
36389
|
-
// If the player has equaled the best possible score,
|
|
36390
|
-
// the winning word is stored here and displayed at the top
|
|
36391
|
-
let bestMove = undefined;
|
|
36392
|
-
// Apply the move selection and allocation algorithm
|
|
36393
|
-
const selectedMoves = selectTopMoves(thisPlayer, playerMoves, globalBestScore);
|
|
36394
|
-
// If the top-scoring move has the bestPossibleScore,
|
|
36395
|
-
// extract it from the move list
|
|
37343
|
+
// Check if the best possible score has been achieved, by this player
|
|
37344
|
+
// or another player. If so, we remove it from the move list, since we
|
|
37345
|
+
// only display it at the top of the thermometer.
|
|
36396
37346
|
if (selectedMoves.length > 0 && selectedMoves[0].score === bestPossibleScore) {
|
|
36397
|
-
|
|
36398
|
-
// The word was played by this player
|
|
36399
|
-
bestMove = selectedMoves.shift();
|
|
36400
|
-
}
|
|
37347
|
+
bestMove = selectedMoves.shift();
|
|
36401
37348
|
}
|
|
36402
37349
|
return { selectedMoves, bestMove };
|
|
36403
37350
|
};
|
|
36404
|
-
const GataDagsins$1 = {
|
|
37351
|
+
const GataDagsins$1 = () => {
|
|
36405
37352
|
// A view of the Gáta Dagsins page
|
|
36406
|
-
|
|
36407
|
-
|
|
36408
|
-
|
|
36409
|
-
|
|
36410
|
-
const {
|
|
36411
|
-
|
|
36412
|
-
|
|
37353
|
+
let showHelp = false;
|
|
37354
|
+
let showStatsModal = false;
|
|
37355
|
+
return {
|
|
37356
|
+
oninit: async (vnode) => {
|
|
37357
|
+
const { model, actions } = vnode.attrs.view;
|
|
37358
|
+
const { riddle } = model;
|
|
37359
|
+
if (!riddle) {
|
|
37360
|
+
const { date, locale } = vnode.attrs;
|
|
37361
|
+
// Initialize a fresh riddle object if it doesn't exist
|
|
37362
|
+
await actions.fetchRiddle(date, locale);
|
|
37363
|
+
}
|
|
37364
|
+
// Initialize dialog states
|
|
37365
|
+
showHelp = false;
|
|
37366
|
+
showStatsModal = false;
|
|
37367
|
+
},
|
|
37368
|
+
view: (vnode) => {
|
|
37369
|
+
var _a;
|
|
37370
|
+
const { view } = vnode.attrs;
|
|
37371
|
+
const { model } = view;
|
|
37372
|
+
const { riddle } = model;
|
|
37373
|
+
const { selectedMoves, bestMove } = (riddle
|
|
37374
|
+
? currentMoveState(riddle)
|
|
37375
|
+
: { selectedMoves: [], bestMove: undefined });
|
|
37376
|
+
const toggleHelp = () => {
|
|
37377
|
+
showHelp = !showHelp;
|
|
37378
|
+
m.redraw();
|
|
37379
|
+
};
|
|
37380
|
+
const toggleStatsModal = () => {
|
|
37381
|
+
showStatsModal = !showStatsModal;
|
|
37382
|
+
m.redraw();
|
|
37383
|
+
};
|
|
37384
|
+
return m("div.drop-target", {
|
|
37385
|
+
id: "gatadagsins-background",
|
|
37386
|
+
}, [
|
|
37387
|
+
// The main content area
|
|
37388
|
+
riddle ? m(".gatadagsins-container", [
|
|
37389
|
+
// Main display area with flex layout
|
|
37390
|
+
m(".gatadagsins-main", [
|
|
37391
|
+
// Board and rack component (left side)
|
|
37392
|
+
m(GataDagsinsBoardAndRack, { view }),
|
|
37393
|
+
// Right-side component with scores and comparisons
|
|
37394
|
+
m(GataDagsinsRightSide, { view, selectedMoves, bestMove }),
|
|
37395
|
+
// Blank dialog
|
|
37396
|
+
riddle.askingForBlank
|
|
37397
|
+
? m(BlankDialog, { game: riddle })
|
|
37398
|
+
: "",
|
|
37399
|
+
])
|
|
37400
|
+
]) : "",
|
|
37401
|
+
// The left margin elements: back button and info/help button
|
|
37402
|
+
// These elements appear after the main container for proper z-order
|
|
37403
|
+
// m(LeftLogo), // Currently no need for the logo for Gáta Dagsins
|
|
37404
|
+
// Show the Beginner component if the user is a beginner
|
|
37405
|
+
((_a = model.state) === null || _a === void 0 ? void 0 : _a.beginner) ? m(Beginner, { view }) : "",
|
|
37406
|
+
// Custom Info button for GataDagsins that shows help dialog
|
|
37407
|
+
m(".info", { title: ts("Upplýsingar og hjálp") }, m("a.iconlink", { href: "#", onclick: (e) => { e.preventDefault(); toggleHelp(); } }, glyph("info-sign"))),
|
|
37408
|
+
// Mobile stats button (hidden on desktop)
|
|
37409
|
+
m(MobileStatsButton, { onClick: toggleStatsModal }),
|
|
37410
|
+
// Help dialog and backdrop
|
|
37411
|
+
showHelp ? [
|
|
37412
|
+
m(".modal-backdrop", { onclick: (e) => { e.preventDefault(); } }),
|
|
37413
|
+
m(GataDagsinsHelp, { onClose: toggleHelp })
|
|
37414
|
+
] : "",
|
|
37415
|
+
// Stats modal and backdrop (mobile only)
|
|
37416
|
+
showStatsModal ? m(StatsModal, { view, onClose: toggleStatsModal }) : "",
|
|
37417
|
+
]);
|
|
36413
37418
|
}
|
|
36414
|
-
}
|
|
36415
|
-
view: (vnode) => {
|
|
36416
|
-
const { view } = vnode.attrs;
|
|
36417
|
-
const { model } = view;
|
|
36418
|
-
const { riddle } = model;
|
|
36419
|
-
const { selectedMoves, bestMove } = (riddle
|
|
36420
|
-
? currentMoveState(riddle)
|
|
36421
|
-
: { selectedMoves: [], bestMove: undefined });
|
|
36422
|
-
return m("div.drop-target", {
|
|
36423
|
-
id: "gatadagsins-background",
|
|
36424
|
-
}, [
|
|
36425
|
-
// The main content area
|
|
36426
|
-
riddle ? m(".gatadagsins-container", [
|
|
36427
|
-
// Main display area with flex layout
|
|
36428
|
-
m(".gatadagsins-main", [
|
|
36429
|
-
// Board and rack component (left side)
|
|
36430
|
-
m(GataDagsinsBoardAndRack, { view }),
|
|
36431
|
-
// Right-side component with scores and comparisons
|
|
36432
|
-
m(GataDagsinsRightSide, { view, selectedMoves, bestMove }),
|
|
36433
|
-
// Blank dialog
|
|
36434
|
-
riddle.askingForBlank
|
|
36435
|
-
? m(BlankDialog, { game: riddle })
|
|
36436
|
-
: "",
|
|
36437
|
-
])
|
|
36438
|
-
]) : "",
|
|
36439
|
-
// The left margin elements: back button and info/help button
|
|
36440
|
-
// These elements appear after the main container for proper z-order
|
|
36441
|
-
m(LeftLogo),
|
|
36442
|
-
m(Info),
|
|
36443
|
-
]);
|
|
36444
|
-
}
|
|
37419
|
+
};
|
|
36445
37420
|
};
|
|
36446
37421
|
|
|
36447
37422
|
/*
|
|
@@ -36477,11 +37452,22 @@ async function main(state, container) {
|
|
|
36477
37452
|
const model = new Model(settings, state);
|
|
36478
37453
|
const actions = new Actions(model);
|
|
36479
37454
|
const view = new View(actions);
|
|
37455
|
+
// Get date from URL parameter, fallback to today
|
|
37456
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
37457
|
+
const dateParam = urlParams.get('date');
|
|
36480
37458
|
const today = new Date().toISOString().split("T")[0];
|
|
37459
|
+
const riddleDate = dateParam || today;
|
|
37460
|
+
// Validate date format (YYYY-MM-DD)
|
|
37461
|
+
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
37462
|
+
const validDate = dateRegex.test(riddleDate) ? riddleDate : today;
|
|
36481
37463
|
const locale = state.locale || "is_IS";
|
|
37464
|
+
// Log the date being used (helpful for debugging)
|
|
37465
|
+
if (dateParam) {
|
|
37466
|
+
console.log(`Loading Gáta Dagsins for date: ${validDate} (from URL parameter)`);
|
|
37467
|
+
}
|
|
36482
37468
|
// Mount the Gáta Dagsins UI using an anonymous closure component
|
|
36483
37469
|
m.mount(container, {
|
|
36484
|
-
view: () => m(GataDagsins$1, { view, date:
|
|
37470
|
+
view: () => m(GataDagsins$1, { view, date: validDate, locale }),
|
|
36485
37471
|
});
|
|
36486
37472
|
}
|
|
36487
37473
|
catch (e) {
|
|
@@ -36491,6 +37477,8 @@ async function main(state, container) {
|
|
|
36491
37477
|
return "success";
|
|
36492
37478
|
}
|
|
36493
37479
|
|
|
37480
|
+
// Note: To load a specific date for debugging, use URL parameter: ?date=YYYY-MM-DD
|
|
37481
|
+
// Example: http://localhost:6006/?date=2025-01-25
|
|
36494
37482
|
const mountForUser = async (state) => {
|
|
36495
37483
|
// Return a DOM tree containing a mounted Gáta Dagsins UI
|
|
36496
37484
|
// for the user specified in the state object
|