Compare commits

...

5 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
17 changed files with 770 additions and 179 deletions

View File

@ -140,9 +140,9 @@ class Crossword:
# 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 = 10
for _ in range(max_solution_word_attempts):
random_length = random.randint(10, 20)
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

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,8 +3,8 @@ 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.39

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={
@ -227,7 +247,9 @@ class WebsocketCrosswordServer(object):
solution_word_positions = []
positions = session.crossword.solution_word_positions
for pos in positions:
solution_word_positions.append((pos[1], pos[0])) # convert (row, col) to (col, row)
# 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,

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

@ -335,7 +335,7 @@ export class ClueArea extends LitElement {
// 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._toggleShowAllCluesAcross}">
@ -360,7 +360,7 @@ 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}">
@ -388,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`
@ -396,7 +396,7 @@ export class ClueArea extends LitElement {
`}
<div class="clue-toggle-group">
<div class="clue-text empty">Show all clues:</div>
<div class="clue-text empty">Clues:</div>
<button class="clue-toggle" @click="${this._toggleShowAllCluesAcross}" title="Show all across clues">
<span class="chevron">▶</span>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -81,20 +81,47 @@ 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`
<h3 style="margin-top: 2rem;">Solution Word</h3>
<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);

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,10 @@
// 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>
@ -408,54 +419,8 @@
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
@ -495,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

@ -41,7 +41,12 @@ export class MobileKeyboard extends LitElement {
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) => {
@ -136,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>
`;

View File

@ -1,8 +1,25 @@
:root {
--keyboard-space: 30vh; /* reserve ~30% of viewport height for keyboard on small screens */
--page-padding: clamp(0.75rem, 3.5vw, 2rem);
--keyboard-space: 30vh; /* fallback - will be calculated dynamically */
--page-padding: 0rem;
--max-container-width: 720px;
/* Calculated keyboard height - all values proportional to key-width for consistency */
--key-width: clamp(1.7rem, 5.5vw, 2.1rem);
--key-height: calc(var(--key-width) * 1.4);
--keyboard-gap: calc(var(--key-width) * 0.2); /* gap between rows, proportional to key size */
--keyboard-padding: calc(var(--key-width) * 0.4); /* keyboard padding, proportional to key size */
--handle-height: calc(var(--key-width) * 0.8); /* handle height, proportional to key size */
--handle-padding: calc(var(--key-width) * 0.2); /* handle padding, proportional to key size */
--keyboard-border: 2px; /* fixed border */
--keyboard-buffer: calc(var(--key-width) * 0.3); /* bottom buffer, proportional to key size */
--keyboard-height: calc(
(var(--key-height) * 4) + /* 4 rows of keys */
(var(--keyboard-gap) * 3) + /* 3 gaps between rows */
(var(--keyboard-padding) * 2) + /* keyboard padding top/bottom */
var(--keyboard-border) + /* keyboard border-top */
);
/* Crossword color palette - lighter, more paper-like */
--paper-bg: #f9f7f3;
--paper-light: #fcfaf8;
@ -42,15 +59,14 @@ main {
justify-content: center;
align-items: flex-start;
padding: var(--page-padding);
padding-bottom: 0; /* Remove keyboard space by default (desktop) */
min-height: calc(100vh - 0px);
min-height: 100vh;
box-sizing: border-box;
}
/* Add keyboard space on mobile devices */
/* On mobile devices, expand main to fill more space when keyboard is collapsed */
@media (max-width: 900px) {
main {
padding-bottom: var(--keyboard-space);
min-height: calc(100vh - var(--keyboard-space));
}
}
.container {
@ -82,7 +98,9 @@ crossword-grid { display: block; margin: 0 auto; }
display: inline-block;
gap: 0;
overflow: visible;
padding: 20px;
/* padding only top and bottom for better centering */
padding-bottom: 1rem;
padding-top: 1rem;
}
.grid {
@ -94,10 +112,7 @@ crossword-grid { display: block; margin: 0 auto; }
border: none;
}
.grid-container.complete .grid {
animation: grid-glow 2s ease-in-out infinite;
will-change: box-shadow;
}
/* Grid glow animation removed - now using solution letter glow */
.cell {
width: var(--cell-size, 4rem);
@ -249,6 +264,13 @@ crossword-grid { display: block; margin: 0 auto; }
display: block;
}
.solution-word-grid {
max-width: 100%;
width: fit-content; /* Only take the space needed */
overflow-x: auto;
overflow-y: hidden;
}
.grid .cell .solution-circle {
display: block;
}
@ -293,15 +315,6 @@ crossword-grid { display: block; margin: 0 auto; }
}
}
@keyframes grid-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(100, 200, 100, 0.3), inset 0 0 20px rgba(100, 200, 100, 0.1);
}
50% {
box-shadow: 0 0 40px rgba(100, 200, 100, 0.6), inset 0 0 40px rgba(100, 200, 100, 0.2);
}
}
.cell.solved {
background:
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(100,200,100,.1) 3px, rgba(100,200,100,.1) 5px),
@ -340,25 +353,9 @@ crossword-grid { display: block; margin: 0 auto; }
}
}
.solution-word-grid.complete .cell {
animation: cell-bounce 0.8s ease-in-out;
}
.solution-word-grid.complete .cell:nth-child(1) { animation-delay: 0s; }
.solution-word-grid.complete .cell:nth-child(2) { animation-delay: 0.1s; }
.solution-word-grid.complete .cell:nth-child(3) { animation-delay: 0.2s; }
.solution-word-grid.complete .cell:nth-child(4) { animation-delay: 0.3s; }
.solution-word-grid.complete .cell:nth-child(5) { animation-delay: 0.4s; }
.solution-word-grid.complete .cell:nth-child(6) { animation-delay: 0.5s; }
.solution-word-grid.complete .cell:nth-child(7) { animation-delay: 0.6s; }
.solution-word-grid.complete .cell:nth-child(8) { animation-delay: 0.7s; }
.solution-word-grid.complete .cell:nth-child(9) { animation-delay: 0.8s; }
.solution-word-grid.complete .cell:nth-child(10) { animation-delay: 0.9s; }
.solution-word-grid.complete .cell:nth-child(11) { animation-delay: 1.0s; }
.solution-word-grid.complete .cell:nth-child(12) { animation-delay: 1.1s; }
.solution-word-grid.complete .cell:nth-child(13) { animation-delay: 1.2s; }
.solution-word-grid.complete .cell:nth-child(14) { animation-delay: 1.3s; }
.solution-word-grid.complete .cell:nth-child(15) { animation-delay: 1.4s; }
.cell.selected {
outline: none;
@ -448,8 +445,8 @@ mobile-keyboard[collapsed] .keyboard-wrapper {
mobile-keyboard .keyboard {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.75em;
gap: var(--keyboard-gap);
padding: var(--keyboard-padding);
background:
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 5px),
repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(255,255,255,.015) 4px, rgba(255,255,255,.015) 6px),
@ -657,26 +654,38 @@ mobile-keyboard .key-spacer {
mobile-keyboard .handle {
position: absolute;
top: -2.4rem;
left: 50%;
transform: translateX(-50%);
top: calc(-1 * var(--handle-height));
left: var(--keyboard-padding);
transform: none;
background:
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(255,255,255,.02) 3px, rgba(255,255,255,.02) 5px),
repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(255,255,255,.015) 4px, rgba(255,255,255,.015) 6px),
repeating-linear-gradient(59deg, transparent, transparent 3px, rgba(0,0,0,.02) 3px, rgba(0,0,0,.02) 5px),
repeating-linear-gradient(-11deg, transparent, transparent 4px, rgba(0,0,0,.015) 4px, rgba(0,0,0,.015) 6px),
repeating-radial-gradient(circle at 12% 18%, rgba(255,255,255,.02) 1px, transparent 1px),
repeating-radial-gradient(circle at 67% 77%, rgba(0,0,0,.03) 1px, transparent 1px),
repeating-radial-gradient(circle at 34% 51%, rgba(255,255,255,.015) 1.5px, transparent 1.5px),
linear-gradient(135deg, #0a0805 0%, #0f0c09 100%);
color: #a89f99;
padding: 0.55rem 0.9rem;
repeating-linear-gradient(87deg, transparent, transparent 3px, rgba(255,255,255,.01) 3px, rgba(255,255,255,.01) 5px),
repeating-linear-gradient(22deg, transparent, transparent 4px, rgba(255,255,255,.008) 4px, rgba(255,255,255,.008) 6px),
repeating-linear-gradient(59deg, transparent, transparent 3px, rgba(0,0,0,.012) 3px, rgba(0,0,0,.012) 5px),
repeating-linear-gradient(-11deg, transparent, transparent 4px, rgba(0,0,0,.008) 4px, rgba(0,0,0,.008) 6px),
repeating-radial-gradient(circle at 12% 18%, rgba(255,255,255,.01) 1px, transparent 1px),
repeating-radial-gradient(circle at 67% 77%, rgba(0,0,0,.015) 1px, transparent 1px),
repeating-radial-gradient(circle at 34% 51%, rgba(255,255,255,.008) 1.5px, transparent 1.5px),
linear-gradient(135deg, #1a1512 0%, #0f0c09 100%);
color: #7a7268;
padding: var(--handle-padding) calc(var(--handle-padding) * 1.5);
border-radius: 0.25rem 0.25rem 0 0;
border: 1px solid #2a2520;
border: 1px solid rgba(58, 53, 48, 0.4);
cursor: pointer;
box-shadow: 0 -2px 8px rgba(0,0,0,0.5);
font-size: 0.9rem;
font-weight: 600;
box-shadow: 0 -1px 3px rgba(0,0,0,0.3);
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
opacity: 0.75;
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.3rem;
}
mobile-keyboard .handle:hover {
opacity: 0.95;
}
mobile-keyboard[collapsed] .keyboard-container { transform: translateY(calc(100% - 2.6rem)); }
@ -684,6 +693,19 @@ mobile-keyboard:not([collapsed]) .keyboard-container { transform: translateY(0);
@media (max-width: 980px) and (orientation: portrait) {
mobile-keyboard .keyboard { padding-bottom: 1.6rem; }
/* Override :root variables globally for portrait mode */
:root {
--keyboard-height: calc(
(calc(8vw * 1.4) * 4) + /* 4 rows with 8vw-based key-height */
(calc(8vw * 0.2) * 3) + /* 3 gaps between rows (8vw-based) */
calc(8vw * 0.4) + /* bottom padding (8vw-based) - the 1.6rem is separate */
calc(8vw * 0.8) + /* handle height (8vw-based) */
2px + /* keyboard border-top */
0.6rem /* visual buffer for shadow effects */
);
}
mobile-keyboard {
width: 100%;
--key-width: 8vw;
@ -827,6 +849,108 @@ crossword-menu {
transform: none;
}
/* Session management styles */
.saved-sessions {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.saved-sessions h3 {
margin: 0 0 1rem 0;
color: #5c6fc3;
font-size: 1.1rem;
}
.session-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.session-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 0.25rem;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.session-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.session-id {
font-family: monospace;
font-weight: bold;
color: #222c55;
}
.session-time, .session-lang {
font-size: 0.8rem;
color: rgba(0, 0, 0, 0.7);
}
.session-actions {
display: flex;
gap: 0.5rem;
}
.reconnect-btn {
padding: 0.4rem 0.8rem;
background: rgba(76, 175, 80, 0.2);
color: #81c784;
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
}
.reconnect-btn:hover {
background: rgba(76, 175, 80, 0.3);
border-color: rgba(76, 175, 80, 0.5);
}
.remove-btn {
padding: 0.4rem 0.6rem;
background: rgba(244, 67, 54, 0.2);
color: #ef5350;
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
font-weight: bold;
transition: all 0.2s ease;
}
.remove-btn:hover {
background: rgba(244, 67, 54, 0.3);
border-color: rgba(244, 67, 54, 0.5);
}
.clear-all-btn {
padding: 0.5rem 1rem;
background: rgba(255, 152, 0, 0.2);
color: #ffab40;
border: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
width: 100%;
}
.clear-all-btn:hover {
background: rgba(255, 152, 0, 0.3);
border-color: rgba(255, 152, 0, 0.5);
}
.loading {
display: flex;
align-items: center;
@ -993,20 +1117,29 @@ crossword-menu {
/* Game Header with Close Button */
.game-header {
display: flex;
justify-content: space-between;
justify-content: center;
align-items: center;
margin-bottom: 1.5rem;
padding: 0 0.5rem;
margin-bottom: 0.2rem;
padding: 0.5rem 0.5rem;
position: relative;
}
.game-header h2 {
margin: 0;
flex-grow: 1;
}
/* On very small screens, align title to the left */
@media (max-width: 600px) {
.game-header {
justify-content: flex-start;
}
}
.header-buttons {
display: flex;
gap: 0.5rem;
position: absolute;
right: 0.5rem;
}
.share-game-btn {
@ -1026,7 +1159,7 @@ crossword-menu {
border: 1px solid rgba(100, 180, 255, 0.3);
color: #f5f1ed;
cursor: pointer;
width: 2.5rem;
/* width: 2.5rem; */
height: 2.5rem;
display: flex;
align-items: center;
@ -1035,6 +1168,7 @@ crossword-menu {
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255,255,255,0.08);
font-size: 1.1rem;
}
.share-game-btn:hover {
@ -1111,20 +1245,42 @@ crossword-menu {
.game-content {
display: block;
overflow-y: auto;
overflow-x: auto;
overflow-x: auto; /* Allow horizontal scrolling */
max-height: calc(100vh - 200px); /* Leave space for header, clue area, and keyboard */
width: 100%;
padding: 1rem 0; /* Add some padding for breathing room */
text-align: center; /* Center inline-block children */
}
crossword-grid {
display: inline-block;
min-width: fit-content; /* Ensure grid takes its full needed width */
display: block;
width: 100%;
border-radius: 0px;
position: relative;
}
/* Completely independent scroll containers */
.main-grid-scroll-container {
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
margin-bottom: 1rem;
align-items: center;
}
.main-grid-scroll-container .grid-container {
display: inline-block;
min-width: fit-content;
align-items: center;
}
.solution-scroll-container {
overflow-x: auto;
overflow-y: hidden;
max-width: 100%;
display: inline-block; /* Behave like inline-block to only take needed space */
width: auto; /* Auto width to fit content */
}
/* Clue Area */
.clue-area {
position: fixed;
@ -1147,8 +1303,14 @@ crossword-grid {
z-index: 1000;
padding: 1rem;
box-sizing: border-box;
max-height: 50vh;
overflow-y: auto;
max-height: 3.5rem;
transition: max-height 0.3s ease;
overflow-y: hidden;
}
/* Expanded clue area - show all clues */
.clue-area.expanded {
max-height: 100vh;
}
.clue-header {
@ -1162,6 +1324,7 @@ crossword-grid {
font-weight: 600;
color: #a8b8ff;
font-size: 0.875rem;
min-width: 2rem;
}
.clue-number {
@ -1173,8 +1336,12 @@ crossword-grid {
.clue-text {
color: #f5f1ed;
font-size: 0.95rem;
line-height: 1.4;
line-height: 1.7;
flex-grow: 1;
white-space: nowrap;
overflow-x: auto;
overflow-y: scroll;
padding-bottom: 0.5rem;
}
.clue-text.empty {
@ -1200,8 +1367,8 @@ crossword-grid {
color: #f5f1ed;
font-size: 1rem;
cursor: pointer;
width: 2rem;
height: 2rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
@ -1239,6 +1406,7 @@ crossword-grid {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
/* All Clues View */
@ -1307,6 +1475,50 @@ crossword-grid {
/* Adjust main content for clue area */
main {
padding-top: calc(var(--page-padding) + 6rem);
padding-top: calc(var(--page-padding) + 4rem);
}
/* Solution circle rotation animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Solution circles spin continuously when complete */
@keyframes spin-with-translate {
from {
transform: translate(calc(-50% + var(--cell-size) * 0.0625), calc(-50% + var(--cell-size) * 0.0625)) rotate(0deg);
}
to {
transform: translate(calc(-50% + var(--cell-size) * 0.0625), calc(-50% + var(--cell-size) * 0.0625)) rotate(360deg);
}
}
.solution-word-grid.complete .solution-circle,
.grid-container.complete .solution-circle {
animation: spin-with-translate 3s linear infinite;
}
/* Pulse animation for all grid cells when puzzle is solved */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.5);
}
}
.grid-container.complete .cell {
animation: pulse 0.6s ease-in-out;
}
/* Staggered animation delays using CSS custom property */
.grid-container.complete .cell {
animation-delay: var(--pulse-delay, 0s);
}

View File

@ -1,8 +1,20 @@
const cacheName = 'pwa-conf-v4';
const staticAssets = [
'./app.js ',
'./websocket.js' //TODO: add all necessary files
'./',
'./index.html',
'./app.js',
'./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"