Compare commits

..

7 Commits

Author SHA1 Message Date
fe8b93e8a8 add session management to the ui through cookies 2025-11-15 14:53:13 +01:00
3ffb2c5785 avoid session deadlock 2025-11-14 23:26:11 +01:00
5b337e3168 update backend logic 2025-11-14 17:21:42 +01:00
8939c6ffb5 keep session id history 2025-11-14 16:47:01 +01:00
8d194c0dff keep session id history 2025-11-14 15:26:45 +01:00
372e246124 more frontend fixes 2025-11-14 15:17:31 +01:00
9f02bc1888 more frontend fixes 2025-11-13 18:41:34 +01:00
18 changed files with 1962 additions and 358 deletions

View File

@ -50,6 +50,10 @@ class Crossword:
def current_grid(self) -> List[List[Optional[str]]]:
return self._current_grid
@property
def solution_word_positions(self) -> Optional[List[Tuple[int, int]]]:
return self._solution_word_positions
def get_words_by_y_x_position(self, x, y) -> List[CrosswordWord]:
"""Get the list of CrosswordWord objects that start at position (x, y)."""
@ -123,13 +127,62 @@ class Crossword:
logger.info("Crossword generated successfully for grid size %dx%d with block ratio %.2f", grid_width, grid_height, grid_block_ratio)
# Build letter position index for efficient lookup
letter_to_positions: Dict[str, List[Tuple[int, int]]] = {}
for r in range(len(final_step.grid)):
for c in range(len(final_step.grid[0])):
cell = final_step.grid[r][c]
if cell and cell != '#':
if cell not in letter_to_positions:
letter_to_positions[cell] = []
letter_to_positions[cell].append((r, c))
# Now find a solution word: generate a random word from the dictionary (with 10-20 letters)
# and try to find the necessary letter positions in the grid. if we fail we repeat with another word (max 10 tries)
solution_word_positions: Optional[List[Tuple[int, int]]] = None
max_solution_word_attempts = 20
for i in range(max_solution_word_attempts):
random_length = random.randint(10, 27 - (i // 2))
possible_words = dictionary.find_by_pattern('*' * random_length)
if not possible_words:
continue
chosen_word = random.choice(possible_words)
letter_positions = []
used_positions = set()
for letter in chosen_word.word:
if letter not in letter_to_positions:
letter_positions = []
break
# Pick random position for this letter that's not already used
available = [p for p in letter_to_positions[letter] if p not in used_positions]
if not available:
letter_positions = []
break
chosen_position = random.choice(available)
letter_positions.append(chosen_position)
used_positions.add(chosen_position)
if len(letter_positions) == random_length:
solution_word_positions = letter_positions
break
if solution_word_positions is None:
logger.warning("Failed to find a solution word for the generated crossword after %d attempts", max_solution_word_attempts)
return None
cw = Crossword(
dictionary=dictionary,
grid=final_step.grid,
solution_word_positions=solution_word_positions
)
logger.debug("Generated Crossword: \n\n%s", cw)
return cw
@ -139,7 +192,19 @@ class Crossword:
grid: List[List[Optional[str]]],
current_grid: Optional[List[List[Optional[str]]]] = None,
words: Optional[List[CrosswordWord]] = None,
solution_word_positions: Optional[List[Tuple[int, int]]] = None,
):
"""
Initialize a Crossword object.
Args:
dictionary (Dictionary): The dictionary containing words and hints.
grid (List[List[Optional[str]]]): The solved crossword grid.
current_grid (Optional[List[List[Optional[str]]]]): The current state of the crossword grid.
words (Optional[List[CrosswordWord]]): Pre-extracted list of CrosswordWord objects.
solution_word_positions (Optional[List[Tuple[int, int]]]): Positions of letters building the solution word.
"""
self._dictionary = dictionary
self._solved_grid = grid
self._words: List[CrosswordWord] = []
@ -147,6 +212,8 @@ class Crossword:
self._horizontal_words_by_y_x_position = {}
self._vertical_words_by_y_x_position = {}
self._solution_word_positions = solution_word_positions
if current_grid is not None:
self._current_grid = current_grid

View File

@ -0,0 +1,35 @@
from pathlib import Path
import argparse
from http import server as http
import http.server
BASE_DIR = Path(__file__).resolve().parent.parent / "webui"
def main():
parser = argparse.ArgumentParser(description="Serve Multiplayer Crossword Frontend")
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--no-file-list", action="store_true", help="Disable directory listing")
args = parser.parse_args()
path = BASE_DIR
host = args.host
port = args.port
no_file_list = args.no_file_list
class CustomHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(path), **kwargs)
if no_file_list:
def list_directory(self, path):
self.send_error(403, "Directory listing not allowed")
return None
server_address = (host, port)
httpd = http.server.HTTPServer(server_address, CustomHandler)
print(f"Serving frontend at http://{host}:{port}/ from {path}")
httpd.serve_forever()
if __name__ == "__main__":
main()

View File

@ -3,10 +3,10 @@ from pydantic import BaseModel
DEFAULT_WEBSOCKET_HOST = "0.0.0.0"
DEFAULT_WEBSOCKET_PORT = 8765
DEFAULT_MIN_GRID_SIZE = 10
DEFAULT_MAX_GRID_SIZE = 30
DEFAULT_MIN_GRID_SIZE = 12
DEFAULT_MAX_GRID_SIZE = 25
DEFAULT_GRID_BLOCK_RATIO = 0.38
DEFAULT_GRID_BLOCK_RATIO = 0.39
DEFAULT_MAX_SESSION_IDLE_TIME_SECONDS = 3600 * 48 # 2 days

View File

@ -30,9 +30,10 @@ class SendFullSessionStateServerMessage(ServerMessageBase):
grid: list[list[str]] # 2D array representing the current grid state
clues_across: dict[str, str] # mapping from clue number to clue text for across clues
clues_down: dict[str, str] # mapping from clue number to clue text for down clues
clue_positions_across: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position
clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (col, row) position
solved_positions: list[tuple[int, int]] # list of (col, row) positions that are solved
clue_positions_across: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position
clue_positions_down: dict[str, tuple[int, int]] # mapping from clue number to its (row, col) position
solved_positions: list[tuple[int, int]] # list of (row, col) positions that are solved
solution_word_positions: list[tuple[int, int]] # list of (row, col) positions that are part of solution word
class LetterUpdateBroadcastServerMessage(ServerMessageBase):
type: str = "letter_update"

View File

@ -34,7 +34,7 @@ class BoardSizePreset(str, Enum):
elif self == BoardSizePreset.SMALL:
return (min_size + (max_size - min_size) // 4, min_size + (max_size - min_size) // 4)
elif self == BoardSizePreset.MEDIUM:
return (max_size // 2, max_size // 2)
return (min_size + (max_size - min_size) // 2, min_size + (max_size - min_size) // 2)
elif self == BoardSizePreset.LARGE:
return (min_size + 3 * (max_size - min_size) // 4, min_size + 3 * (max_size - min_size) // 4)
elif self == BoardSizePreset.VERY_LARGE:

View File

@ -109,12 +109,30 @@ class MultiplayerSessionManager(object):
self._sessions_lock = asyncio.Lock()
self._grid_block_ratio = ServerConfig.get_config().GRID_BLOCK_RATIO
async def remove_idle_sessions(self):
"""Remove all idle sessions that have exceeded their max idle time."""
async with self._sessions_lock:
idle_session_ids = [
session_id for session_id, session in self._sessions.items()
if session.is_idle
]
for session_id in idle_session_ids:
logger.info("Removing idle session %s", session_id)
del self._sessions[session_id]
if idle_session_ids:
logger.info("Removed %d idle sessions", len(idle_session_ids))
async def create_session(self, lang: str | Languages, grid_w: int, grid_h: int) -> MultiplayerSession:
# Remove idle sessions before creating a new one
await self.remove_idle_sessions()
async with self._sessions_lock:
if isinstance(lang, str):
lang = Languages(lang)
dictionary = lang.load_dictionary()
max_tries = 4
for i in range(max_tries):
crossword = Crossword.generate(
dictionary=dictionary,
seed=None,
@ -122,6 +140,8 @@ class MultiplayerSessionManager(object):
grid_height=grid_h,
grid_block_ratio=self._grid_block_ratio,
)
if crossword is not None:
break
if crossword is None:
raise RuntimeError("Failed to generate crossword for the given parameters.")
session_id = str(uuid.uuid4())
@ -146,7 +166,7 @@ class WebsocketCrosswordServer(object):
async def handle_request_available_session_properties(handler: WebsocketConnectionHandler, message: client_messages.RequestAvailableSessionPropertiesClientMessage):
server_config = ServerConfig.get_config()
response = server_messages.AvailableSessionPropertiesServerMessage(
supported_languages=[lang.value for lang in Languages],
supported_languages=list(reversed([lang.value for lang in Languages])),
min_grid_size=server_config.MIN_GRID_SIZE,
max_grid_size=server_config.MAX_GRID_SIZE,
board_size_presets={
@ -224,6 +244,13 @@ class WebsocketCrosswordServer(object):
solved_positions = list(solved_positions)
solution_word_positions = []
positions = session.crossword.solution_word_positions
for pos in positions:
# Convert from (row, col) to (col, row) for client
row, col = pos
solution_word_positions.append((col, row))
response = server_messages.SendFullSessionStateServerMessage(
session_id=session.session_id,
grid=grid_state,
@ -232,6 +259,7 @@ class WebsocketCrosswordServer(object):
clue_positions_across=clue_positions_across,
clue_positions_down=clue_positions_down,
solved_positions=solved_positions,
solution_word_positions=solution_word_positions,
)
# register the client to the session
session.add_client(handler)

View File

@ -21,7 +21,48 @@ def load_dictionary(p: str | Path) -> Dictionary:
if not word.isalpha():
continue
word = word.lower()
dict_obj.add_word(Word(word=word, hints=[], difficulty=1))
hints = []
try:
if "senses" in obj:
for sense in obj["senses"]:
text = sense
if word in text.lower():
continue
hints.append(text)
except Exception:
pass
try:
if "synonyms" in obj:
for synonym in obj["synonyms"]:
text = synonym
if word in text.lower():
continue
hints.append( text)
except Exception:
pass
try:
if "antonyms" in obj:
for antonym in obj["antonyms"]:
text = antonym
if word in text.lower():
continue
if "de" in p.name:
text = "Gegenteil von " + text
else:
text = "Opposite of " + text
hints.append( text)
except Exception:
pass
if len(hints) > 0:
w = Word(word=word,
hints=hints,
difficulty=1)
dict_obj.add_word(w)
load_dictionary._cache[cache_key] = dict_obj
return dict_obj

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -15,8 +15,11 @@ export class ClueArea extends LitElement {
selectedCol: { type: Number },
selectedMode: { type: String }, // 'horizontal' or 'vertical'
grid: { type: Array }, // 2D grid from server (needed to find walls)
gridData: { type: Object }, // { rows, cols, walls, solvedCells }
_showAllCluesAcross: { state: true },
_showAllCluesDown: { state: true }
_showAllCluesDown: { state: true },
_solvedCluesAcross: { state: true }, // Set of solved clue numbers
_solvedCluesDown: { state: true } // Set of solved clue numbers
};
}
@ -30,8 +33,11 @@ export class ClueArea extends LitElement {
this.selectedCol = 0;
this.selectedMode = 'horizontal';
this.grid = [];
this.gridData = { rows: 0, cols: 0, walls: new Set(), solvedCells: new Set() };
this._showAllCluesAcross = false;
this._showAllCluesDown = false;
this._solvedCluesAcross = new Set();
this._solvedCluesDown = new Set();
}
/**
@ -178,7 +184,7 @@ export class ClueArea extends LitElement {
}
}
_toggleShowAllClues() {
_toggleShowAllCluesAcross() {
this._showAllCluesAcross = !this._showAllCluesAcross;
}
@ -186,24 +192,161 @@ export class ClueArea extends LitElement {
this._showAllCluesDown = !this._showAllCluesDown;
}
/**
* Find the starting row,col of an across clue by clue number
*/
_getAcrossClueStart(clueNum) {
const position = this.cluePositionsAcross[clueNum];
if (!position) return null;
// Server sends (x, y) = (col, row)
const col = position[0];
const row = position[1];
return { row, col };
}
/**
* Find the starting row,col of a down clue by clue number
*/
_getDownClueStart(clueNum) {
const position = this.cluePositionsDown[clueNum];
if (!position) return null;
// Server sends (x, y) = (col, row)
const col = position[0];
const row = position[1];
return { row, col };
}
/**
* Get all cells that belong to an across clue
*/
_getAcrossCluesCells(clueNum) {
const startPos = this._getAcrossClueStart(clueNum);
if (!startPos) return [];
const { row, col } = startPos;
const cells = [];
// Expand right until we hit a wall
for (let c = col; c < this.gridData.cols; c++) {
if (this.gridData.walls.has(`${row},${c}`)) {
break;
}
cells.push({ row, col: c });
}
return cells;
}
/**
* Get all cells that belong to a down clue
*/
_getDownCluesCells(clueNum) {
const startPos = this._getDownClueStart(clueNum);
if (!startPos) return [];
const { row, col } = startPos;
const cells = [];
// Expand down until we hit a wall
for (let r = row; r < this.gridData.rows; r++) {
if (this.gridData.walls.has(`${r},${col}`)) {
break;
}
cells.push({ row: r, col });
}
return cells;
}
/**
* Check if a clue is fully solved
*/
_isCluesSolved(clueNum, direction) {
const cells = direction === 'across'
? this._getAcrossCluesCells(clueNum)
: this._getDownCluesCells(clueNum);
return cells.length > 0 && cells.every(cell =>
this.gridData.solvedCells.has(`${cell.row},${cell.col}`)
);
}
/**
* Update which clues are solved
*/
_updateSolvedClues() {
this._solvedCluesAcross = new Set();
this._solvedCluesDown = new Set();
for (const clueNum of Object.keys(this.cluesAcross)) {
if (this._isCluesSolved(clueNum, 'across')) {
this._solvedCluesAcross.add(clueNum);
}
}
for (const clueNum of Object.keys(this.cluesDown)) {
if (this._isCluesSolved(clueNum, 'down')) {
this._solvedCluesDown.add(clueNum);
}
}
}
/**
* Handle clue item click - focus the cell and set orientation
*/
_onClueItemClick(clueNum, direction) {
let startPos;
let mode;
if (direction === 'across') {
startPos = this._getAcrossClueStart(clueNum);
mode = 'horizontal';
} else {
startPos = this._getDownClueStart(clueNum);
mode = 'vertical';
}
if (!startPos) return;
// Update selected cell and mode in parent grid
this.selectedRow = startPos.row;
this.selectedCol = startPos.col;
this.selectedMode = mode;
// Dispatch event to notify grid component
this.dispatchEvent(new CustomEvent('clue-selected', {
detail: {
row: startPos.row,
col: startPos.col,
mode: mode
},
bubbles: true,
composed: true
}));
// Close the all-clues view and return to default view
this._showAllCluesAcross = false;
this._showAllCluesDown = false;
}
render() {
const currentClue = this._getCurrentClue();
// Show across clues
if (this._showAllCluesAcross) {
return html`
<div class="clue-area">
<div class="clue-area expanded">
<div class="clue-header">
<h3>Across Clues</h3>
<button class="clue-toggle" @click="${this._toggleShowAllClues}">
<span class="chevron"></span>
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}">
<span class="chevron"></span>
</button>
</div>
<div class="clue-list-container">
<div class="clue-list">
${Object.entries(this.cluesAcross).map(([num, text]) => html`
<div class="clue-item">
<div class="clue-item ${this._solvedCluesAcross.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'across')}" style="cursor: pointer;">
<span class="clue-number">${num}.</span>
<span class="clue-text">${text}</span>
</div>
@ -217,18 +360,18 @@ export class ClueArea extends LitElement {
// Show down clues
if (this._showAllCluesDown) {
return html`
<div class="clue-area">
<div class="clue-area expanded">
<div class="clue-header">
<h3>Down Clues</h3>
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}">
<span class="chevron"></span>
<span class="chevron"></span>
</button>
</div>
<div class="clue-list-container">
<div class="clue-list">
${Object.entries(this.cluesDown).map(([num, text]) => html`
<div class="clue-item">
<div class="clue-item ${this._solvedCluesDown.has(num) ? 'solved' : ''}" @click="${() => this._onClueItemClick(num, 'down')}" style="cursor: pointer;">
<span class="clue-number">${num}.</span>
<span class="clue-text">${text}</span>
</div>
@ -245,7 +388,7 @@ export class ClueArea extends LitElement {
<div class="clue-header">
${currentClue ? html`
<div class="current-clue">
<span class="clue-number">${currentClue.number}. ${currentClue.direction}</span>
<span class="clue-number">${currentClue.direction === 'across' ? '▶' : '▼'} ${currentClue.number}</span>
</div>
<div class="clue-text">${currentClue.text}</div>
` : html`
@ -253,11 +396,13 @@ export class ClueArea extends LitElement {
`}
<div class="clue-toggle-group">
<button class="clue-toggle" @click="${this._toggleShowAllClues}" title="Show all across clues">
<span class="chevron">▶ A</span>
<div class="clue-text empty">Clues:</div>
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}" title="Show all across clues">
<span class="chevron">▶</span>
</button>
<button class="clue-toggle" @click="${this._toggleShowAllCluesDown}" title="Show all down clues">
<span class="chevron">▼ D</span>
<span class="chevron">▼</span>
</button>
</div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -17,6 +17,11 @@ export class CrosswordGrid extends LitElement {
_selected: { state: true },
_inputMode: { state: true }, // 'horizontal' or 'vertical'
_solvedCells: { state: true }, // tracks which cells are solved
_clueNumbers: { state: true }, // map of "row,col" -> { across: number, down: number }
_solutionIndices: { state: true }, // map of "row,col" -> solution index
_solutionWordPositions: { state: true }, // list of [col, row] positions for solution word
_solutionWordValues: { state: true }, // map of index -> letter for solution word
_solutionWordSolved: { state: true }, // set of solution word indices that are solved
};
// styles moved to webui/styles.css; render into light DOM so external CSS applies
@ -29,6 +34,11 @@ export class CrosswordGrid extends LitElement {
this._selected = { r: 0, c: 0 };
this._inputMode = 'horizontal'; // default input mode
this._solvedCells = new Set(); // set of "r,c" strings for solved cells
this._clueNumbers = new Map(); // map of "row,col" -> { across: number, down: number }
this._solutionIndices = new Map(); // map of "row,col" -> solution index (1-indexed)
this._solutionWordPositions = []; // list of [col, row] positions
this._solutionWordValues = new Map(); // map of index -> letter
this._solutionWordSolved = new Set(); // set of solution word indices that are solved
this.sessionId = null; // Session ID for sending updates to server
}
@ -71,9 +81,59 @@ export class CrosswordGrid extends LitElement {
this._ensureGrid();
// set CSS variables for cell-size and column count; layout done in external stylesheet
return html`
<div class="main-grid-scroll-container">
<div class="grid-container ${this._isSolutionWordComplete() ? 'complete' : ''}">
<div class="grid" style="--cell-size: ${this._cellSize}px; --cols: ${this.cols};">
${this._grid.map((row, r) => row.map((cell, c) => this._renderCell(r, c, cell))).flat()}
</div>
</div>
</div>
${this._solutionWordPositions.length > 0 ? html`
<div class="solution-scroll-container">
<h2 style="text-align: center;">Solution Word</h2>
<div class="grid solution-word-grid" style="--cell-size: 40px; --cols: ${this._solutionWordPositions.length};">
${this._solutionWordPositions.map((pos, i) => this._renderSolutionCell(i, pos))}
</div>
</div>
` : ''}
`;
}
updated(changedProperties) {
super.updated(changedProperties);
// Set pulse animation delays when grid becomes complete
if (this._isSolutionWordComplete()) {
this._setPulseDelays();
}
}
_setPulseDelays() {
const gridContainer = this.querySelector('.grid-container');
if (gridContainer && gridContainer.classList.contains('complete')) {
const cells = gridContainer.querySelectorAll('.cell');
cells.forEach((cell, index) => {
// Calculate row and column from index
const row = Math.floor(index / this.cols);
const col = index % this.cols;
// Diagonal wave: delay based on row + col (top-left to bottom-right)
const diagonalIndex = row + col;
cell.style.setProperty('--pulse-delay', `${diagonalIndex * 0.1}s`);
});
}
}
_renderSolutionCell(index, position) {
const letter = this._solutionWordValues.get(index) || '';
const isSolved = this._solutionWordSolved.has(index);
const classes = ['cell'];
if (isSolved) classes.push('solved');
return html`
<div class="${classes.join(' ')}" data-solution-index="${index}" data-row="${position[1]}" data-col="${position[0]}" @click=${() => this._onSolutionCellClick(index, position)}>
<div class="solution-circle"></div>
<span class="solution-index">${index + 1}</span>
<span class="cell-letter">${letter}</span>
</div>
`;
}
@ -119,7 +179,34 @@ export class CrosswordGrid extends LitElement {
}
}
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${value}</div>`;
// Get clue numbers for this cell
const clueInfo = this._clueNumbers.get(cellKey);
let clueNumberDisplay = '';
if (clueInfo) {
if (clueInfo.across !== null && clueInfo.down !== null) {
// Both across and down clues: show "across/down" format
clueNumberDisplay = `${clueInfo.across}/${clueInfo.down}`;
} else if (clueInfo.across !== null) {
// Only across clue
clueNumberDisplay = String(clueInfo.across);
} else if (clueInfo.down !== null) {
// Only down clue
clueNumberDisplay = String(clueInfo.down);
}
}
// Get solution index for this cell
const solutionIndex = this._solutionIndices.get(cellKey);
const cellContent = clueNumberDisplay
? html`<span class="clue-number">${clueNumberDisplay}</span><span class="cell-letter">${value}</span>`
: html`<span class="cell-letter">${value}</span>`;
const cellHTML = solutionIndex !== undefined
? html`${cellContent}<div class="solution-circle"></div><span class="solution-index">${solutionIndex}</span>`
: cellContent;
return html`<div class="${classes.join(' ')}" @click=${() => this._onCellClick(r, c)} data-r="${r}" data-c="${c}">${cellHTML}</div>`;
}
/**
@ -182,6 +269,14 @@ export class CrosswordGrid extends LitElement {
return end - start + 1;
}
/**
* Check if the entire solution word is solved
*/
_isSolutionWordComplete() {
if (this._solutionWordPositions.length === 0) return false;
return this._solutionWordPositions.every((_, i) => this._solutionWordSolved.has(i));
}
/**
* Check if cell (r, c) is part of the vertical line from the selected cell
* (i.e., same column and not blocked by walls above/below this cell)
@ -206,14 +301,23 @@ export class CrosswordGrid extends LitElement {
return r >= start && r <= end;
}
_onCellClick(r, c) {
_onCellClick(r, c, preferredMode = null) {
// if same cell is clicked again, toggle the input mode
if (this._selected.r === r && this._selected.c === c) {
// If a preferred mode is provided, use it (don't toggle)
if (preferredMode) {
this._inputMode = preferredMode;
} else {
this._inputMode = this._inputMode === 'horizontal' ? 'vertical' : 'horizontal';
}
} else {
// select a new cell
this._selected = { r, c };
// Use preferred mode if provided, otherwise auto-select based on line lengths
if (preferredMode) {
this._inputMode = preferredMode;
} else {
// auto-select mode based on line lengths
const horizontalLength = this._getHorizontalLineLength(r, c);
const verticalLength = this._getVerticalLineLength(r, c);
@ -226,12 +330,19 @@ export class CrosswordGrid extends LitElement {
}
// otherwise keep current mode (both >1 or both =1)
}
}
this.requestUpdate();
this.dispatchEvent(new CustomEvent('cell-selected', { detail: { row: r, col: c, mode: this._inputMode }, bubbles: true, composed: true }));
// focus the element so keyboard input goes to the grid
this.focus();
}
_onSolutionCellClick(index, position) {
// When clicking a solution word cell, select the corresponding grid cell
const [col, row] = position;
this._onCellClick(row, col);
}
_onKeydown(e) {
// Only handle keys when the grid has focus
// Map letters, arrows and backspace to our handlers
@ -461,10 +572,125 @@ export class CrosswordGrid extends LitElement {
this._solvedCells.delete(cellKey);
}
// Update solution word if this position is part of it
for (let i = 0; i < this._solutionWordPositions.length; i++) {
const [col_sw, row_sw] = this._solutionWordPositions[i];
if (row === row_sw && col === col_sw) {
this._solutionWordValues.set(i, letter);
// Mark solution word cell as solved
if (is_solved) {
this._solutionWordSolved.add(i);
} else {
this._solutionWordSolved.delete(i);
}
break;
}
}
this.requestUpdate();
// Trigger animation if solution word just completed
if (this._isSolutionWordComplete()) {
this.updateComplete.then(() => {
const gridContainer = this.querySelector('.solution-word-grid');
if (gridContainer) {
// Force reflow to trigger animation
gridContainer.offsetHeight;
gridContainer.classList.remove('complete');
gridContainer.offsetHeight;
gridContainer.classList.add('complete');
}
});
}
// Emit a letter-changed event so solution word can update
this.dispatchEvent(new CustomEvent('letter-changed', {
detail: { row, col, letter, is_solved },
bubbles: true,
composed: true
}));
console.log(`Letter update from server: [${row}, ${col}] = "${letter}" (solved: ${is_solved})`);
}
}
/**
* Populate clue numbers from server data
* @param {Object} cluePositionsAcross - dict of clue_number -> [col, row]
* @param {Object} cluePositionsDown - dict of clue_number -> [col, row]
*/
populateClueNumbers(cluePositionsAcross = {}, cluePositionsDown = {}) {
this._clueNumbers.clear();
// Add across clues
for (const [clueNum, position] of Object.entries(cluePositionsAcross)) {
const [col, row] = position;
const cellKey = `${row},${col}`;
if (!this._clueNumbers.has(cellKey)) {
this._clueNumbers.set(cellKey, { across: null, down: null });
}
this._clueNumbers.get(cellKey).across = parseInt(clueNum);
}
// Add down clues
for (const [clueNum, position] of Object.entries(cluePositionsDown)) {
const [col, row] = position;
const cellKey = `${row},${col}`;
if (!this._clueNumbers.has(cellKey)) {
this._clueNumbers.set(cellKey, { across: null, down: null });
}
this._clueNumbers.get(cellKey).down = parseInt(clueNum);
}
this.requestUpdate();
}
/**
* Populate solution word indices from server data
* @param {Array} solutionPositions - list of [col, row] positions in order
*/
populateSolutionIndices(solutionPositions = []) {
this._solutionIndices.clear();
this._solutionWordPositions = solutionPositions;
this._solutionWordValues.clear();
this._solutionWordSolved.clear();
for (let i = 0; i < solutionPositions.length; i++) {
const [col, row] = solutionPositions[i];
const cellKey = `${row},${col}`;
this._solutionIndices.set(cellKey, i + 1); // 1-indexed
// Initialize solution word value with current grid letter
const letter = this._grid[row][col] || '';
this._solutionWordValues.set(i, letter);
// Check if this position is already solved
if (this._solvedCells.has(cellKey)) {
this._solutionWordSolved.add(i);
}
}
console.log('Solution word initialized. Solved:', this._solutionWordSolved.size, 'Total:', this._solutionWordPositions.length);
this.requestUpdate();
// Trigger animation on init if already complete
if (this._isSolutionWordComplete()) {
this.updateComplete.then(() => {
const gridContainer = this.querySelector('.solution-word-grid');
if (gridContainer) {
// Force reflow to trigger animation
gridContainer.offsetHeight;
gridContainer.classList.remove('complete');
gridContainer.offsetHeight;
gridContainer.classList.add('complete');
}
});
}
}
}
customElements.define('crossword-grid', CrosswordGrid);

View File

@ -50,6 +50,7 @@
let currentSessionId = null;
let clueArea = null;
let gridElement = null;
let isClosingGame = false; // Flag to prevent popstate from reloading session
// Test notifications
notificationManager.success('App loaded successfully');
@ -64,7 +65,7 @@
function updateUrlWithSessionId(sessionId) {
const params = new URLSearchParams(window.location.search);
params.set('session_id', sessionId);
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
window.history.pushState({ sessionId }, '', `${window.location.pathname}?${params.toString()}`);
}
// Helper function to subscribe to a session
@ -72,6 +73,12 @@
console.log('Subscribing to session:', sessionId);
currentSessionId = sessionId;
// Show game UI immediately
menu.style.display = 'none';
gridContainer.style.display = 'block';
keyboard.style.display = 'block';
gridContainer.innerHTML = '<div class="loading-spinner">Loading session...</div>';
const message = {
type: 'subscribe_session',
session_id: sessionId
@ -81,6 +88,9 @@
notificationManager.info('Loading session...');
}
// Make subscribeToSession available globally for the menu component
window.subscribeToSession = subscribeToSession;
// Handle session creation response
wsManager.onMessage('session_created', (message) => {
console.log('Session created:', message);
@ -124,9 +134,17 @@
// Create container with close button
gridContainer.innerHTML = `
<div class="game-header">
<h2>Crossword</h2>
<h2 style="text-align: center;">Crossword</h2>
<div class="header-buttons">
<button class="share-game-btn" aria-label="Share game">
<span style="padding-right: 0.5rem;">Share Session</span>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.15c.52.47 1.2.77 1.96.77 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.44 9.31 6.77 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.77 0 1.44-.3 1.96-.77l7.12 4.16c-.057.21-.087.43-.087.66 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
</svg>
</button>
<button class="close-game-btn" aria-label="Close game">✕</button>
</div>
</div>
<div class="game-content">
</div>
`;
@ -163,6 +181,7 @@
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
const cell = message.grid[r][c];
// Skip walls and empty cells
if (cell !== '#' && cell !== '') {
gridElement._grid[r][c] = cell;
@ -178,6 +197,14 @@
}
}
// Populate clue numbers for display
gridElement.populateClueNumbers(message.clue_positions_across, message.clue_positions_down);
// Populate solution word
if (message.solution_word_positions) {
gridElement.populateSolutionIndices(message.solution_word_positions);
}
gridElement.requestUpdate();
console.log(`Grid created: ${gridRows}x${gridCols} with ${wallPositions.length} walls`);
@ -185,6 +212,9 @@
if (message.solved_positions) {
console.log(`Solved positions: ${message.solved_positions.length}`);
}
if (message.solution_word_positions) {
console.log(`Solution word positions: ${message.solution_word_positions.length}`);
}
}, 0);
// Create and add clue area
@ -194,9 +224,36 @@
clueArea.cluePositionsAcross = message.clue_positions_across;
clueArea.cluePositionsDown = message.clue_positions_down;
clueArea.grid = message.grid; // Pass grid for dimension calculation
// Setup gridData for solved clue tracking
const walls = new Set();
for (let r = 0; r < gridRows; r++) {
for (let c = 0; c < gridCols; c++) {
if (message.grid[r][c] === '#') {
walls.add(`${r},${c}`);
}
}
}
const solvedCells = new Set();
if (message.solved_positions) {
for (const [col, row] of message.solved_positions) {
solvedCells.add(`${row},${col}`);
}
}
clueArea.gridData = {
rows: gridRows,
cols: gridCols,
walls: walls,
solvedCells: solvedCells
};
clueArea.selectedRow = 0;
clueArea.selectedCol = 0;
clueArea.selectedMode = 'horizontal';
// Update solved clues initially
clueArea._updateSolvedClues();
document.body.insertBefore(clueArea, document.body.firstChild);
// Listen for cell selection changes
@ -207,64 +264,163 @@
clueArea.requestUpdate();
});
// Listen for clue clicks to navigate grid
clueArea.addEventListener('clue-selected', (e) => {
const { row, col, mode } = e.detail;
// Call _onCellClick with preferred mode from the clue
gridElement._onCellClick(row, col, mode);
gridElement.focus();
});
// Listen for letter updates to update solved clues
gridElement.addEventListener('letter-changed', (e) => {
const { row, col, is_solved } = e.detail;
const cellKey = `${row},${col}`;
if (is_solved) {
clueArea.gridData.solvedCells.add(cellKey);
} else {
clueArea.gridData.solvedCells.delete(cellKey);
}
clueArea._updateSolvedClues();
clueArea.requestUpdate();
});
// Close button handler
closeBtn.addEventListener('click', closeGame);
const shareBtn = gridContainer.querySelector('.share-game-btn');
shareBtn.addEventListener('click', shareGame);
notificationManager.success('Game loaded successfully');
});
// Function to share game
function shareGame() {
console.log('Sharing game with session ID:', currentSessionId);
// Build URL with session ID
const url = `${window.location.origin}${window.location.pathname}?session_id=${currentSessionId}`;
// Try native share API first (mobile)
if (navigator.share) {
navigator.share({
title: 'Join my Crossword!',
text: 'Play crossword with me!',
url: url
}).then(() => {
console.log('Share successful');
}).catch(err => {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
showShareDialog(url);
}
});
} else {
// Fallback: show dialog with link
showShareDialog(url);
}
}
// Function to show share dialog with copy option
function showShareDialog(url) {
console.log('Showing share dialog with URL:', url);
// Create modal dialog
const dialog = document.createElement('div');
dialog.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const content = document.createElement('div');
content.style.cssText = `
background: #2a2520;
padding: 1.5rem;
border-radius: 0.5rem;
max-width: 90%;
width: 100%;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
`;
content.innerHTML = `
<h3 style="margin: 0 0 1rem 0; color: #f5f1ed; font-size: 1.2rem;">Share Game Link</h3>
<p style="margin: 0 0 1rem 0; color: #d4cdc5; font-size: 0.9rem;">Copy this link and send it to friends:</p>
<input type="text" value="${url}" readonly style="
width: 100%;
padding: 0.75rem;
background: #1a1511;
color: #f5f1ed;
border: 1px solid #5a4a4a;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.85rem;
box-sizing: border-box;
margin-bottom: 1rem;
" id="share-url-input" />
<div style="display: flex; gap: 0.5rem;">
<button id="copy-btn" style="
flex: 1;
padding: 0.75rem;
background: #4a7a9e;
color: #f5f1ed;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 600;
">Copy</button>
<button id="close-share-btn" style="
flex: 1;
padding: 0.75rem;
background: #5a4a4a;
color: #f5f1ed;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 600;
">Close</button>
</div>
`;
dialog.appendChild(content);
document.body.appendChild(dialog);
// Copy button
document.getElementById('copy-btn').addEventListener('click', () => {
const input = document.getElementById('share-url-input');
input.select();
document.execCommand('copy');
notificationManager.success('Link copied!');
dialog.remove();
});
// Close button
document.getElementById('close-share-btn').addEventListener('click', () => {
dialog.remove();
});
// Close on background click
dialog.addEventListener('click', (e) => {
if (e.target === dialog) {
dialog.remove();
}
});
}
// Function to close game and return to menu
function closeGame() {
console.log('Closing game');
// Clear session ID from URL
window.history.replaceState({}, '', window.location.pathname);
// Reset state
currentSessionId = null;
// Destroy clue area - check multiple ways it could be in the DOM
if (clueArea) {
if (clueArea.parentNode) {
clueArea.parentNode.removeChild(clueArea);
}
clueArea = null;
}
// Also remove any clue-area elements that might exist
const allClueAreas = document.querySelectorAll('clue-area');
allClueAreas.forEach(elem => {
if (elem.parentNode) {
elem.parentNode.removeChild(elem);
}
});
// Destroy grid element
if (gridElement) {
gridElement = null;
}
// Hide grid, show menu
menu.style.display = 'block';
gridContainer.style.display = 'none';
keyboard.style.display = 'none';
gridContainer.innerHTML = '';
// Close and reopen WebSocket to interrupt connection
wsManager.close();
// Reconnect WebSocket after a short delay
setTimeout(() => {
const wsUrl = menu._getWebsocketUrl ? menu._getWebsocketUrl() : (() => {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.hostname;
const port = 8765;
return `${protocol}://${host}:${port}`;
})();
wsManager.connect(wsUrl);
notificationManager.info('Returned to menu');
}, 100);
// Simply reload the page without session ID to return to menu
window.location.href = window.location.pathname;
}
// Handle errors
@ -304,6 +460,26 @@
gridContainer.innerHTML = '<div class="loading-spinner">Reconnecting to session...</div>';
}
});
// Handle back button to switch between sessions
window.addEventListener('popstate', (event) => {
// Skip if we just closed the game intentionally
if (isClosingGame) {
console.log('Game is being closed, skipping popstate');
return;
}
console.log('Popstate event:', event);
const sessionId = getSessionIdFromUrl();
if (sessionId && currentSessionId !== sessionId) {
console.log('Navigating to session:', sessionId);
subscribeToSession(sessionId);
} else if (!sessionId) {
console.log('No session in URL, showing menu');
closeGame();
}
});
</script>
</body>
</html>

View File

@ -33,15 +33,20 @@ export class MobileKeyboard extends LitElement {
'zxcvbnm'.split(''),
];
// compute the maximum number of columns across rows (account for backspace in first row)
const counts = rows.map((r, idx) => r.length + (idx === 0 ? 1 : 0));
// compute the maximum number of columns across rows (account for backspace in second row now)
const counts = rows.map((r, idx) => r.length + (idx === 1 ? 1 : 0));
const arrowCols = 3; // reserve 3 columns on the right for [left][down][right]
const baseMax = Math.max(...counts, 10);
const maxCols = baseMax;
return html`
<div class="keyboard-container">
${html`<div class="handle" @click=${this._toggleCollapse}>${this.collapsed ? '▲' : '▼'}</div>`}
${html`<div class="handle" @click=${this._toggleCollapse}>
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" style="vertical-align: middle; margin-right: 0.3rem;">
<path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm6 7H5v-2h8v2zm0-4h-2v-2h2v2zm3 0h-2v-2h2v2zm3 0h-2v-2h2v2zm0-3h-2V8h2v2z"/>
</svg>
${this.collapsed ? '▲' : '▼'}
</div>`}
<div class="keyboard-wrapper">
<div class="keyboard">
${rows.map((r, idx) => {
@ -53,7 +58,7 @@ export class MobileKeyboard extends LitElement {
return html`<div class="${rowClasses}" style="--cols:${maxCols-idx}; --arrow-cols:${arrowCols};">
<div class="keys">
${r.map(l => html`<button @click=${() => this._emitLetter(l)}>${l}</button>`) }
${idx === 0 ? html`<button class="backspace" @click=${() => this._emit({ type: 'backspace' })}>⌫</button>` : ''}
${idx === 1 ? html`<button class="backspace" @click=${() => this._emitBackspace()}>⌫</button>` : ''}
</div>
<div class="arrows">
${Array.from({ length: arrowCols }).map((_, i) => {
@ -67,7 +72,7 @@ export class MobileKeyboard extends LitElement {
<!-- spacebar row -->
<div class="row" style="--cols:${maxCols};">
<!-- spacebar spans all but the right arrow columns -->
<button class="space" @click=${() => this._emit({ type: 'letter', value: '' })}></button>
<button class="space" @click=${() => this._emitSpace()}></button>
<!-- arrow columns: left, down, right (will occupy the last 3 columns) -->
<button class="nav" @click=${() => this._emitNavigate('left')}></button>
<button class="nav" @click=${() => this._emitNavigate('down')}></button>
@ -77,20 +82,45 @@ export class MobileKeyboard extends LitElement {
</div>
</div>
`;
}
_emitLetter(l) {
} _emitLetter(l) {
this._vibrate();
this._emit({ type: 'letter', value: l });
}
_emitNavigate(dir) {
this._vibrate();
this._emit({ type: 'navigate', value: dir });
}
_emitBackspace() {
this._vibrate();
this._emit({ type: 'backspace' });
}
_emitSpace() {
this._vibrate();
this._emit({ type: 'letter', value: '' });
}
_emit(detail) {
window.dispatchEvent(new CustomEvent('key-press', { detail }));
}
_vibrate() {
// Use Vibration API for haptic feedback
try {
console.log('Attempting vibration... navigator.vibrate:', typeof navigator.vibrate);
if (navigator.vibrate) {
navigator.vibrate(10); // 10ms short buzz
console.log('Vibration sent!');
} else {
console.log('Vibration API not available on this device');
}
} catch (e) {
console.warn('Vibration API error:', e);
}
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('resize', this._onResize);
@ -111,15 +141,38 @@ export class MobileKeyboard extends LitElement {
const wide = (window.innerWidth / window.innerHeight) > 1.6;
this._wideScreen = wide;
this.classList.toggle('wide-screen', wide);
// collapsed default: expanded on mobile, collapsed on desktop
const wasCollapsed = this.collapsed;
if (mobile) this.collapsed = false;
else this.collapsed = true;
}
_toggleCollapse() {
// Set padding immediately when state changes
const main = document.querySelector('main');
if (main) {
if (this.collapsed) {
main.style.paddingBottom = 'var(--page-padding)';
} else {
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
main.style.paddingBottom = computedHeight;
}
}
} _toggleCollapse() {
this.collapsed = !this.collapsed;
if (this.collapsed) this.setAttribute('collapsed', '');
else this.removeAttribute('collapsed');
if (this.collapsed) {
this.setAttribute('collapsed', '');
// Remove padding when keyboard is collapsed
const main = document.querySelector('main');
if (main) main.style.paddingBottom = 'var(--page-padding)';
} else {
this.removeAttribute('collapsed');
// Add padding when keyboard is expanded - get actual computed height
const main = document.querySelector('main');
if (main) {
const computedHeight = getComputedStyle(this).getPropertyValue('--keyboard-height').trim();
main.style.paddingBottom = computedHeight;
}
}
}
}

View File

@ -13,7 +13,9 @@ export class CrosswordMenu extends LitElement {
_error: { state: true },
_sessionProperties: { state: true },
_selectedLanguage: { state: true },
_selectedBoardSize: { state: true }
_selectedBoardSize: { state: true },
_saveSessionsEnabled: { state: true },
_savedSessions: { state: true }
};
}
@ -24,12 +26,19 @@ export class CrosswordMenu extends LitElement {
this._sessionProperties = null;
this._selectedLanguage = '';
this._selectedBoardSize = '';
this._saveSessionsEnabled = false;
this._savedSessions = [];
this._initializeSessionStorage();
}
connectedCallback() {
super.connectedCallback();
// Register notification manager with WebSocket
wsManager.setNotificationManager(notificationManager);
// Listen for session creation/subscription events
wsManager.onMessage('session_created', (msg) => this._onSessionCreated(msg));
wsManager.onMessage('session_subscribed', (msg) => this._onSessionSubscribed(msg));
wsManager.onMessage('session_not_found', (msg) => this._onSessionNotFound(msg));
this._initializeConnection();
}
@ -38,6 +47,9 @@ export class CrosswordMenu extends LitElement {
// Remove message handlers
wsManager.offMessage('available_session_properties', this._handleSessionProperties);
wsManager.offMessage('error', this._handleError);
wsManager.offMessage('session_created', this._onSessionCreated);
wsManager.offMessage('session_subscribed', this._onSessionSubscribed);
wsManager.offMessage('session_not_found', this._onSessionNotFound);
}
_initializeConnection() {
@ -58,10 +70,24 @@ export class CrosswordMenu extends LitElement {
}
_getWebsocketUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.hostname;
const port = 8765;
return `${protocol}://${host}:${port}`;
// Special case for GitHub Pages deployment
if (host === 'antielektron.github.io') {
return 'wss://the-cake-is-a-lie.net/crosswords_backend/';
}
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
// If host is localhost, use port 8765. Otherwise, use default port (443 for wss, 80 for ws)
const isLocalhost = host === 'localhost' || host === '127.0.0.1';
const port = isLocalhost ? 8765 : '';
const portStr = port ? `:${port}` : '';
// If host is localhost, use it as is. Otherwise, add crosswords_backend/ to the path
const path = isLocalhost ? '' : 'crosswords_backend/';
return `${protocol}://${host}${portStr}/${path}`;
}
_requestSessionProperties() {
@ -130,6 +156,151 @@ export class CrosswordMenu extends LitElement {
notificationManager.info('Creating session...');
}
_toggleDataInfo() {
const element = this.querySelector('.data-info-details');
if (element) {
element.style.display = element.style.display === 'none' ? 'block' : 'none';
}
}
// Session storage management
_initializeSessionStorage() {
const savedSessionsData = this._getCookie('savedSessions');
if (savedSessionsData) {
try {
this._savedSessions = JSON.parse(savedSessionsData);
this._saveSessionsEnabled = true;
} catch (e) {
console.warn('Failed to parse saved sessions cookie:', e);
this._clearAllCookies();
}
}
}
_getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
_setCookie(name, value, days = 30) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
}
_deleteCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
}
_clearAllCookies() {
this._deleteCookie('savedSessions');
this._savedSessions = [];
this._saveSessionsEnabled = false;
this.requestUpdate();
}
_toggleSessionSaving() {
this._saveSessionsEnabled = !this._saveSessionsEnabled;
if (!this._saveSessionsEnabled) {
this._clearAllCookies();
}
this.requestUpdate();
}
_saveSession(sessionId, sessionInfo = {}) {
if (!this._saveSessionsEnabled) return;
// Remove existing entry for this session
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
// Add new entry
this._savedSessions.unshift({
id: sessionId,
timestamp: Date.now(),
...sessionInfo
});
// Keep only last 10 sessions
this._savedSessions = this._savedSessions.slice(0, 10);
// Save to cookie
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
this.requestUpdate();
}
_removeSession(sessionId) {
this._savedSessions = this._savedSessions.filter(s => s.id !== sessionId);
if (this._savedSessions.length === 0) {
this._clearAllCookies();
} else {
this._setCookie('savedSessions', JSON.stringify(this._savedSessions));
}
this.requestUpdate();
}
_onSessionCreated(message) {
if (message.session_id) {
this._saveSession(message.session_id, {
type: 'created',
language: this._selectedLanguage,
boardSize: this._selectedBoardSize
});
}
}
_onSessionSubscribed(message) {
if (message.session_id) {
this._saveSession(message.session_id, {
type: 'joined'
});
}
}
_onSessionNotFound(message) {
if (message.session_id) {
this._removeSession(message.session_id);
notificationManager.warning(`Session ${message.session_id.substring(0, 8)}... no longer exists and was removed from saved sessions`);
}
}
_reconnectToSession(sessionId) {
// Update the timestamp to move this session to the top
this._saveSession(sessionId, { type: 'rejoined' });
// Use the global subscribeToSession function to properly set currentSessionId
if (window.subscribeToSession) {
window.subscribeToSession(sessionId);
} else {
// Fallback if function not available
const message = {
type: 'subscribe_session',
session_id: sessionId
};
wsManager.send(message);
notificationManager.info('Reconnecting to session...');
}
}
_clearSavedSessions() {
this._clearAllCookies();
notificationManager.info('All saved sessions cleared');
}
_formatTimestamp(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diffHours = Math.floor((now - date) / (1000 * 60 * 60));
const diffMinutes = Math.floor((now - date) / (1000 * 60));
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
render() {
if (this._loading) {
return html`
@ -152,7 +323,9 @@ export class CrosswordMenu extends LitElement {
return html`
<div class="menu-container">
<div class="menu">
<h1>Crossword</h1>
<h1>Multiplayer Crossword</h1>
<p class="description">Collaborate with others to solve crosswords in real-time. Create a session and share the link with friends to play together!</p>
${this._error ? html`<div class="error">${this._error}</div>` : ''}
@ -174,7 +347,52 @@ export class CrosswordMenu extends LitElement {
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" ?checked="${this._saveSessionsEnabled}" @change="${this._toggleSessionSaving}">
Save list of recently joined sessions (uses cookies)
</label>
</div>
<button @click="${this._onCreateCrossword}">Create Crossword</button>
${this._savedSessions.length > 0 ? html`
<div class="saved-sessions">
<h3>Recent Sessions</h3>
<div class="session-list">
${this._savedSessions.map(session => html`
<div class="session-item">
<div class="session-info">
<span class="session-id">${session.id.substring(0, 8)}...</span>
<span class="session-time">${this._formatTimestamp(session.timestamp)}</span>
${session.language ? html`<span class="session-lang">${session.language.toUpperCase()}</span>` : ''}
</div>
<div class="session-actions">
<button class="reconnect-btn" @click="${() => this._reconnectToSession(session.id)}">Rejoin</button>
<button class="remove-btn" @click="${() => this._removeSession(session.id)}">×</button>
</div>
</div>
`)}
</div>
<button class="clear-all-btn" @click="${this._clearSavedSessions}">Clear All Sessions</button>
</div>
` : ''}
<div class="data-info">
<span class="data-info-toggle" @click="${this._toggleDataInfo}"> 📋 Data Usage for the Multiplayer functionality</span>
<div class="data-info-details" style="display: none;">
<ul>
<li><strong>Shared Data:</strong> Only the letters you type into the grid during a session are shared with other users and the backend server in that session.</li>
<li><strong>Session Lifetime:</strong> Sessions are automatically deleted after 48 hours of inactivity.</li>
<li><strong>No Tracking:</strong> No personal data is collected or stored beyond the session duration.</li>
</ul>
</div>
</div>
<p style="margin-top: 12px; font-size: 0.9em;">
<a href="https://the-cake-is-a-lie.net/gitea/jonas/multiplayer_crosswords" target="_blank" rel="noopener noreferrer">🔗 View source code on Gitea</a>
</p>
</div>
</div>
`;

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,20 @@
const cacheName = 'pwa-conf-v4';
const staticAssets = [
'./',
'./index.html',
'./app.js',
'./websocket.js' //TODO: add all necessary files
'./main.js',
'./websocket.js',
'./grid.js',
'./clue_area.js',
'./keyboard.js',
'./menu.js',
'./notification-area.js',
'./notification-manager.js',
'./styles.css',
'./manifest.json',
'./favicon.png',
'./big_icon.png'
];

View File

@ -111,16 +111,16 @@ class WebSocketManager {
* Internal handler - called on socket close
*/
_onClose(event) {
console.log('WebSocket closed');
console.log('WebSocket closed - reloading page');
if (this.notificationManager) {
this.notificationManager.info('Connection lost, reconnecting...', 0); // No auto-dismiss
this.notificationManager.info('Connection lost, reloading...', 2000);
}
this._callHandlers('close', { type: 'close' });
if (!this.isReconnecting) {
this.isReconnecting = true;
setTimeout(() => this.connect(this.url), this.reconnectDelay);
}
// Simply reload the page instead of trying to reconnect
setTimeout(() => {
window.location.reload();
}, 2000);
}
/**

View File

@ -31,6 +31,17 @@ pytest = "^7.0"
[tool.poetry.group.dev.dependencies]
jupyterlab = "^4.4.3"
[[tool.poetry.packages]]
include = "multiplayer_crosswords"
[[tool.poetry.include]]
path = "multiplayer_crosswords/webui"
format = "sdist"
[[tool.poetry.include]]
path = "multiplayer_crosswords/webui"
format = "wheel"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"