Compare commits

..
5 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 eccee4d3f1 always remove the SSH key after a deploy
The clean up step was skipped whenever a transfer failed, leaving the
deploy key on the runner. Run it even when an earlier step fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:21:50 +00:00
adamandClaude Opus 5.5 45de1fd982 deploy website pages with the game
Copy the pages in www/html/wordle to /var/www/html/wordle on the server,
where nginx serves them from, on every deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:21:41 +00:00
adamandClaude Opus 5.5 7a95e50fe7 lowercase usernames on the website
The game saves usernames in lowercase, but the search box and the
user/board URL parameters were used as typed, so searching "Adam"
reported that the user had never played. Lowercase them before use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:50 +00:00
adamandClaude Opus 5.5 3d78ff7863 show board dates in UTC on the website
Board days are UTC epoch days (midnight UTC), but toLocaleDateString
formatted them in the viewer's timezone, so anyone west of UTC saw
every game dated the day before. Format them in UTC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:37 +00:00
adamandClaude Opus 5.5 4c6c53ff13 add website pages
Copies of the pages served from /var/www/html/wordle on the server:
the user search page (index.html), stats.html, games.html and
game.html. Browsers are sent here by nginx while curl gets the raw
board and stats files. These aren't deployed by the workflow yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:50:22 +00:00
5 changed files with 928 additions and 0 deletions

No files matched your search

+5
View File
@@ -26,5 +26,10 @@ jobs:
run: |
scp -i ~/.ssh/id_rsa wordle ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:${{ vars.REMOTE_DEST }}
- name: Transfer web pages via SCP
run: |
scp -i ~/.ssh/id_rsa www/html/wordle/* ${{ vars.REMOTE_USER }}@${{ vars.REMOTE_SERVER }}:/var/www/html/wordle/
- name: Clean up SSH key
if: always() # remove the key even if a transfer failed
run: rm -rf ~/.ssh
+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wordle Board Viewer</title>
<style>
body {
background: #111;
color: #eee;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
margin: 0;
min-height: 100vh;
}
h2 {
font-size: 26px;
margin-bottom: 6px;
color: white;
text-align: center;
}
.date-label {
margin-bottom: 16px;
font-size: 15px;
color: #ccc;
}
.nav {
margin-bottom: 18px;
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.nav a {
text-decoration: none;
padding: 0.35rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
/* --- grid --- */
.grid {
display: grid;
grid-template-columns: repeat(5, 50px);
grid-gap: 6px;
margin-bottom: 20px;
}
.tile {
width: 50px;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: bold;
color: white;
border-radius: 6px;
box-shadow: 0 0 4px rgba(0,0,0,0.5);
transition: color 0.3s ease;
}
.empty {
background: #333 !important;
color: #666;
}
.hidden {
color: transparent !important;
}
/* Reveal button */
#reveal {
padding: 10px 18px;
font-size: 16px;
border: none;
border-radius: 6px;
background: #4CAF50;
color: white;
cursor: pointer;
margin-bottom: 25px;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
#reveal:hover {
background: #45A047;
}
#reveal.pressed {
transform: translateY(2px);
box-shadow: inset 0 0 10px rgba(0,0,0,0.4);
}
/* Raw ANSI box (hidden) */
pre {
background: #222;
color: #ccc;
padding: 10px;
border-radius: 6px;
width: 90%;
max-width: 600px;
overflow-x: auto;
display: none;
}
/* --- Error box (nice green glow) --- */
.error-box {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
margin-top: 25px;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
max-width: 600px;
text-align: center;
font-size: 1.1rem;
color: #ccc;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<h2 id="title">Wordle Board</h2>
<div id="date" class="date-label"></div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="stats-link">📊 Back to stats</a>
<a href="#" id="games-link">📅 Recent games</a>
</div>
<div id="grid-container"></div>
<button id="reveal">Reveal Letters</button>
<div id="error" class="error-box" style="display:none;"></div>
<pre id="raw-output"></pre>
<script>
/* Convert xterm colors */
function xtermToRgb(n) {
// Custom overrides for Wordle colors
if (n === 10) return "rgb(44, 192, 44)"; // green
if (n === 3) return "rgb(176, 176, 33)"; // yellow
// Default behavior for all other xterm colors
if (n < 16) {
const base = [
'#000000','#800000','#008000','#808000',
'#000080','#800080','#008080','#c0c0c0',
'#808080','#ff0000','#00ff00','#ffff00',
'#0000ff','#ff00ff','#00ffff','#ffffff'
];
return base[n] || '#000';
}
if (n <= 231) {
n -= 16;
const r = Math.floor(n / 36) % 6;
const g = Math.floor(n / 6) % 6;
const b = n % 6;
const f = x => (x === 0 ? 0 : x * 40 + 55);
return `rgb(${f(r)}, ${f(g)}, ${f(b)})`;
}
const c = (n - 232) * 10 + 8;
return `rgb(${c}, ${c}, ${c})`;
}
/* Parse ANSI board text into rows of {char, bg} */
function parseAnsiBoard(text) {
const lines = text.split("\n").filter(l => l.includes("│"));
const board = [];
for (const line of lines) {
const inside = line.replace(/.*│(.*)│.*/, "$1").trim();
let row = [];
let bg = "#000";
let i = 0;
while (i < inside.length) {
if (inside[i] === "\x1b") {
const end = inside.indexOf("m", i);
const seq = inside.substring(i+2, end).split(";").map(Number);
if (seq[0] === 48 && seq[1] === 5) {
bg = xtermToRgb(seq[2]);
} else if (seq[0] === 0) {
bg = "#000";
}
i = end + 1;
continue;
}
if (/[A-Z]/.test(inside[i])) {
row.push({ char: inside[i], bg });
}
i++;
}
if (row.length === 5) board.push(row);
}
return board;
}
/* Fetch an epoch-day-based board (user-<day>.board) */
async function fetchBoard(user, epochDay) {
const boardName = `${user}-${epochDay}.board`;
const url = `/wordle/rawboard/${boardName}`;
const resp = await fetch(url);
if (!resp.ok) return null;
const text = await resp.text();
return { boardName, text };
}
async function loadBoard() {
const params = new URLSearchParams(location.search);
const boardName = params.get("board")?.toLowerCase(); // game saves names in lowercase
const errorBox = document.getElementById("error");
const dateEl = document.getElementById("date");
const titleEl = document.getElementById("title");
const gridContainer = document.getElementById("grid-container");
const rawOutput = document.getElementById("raw-output");
const revealBtn = document.getElementById("reveal");
// Hide button until we know we have a board
revealBtn.style.display = "none";
if (!boardName) {
errorBox.style.display = "block";
errorBox.textContent = "No board specified.";
return;
}
const user = boardName.replace(/-.*$/, "").replace(".board", "");
titleEl.textContent = `Wordle Board for ${user}`;
// Wire nav
document.getElementById("stats-link").href = `/stats.html?user=${encodeURIComponent(user)}`;
document.getElementById("games-link").href = `/games.html?user=${encodeURIComponent(user)}`;
let finalText = "";
let finalEpoch = null;
const idMatch = boardName.match(/-(\d+)\.board$/);
// Case 1: boardName has ID → user-<epochDay>.board
if (idMatch) {
const epochDay = Number(idMatch[1]);
const r = await fetchBoard(user, epochDay);
// If not found OR text-only (no ANSI), this day has no board
if (!r || !r.text.includes("\x1b[")) {
errorBox.style.display = "block";
errorBox.textContent = "This board does not exist for that day.";
return;
}
finalText = r.text;
finalEpoch = epochDay;
}
// Case 2: boardName has NO ID → "user.board" semantics
else {
const today = Math.floor(Date.now() / 1000 / 86400);
// First: check today's epoch board (user-<today>.board)
const rToday = await fetchBoard(user, today);
if (rToday && rToday.text.includes("\x1b[")) {
finalText = rToday.text;
finalEpoch = today;
} else {
// Fallback: last board played via /wordle/rawboard/user.board
const latestUrl = `/wordle/rawboard/${encodeURIComponent(user)}.board`;
const latestResp = await fetch(latestUrl);
if (!latestResp.ok) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
return;
}
const latestText = await latestResp.text();
// If that "latest" file is only text (no ANSI), treat as no boards
if (!latestText.includes("\x1b[")) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
return;
}
finalText = latestText;
finalEpoch = null;
dateEl.textContent = "Last board played";
}
}
// If we know the exact epoch day and haven't set date text yet, show it
if (finalEpoch !== null && !dateEl.textContent) {
// Epoch days start at midnight UTC, so format in UTC or viewers
// west of UTC see the previous day
dateEl.textContent = new Date(finalEpoch * 86400 * 1000)
.toLocaleDateString(undefined, {
timeZone: "UTC",
weekday: "long",
year: "numeric",
month: "long",
day: "numeric"
});
}
rawOutput.textContent = finalText;
let parsed = parseAnsiBoard(finalText);
// If somehow there is no parsed grid (safety), treat as "no boards"
if (!parsed.length) {
errorBox.style.display = "block";
errorBox.textContent = "This user has no recorded boards.";
gridContainer.innerHTML = "";
return;
}
// Always fill to 6 rows with empties for a full Wordle grid
while (parsed.length < 6) {
parsed.push([
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true },
{ char: " ", bg: "#333", empty: true }
]);
}
const grid = document.createElement("div");
grid.className = "grid";
parsed.forEach(row => {
row.forEach(cell => {
const div = document.createElement("div");
div.className = "tile hidden";
div.textContent = cell.char.trim() === "" ? "" : cell.char;
if (cell.empty) {
div.classList.add("empty");
} else {
div.style.backgroundColor = cell.bg;
}
grid.appendChild(div);
});
});
gridContainer.innerHTML = "";
gridContainer.appendChild(grid);
// Now that we have a real board, show reveal button and wire press-to-reveal behavior
revealBtn.style.display = "inline-block";
const tiles = document.querySelectorAll(".tile");
function showLetters() {
tiles.forEach(t => t.classList.remove("hidden"));
revealBtn.classList.add("pressed");
}
function hideLetters() {
tiles.forEach(t => t.classList.add("hidden"));
revealBtn.classList.remove("pressed");
}
// Mouse support
revealBtn.addEventListener("mousedown", showLetters);
document.addEventListener("mouseup", hideLetters);
// Touch support
revealBtn.addEventListener("touchstart", (e) => {
e.preventDefault();
showLetters();
}, { passive: false });
document.addEventListener("touchend", hideLetters);
}
loadBoard();
</script>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Recent Wordle Games</title>
<style>
body {
background: #0d0d0d;
color: #f2f2f2;
font-family: Arial, sans-serif;
padding: 2rem;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 700px;
width: 100%;
text-align: center;
animation: fadein 0.7s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #51ff7a;
text-shadow: 0 0 8px rgba(81,255,122,0.7);
}
.subtitle {
margin-bottom: 1.5rem;
opacity: 0.9;
color: #b0ffca;
}
.nav {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
.games-card {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
text-align: left;
margin: 0 auto;
}
ul.games {
list-style: none;
padding: 0;
margin: 0;
}
ul.games li {
padding: 0.5rem 0;
border-bottom: 1px solid #252525;
display: flex;
justify-content: space-between;
align-items: center;
}
ul.games li:last-child {
border-bottom: none;
}
.game-date {
color: #e0ffe8;
font-family: monospace;
}
.game-link {
text-decoration: none;
color: #51ff7a;
font-size: 0.9rem;
}
.game-link:hover {
text-decoration: underline;
}
.empty-msg {
color: #aaa;
font-style: italic;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Recent Games for <span id="username">...</span></h1>
<div class="subtitle">Last 30 days (only days actually played)</div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="stats-link">📊 Back to stats</a>
</div>
<div class="games-card">
<ul class="games" id="games-list"></ul>
<div id="empty" class="empty-msg" style="display:none;">No games in the last 30 days.</div>
</div>
</div>
<script>
function formatDateFromEpochDays(epochDays) {
// Epoch days start at midnight UTC, so format in UTC or viewers
// west of UTC see the previous day
const date = new Date(epochDays * 86400 * 1000);
return date.toLocaleDateString(undefined, {
timeZone: "UTC",
weekday: "short",
year: "numeric",
month: "short",
day: "numeric"
});
}
(async () => {
const params = new URLSearchParams(location.search);
const user = params.get("user")?.toLowerCase(); // game saves names in lowercase
const usernameSpan = document.getElementById("username");
const gamesList = document.getElementById("games-list");
const emptyMsg = document.getElementById("empty");
const statsLink = document.getElementById("stats-link");
if (!user) {
usernameSpan.textContent = "Unknown";
emptyMsg.style.display = "block";
emptyMsg.textContent = "Error: missing ?user=username in URL.";
statsLink.removeAttribute("href");
return;
}
usernameSpan.textContent = user;
statsLink.href = `/stats.html?user=${encodeURIComponent(user)}`;
const todayEpochDays = Math.floor(Date.now() / 1000 / 86400);
const games = [];
// Look back over last 30 days
for (let d = todayEpochDays; d > todayEpochDays - 30; d--) {
const boardName = `${user}-${d}.board`;
const url = `/wordle/rawboard/${encodeURIComponent(boardName)}`;
try {
const resp = await fetch(url);
if (!resp.ok) continue;
const text = await resp.text();
// A "real" game day has ANSI escape codes
if (text.includes("\x1b[")) {
games.push({ epochDays: d, boardName });
}
} catch (e) {
// ignore errors for individual days
}
}
if (!games.length) {
emptyMsg.style.display = "block";
return;
}
gamesList.innerHTML = "";
games.forEach(game => {
const li = document.createElement("li");
const dateSpan = document.createElement("span");
dateSpan.className = "game-date";
dateSpan.textContent = formatDateFromEpochDays(game.epochDays);
const link = document.createElement("a");
link.className = "game-link";
link.href = `/game.html?board=${encodeURIComponent(game.boardName)}`;
link.textContent = "View board";
li.appendChild(dateSpan);
li.appendChild(link);
gamesList.appendChild(li);
});
})();
</script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wordle Viewer</title>
<style>
body {
background: #0b0b0b;
color: #f2f2f2;
font-family: Arial, sans-serif;
min-height: 100vh;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
}
.container {
background: #141414;
border-radius: 12px;
padding: 2rem 2.5rem;
box-shadow: 0 0 30px rgba(0,0,0,0.7);
max-width: 420px;
width: 100%;
text-align: center;
}
h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-size: 2rem;
color: #51ff7a;
text-shadow: 0 0 10px rgba(81,255,122,0.6);
}
p {
margin-top: 0;
margin-bottom: 2rem;
color: #aaa;
font-size: 0.95rem;
}
.form-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input[type="text"] {
flex: 1;
padding: 0.6rem 0.7rem;
border-radius: 6px;
border: 1px solid #333;
background: #1c1c1c;
color: #f2f2f2;
font-size: 1rem;
}
input[type="text"]:focus {
outline: none;
border-color: #51ff7a;
box-shadow: 0 0 6px rgba(81,255,122,0.6);
}
button {
padding: 0.6rem 1rem;
border-radius: 6px;
border: none;
background: #51ff7a;
color: #061006;
font-weight: bold;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #3ee067;
}
.hint {
margin-top: 1rem;
font-size: 0.85rem;
color: #777;
}
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Wordle Viewer</h1>
<p>Enter a username to view their stats and games.</p>
<form id="search-form">
<div class="form-row">
<input id="user-input" type="text" placeholder="Username (e.g. adam)" autocomplete="off" />
<button type="submit">View stats</button>
</div>
</form>
<div class="hint">
You’ll be taken to <code>stats.html?user=&lt;name&gt;</code>.
</div>
</div>
<script>
const form = document.getElementById("search-form");
const input = document.getElementById("user-input");
form.addEventListener("submit", (e) => {
e.preventDefault();
// The game saves usernames in lowercase
const user = input.value.trim().toLowerCase();
if (!user) return;
window.location.href = `/stats.html?user=${encodeURIComponent(user)}`;
});
</script>
</body>
</html>
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wordle Stats</title>
<style>
body {
background: #0d0d0d;
color: #f2f2f2;
font-family: monospace;
padding: 2rem;
display: flex;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.container {
max-width: 700px;
width: 100%;
text-align: center;
animation: fadein 0.7s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #51ff7a;
text-shadow: 0 0 8px rgba(81,255,122,0.7);
}
.subtitle {
margin-bottom: 1.5rem;
opacity: 0.9;
color: #b0ffca;
}
.nav {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.nav a {
text-decoration: none;
padding: 0.4rem 0.9rem;
border-radius: 999px;
border: 1px solid #2f5740;
background: #162218;
color: #9af7b8;
font-size: 0.85rem;
transition: background 0.2s, transform 0.1s, box-shadow 0.2s;
}
.nav a:hover {
background: #1c3421;
transform: translateY(-1px);
box-shadow: 0 0 6px rgba(81,255,122,0.4);
}
.stats-card {
background: #141414;
border: 1px solid #2f5740;
padding: 1.5rem;
border-radius: 10px;
box-shadow: 0 0 20px #000 inset, 0 0 15px rgba(81,255,122,0.12);
text-align: left;
margin: 0 auto;
white-space: pre-wrap;
}
pre {
font-family: inherit;
margin-top: 1rem;
white-space: pre-wrap;
}
/* ANSI color classes */
.ansi-black { color: #000; }
.ansi-red { color: #ff6b6b; }
.ansi-green { color: #51ff7a; }
.ansi-yellow { color: #ffe66d; }
.ansi-blue { color: #4dc0ff; }
.ansi-magenta { color: #ff70ff; }
.ansi-cyan { color: #63e6ff; }
.ansi-white { color: #ddd; }
</style>
<link rel="icon" type="image/png" href="https://www.adbraun.lol/wordle/favicon.ico"/>
</head>
<body>
<div class="container">
<h1>Wordle Stats for <span id="username">...</span></h1>
<div class="subtitle">Daily performance summary</div>
<div class="nav">
<a href="/wordle" id="search-link">🔍 Search users</a>
<a href="#" id="games-link">📅 Recent games</a>
<a href="#" id="latest-board-link">🧩 Latest board</a>
</div>
<div class="stats-card">
<pre id="output">Loading…</pre>
</div>
</div>
<script>
(async () => {
const params = new URLSearchParams(location.search);
const user = params.get("user")?.toLowerCase(); // game saves names in lowercase
const output = document.getElementById("output");
const usernameSpan = document.getElementById("username");
const gamesLink = document.getElementById("games-link");
const latestBoardLink = document.getElementById("latest-board-link");
if (!user) {
usernameSpan.textContent = "Unknown";
output.textContent = "Error: missing ?user=username in URL.";
gamesLink.removeAttribute("href");
latestBoardLink.removeAttribute("href");
return;
}
usernameSpan.textContent = user;
// Wire nav links for this user
gamesLink.href = `/games.html?user=${encodeURIComponent(user)}`;
latestBoardLink.href = `/game.html?board=${encodeURIComponent(user)}.board`;
try {
const resp = await fetch(`/wordle/rawstats/${encodeURIComponent(user)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
let text = await resp.text();
const ansiMap = {
'30': 'ansi-black',
'31': 'ansi-red',
'32': 'ansi-green',
'33': 'ansi-yellow',
'34': 'ansi-blue',
'35': 'ansi-magenta',
'36': 'ansi-cyan',
'37': 'ansi-white'
};
text = text.replace(/\x1b\[(\d+)m/g, (match, code) => {
if (code === "0") return "</span>";
const cls = ansiMap[code];
return cls ? `<span class="${cls}">` : "";
});
if ((text.match(/<span/g) || []).length > (text.match(/<\/span>/g) || []).length) {
text += "</span>";
}
output.innerHTML = text;
} catch (err) {
output.textContent = `Error loading stats for ${user}: ${err.message}`;
}
})();
</script>
</body>
</html>