From c5420c97eb779ac50f856faa8f281fffd4b9f2e4 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 21 Dec 2025 10:56:49 +1100 Subject: [PATCH 01/18] Finished the scorecard --- site/index.html | 1 + site/mahjong/guide.html | 26 ++++++++++++++++ site/mahjong/index.html | 12 ++++++-- site/mahjong/main.js | 46 ++++++++++++++++++---------- site/mahjong/models.js | 68 ++++++++++++++++++++++++++++------------- site/mahjong/style.css | 41 +++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 40 deletions(-) diff --git a/site/index.html b/site/index.html index c71b5cc..444f37f 100644 --- a/site/index.html +++ b/site/index.html @@ -24,6 +24,7 @@
  • Gradient Bands
  • Two-letter Country Codes
  • Timestamp Converter
  • +
  • Mahjong Scorecard
  • diff --git a/site/mahjong/guide.html b/site/mahjong/guide.html index 43ceeb7..0609f8f 100644 --- a/site/mahjong/guide.html +++ b/site/mahjong/guide.html @@ -32,66 +32,92 @@ m Mahjong + 20 ps Pung of simples + 4 pt Pung of terminals + 8 ph Pung of honours + 8 xps Exposed pung of simples + 2 xpt Exposed pung of terminals + 4 xph Exposed pung of honours + 4 ks Kong of simples + 16 kt Kong of terminals + 32 kh Kong of honours + 32 xks Exposed kong of simples + 8 xkt Exposed kong of terminals + 16 xkh Exposed kong of honours + 16 pd Pair of dragons + 2 pw Pair of winds (either player or prevailing) + 2 b Bonus (flower or season) + 4 + + + p + Point (used for adjusting scores) + 1 + + + d + Penalty (used for adjusting scores) + -1 diff --git a/site/mahjong/index.html b/site/mahjong/index.html index e1dce3f..3ac7778 100644 --- a/site/mahjong/index.html +++ b/site/mahjong/index.html @@ -78,15 +78,21 @@
    -

    -
    -
    +
    +
    +
    +
    +
    +
    + Notation Guide +
    + diff --git a/site/mahjong/main.js b/site/mahjong/main.js index f9ba03c..584bcec 100644 --- a/site/mahjong/main.js +++ b/site/mahjong/main.js @@ -2,13 +2,10 @@ import { Application, Controller } from "https://unpkg.com/@hotwired/stimulus/di import { GameState, getGameState, setGameState, calculateScore } from "./models.js"; function emitGameStateEvent(details) { - // window.setTimeout(() => { - let ev = new CustomEvent("gamestatechanged", { detail: details }); - window.dispatchEvent(ev); - // }, 1); + let ev = new CustomEvent("gamestatechanged", { detail: details }); + window.dispatchEvent(ev); } - window.Stimulus = Application.start(); Stimulus.register("scorecard", class extends Controller { @@ -18,8 +15,6 @@ Stimulus.register("scorecard", class extends Controller { } connect() { - // this._rebuildTable(); - // this.element.classList.remove("hidden"); } handleGameState(ev) { @@ -75,8 +70,8 @@ Stimulus.register("scorecard", class extends Controller { let td = document.createElement("td"); td.textContent = c.s; if (c.w) { + td.textContent += " 🀄"; td.classList.add("winner"); - td.textContent += " 🏆"; } tr.append(td); } @@ -94,7 +89,7 @@ Stimulus.register("scorecard", class extends Controller { this.element.querySelector("table.scorecard").replaceWith(tbl); - this.prevailingTarget.textContent = `Prevailing: ${gameState.prevaling}`; + this.prevailingTarget.textContent = `Prevailing: ${gameState.prevailing}`; } }); @@ -102,7 +97,6 @@ Stimulus.register("newgame", class extends Controller { static targets = ["playerName"]; connect() { - this.element.classList.remove("hidden"); } handleGameState(ev) { @@ -133,10 +127,9 @@ Stimulus.register("newgame", class extends Controller { }); Stimulus.register("endround", class extends Controller { - static targets = ["form", "input", "wind"]; + static targets = ["form", "input", "wind", "prevailing"]; connect() { - // this.element.classList.remove("hidden"); } handleGameState(ev) { @@ -148,6 +141,7 @@ Stimulus.register("endround", class extends Controller { break; case "endround": this._prepForms(); + this._updatePreview(); this.element.classList.remove("hidden"); break; } @@ -155,7 +149,10 @@ Stimulus.register("endround", class extends Controller { updatePreview(ev) { ev.preventDefault(); + this._updatePreview(); + } + _updatePreview() { let scoreExprs = this._readScoreExprs(); let nextRound = getGameState().determineNextRound(scoreExprs); @@ -163,15 +160,23 @@ Stimulus.register("endround", class extends Controller { let previewElem = this.element.querySelector(`.preview[data-player="${i}"]`); previewElem.textContent = nextRound.roundScores[i].score; if (parseInt(i) === nextRound.roundWinner) { - previewElem.textContent += " 🏆"; + previewElem.classList.add("winner"); + previewElem.textContent += " 🀄"; + } else { + previewElem.classList.remove("winner"); } } if (nextRound.windsBump) { - this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will be East next round`; + this.windTarget.textContent = ` will be East next round`; } else { - this.windTarget.textContent = `${getGameState().players[nextRound.nextRoundEast].name} will remain East next round`; + this.windTarget.textContent = ` will remain East next round`; } + let nextWindNameElem = document.createElement("strong"); + nextWindNameElem.textContent = getGameState().players[nextRound.nextRoundEast].name; + this.windTarget.prepend(nextWindNameElem); + + this.prevailingTarget.textContent = `Next prevailing: ${nextRound.nextPrevailing}`; } goBack(ev) { @@ -224,4 +229,13 @@ Stimulus.register("endround", class extends Controller { } return scoreExprs; } -}); \ No newline at end of file +}); + +document.addEventListener("DOMContentLoaded", () => { + let game = getGameState(); + if (game !== null) { + emitGameStateEvent({mode: "startgame"}); + } else { + emitGameStateEvent({mode: "newgame"}); + } +}) \ No newline at end of file diff --git a/site/mahjong/models.js b/site/mahjong/models.js index 48d1762..d49ee27 100644 --- a/site/mahjong/models.js +++ b/site/mahjong/models.js @@ -7,22 +7,24 @@ const windDistributions = [ ]; const scoreTokens = { - 'm': 1, - 'ps': 1, - 'pt': 1, - 'ph': 1, - 'xps': 1, - 'xpt': 1, - 'xph': 1, - 'ks': 1, - 'kt': 1, - 'kh': 1, - 'xks': 1, - 'xkt': 1, - 'xkh': 1, - 'pd': 1, - 'pw': 1, - 'b': 1, + 'm': 20, + 'ps': 4, + 'pt': 8, + 'ph': 8, + 'xps': 2, + 'xpt': 4, + 'xph': 4, + 'ks': 16, + 'kt': 32, + 'kh': 32, + 'xks': 8, + 'xkt': 16, + 'xkh': 16, + 'pd': 2, + 'pw': 2, + 'b': 4, + 'p': 1, + 'd': -1, } function parseTokens(str) { @@ -67,7 +69,7 @@ export class GameState { this.players = players; this.rounds = rounds; this.bumpWind = 0; - this.prevaling = "E"; + this.prevailing = "E"; } static newGame(playerNames) { @@ -78,7 +80,21 @@ export class GameState { } let rounds = []; - return new GameState(players, rounds); + let gs = new GameState(players, rounds); + gs.save(); + return gs; + } + + static load() { + let json = localStorage.getItem('mahjong-scorecard'); + if (!json) { + return null; + } + let o = JSON.parse(json); + let gs = new GameState(o.p, o.r); + gs.bumpWind = o.b; + gs.prevailing = o.w; + return gs; } determineNextRound(playerScoreExprs) { @@ -92,7 +108,7 @@ export class GameState { } let windsBump = roundWinner !== currentEast; - let nextPrevailing = this.prevaling; + let nextPrevailing = this.prevailing; if (windsBump) { nextPrevailing = windDistributions[3][((this.bumpWind + 1) / this.players.length)|0]; } @@ -133,14 +149,24 @@ export class GameState { this.players[pi].wind = windDistribution[i]; } - this.prevaling = nr.nextPrevailing; + this.prevailing = nr.nextPrevailing; if (nr.windsBump) { this.bumpWind++; } + this.save(); + } + + save() { + localStorage.setItem('mahjong-scorecard', JSON.stringify({ + p: this.players, + r: this.rounds, + b: this.bumpWind, + w: this.prevailing, + })); } } -let gameState = new GameState(); +let gameState = GameState.load(); export function getGameState() { return gameState; diff --git a/site/mahjong/style.css b/site/mahjong/style.css index 1940ccb..4f1eed1 100644 --- a/site/mahjong/style.css +++ b/site/mahjong/style.css @@ -1,3 +1,7 @@ +.container { + margin-block-end: 20px; +} + .hidden { display: none; } @@ -18,4 +22,41 @@ gap: 10px; justify-content: space-between; align-items: center; + margin-block-end: 12px; +} + +@media (max-width: 600px) { + .btns { + flex-direction: column; + gap: 20px; + } + + .btns :nth-child(1) { + align-self: start; + } + + .btns :nth-child(2) { + align-self: end; + } +} + +.winner { + color: #9B2318; + font-weight: bold; +} + + +th { + background: #E2E2E2; +} + +@media (prefers-color-scheme: dark) { + th { + background: #474747; + } + + .winner { + color: #F06048; + font-weight: bold; + } } \ No newline at end of file From 792c54f6915546a1edcd283f1b88dbf09027b3fa Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 21 Dec 2025 22:57:31 +0100 Subject: [PATCH 02/18] Added finska scorecard --- site/finska/index.html | 59 +++++++++++ site/finska/scripts/controllers.js | 160 +++++++++++++++++++++++++++++ site/finska/scripts/main.js | 7 ++ site/finska/scripts/models.js | 152 +++++++++++++++++++++++++++ site/finska/style.css | 17 +++ 5 files changed, 395 insertions(+) create mode 100644 site/finska/index.html create mode 100644 site/finska/scripts/controllers.js create mode 100644 site/finska/scripts/main.js create mode 100644 site/finska/scripts/models.js create mode 100644 site/finska/style.css diff --git a/site/finska/index.html b/site/finska/index.html new file mode 100644 index 0000000..3112edc --- /dev/null +++ b/site/finska/index.html @@ -0,0 +1,59 @@ + + + + + + Mahjong Score Card - Tools + + + + + +

    Finska Scorecard

    + +
    + + + + + + + + + + + + + + + + + +
    Team ATeam B
    +
    + +
    +
    +
    + +
    +
    + +
    + + +
    +
    + + \ No newline at end of file diff --git a/site/finska/scripts/controllers.js b/site/finska/scripts/controllers.js new file mode 100644 index 0000000..03fd023 --- /dev/null +++ b/site/finska/scripts/controllers.js @@ -0,0 +1,160 @@ +import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { Scorecard, getStoreDAO } from "./models.js"; + +const storeDao = getStoreDAO(); + +export class FinskaScorecardController extends Controller { + static targets = ["score1Input", "score2Input", "scoreTable"]; + static values = { + maxScore: Number, + overflowScoreTo: Number + } + + connect() { + let rules = { + maxScore: Math.floor(this.maxScoreValue), + overflowScoreTo: Math.floor(this.overflowScoreToValue) + }; + + this._undoStack = []; + this._scorecard = storeDao.loadOrCreate(rules); + this.updateTable(); + } + + updateTable() { + let tableBody = this.scoreTableTarget; + let tableRows = tableBody.querySelectorAll("tr"); + let pairs = this._scorecard.pairs(); + + for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) { + let tableRow; + if (pairIndex >= tableRows.length) { + tableRow = this._appendRow(); + } else { + tableRow = tableRows[pairIndex]; + } + + this._updateRow(tableRow, pairs[pairIndex]) + } + + // Remove any extra rows + for (let i = pairs.length; i < tableRows.length; i++) { + tableBody.removeChild(tableRows[i]); + } + + console.log(JSON.stringify(this._scorecard.toJson())); + } + + _updateRow(tableRow, pair) { + let tds = tableRow.querySelectorAll("td"); + + this._updateCell(pair.p1, tds[0], tds[1]); + this._updateCell(pair.p2, tds[2], tds[3]); + } + + _updateCell(score, scoreCell, totalCell) { + scoreCell.classList.value = ""; + totalCell.classList.value = ""; + + if (score != null) { + scoreCell.textContent = score.score; + totalCell.textContent = score.total; + + if (score.score === 0) { + scoreCell.classList.add("score-foul"); + totalCell.classList.add("score-foul"); + } else if (score.wasOverflow) { + scoreCell.classList.add("score-overflow"); + totalCell.classList.add("score-overflow"); + } else if (score.wasWin) { + scoreCell.classList.add("score-win"); + totalCell.classList.add("score-win"); + } + } else { + scoreCell.textContent = ""; + totalCell.textContent = ""; + } + } + + _appendRow() { + let newRow = document.createElement("tr"); + newRow.classList.add("score-entry"); + + for (let i = 0; i < 4; i++) { + newRow.appendChild(document.createElement("td")); + } + + this.scoreTableTarget.appendChild(newRow); + return newRow; + } + + addScore1() { + this._addScore(this.score1InputTarget, this.score2InputTarget, + this._scorecard.addPlayer1Score.bind(this._scorecard), + this._scorecard.removeLastPlayer1Score.bind(this._scorecard)); + } + + addScore2() { + this._addScore(this.score2InputTarget, this.score1InputTarget, + this._scorecard.addPlayer2Score.bind(this._scorecard), + this._scorecard.removeLastPlayer2Score.bind(this._scorecard)); + + } + + _addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) { + let score = parseInt(inputElem.value); + if (isNaN(score)) { + score = 0; + } + + addScoreFn(score); + this._undoStack.push(queueUndoFn); + + inputElem.value = ""; + + storeDao.save(this._scorecard); + this.updateTable(); + + focusToInputElem.focus(); + } + + score1KeyDown(e) { + this._handleKeyDown(e, this.addScore1.bind(this)); + } + + score2KeyDown(e) { + this._handleKeyDown(e, this.addScore2.bind(this)); + } + + _handleKeyDown(e, addScoreFn) { + if (e.key === "Enter") { + e.preventDefault(); + addScoreFn(); + } + } + + undoLast() { + if (this._undoStack.length === 0) { + return; + } + if (!confirm("Really undo last move?")) { + return; + } + + (this._undoStack.pop())(); + + storeDao.save(this._scorecard); + this.updateTable(); + } + + resetAll() { + if (!confirm("Really reset?")) { + return; + } + + this._scorecard.reset(); + storeDao.clear(); + this._undoStack = []; + this.updateTable(); + } +} diff --git a/site/finska/scripts/main.js b/site/finska/scripts/main.js new file mode 100644 index 0000000..34ece68 --- /dev/null +++ b/site/finska/scripts/main.js @@ -0,0 +1,7 @@ +import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { FinskaScorecardController } from "./controllers.js" + +window.Stimulus = Application.start(); + +window.Stimulus.register('finska-scorecard', FinskaScorecardController); + diff --git a/site/finska/scripts/models.js b/site/finska/scripts/models.js new file mode 100644 index 0000000..61d917a --- /dev/null +++ b/site/finska/scripts/models.js @@ -0,0 +1,152 @@ +export class ScoreEntry { + constructor(score, total, foul, wasOverflow, wasWin) { + this.score = score; + this.total = total; + this.fouls = foul; + this.wasOverflow = wasOverflow; + this.wasWin = wasWin; + } +}; + +export class Scorecard { + + constructor(rules) { + this.rules = rules; + this.reset(); + } + + reset() { + this._player1Scores = []; + this._player2Scores = []; + } + + addPlayer1Score(newScore) { + this._addScore(this._player1Scores, newScore); + } + + removeLastPlayer1Score() { + this._player1Scores.pop(); + } + + addPlayer2Score(newScore) { + this._addScore(this._player2Scores, newScore); + } + + removeLastPlayer2Score() { + this._player2Scores.pop(); + } + + _addScore(playerScores, newScore) { + let lastEntry; + if (playerScores.length === 0) { + lastEntry = new ScoreEntry(0, 0, 0, false, false); + } else { + lastEntry = playerScores[playerScores.length - 1]; + } + + let newEntry = this._newEntryFromPrevious(newScore, lastEntry); + + playerScores.push(newEntry); + } + + length() { + return Math.max(this._player1Scores.length, this._player2Scores.length); + } + + pairs() { + let pairs = []; + + for (let i = 0; i < this.length(); i++) { + pairs.push({ + p1: (i < this._player1Scores.length ? this._player1Scores[i] : null), + p2: (i < this._player2Scores.length ? this._player2Scores[i] : null), + }) + } + + return pairs; + } + + _newEntryFromPrevious(score, previousScoreEntry) { + if (previousScoreEntry === null) { + return new ScoreEntry(score, score, (score === 0 ? 1 : 0)); + } + + let wasOverflow = false, wasWin = false; + let newTotal = previousScoreEntry.total + score; + + if (newTotal === this.rules.maxScore) { + wasWin = true; + } else if (newTotal > this.rules.maxScore) { + newTotal = this.rules.overflowScoreTo; + wasOverflow = true; + } + + let newFouls = previousScoreEntry.foul; + if (score === 0) { + newFouls++; + } + + return new ScoreEntry(score, newTotal, newFouls, wasOverflow, wasWin); + } + + toJson() { + return { + "version": 1, + "rules": this.rules, + "p1": { "scores": this._player1Scores.map(p => p.score) }, + "p2": { "scores": this._player2Scores.map(p => p.score) }, + }; + } + + static fromJson(o) { + let scorecard = new Scorecard(o.rules); + o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x)); + o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x)); + return scorecard; + } +} + +class StoreDAO { + + constructor(localStorage) { + this._localStorage = localStorage; + } + + save(scoreCard) { + this._localStorage.setItem('score_card', JSON.stringify(scoreCard.toJson())); + } + + loadOrCreate(rules) { + try { + console.log("Loading scorecard"); + let scoreCardJson = this._localStorage.getItem('score_card'); + if (scoreCardJson !== null) { + return Scorecard.fromJson(JSON.parse(scoreCardJson)); + } + } catch (e) { + console.log(`Could not restore game: ${e}`); + } + + return new Scorecard(rules); + } + + clear() { + this._localStorage.clear(); + } +} + +class DummyStoreDAO { + save(scoreCard) { } + loadOrCreate(rules) { + return new Scorecard(rules); + } + clear() { } +} + +export function getStoreDAO() { + if (!!window.localStorage) { + return new StoreDAO(window.localStorage); + } else { + return new DummyStoreDAO(); + } +} \ No newline at end of file diff --git a/site/finska/style.css b/site/finska/style.css new file mode 100644 index 0000000..f4ef62c --- /dev/null +++ b/site/finska/style.css @@ -0,0 +1,17 @@ +.score-foul { + color: #861D13; + background-color: #F8DCD6; + font-weight: bold; +} + +.score-overflow { + color: #5B4200; + background-color: #FCEFD9; + font-weight: bold; +} + +.score-win { + color: #394D00; + background-color: #DEFC85; + font-weight: bold; +} \ No newline at end of file From 6caadc9c001c2447d38b503f9fe40640f04aed72 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 21 Dec 2025 22:59:51 +0100 Subject: [PATCH 03/18] Added to the finksa scorecard --- site/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/site/index.html b/site/index.html index 444f37f..85da7ef 100644 --- a/site/index.html +++ b/site/index.html @@ -25,6 +25,7 @@
  • Two-letter Country Codes
  • Timestamp Converter
  • Mahjong Scorecard
  • +
  • Finska Scorecard
  • From a6b5dff6798a433f814a87e31432bcf1a9ca28f0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 21 Dec 2025 23:06:43 +0100 Subject: [PATCH 04/18] Fixed netlify CLI --- .forgejo/workflows/publish.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.forgejo/workflows/publish.yaml b/.forgejo/workflows/publish.yaml index b8f7077..d192d28 100644 --- a/.forgejo/workflows/publish.yaml +++ b/.forgejo/workflows/publish.yaml @@ -23,4 +23,5 @@ jobs: make - name: Deploy run: | - netlify deploy --dir target --prod \ No newline at end of file + npm install netlify-cli --save-dev + npx netlify deploy --dir target --prod \ No newline at end of file From 6095bc9c8d1321e0a48c4192343d2d62aa22bce6 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 22 Dec 2025 01:41:32 +0100 Subject: [PATCH 05/18] finska: fixed reset and changed localstorage name --- site/finska/index.html | 4 ++-- site/finska/scripts/models.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/site/finska/index.html b/site/finska/index.html index 3112edc..1aea057 100644 --- a/site/finska/index.html +++ b/site/finska/index.html @@ -30,14 +30,14 @@
    -
    -
    diff --git a/site/finska/scripts/models.js b/site/finska/scripts/models.js index 61d917a..321122d 100644 --- a/site/finska/scripts/models.js +++ b/site/finska/scripts/models.js @@ -113,13 +113,13 @@ class StoreDAO { } save(scoreCard) { - this._localStorage.setItem('score_card', JSON.stringify(scoreCard.toJson())); + this._localStorage.setItem('finska-scorecard', JSON.stringify(scoreCard.toJson())); } loadOrCreate(rules) { try { console.log("Loading scorecard"); - let scoreCardJson = this._localStorage.getItem('score_card'); + let scoreCardJson = this._localStorage.getItem('finska-scorecard'); if (scoreCardJson !== null) { return Scorecard.fromJson(JSON.parse(scoreCardJson)); } @@ -131,7 +131,7 @@ class StoreDAO { } clear() { - this._localStorage.clear(); + this._localStorage.removeItem('finska-scorecard'); } } From 1b5d3d81b2a172106d101d36d25d1a29715295cd Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 22 Dec 2025 01:48:40 +0100 Subject: [PATCH 06/18] finska: slight update to the input borders --- site/finska/style.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/site/finska/style.css b/site/finska/style.css index f4ef62c..e2cd398 100644 --- a/site/finska/style.css +++ b/site/finska/style.css @@ -1,3 +1,7 @@ +tfoot input { + border-width: 1px; +} + .score-foul { color: #861D13; background-color: #F8DCD6; From 5bcff37e0740dd8c57abac1cdbfad2f9e5597b96 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 22 Dec 2025 23:50:47 +0100 Subject: [PATCH 07/18] scorecard: added a generic scorecard --- site/finska/index.html | 2 +- site/index.html | 1 + site/scorecard/index.html | 57 +++++++++++ site/scorecard/scripts/controllers.js | 137 ++++++++++++++++++++++++++ site/scorecard/scripts/main.js | 7 ++ site/scorecard/scripts/models.js | 133 +++++++++++++++++++++++++ site/scorecard/style.css | 21 ++++ 7 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 site/scorecard/index.html create mode 100644 site/scorecard/scripts/controllers.js create mode 100644 site/scorecard/scripts/main.js create mode 100644 site/scorecard/scripts/models.js create mode 100644 site/scorecard/style.css diff --git a/site/finska/index.html b/site/finska/index.html index 1aea057..c9e5918 100644 --- a/site/finska/index.html +++ b/site/finska/index.html @@ -3,7 +3,7 @@ - Mahjong Score Card - Tools + Finska Score Card - Tools Gradient Bands
  • Two-letter Country Codes
  • Timestamp Converter
  • +
  • Generic Scorecard
  • Mahjong Scorecard
  • Finska Scorecard
  • diff --git a/site/scorecard/index.html b/site/scorecard/index.html new file mode 100644 index 0000000..0f0fe13 --- /dev/null +++ b/site/scorecard/index.html @@ -0,0 +1,57 @@ + + + + + + Score Card - Tools + + + + + +

    Scorecard

    + +
    + + + + + + + + + + + + + + + + + +
    Player APlayer B
    +
    + +
    +
    +
    + +
    +
    + +
    + + +
    +
    + + \ No newline at end of file diff --git a/site/scorecard/scripts/controllers.js b/site/scorecard/scripts/controllers.js new file mode 100644 index 0000000..d06c0a5 --- /dev/null +++ b/site/scorecard/scripts/controllers.js @@ -0,0 +1,137 @@ +import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { Scorecard, getStoreDAO } from "./models.js"; + +const storeDao = getStoreDAO(); + +export class FinskaScorecardController extends Controller { + static targets = ["score1Input", "score2Input", "scoreTable"]; + + connect() { + this._undoStack = []; + this._scorecard = storeDao.loadOrCreate(); + this.updateTable(); + } + + updateTable() { + let tableBody = this.scoreTableTarget; + let tableRows = tableBody.querySelectorAll("tr"); + let pairs = this._scorecard.pairs(); + + for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) { + let tableRow; + if (pairIndex >= tableRows.length) { + tableRow = this._appendRow(); + } else { + tableRow = tableRows[pairIndex]; + } + + this._updateRow(tableRow, pairs[pairIndex]) + } + + // Remove any extra rows + for (let i = pairs.length; i < tableRows.length; i++) { + tableBody.removeChild(tableRows[i]); + } + + console.log(JSON.stringify(this._scorecard.toJson())); + } + + _updateRow(tableRow, pair) { + let tds = tableRow.querySelectorAll("td"); + + this._updateCell(pair.p1, tds[0], tds[1]); + this._updateCell(pair.p2, tds[2], tds[3]); + } + + _updateCell(score, scoreCell, totalCell) { + scoreCell.classList.value = ""; + totalCell.classList.value = ""; + + if (score != null) { + scoreCell.textContent = score.score; + totalCell.textContent = score.total; + } else { + scoreCell.textContent = ""; + totalCell.textContent = ""; + } + } + + _appendRow() { + let newRow = document.createElement("tr"); + newRow.classList.add("score-entry"); + + for (let i = 0; i < 4; i++) { + newRow.appendChild(document.createElement("td")); + } + + this.scoreTableTarget.appendChild(newRow); + return newRow; + } + + addScore1() { + this._addScore(this.score1InputTarget, this.score2InputTarget, + this._scorecard.addPlayer1Score.bind(this._scorecard), + this._scorecard.removeLastPlayer1Score.bind(this._scorecard)); + } + + addScore2() { + this._addScore(this.score2InputTarget, this.score1InputTarget, + this._scorecard.addPlayer2Score.bind(this._scorecard), + this._scorecard.removeLastPlayer2Score.bind(this._scorecard)); + + } + + _addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) { + let score = parseInt(inputElem.value); + if (isNaN(score)) { + score = 0; + } + + addScoreFn(score); + this._undoStack.push(queueUndoFn); + + inputElem.value = ""; + + storeDao.save(this._scorecard); + this.updateTable(); + + focusToInputElem.focus(); + } + + score1KeyDown(e) { + this._handleKeyDown(e, this.addScore1.bind(this)); + } + + score2KeyDown(e) { + this._handleKeyDown(e, this.addScore2.bind(this)); + } + + _handleKeyDown(e, addScoreFn) { + if (e.key === "Enter") { + e.preventDefault(); + addScoreFn(); + } + } + + undoLast() { + if (this._undoStack.length === 0) { + return; + } + + (this._undoStack.pop())(); + + storeDao.save(this._scorecard); + this.updateTable(); + } + + resetAll() { + if (!confirm("Really reset?")) { + return; + } + + this._scorecard.reset(); + storeDao.clear(); + this._undoStack = []; + this.updateTable(); + } +} diff --git a/site/scorecard/scripts/main.js b/site/scorecard/scripts/main.js new file mode 100644 index 0000000..34ece68 --- /dev/null +++ b/site/scorecard/scripts/main.js @@ -0,0 +1,7 @@ +import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { FinskaScorecardController } from "./controllers.js" + +window.Stimulus = Application.start(); + +window.Stimulus.register('finska-scorecard', FinskaScorecardController); + diff --git a/site/scorecard/scripts/models.js b/site/scorecard/scripts/models.js new file mode 100644 index 0000000..fb97c05 --- /dev/null +++ b/site/scorecard/scripts/models.js @@ -0,0 +1,133 @@ +export class ScoreEntry { + constructor(score, total) { + this.score = score; + this.total = total; + } +}; + +export class Scorecard { + + constructor() { + this.reset(); + } + + reset() { + this._player1Scores = []; + this._player2Scores = []; + } + + addPlayer1Score(newScore) { + this._addScore(this._player1Scores, newScore); + } + + removeLastPlayer1Score() { + this._player1Scores.pop(); + } + + addPlayer2Score(newScore) { + this._addScore(this._player2Scores, newScore); + } + + removeLastPlayer2Score() { + this._player2Scores.pop(); + } + + _addScore(playerScores, newScore) { + let lastEntry; + if (playerScores.length === 0) { + lastEntry = new ScoreEntry(0, 0, 0, false, false); + } else { + lastEntry = playerScores[playerScores.length - 1]; + } + + let newEntry = this._newEntryFromPrevious(newScore, lastEntry); + + playerScores.push(newEntry); + } + + length() { + return Math.max(this._player1Scores.length, this._player2Scores.length); + } + + pairs() { + let pairs = []; + + for (let i = 0; i < this.length(); i++) { + pairs.push({ + p1: (i < this._player1Scores.length ? this._player1Scores[i] : null), + p2: (i < this._player2Scores.length ? this._player2Scores[i] : null), + }) + } + + return pairs; + } + + _newEntryFromPrevious(score, previousScoreEntry) { + if (previousScoreEntry === null) { + return new ScoreEntry(score, score, (score === 0 ? 1 : 0)); + } + + let newTotal = previousScoreEntry.total + score; + return new ScoreEntry(score, newTotal); + } + + toJson() { + return { + "version": 1, + "p1": { "scores": this._player1Scores.map(p => p.score) }, + "p2": { "scores": this._player2Scores.map(p => p.score) }, + }; + } + + static fromJson(o) { + let scorecard = new Scorecard(); + o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x)); + o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x)); + return scorecard; + } +} + +class StoreDAO { + + constructor(localStorage) { + this._localStorage = localStorage; + } + + save(scoreCard) { + this._localStorage.setItem('generic-scorecard', JSON.stringify(scoreCard.toJson())); + } + + loadOrCreate() { + try { + console.log("Loading scorecard"); + let scoreCardJson = this._localStorage.getItem('generic-scorecard'); + if (scoreCardJson !== null) { + return Scorecard.fromJson(JSON.parse(scoreCardJson)); + } + } catch (e) { + console.log(`Could not restore game: ${e}`); + } + + return new Scorecard(); + } + + clear() { + this._localStorage.removeItem('generic-scorecard'); + } +} + +class DummyStoreDAO { + save(scoreCard) { } + loadOrCreate() { + return new Scorecard(); + } + clear() { } +} + +export function getStoreDAO() { + if (!!window.localStorage) { + return new StoreDAO(window.localStorage); + } else { + return new DummyStoreDAO(); + } +} \ No newline at end of file diff --git a/site/scorecard/style.css b/site/scorecard/style.css new file mode 100644 index 0000000..e2cd398 --- /dev/null +++ b/site/scorecard/style.css @@ -0,0 +1,21 @@ +tfoot input { + border-width: 1px; +} + +.score-foul { + color: #861D13; + background-color: #F8DCD6; + font-weight: bold; +} + +.score-overflow { + color: #5B4200; + background-color: #FCEFD9; + font-weight: bold; +} + +.score-win { + color: #394D00; + background-color: #DEFC85; + font-weight: bold; +} \ No newline at end of file From cbd55175ae342b7fbf67ae67749d232cf755eb1f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 23 Dec 2025 00:21:03 +0100 Subject: [PATCH 08/18] scorecard-4p: added the new 4p version --- site/{scorecard => scorecard-4p}/index.html | 16 +++++++++ .../scripts/controllers.js | 35 +++++++++++++++---- .../scripts/main.js | 0 .../scripts/models.js | 35 ++++++++++++++++--- site/{scorecard => scorecard-4p}/style.css | 0 5 files changed, 74 insertions(+), 12 deletions(-) rename site/{scorecard => scorecard-4p}/index.html (72%) rename site/{scorecard => scorecard-4p}/scripts/controllers.js (77%) rename site/{scorecard => scorecard-4p}/scripts/main.js (100%) rename site/{scorecard => scorecard-4p}/scripts/models.js (75%) rename site/{scorecard => scorecard-4p}/style.css (100%) diff --git a/site/scorecard/index.html b/site/scorecard-4p/index.html similarity index 72% rename from site/scorecard/index.html rename to site/scorecard-4p/index.html index 0f0fe13..a54d119 100644 --- a/site/scorecard/index.html +++ b/site/scorecard-4p/index.html @@ -20,6 +20,8 @@ Player A Player B + Player C + Player D @@ -40,6 +42,20 @@ + +
    + +
    + + + +
    + +
    + + diff --git a/site/scorecard/scripts/controllers.js b/site/scorecard-4p/scripts/controllers.js similarity index 77% rename from site/scorecard/scripts/controllers.js rename to site/scorecard-4p/scripts/controllers.js index d06c0a5..5159f4f 100644 --- a/site/scorecard/scripts/controllers.js +++ b/site/scorecard-4p/scripts/controllers.js @@ -4,7 +4,7 @@ import { Scorecard, getStoreDAO } from "./models.js"; const storeDao = getStoreDAO(); export class FinskaScorecardController extends Controller { - static targets = ["score1Input", "score2Input", "scoreTable"]; + static targets = ["score1Input", "score2Input", "score3Input", "score4Input", "scoreTable"]; connect() { this._undoStack = []; @@ -38,9 +38,11 @@ export class FinskaScorecardController extends Controller { _updateRow(tableRow, pair) { let tds = tableRow.querySelectorAll("td"); - + this._updateCell(pair.p1, tds[0], tds[1]); this._updateCell(pair.p2, tds[2], tds[3]); + this._updateCell(pair.p3, tds[4], tds[5]); + this._updateCell(pair.p4, tds[6], tds[7]); } _updateCell(score, scoreCell, totalCell) { @@ -59,11 +61,11 @@ export class FinskaScorecardController extends Controller { _appendRow() { let newRow = document.createElement("tr"); newRow.classList.add("score-entry"); - - for (let i = 0; i < 4; i++) { + + for (let i = 0; i < 8; i++) { newRow.appendChild(document.createElement("td")); } - + this.scoreTableTarget.appendChild(newRow); return newRow; } @@ -75,10 +77,21 @@ export class FinskaScorecardController extends Controller { } addScore2() { - this._addScore(this.score2InputTarget, this.score1InputTarget, + this._addScore(this.score2InputTarget, this.score3InputTarget, this._scorecard.addPlayer2Score.bind(this._scorecard), this._scorecard.removeLastPlayer2Score.bind(this._scorecard)); - + } + + addScore3() { + this._addScore(this.score3InputTarget, this.score4InputTarget, + this._scorecard.addPlayer3Score.bind(this._scorecard), + this._scorecard.removeLastPlayer3Score.bind(this._scorecard)); + } + + addScore4() { + this._addScore(this.score4InputTarget, this.score1InputTarget, + this._scorecard.addPlayer4Score.bind(this._scorecard), + this._scorecard.removeLastPlayer4Score.bind(this._scorecard)); } _addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) { @@ -105,6 +118,14 @@ export class FinskaScorecardController extends Controller { score2KeyDown(e) { this._handleKeyDown(e, this.addScore2.bind(this)); } + + score3KeyDown(e) { + this._handleKeyDown(e, this.addScore3.bind(this)); + } + + score4KeyDown(e) { + this._handleKeyDown(e, this.addScore4.bind(this)); + } _handleKeyDown(e, addScoreFn) { if (e.key === "Enter") { diff --git a/site/scorecard/scripts/main.js b/site/scorecard-4p/scripts/main.js similarity index 100% rename from site/scorecard/scripts/main.js rename to site/scorecard-4p/scripts/main.js diff --git a/site/scorecard/scripts/models.js b/site/scorecard-4p/scripts/models.js similarity index 75% rename from site/scorecard/scripts/models.js rename to site/scorecard-4p/scripts/models.js index fb97c05..1b0e894 100644 --- a/site/scorecard/scripts/models.js +++ b/site/scorecard-4p/scripts/models.js @@ -13,7 +13,9 @@ export class Scorecard { reset() { this._player1Scores = []; - this._player2Scores = []; + this._player2Scores = []; + this._player3Scores = []; + this._player4Scores = []; } addPlayer1Score(newScore) { @@ -29,7 +31,23 @@ export class Scorecard { } removeLastPlayer2Score() { - this._player2Scores.pop(); + this._player2Scores.pop(); + } + + addPlayer3Score(newScore) { + this._addScore(this._player3Scores, newScore); + } + + removeLastPlayer3Score() { + this._player3Scores.pop(); + } + + addPlayer4Score(newScore) { + this._addScore(this._player4Scores, newScore); + } + + removeLastPlayer4Score() { + this._player4Scores.pop(); } _addScore(playerScores, newScore) { @@ -46,19 +64,22 @@ export class Scorecard { } length() { - return Math.max(this._player1Scores.length, this._player2Scores.length); + return Math.max(this._player1Scores.length, this._player2Scores.length, + this._player3Scores.length, this._player4Scores.length); } pairs() { let pairs = []; - + for (let i = 0; i < this.length(); i++) { pairs.push({ p1: (i < this._player1Scores.length ? this._player1Scores[i] : null), p2: (i < this._player2Scores.length ? this._player2Scores[i] : null), + p3: (i < this._player3Scores.length ? this._player3Scores[i] : null), + p4: (i < this._player4Scores.length ? this._player4Scores[i] : null), }) } - + return pairs; } @@ -76,6 +97,8 @@ export class Scorecard { "version": 1, "p1": { "scores": this._player1Scores.map(p => p.score) }, "p2": { "scores": this._player2Scores.map(p => p.score) }, + "p3": { "scores": this._player3Scores.map(p => p.score) }, + "p4": { "scores": this._player4Scores.map(p => p.score) }, }; } @@ -83,6 +106,8 @@ export class Scorecard { let scorecard = new Scorecard(); o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x)); o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x)); + if (o["p3"]) o["p3"]["scores"].forEach(x => scorecard.addPlayer3Score(x)); + if (o["p4"]) o["p4"]["scores"].forEach(x => scorecard.addPlayer4Score(x)); return scorecard; } } diff --git a/site/scorecard/style.css b/site/scorecard-4p/style.css similarity index 100% rename from site/scorecard/style.css rename to site/scorecard-4p/style.css From 6f258bf13d97cc9086e3156d296c682a7eea1240 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 23 Dec 2025 00:29:40 +0100 Subject: [PATCH 09/18] Split generic scorecard with 2 and 4 player variants --- site/index.html | 3 +- site/scorecard-2p/index.html | 63 +++++++++++ site/scorecard-2p/main.js | 0 site/scorecard-2p/scripts/controllers.js | 137 +++++++++++++++++++++++ site/scorecard-2p/scripts/main.js | 7 ++ site/scorecard-2p/scripts/models.js | 133 ++++++++++++++++++++++ site/scorecard-2p/style.css | 21 ++++ site/scorecard-4p/index.html | 8 +- site/scorecard-4p/scripts/models.js | 6 +- 9 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 site/scorecard-2p/index.html create mode 100644 site/scorecard-2p/main.js create mode 100644 site/scorecard-2p/scripts/controllers.js create mode 100644 site/scorecard-2p/scripts/main.js create mode 100644 site/scorecard-2p/scripts/models.js create mode 100644 site/scorecard-2p/style.css diff --git a/site/index.html b/site/index.html index 683ba05..d36f391 100644 --- a/site/index.html +++ b/site/index.html @@ -24,7 +24,8 @@
  • Gradient Bands
  • Two-letter Country Codes
  • Timestamp Converter
  • -
  • Generic Scorecard
  • +
  • Generic Scorecard - 2 Players
  • +
  • Generic Scorecard - 4 Players
  • Mahjong Scorecard
  • Finska Scorecard
  • diff --git a/site/scorecard-2p/index.html b/site/scorecard-2p/index.html new file mode 100644 index 0000000..0bf3b74 --- /dev/null +++ b/site/scorecard-2p/index.html @@ -0,0 +1,63 @@ + + + + + + Score Card - Tools + + + + + +
    +
    +

    Scorecard

    +

    2 Players

    +
    +
    + + +
    + + + + + + + + + + + + + + + + + +
    Player APlayer B
    +
    + +
    +
    +
    + +
    +
    + +
    + + +
    +
    + + \ No newline at end of file diff --git a/site/scorecard-2p/main.js b/site/scorecard-2p/main.js new file mode 100644 index 0000000..e69de29 diff --git a/site/scorecard-2p/scripts/controllers.js b/site/scorecard-2p/scripts/controllers.js new file mode 100644 index 0000000..d06c0a5 --- /dev/null +++ b/site/scorecard-2p/scripts/controllers.js @@ -0,0 +1,137 @@ +import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { Scorecard, getStoreDAO } from "./models.js"; + +const storeDao = getStoreDAO(); + +export class FinskaScorecardController extends Controller { + static targets = ["score1Input", "score2Input", "scoreTable"]; + + connect() { + this._undoStack = []; + this._scorecard = storeDao.loadOrCreate(); + this.updateTable(); + } + + updateTable() { + let tableBody = this.scoreTableTarget; + let tableRows = tableBody.querySelectorAll("tr"); + let pairs = this._scorecard.pairs(); + + for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) { + let tableRow; + if (pairIndex >= tableRows.length) { + tableRow = this._appendRow(); + } else { + tableRow = tableRows[pairIndex]; + } + + this._updateRow(tableRow, pairs[pairIndex]) + } + + // Remove any extra rows + for (let i = pairs.length; i < tableRows.length; i++) { + tableBody.removeChild(tableRows[i]); + } + + console.log(JSON.stringify(this._scorecard.toJson())); + } + + _updateRow(tableRow, pair) { + let tds = tableRow.querySelectorAll("td"); + + this._updateCell(pair.p1, tds[0], tds[1]); + this._updateCell(pair.p2, tds[2], tds[3]); + } + + _updateCell(score, scoreCell, totalCell) { + scoreCell.classList.value = ""; + totalCell.classList.value = ""; + + if (score != null) { + scoreCell.textContent = score.score; + totalCell.textContent = score.total; + } else { + scoreCell.textContent = ""; + totalCell.textContent = ""; + } + } + + _appendRow() { + let newRow = document.createElement("tr"); + newRow.classList.add("score-entry"); + + for (let i = 0; i < 4; i++) { + newRow.appendChild(document.createElement("td")); + } + + this.scoreTableTarget.appendChild(newRow); + return newRow; + } + + addScore1() { + this._addScore(this.score1InputTarget, this.score2InputTarget, + this._scorecard.addPlayer1Score.bind(this._scorecard), + this._scorecard.removeLastPlayer1Score.bind(this._scorecard)); + } + + addScore2() { + this._addScore(this.score2InputTarget, this.score1InputTarget, + this._scorecard.addPlayer2Score.bind(this._scorecard), + this._scorecard.removeLastPlayer2Score.bind(this._scorecard)); + + } + + _addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) { + let score = parseInt(inputElem.value); + if (isNaN(score)) { + score = 0; + } + + addScoreFn(score); + this._undoStack.push(queueUndoFn); + + inputElem.value = ""; + + storeDao.save(this._scorecard); + this.updateTable(); + + focusToInputElem.focus(); + } + + score1KeyDown(e) { + this._handleKeyDown(e, this.addScore1.bind(this)); + } + + score2KeyDown(e) { + this._handleKeyDown(e, this.addScore2.bind(this)); + } + + _handleKeyDown(e, addScoreFn) { + if (e.key === "Enter") { + e.preventDefault(); + addScoreFn(); + } + } + + undoLast() { + if (this._undoStack.length === 0) { + return; + } + + (this._undoStack.pop())(); + + storeDao.save(this._scorecard); + this.updateTable(); + } + + resetAll() { + if (!confirm("Really reset?")) { + return; + } + + this._scorecard.reset(); + storeDao.clear(); + this._undoStack = []; + this.updateTable(); + } +} diff --git a/site/scorecard-2p/scripts/main.js b/site/scorecard-2p/scripts/main.js new file mode 100644 index 0000000..34ece68 --- /dev/null +++ b/site/scorecard-2p/scripts/main.js @@ -0,0 +1,7 @@ +import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { FinskaScorecardController } from "./controllers.js" + +window.Stimulus = Application.start(); + +window.Stimulus.register('finska-scorecard', FinskaScorecardController); + diff --git a/site/scorecard-2p/scripts/models.js b/site/scorecard-2p/scripts/models.js new file mode 100644 index 0000000..64cd910 --- /dev/null +++ b/site/scorecard-2p/scripts/models.js @@ -0,0 +1,133 @@ +export class ScoreEntry { + constructor(score, total) { + this.score = score; + this.total = total; + } +}; + +export class Scorecard { + + constructor() { + this.reset(); + } + + reset() { + this._player1Scores = []; + this._player2Scores = []; + } + + addPlayer1Score(newScore) { + this._addScore(this._player1Scores, newScore); + } + + removeLastPlayer1Score() { + this._player1Scores.pop(); + } + + addPlayer2Score(newScore) { + this._addScore(this._player2Scores, newScore); + } + + removeLastPlayer2Score() { + this._player2Scores.pop(); + } + + _addScore(playerScores, newScore) { + let lastEntry; + if (playerScores.length === 0) { + lastEntry = new ScoreEntry(0, 0, 0, false, false); + } else { + lastEntry = playerScores[playerScores.length - 1]; + } + + let newEntry = this._newEntryFromPrevious(newScore, lastEntry); + + playerScores.push(newEntry); + } + + length() { + return Math.max(this._player1Scores.length, this._player2Scores.length); + } + + pairs() { + let pairs = []; + + for (let i = 0; i < this.length(); i++) { + pairs.push({ + p1: (i < this._player1Scores.length ? this._player1Scores[i] : null), + p2: (i < this._player2Scores.length ? this._player2Scores[i] : null), + }) + } + + return pairs; + } + + _newEntryFromPrevious(score, previousScoreEntry) { + if (previousScoreEntry === null) { + return new ScoreEntry(score, score, (score === 0 ? 1 : 0)); + } + + let newTotal = previousScoreEntry.total + score; + return new ScoreEntry(score, newTotal); + } + + toJson() { + return { + "version": 1, + "p1": { "scores": this._player1Scores.map(p => p.score) }, + "p2": { "scores": this._player2Scores.map(p => p.score) }, + }; + } + + static fromJson(o) { + let scorecard = new Scorecard(); + o["p1"]["scores"].forEach(x => scorecard.addPlayer1Score(x)); + o["p2"]["scores"].forEach(x => scorecard.addPlayer2Score(x)); + return scorecard; + } +} + +class StoreDAO { + + constructor(localStorage) { + this._localStorage = localStorage; + } + + save(scoreCard) { + this._localStorage.setItem('generic-scorecard-2p', JSON.stringify(scoreCard.toJson())); + } + + loadOrCreate() { + try { + console.log("Loading scorecard"); + let scoreCardJson = this._localStorage.getItem('generic-scorecard-2p'); + if (scoreCardJson !== null) { + return Scorecard.fromJson(JSON.parse(scoreCardJson)); + } + } catch (e) { + console.log(`Could not restore game: ${e}`); + } + + return new Scorecard(); + } + + clear() { + this._localStorage.removeItem('generic-scorecard-2p'); + } +} + +class DummyStoreDAO { + save(scoreCard) { } + loadOrCreate() { + return new Scorecard(); + } + clear() { } +} + +export function getStoreDAO() { + if (!!window.localStorage) { + return new StoreDAO(window.localStorage); + } else { + return new DummyStoreDAO(); + } +} \ No newline at end of file diff --git a/site/scorecard-2p/style.css b/site/scorecard-2p/style.css new file mode 100644 index 0000000..e2cd398 --- /dev/null +++ b/site/scorecard-2p/style.css @@ -0,0 +1,21 @@ +tfoot input { + border-width: 1px; +} + +.score-foul { + color: #861D13; + background-color: #F8DCD6; + font-weight: bold; +} + +.score-overflow { + color: #5B4200; + background-color: #FCEFD9; + font-weight: bold; +} + +.score-win { + color: #394D00; + background-color: #DEFC85; + font-weight: bold; +} \ No newline at end of file diff --git a/site/scorecard-4p/index.html b/site/scorecard-4p/index.html index a54d119..d495e38 100644 --- a/site/scorecard-4p/index.html +++ b/site/scorecard-4p/index.html @@ -12,7 +12,13 @@ -

    Scorecard

    +
    +
    +

    Scorecard

    +

    4 Players

    +
    +
    +
    diff --git a/site/scorecard-4p/scripts/models.js b/site/scorecard-4p/scripts/models.js index 1b0e894..2472177 100644 --- a/site/scorecard-4p/scripts/models.js +++ b/site/scorecard-4p/scripts/models.js @@ -119,13 +119,13 @@ class StoreDAO { } save(scoreCard) { - this._localStorage.setItem('generic-scorecard', JSON.stringify(scoreCard.toJson())); + this._localStorage.setItem('generic-scorecard-4p', JSON.stringify(scoreCard.toJson())); } loadOrCreate() { try { console.log("Loading scorecard"); - let scoreCardJson = this._localStorage.getItem('generic-scorecard'); + let scoreCardJson = this._localStorage.getItem('generic-scorecard-4p'); if (scoreCardJson !== null) { return Scorecard.fromJson(JSON.parse(scoreCardJson)); } @@ -137,7 +137,7 @@ class StoreDAO { } clear() { - this._localStorage.removeItem('generic-scorecard'); + this._localStorage.removeItem('generic-scorecard-4p'); } } From c3a43148c08135bb6dd5b8e858850314b2954d78 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 23 Dec 2025 02:17:36 +0100 Subject: [PATCH 10/18] Added neon snake --- site/index.html | 1 + site/neon-snake/index.html | 441 +++++++++++++++++++++++++++++++++++++ 2 files changed, 442 insertions(+) create mode 100644 site/neon-snake/index.html diff --git a/site/index.html b/site/index.html index d36f391..b5f8c92 100644 --- a/site/index.html +++ b/site/index.html @@ -28,6 +28,7 @@
  • Generic Scorecard - 4 Players
  • Mahjong Scorecard
  • Finska Scorecard
  • +
  • Neon Snake: vibe-coded by Google Gemini
  • diff --git a/site/neon-snake/index.html b/site/neon-snake/index.html new file mode 100644 index 0000000..07629b3 --- /dev/null +++ b/site/neon-snake/index.html @@ -0,0 +1,441 @@ + + + + + + Neon Snake + + + + +
    + +
    + + +
    +
    + SCORE: 000 + HI: 000 +
    +
    + +
    +

    NEON SNAKE

    +

    USE ARROW KEYS OR SWIPE

    + +
    + + +
    + +
    Desktop: Arrows/WASD | Mobile: Swipe
    + + + + \ No newline at end of file From beb38f17e872fe2a44f0b2121703cff7eee6138f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 23 Dec 2025 04:00:42 +0100 Subject: [PATCH 11/18] mahjong: fixed scores for bonuses --- site/mahjong/guide.html | 2 +- site/mahjong/models.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/mahjong/guide.html b/site/mahjong/guide.html index 0609f8f..3133de2 100644 --- a/site/mahjong/guide.html +++ b/site/mahjong/guide.html @@ -107,7 +107,7 @@
    - + diff --git a/site/mahjong/models.js b/site/mahjong/models.js index d49ee27..6789bc4 100644 --- a/site/mahjong/models.js +++ b/site/mahjong/models.js @@ -22,7 +22,7 @@ const scoreTokens = { 'xkh': 16, 'pd': 2, 'pw': 2, - 'b': 4, + 'b': 2, 'p': 1, 'd': -1, } From ca67aadac04c7e26bc91f0296e3856cceefa4dd8 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 23 Dec 2025 22:04:27 +0100 Subject: [PATCH 12/18] neon-snake: allow starting game on keyboard --- site/neon-snake/index.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/site/neon-snake/index.html b/site/neon-snake/index.html index 07629b3..4537aa1 100644 --- a/site/neon-snake/index.html +++ b/site/neon-snake/index.html @@ -174,7 +174,7 @@ -
    Desktop: Arrows/WASD | Mobile: Swipe
    +
    Desktop: Arrows/WASD/IJKL | Mobile: Swipe
    + + +
    +
    +
    +

    Mental Arithmatic

    +

    A simple mental arithmetic game

    +
    +
    + +
    +
    +
    + + + \ No newline at end of file diff --git a/site/mental-arithmatic/scripts/game.js b/site/mental-arithmatic/scripts/game.js new file mode 100644 index 0000000..e583a19 --- /dev/null +++ b/site/mental-arithmatic/scripts/game.js @@ -0,0 +1,104 @@ +import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; + +export class GameController extends Controller { + static targets = [ + "clock", + "score", + "problem", + "indicators", + "answer", + ]; + + start() { + this.startGame(); + this.element.classList.remove('hidden'); + } + + submitAnswer(ev) { + ev.preventDefault(); + + this._checkAnswer(); + } + + startGame() { + this._clock = 120; + this._score = 0; + + this._generateProblem(); + this._startClock(); + + this._updateClock(); + } + + _startClock() { + this._clockInterval = window.setInterval(() => { + if (this._clock <= 0) { + this._gameOver(); + return; + } + + this._clock -= 1; + this._updateClock(); + }, 1000); + } + + _gameOver() { + window.clearInterval(this._clockInterval); + + this.element.classList.add('hidden'); + window.dispatchEvent(new CustomEvent("endGame")); + } + + _generateProblem() { + const num1 = Math.floor(Math.random() * 99) + 1; + const num2 = Math.floor(Math.random() * 99) + 1; + + this._problem = [num1, num2]; + this._answer = num1 + num2; + + this.indicatorsTarget.classList.remove('right', 'wrong'); + this.problemTarget.innerHTML = ''; + + for (let i = 0; i < this._problem.length; i++) { + const div = document.createElement('div'); + + if (i === this._problem.length - 1) { + div.textContent = '+ ' + this._problem[i]; + } else { + div.textContent = this._problem[i]; + } + this.problemTarget.appendChild(div); + } + + this.answerTarget.disabled = false; + this.answerTarget.value = ""; + this.answerTarget.focus(); + } + + _checkAnswer() { + let isRight = parseInt(this.answerTarget.value) === this._answer; + + this.answerTarget.disabled = true; + + let delay = 500; + + if (isRight) { + this._score += 1; + this.indicatorsTarget.classList.add('right'); + } else { + this.indicatorsTarget.classList.add('wrong'); + this.answerTarget.value = this._answer; + delay = 800; + } + + this.scoreTarget.textContent = this._score; + + window.setTimeout(() => { this._generateProblem(); }, delay); + } + + _updateClock() { + let m = Math.floor(this._clock / 60); + let s = this._clock % 60; + this.clockTarget.textContent = `${m}:${s < 10 ? '0' : ''}${s}`; + } +} \ No newline at end of file diff --git a/site/mental-arithmatic/scripts/main.js b/site/mental-arithmatic/scripts/main.js new file mode 100644 index 0000000..c15b905 --- /dev/null +++ b/site/mental-arithmatic/scripts/main.js @@ -0,0 +1,8 @@ +import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; +import { WelcomeController } from "./welcome.js" +import { GameController } from "./game.js" + +window.Stimulus = Application.start(); + +window.Stimulus.register('welcome', WelcomeController); +window.Stimulus.register('game', GameController); \ No newline at end of file diff --git a/site/mental-arithmatic/scripts/welcome.js b/site/mental-arithmatic/scripts/welcome.js new file mode 100644 index 0000000..c4ce76f --- /dev/null +++ b/site/mental-arithmatic/scripts/welcome.js @@ -0,0 +1,14 @@ +import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"; + +export class WelcomeController extends Controller { + startGame(ev) { + ev.preventDefault(); + + this.element.classList.add('hidden'); + window.dispatchEvent(new CustomEvent("startGame")); + } + + gameEnded(ev) { + this.element.classList.remove('hidden'); + } +} \ No newline at end of file diff --git a/site/mental-arithmatic/style.css b/site/mental-arithmatic/style.css new file mode 100644 index 0000000..58d4173 --- /dev/null +++ b/site/mental-arithmatic/style.css @@ -0,0 +1,99 @@ +.hidden { + display: none !important; +} + +body { + min-height: 100dvh; + --pico-form-element-disabled-opacity: 1.0; +} + +.game-card { + display: flex; + flex-direction: column; + min-height: 80dvh; +} + +.game-card header, +.game-card footer { + display: flex; + justify-content: space-between; + + font-size: 2em; + margin-block: 12px; + margin-inline: 20px; +} + +.game-card main { + flex-grow: 1; + flex-shrink: 1; + + display: flex; + align-items: center; +} + +.game-card .question { + margin: auto; + width: 60vw; + + text-align: right; + font-size: 3em; +} + +.game-card .answer { + border-block: 2px solid black; + padding-block: 2px; + position: relative; +} + +.game-card .answer.right { + border-color: green; + color: green; +} + +.game-card .answer.wrong { + border-color: red; + color: red; +} + +.game-card .answer .indicator { + display: none; + position: absolute; + top: 0; + bottom: 0; + left: 0; +} + +.game-card .answer.right .indicator.right { + display: block; +} + +.game-card .answer.wrong .indicator.wrong { + display: block; +} + +.game-card .question input { + border-inline: none; + border-radius: 0; + box-shadow: none; + + border: none; + + margin: 0; + padding: 0; + + text-align: right; + font-size: 1em; + height: 1em; +} + +.game-card .question input:focus { + border-none: none; +} + +.game-card .answer.right input { + color: green; +} + +.game-card .answer.wrong input { + color: red; +} \ No newline at end of file From 89a6b4d062208c8e9c029a3f01f63e5f9c3e79ba Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 16 Jan 2026 22:12:30 +1100 Subject: [PATCH 14/18] Added a countdown timer --- site/mental-arithmatic/index.html | 27 +++++++++++++--- site/mental-arithmatic/scripts/countdown.js | 35 +++++++++++++++++++++ site/mental-arithmatic/scripts/game.js | 9 +++--- site/mental-arithmatic/scripts/main.js | 2 ++ site/mental-arithmatic/scripts/welcome.js | 23 +++++++++++++- site/mental-arithmatic/style.css | 35 +++++++++++++++++---- 6 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 site/mental-arithmatic/scripts/countdown.js diff --git a/site/mental-arithmatic/index.html b/site/mental-arithmatic/index.html index 31e8a6c..16c4321 100644 --- a/site/mental-arithmatic/index.html +++ b/site/mental-arithmatic/index.html @@ -2,25 +2,44 @@ - + Mental Arithmatic - Tools -
    -

    Mental Arithmatic

    +

    Fear of All Sums

    A simple mental arithmetic game

    - + +
    +

    Simple Sums

    +

    A two number sum with each term up to two digits.

    +
    + +
    +
    +
    +
    b Bonus (flower or season)42
    p
    + + + + + + + + + + + + + + + + diff --git a/site/hex-color/script.js b/site/hex-color/script.js new file mode 100644 index 0000000..07852ed --- /dev/null +++ b/site/hex-color/script.js @@ -0,0 +1,116 @@ +async function addHexFromClipboard() { + try { + const text = await navigator.clipboard.readText(); + const hex = text.trim(); + + // Parse hex color + const color = parseHexColor(hex); + if (!color) { + alert('Invalid hex color in clipboard. Expected format: #RRGGBB or #RRGGBBAA'); + return; + } + + // Add row to table + addColorRow(hex, color); + + // Show table if hidden + document.getElementById('colorTable').classList.remove('hidden'); + } catch (err) { + alert('Failed to read from clipboard: ' + err.message); + } +} + +document.getElementById('addHexBtn').addEventListener('click', addHexFromClipboard); + +document.getElementById('showHiddenBtn').addEventListener('click', () => { + const rows = document.querySelectorAll('#colorTableBody tr.hidden'); + rows.forEach(row => row.classList.remove('hidden')); + updateShowHiddenButton(); +}); + +document.addEventListener('keydown', (e) => { + if (e.key === 'p' || e.key === 'P') { + addHexFromClipboard(); + } +}); + +function parseHexColor(hex) { + // Remove leading hash if present + const cleanHex = hex.startsWith('#') ? hex.slice(1) : hex; + + // Validate hex string (6 or 8 characters) + if (!/^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$/.test(cleanHex)) { + return null; + } + + // Parse components + const r = parseInt(cleanHex.slice(0, 2), 16) / 255; + const g = parseInt(cleanHex.slice(2, 4), 16) / 255; + const b = parseInt(cleanHex.slice(4, 6), 16) / 255; + const a = cleanHex.length === 8 ? parseInt(cleanHex.slice(6, 8), 16) / 255 : 1.0; + + return { r, g, b, a, hex: '#' + cleanHex }; +} + +function addColorRow(originalHex, color) { + const tbody = document.getElementById('colorTableBody'); + const row = tbody.insertRow(); + + // Preview cell + const previewCell = row.insertCell(); + const preview = document.createElement('div'); + preview.style.width = '40px'; + preview.style.height = '40px'; + preview.style.backgroundColor = `rgba(${color.r * 255}, ${color.g * 255}, ${color.b * 255}, ${color.a})`; + preview.style.border = '1px solid #ccc'; + preview.style.borderRadius = '4px'; + previewCell.appendChild(preview); + + // Hex cell + const hexCell = row.insertCell(); + hexCell.textContent = color.hex; + + // Normalized components cell + const normalizedCell = row.insertCell(); + const normalizedText = `${color.r.toFixed(1)}, ${color.g.toFixed(1)}, ${color.b.toFixed(1)}, ${color.a.toFixed(1)}`; + normalizedCell.textContent = normalizedText; + + // Action cell with copy and hide buttons + const actionCell = row.insertCell(); + + const copyBtn = document.createElement('button'); + copyBtn.textContent = 'Copy'; + copyBtn.className = 'secondary'; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(normalizedText).then(() => { + const originalText = copyBtn.textContent; + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = originalText; + }, 1500); + }).catch(err => { + alert('Failed to copy: ' + err.message); + }); + }); + actionCell.appendChild(copyBtn); + + const hideBtn = document.createElement('button'); + hideBtn.textContent = 'Hide'; + hideBtn.className = 'secondary'; + hideBtn.addEventListener('click', () => { + row.classList.add('hidden'); + updateShowHiddenButton(); + }); + actionCell.appendChild(hideBtn); +} + +function updateShowHiddenButton() { + const hiddenRows = document.querySelectorAll('#colorTableBody tr.hidden'); + const showHiddenBtn = document.getElementById('showHiddenBtn'); + + if (hiddenRows.length > 0) { + showHiddenBtn.classList.remove('hidden'); + } else { + showHiddenBtn.classList.add('hidden'); + } +} diff --git a/site/hex-color/style.css b/site/hex-color/style.css new file mode 100644 index 0000000..a5b0997 --- /dev/null +++ b/site/hex-color/style.css @@ -0,0 +1,20 @@ +.hidden { + display: none; +} + +#colorTable { + margin-top: 2rem; +} + +#colorTable button { + margin: 0; + margin-right: 0.5rem; +} + +#colorTable td { + vertical-align: middle; +} + +#showHiddenBtn { + margin-top: 1rem; +} diff --git a/site/index.html b/site/index.html index 55afdd4..23d989a 100644 --- a/site/index.html +++ b/site/index.html @@ -30,6 +30,7 @@
  • Finska Scorecard
  • Mental Arithmatic Game
  • Neon Snake: vibe-coded by Google Gemini
  • +
  • Hex Color Converter
  • From c7ff8597aacffd1413ee16860441306689778a65 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 12 Mar 2026 22:52:25 +1100 Subject: [PATCH 16/18] Some more tools around Android icons --- CLAUDE.md | 11 +++ Makefile | 1 + cmds/android-icons/main.go | 144 +++++++++++++++++++++++++++++ go.mod | 7 +- go.sum | 2 + site/android-icons/index.html | 34 +++++++ site/android-icons/main.js | 136 +++++++++++++++++++++++++++ site/android-icons/style.css | 54 +++++++++++ site/gradient-image/index.html | 50 ++++++++++ site/gradient-image/main.js | 70 ++++++++++++++ site/gradient-image/style.css | 22 +++++ site/image-inner-resize/index.html | 32 +++++++ site/image-inner-resize/main.js | 57 ++++++++++++ site/image-inner-resize/style.css | 23 +++++ site/index.html | 3 + 15 files changed, 644 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 cmds/android-icons/main.go create mode 100644 site/android-icons/index.html create mode 100644 site/android-icons/main.js create mode 100644 site/android-icons/style.css create mode 100644 site/gradient-image/index.html create mode 100644 site/gradient-image/main.js create mode 100644 site/gradient-image/style.css create mode 100644 site/image-inner-resize/index.html create mode 100644 site/image-inner-resize/main.js create mode 100644 site/image-inner-resize/style.css diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..780b388 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,11 @@ +# Webtools + +This is a static site for a set of tools available on the web. The root of the site is located in "site", with index.html holding a list of available tools. Within each subdirectory of "site" is a separate tool. Each tool is self-contained with it's own index.html, CSS and JavaScript. Multipes of these files can be added if necessary. Note that each HTML has a standard header, with a typical title convension and importing "pico.min.css". + +Some tools can include a WASM file for more complex tasks. The WASM targets are built from Go files, with each cmd located as a sub directory in the "cmds" directory. Each one is built as a standalone WASM target. + +To build: + +- Run `build.wasm` to build the WASM targets. Additional targets will need to be added to this Make target +- Run `build` to build both the WASM and prepare the site. The site will be available in a "target" directory. +- Run `dev` to build the site and run a dev HTML server to test the site on port 8000. The dev server can be killed by sending a SIGINT signal. diff --git a/Makefile b/Makefile index d9bdbe1..d3f1b85 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ build.wasm: GOOS=js GOARCH=wasm go build -o target/wasm/clocks.wasm ./cmds/clocks GOOS=js GOARCH=wasm go build -o target/wasm/gotemplate.wasm ./cmds/gotemplate GOOS=js GOARCH=wasm go build -o target/wasm/timestamps.wasm ./cmds/timestamps + GOOS=js GOARCH=wasm go build -o target/wasm/android-icons.wasm ./cmds/android-icons cp $(GOROOT)/lib/wasm/wasm_exec.js target/wasm/. .Phony: build.site diff --git a/cmds/android-icons/main.go b/cmds/android-icons/main.go new file mode 100644 index 0000000..cb3ddc8 --- /dev/null +++ b/cmds/android-icons/main.go @@ -0,0 +1,144 @@ +//go:build js + +package main + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "fmt" + "image" + "image/png" + "strings" + "syscall/js" + + "golang.org/x/image/draw" +) + +type density struct { + dir string + size int +} + +var densities = []density{ + {"mipmap-mdpi", 48}, + {"mipmap-hdpi", 72}, + {"mipmap-xhdpi", 96}, + {"mipmap-xxhdpi", 144}, + {"mipmap-xxxhdpi", 192}, +} + +func main() { + js.Global().Set("prepareZip", js.FuncOf(prepareZip)) + <-make(chan struct{}) +} + +func prepareZip(this js.Value, args []js.Value) interface{} { + // args[0] is an array of {name: string, data: string (base64 PNG data)} + files := args[0] + length := files.Length() + + if length == 0 { + js.Global().Call("alert", "No files selected.") + return nil + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + for i := 0; i < length; i++ { + file := files.Index(i) + name := file.Get("name").String() + dataURL := file.Get("data").String() + + // Strip data URL prefix (data:image/png;base64,) + commaIdx := strings.Index(dataURL, ",") + if commaIdx < 0 { + setStatus(fmt.Sprintf("Invalid data for %s", name)) + return nil + } + b64Data := dataURL[commaIdx+1:] + + imgData, err := base64.StdEncoding.DecodeString(b64Data) + if err != nil { + setStatus(fmt.Sprintf("Failed to decode %s: %v", name, err)) + return nil + } + + src, err := png.Decode(bytes.NewReader(imgData)) + if err != nil { + setStatus(fmt.Sprintf("Failed to decode PNG %s: %v", name, err)) + return nil + } + + // Strip extension from name for the base name + baseName := name + if strings.HasSuffix(strings.ToLower(baseName), ".png") { + baseName = baseName[:len(baseName)-4] + } + + for _, d := range densities { + resized := resizeImage(src, d.size) + + var pngBuf bytes.Buffer + if err := png.Encode(&pngBuf, resized); err != nil { + setStatus(fmt.Sprintf("Failed to encode %s/%s.png: %v", d.dir, baseName, err)) + return nil + } + + path := fmt.Sprintf("%s/%s.png", d.dir, baseName) + w, err := zw.Create(path) + if err != nil { + setStatus(fmt.Sprintf("Failed to create zip entry %s: %v", path, err)) + return nil + } + if _, err := w.Write(pngBuf.Bytes()); err != nil { + setStatus(fmt.Sprintf("Failed to write zip entry %s: %v", path, err)) + return nil + } + } + } + + if err := zw.Close(); err != nil { + setStatus(fmt.Sprintf("Failed to finalize zip: %v", err)) + return nil + } + + // Convert zip bytes to a JS Uint8Array and trigger download + zipBytes := buf.Bytes() + jsArray := js.Global().Get("Uint8Array").New(len(zipBytes)) + js.CopyBytesToJS(jsArray, zipBytes) + + // Create Blob and download + blobParts := js.Global().Get("Array").New() + blobParts.Call("push", jsArray) + blobOpts := js.Global().Get("Object").New() + blobOpts.Set("type", "application/zip") + blob := js.Global().Get("Blob").New(blobParts, blobOpts) + + url := js.Global().Get("URL").Call("createObjectURL", blob) + anchor := js.Global().Get("document").Call("createElement", "a") + anchor.Set("href", url) + anchor.Set("download", "android-icons.zip") + js.Global().Get("document").Get("body").Call("appendChild", anchor) + anchor.Call("click") + anchor.Call("remove") + js.Global().Get("URL").Call("revokeObjectURL", url) + + setStatus(fmt.Sprintf("Done! Prepared icons for %d image(s).", length)) + return nil +} + +func resizeImage(src image.Image, size int) image.Image { + dst := image.NewNRGBA(image.Rect(0, 0, size, size)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + return dst +} + +func setStatus(msg string) { + querySelector("#status").Set("innerText", msg) +} + +func querySelector(query string) js.Value { + return js.Global().Get("document").Call("querySelector", query) +} diff --git a/go.mod b/go.mod index 7f220ff..4e30240 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,8 @@ module github.com/lmika/webtools -go 1.24.3 +go 1.25.0 -require github.com/alecthomas/participle/v2 v2.1.4 // indirect +require ( + github.com/alecthomas/participle/v2 v2.1.4 // indirect + golang.org/x/image v0.37.0 // indirect +) diff --git a/go.sum b/go.sum index bff288a..8e1c4f0 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U= github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= diff --git a/site/android-icons/index.html b/site/android-icons/index.html new file mode 100644 index 0000000..d30965e --- /dev/null +++ b/site/android-icons/index.html @@ -0,0 +1,34 @@ + + + + + + Android Icon Resizer - Tools + + + + +
    +
    +

    Android Icon Resizer

    +

    Resize PNG images to Android mipmap density buckets. Everything runs in your browser.

    +
    +
    +
    +
    + + +
    +
    +
    +

    Circle Crop Preview

    + +
    +
    + +
    +
    +
    + + + diff --git a/site/android-icons/main.js b/site/android-icons/main.js new file mode 100644 index 0000000..056050b --- /dev/null +++ b/site/android-icons/main.js @@ -0,0 +1,136 @@ +import "/wasm/wasm_exec.js"; + +const go = new Go(); +let wasmReady = false; + +WebAssembly.instantiateStreaming(fetch("/wasm/android-icons.wasm"), go.importObject) + .then((result) => { + go.run(result.instance); + wasmReady = true; + }); + +const fileInput = document.getElementById("file-input"); +const previewArea = document.getElementById("preview-area"); +const prepareBtn = document.getElementById("prepare-btn"); +const statusEl = document.getElementById("status"); +const circlePreview = document.getElementById("circle-preview"); +const circleCanvas = document.getElementById("circle-canvas"); + +let loadedFiles = []; + +function renderCirclePreview() { + if (loadedFiles.length === 0) { + circlePreview.style.display = "none"; + return; + } + + const nameLower = (f) => f.name.toLowerCase(); + + let bgFile = loadedFiles.find((f) => /back/.test(nameLower(f))); + let fgFile = loadedFiles.find((f) => !/back/.test(nameLower(f)) && !/mono/.test(nameLower(f))); + + // Fallbacks: if only one image or no "back" image, use the first file + if (!bgFile && !fgFile) { + bgFile = loadedFiles[0]; + } else if (!bgFile) { + bgFile = fgFile; + fgFile = null; + } + + const size = 192; + circleCanvas.width = size; + circleCanvas.height = size; + const ctx = circleCanvas.getContext("2d"); + ctx.clearRect(0, 0, size, size); + + // Clip to circle + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + const drawLayer = (src) => { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0, size, size); + resolve(); + }; + img.onerror = resolve; + img.src = src; + }); + }; + + drawLayer(bgFile.data).then(() => { + if (fgFile && fgFile !== bgFile) { + return drawLayer(fgFile.data); + } + }).then(() => { + ctx.restore(); + + // Draw border ring + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2 - 1.5, 0, Math.PI * 2); + ctx.closePath(); + ctx.strokeStyle = "rgba(128, 128, 128, 0.5)"; + ctx.lineWidth = 3; + ctx.stroke(); + + circlePreview.style.display = "block"; + }); +} + +fileInput.addEventListener("change", () => { + const files = Array.from(fileInput.files); + loadedFiles = []; + previewArea.innerHTML = ""; + circlePreview.style.display = "none"; + + if (files.length === 0) { + prepareBtn.disabled = true; + return; + } + + files.forEach((file) => { + const reader = new FileReader(); + reader.onload = (e) => { + const dataURL = e.target.result; + loadedFiles.push({ name: file.name, data: dataURL }); + + const item = document.createElement("div"); + item.className = "preview-item"; + + const img = document.createElement("img"); + img.src = dataURL; + + const label = document.createElement("span"); + label.textContent = file.name; + + item.appendChild(img); + item.appendChild(label); + previewArea.appendChild(item); + + if (loadedFiles.length === files.length) { + prepareBtn.disabled = false; + renderCirclePreview(); + } + }; + reader.readAsDataURL(file); + }); +}); + +prepareBtn.addEventListener("click", () => { + if (!wasmReady) { + statusEl.textContent = "WASM module is still loading, please wait..."; + return; + } + if (loadedFiles.length === 0) { + return; + } + statusEl.textContent = "Preparing..."; + // Pass file data to Go WASM + setTimeout(() => { + prepareZip(loadedFiles); + }, 50); +}); diff --git a/site/android-icons/style.css b/site/android-icons/style.css new file mode 100644 index 0000000..1aea483 --- /dev/null +++ b/site/android-icons/style.css @@ -0,0 +1,54 @@ +#preview-area { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 1rem 0; +} + +.preview-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.preview-item img { + width: 96px; + height: 96px; + object-fit: contain; + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; + background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px; +} + +.preview-item span { + font-size: 0.8rem; + color: var(--pico-muted-color); + word-break: break-all; + max-width: 96px; + text-align: center; +} + +.actions { + margin: 1rem 0; +} + +#circle-preview { + display: none; + margin: 1rem 0; +} + +#circle-preview h3 { + margin-bottom: 0.5rem; +} + +#circle-canvas { + background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px; + border-radius: 50%; + box-shadow: 0 0 0 3px var(--pico-muted-border-color); +} + +#status { + margin-top: 0.5rem; + font-style: italic; +} diff --git a/site/gradient-image/index.html b/site/gradient-image/index.html new file mode 100644 index 0000000..2c1179c --- /dev/null +++ b/site/gradient-image/index.html @@ -0,0 +1,50 @@ + + + + + + Gradient Image - Tools + + + + +
    +
    +

    Gradient Image

    +

    Generate gradient images as downloadable PNGs

    +
    +
    +
    +
    + + + + + + + + + + + + + + +
    +
    + + +
    +
    + + + diff --git a/site/gradient-image/main.js b/site/gradient-image/main.js new file mode 100644 index 0000000..4271b7e --- /dev/null +++ b/site/gradient-image/main.js @@ -0,0 +1,70 @@ +const PREVIEW_SIZE = 256; + +const fromColorEl = document.getElementById("from-color"); +const toColorEl = document.getElementById("to-color"); +const gradientTypeEl = document.getElementById("gradient-type"); +const rotationEl = document.getElementById("rotation"); +const rotationValueEl = document.getElementById("rotation-value"); +const imageSizeEl = document.getElementById("image-size"); +const canvas = document.getElementById("preview-canvas"); +const downloadBtn = document.getElementById("download-btn"); + +function drawGradient(ctx, size) { + const fromColor = fromColorEl.value; + const toColor = toColorEl.value; + const type = gradientTypeEl.value; + const rotation = parseInt(rotationEl.value); + + const cx = size / 2; + const cy = size / 2; + + let gradient; + if (type === "radial") { + const radius = size / 2; + gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + } else { + const angle = (rotation * Math.PI) / 180; + const len = size / 2; + const dx = Math.cos(angle) * len; + const dy = Math.sin(angle) * len; + gradient = ctx.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy); + } + + gradient.addColorStop(0, fromColor); + gradient.addColorStop(1, toColor); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); +} + +function renderPreview() { + canvas.width = PREVIEW_SIZE; + canvas.height = PREVIEW_SIZE; + const ctx = canvas.getContext("2d"); + drawGradient(ctx, PREVIEW_SIZE); +} + +function download() { + const size = parseInt(imageSizeEl.value); + const offscreen = document.createElement("canvas"); + offscreen.width = size; + offscreen.height = size; + const ctx = offscreen.getContext("2d"); + drawGradient(ctx, size); + + const link = document.createElement("a"); + link.download = `gradient-${size}x${size}.png`; + link.href = offscreen.toDataURL("image/png"); + link.click(); +} + +fromColorEl.addEventListener("input", renderPreview); +toColorEl.addEventListener("input", renderPreview); +gradientTypeEl.addEventListener("input", renderPreview); +rotationEl.addEventListener("input", () => { + rotationValueEl.textContent = rotationEl.value; + renderPreview(); +}); +downloadBtn.addEventListener("click", download); + +renderPreview(); diff --git a/site/gradient-image/style.css b/site/gradient-image/style.css new file mode 100644 index 0000000..bfe7c47 --- /dev/null +++ b/site/gradient-image/style.css @@ -0,0 +1,22 @@ +.controls { + display: flex; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#preview-canvas { + width: 256px; + height: 256px; + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; +} + +#download-btn { + width: 100%; +} diff --git a/site/image-inner-resize/index.html b/site/image-inner-resize/index.html new file mode 100644 index 0000000..521be20 --- /dev/null +++ b/site/image-inner-resize/index.html @@ -0,0 +1,32 @@ + + + + + + Image Inner Resize - Tools + + + + +
    +
    +

    Image Inner Resize

    +

    Scale an image within its original dimensions, centered on a transparent background

    +
    +
    +
    +
    + + + + + +
    +
    + + +
    +
    + + + diff --git a/site/image-inner-resize/main.js b/site/image-inner-resize/main.js new file mode 100644 index 0000000..a22fc52 --- /dev/null +++ b/site/image-inner-resize/main.js @@ -0,0 +1,57 @@ +const fileInput = document.getElementById("file-input"); +const scaleEl = document.getElementById("scale"); +const scaleValueEl = document.getElementById("scale-value"); +const canvas = document.getElementById("preview-canvas"); +const downloadBtn = document.getElementById("download-btn"); + +let sourceImage = null; + +function render() { + if (!sourceImage) return; + + const w = sourceImage.naturalWidth; + const h = sourceImage.naturalHeight; + const scale = parseInt(scaleEl.value) / 100; + + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, w, h); + + const sw = w * scale; + const sh = h * scale; + const sx = (w - sw) / 2; + const sy = (h - sh) / 2; + + ctx.drawImage(sourceImage, sx, sy, sw, sh); +} + +fileInput.addEventListener("change", () => { + const file = fileInput.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + sourceImage = img; + downloadBtn.disabled = false; + render(); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); +}); + +scaleEl.addEventListener("input", () => { + scaleValueEl.textContent = scaleEl.value; + render(); +}); + +downloadBtn.addEventListener("click", () => { + if (!sourceImage) return; + const link = document.createElement("a"); + link.download = "resized.png"; + link.href = canvas.toDataURL("image/png"); + link.click(); +}); diff --git a/site/image-inner-resize/style.css b/site/image-inner-resize/style.css new file mode 100644 index 0000000..dbaa0f5 --- /dev/null +++ b/site/image-inner-resize/style.css @@ -0,0 +1,23 @@ +.controls { + display: flex; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#preview-canvas { + max-width: 100%; + max-height: 512px; + border: 1px solid var(--pico-muted-border-color); + border-radius: 4px; + background: repeating-conic-gradient(#eee 0% 25%, #fff 0% 50%) 50% / 16px 16px; +} + +#download-btn { + width: 100%; +} diff --git a/site/index.html b/site/index.html index 23d989a..437258e 100644 --- a/site/index.html +++ b/site/index.html @@ -31,6 +31,9 @@
  • Mental Arithmatic Game
  • Neon Snake: vibe-coded by Google Gemini
  • Hex Color Converter
  • +
  • Android Icon Resizer
  • +
  • Gradient Image
  • +
  • Image Inner Resize
  • From 3953adedd385bcc4cc08b3ba8942e25146c5752c Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Sat, 4 Apr 2026 03:47:57 +0000 Subject: [PATCH 17/18] Allowed renaming of teams in Finska score card --- site/finska/index.html | 14 ++++- site/finska/scripts/controllers.js | 95 +++++++++++++++++++++++++++++- site/finska/style.css | 65 +++++++++++++++++++- 3 files changed, 170 insertions(+), 4 deletions(-) diff --git a/site/finska/index.html b/site/finska/index.html index c9e5918..b68d16b 100644 --- a/site/finska/index.html +++ b/site/finska/index.html @@ -20,8 +20,18 @@ - - + + diff --git a/site/finska/scripts/controllers.js b/site/finska/scripts/controllers.js index 03fd023..acb321f 100644 --- a/site/finska/scripts/controllers.js +++ b/site/finska/scripts/controllers.js @@ -4,7 +4,7 @@ import { Scorecard, getStoreDAO } from "./models.js"; const storeDao = getStoreDAO(); export class FinskaScorecardController extends Controller { - static targets = ["score1Input", "score2Input", "scoreTable"]; + static targets = ["score1Input", "score2Input", "scoreTable", "team1Header", "team2Header"]; static values = { maxScore: Number, overflowScoreTo: Number @@ -18,8 +18,95 @@ export class FinskaScorecardController extends Controller { this._undoStack = []; this._scorecard = storeDao.loadOrCreate(rules); + this._loadTeamNames(); this.updateTable(); } + + _loadTeamNames() { + const stored1 = localStorage.getItem('finska-team1-name'); + const stored2 = localStorage.getItem('finska-team2-name'); + this._team1Name = stored1 || 'Team A'; + this._team2Name = stored2 || 'Team B'; + this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1'); + this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2'); + } + + _renderTeamHeader(td, name, editAction) { + td.innerHTML = ` + ${this._escapeHtml(name)} + + `; + } + + _renderEditHeader(td, currentName, teamNum) { + td.innerHTML = ` + + + + `; + td.querySelector('input').focus(); + td.querySelector('input').select(); + } + + editTeam1() { + this._renderEditHeader(this.team1HeaderTarget, this._team1Name, '1'); + } + + editTeam2() { + this._renderEditHeader(this.team2HeaderTarget, this._team2Name, '2'); + } + + confirmTeam1() { + const input = this.team1HeaderTarget.querySelector('input'); + const newName = input.value.trim() || 'Team A'; + this._team1Name = newName; + localStorage.setItem('finska-team1-name', newName); + this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1'); + } + + confirmTeam2() { + const input = this.team2HeaderTarget.querySelector('input'); + const newName = input.value.trim() || 'Team B'; + this._team2Name = newName; + localStorage.setItem('finska-team2-name', newName); + this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2'); + } + + cancelTeam1() { + this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1'); + } + + cancelTeam2() { + this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2'); + } + + editKeyDown1(e) { + this._editKeyDown(e, this.confirmTeam1.bind(this), this.cancelTeam1.bind(this)); + } + + editKeyDown2(e) { + this._editKeyDown(e, this.confirmTeam2.bind(this), this.cancelTeam2.bind(this)); + } + + _editKeyDown(e, confirmFn, cancelFn) { + if (e.key === 'Enter') { + e.preventDefault(); + confirmFn(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelFn(); + } + } + + _escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + _escapeAttr(str) { + return str.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + } updateTable() { let tableBody = this.scoreTableTarget; @@ -155,6 +242,12 @@ export class FinskaScorecardController extends Controller { this._scorecard.reset(); storeDao.clear(); this._undoStack = []; + this._team1Name = 'Team A'; + this._team2Name = 'Team B'; + localStorage.removeItem('finska-team1-name'); + localStorage.removeItem('finska-team2-name'); + this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1'); + this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2'); this.updateTable(); } } diff --git a/site/finska/style.css b/site/finska/style.css index e2cd398..9bf0432 100644 --- a/site/finska/style.css +++ b/site/finska/style.css @@ -18,4 +18,67 @@ tfoot input { color: #394D00; background-color: #DEFC85; font-weight: bold; -} \ No newline at end of file +} + +.team-header { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} + +.team-header .team-name { + font-weight: bold; +} + +.team-header .edit-btn { + background: none; + border: none; + cursor: pointer; + padding: 0.1rem 0.3rem; + font-size: 0.9rem; + margin: 0; + width: auto; + line-height: 1; + opacity: 0.5; +} + +.team-header .edit-btn:hover { + opacity: 1; +} + +.team-header-edit { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; +} + +.team-header-edit input { + border-width: 1px; + margin: 0; + padding: 0.2rem 0.4rem; + font-size: 0.9rem; + width: 6rem; + text-align: center; +} + +.team-header-edit .confirm-btn, +.team-header-edit .cancel-btn { + background: none; + border: none; + cursor: pointer; + padding: 0.1rem 0.3rem; + font-size: 1rem; + margin: 0; + width: auto; + line-height: 1; +} + +.team-header-edit .confirm-btn { + color: #2e7d32; +} + +.team-header-edit .cancel-btn { + color: #c62828; +} From 09fceca2f9601f210148cd3cb2711fb537c272ca Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 15 Apr 2026 03:02:48 +0000 Subject: [PATCH 18/18] freenslens: increased height of single-line logos --- site/freelens-logo/script.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/freelens-logo/script.js b/site/freelens-logo/script.js index 0404e63..7be5111 100644 --- a/site/freelens-logo/script.js +++ b/site/freelens-logo/script.js @@ -43,6 +43,7 @@ const availableWidth = width - PADDING * 2; const availableHeight = height - PADDING * 2; const halfHeight = availableHeight / 2; + const threeQuartersHeight = availableHeight * 3 / 4; ctx.fillStyle = 'white'; ctx.textAlign = 'center'; @@ -57,7 +58,7 @@ ctx.font = `bold ${fontSize}px sans-serif`; ctx.fillText(lower, width / 2, height - PADDING - halfHeight / 2 + 4); } else { - let fontSize = fitText(upper, availableWidth, halfHeight); + let fontSize = fitText(upper, availableWidth, threeQuartersHeight); ctx.font = `bold ${fontSize}px sans-serif`; ctx.fillText(upper, width / 2, height / 2); }
    Team ATeam B + + Team A + + + + + Team B + + +