Save active sessions in browser local storage
Joining a game now records the session (game ID, player ID, plus player/rat/crew names) in localStorage via the new lib/sessions.js, and the dashboard keeps it enriched as state loads (so the rat name tracks earned Names). The home page lists saved sessions in a "Rejoin a Crew" menu, most-recent first, alongside creating a new game. Deleting an entry only touches localStorage, never the backend, and is undoable: the row greys out with an Undo button and is dropped from storage immediately, so a page refresh finalizes the removal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
## Minor Gameplay Polish
|
## Minor Gameplay Polish
|
||||||
|
|
||||||
- [ ] **Save active sessions in browser local storage**. Whenever you create or join a game, your browser local storage should be updated with the game ID, player ID, crew name, and player/rat names (if available). Going to the home page with saved sessions should provide a menu of games to rejoin in addition to the ability to start a new game. It should be possible to delete entries from this list, which only affects the browser local storage, not the backend database, and is undoable (the entry doesn't disappear immediately, it's just marked as deleted until page refresh).
|
|
||||||
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
|
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.
|
||||||
|
|
||||||
## Words Words Words
|
## Words Words Words
|
||||||
|
|||||||
46
frontend/src/lib/sessions.js
Normal file
46
frontend/src/lib/sessions.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Tracks the games this browser has joined so the home page can offer a
|
||||||
|
// "rejoin" menu. Stored in localStorage only — deleting an entry never touches
|
||||||
|
// the backend. Each entry is keyed by gameId + playerId:
|
||||||
|
// { gameId, playerId, crewName, playerName, ratName, updatedAt }
|
||||||
|
const KEY = 'pirats-sessions';
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(KEY));
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(list) {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSession(a, gameId, playerId) {
|
||||||
|
return a.gameId === gameId && a.playerId === playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most-recently-updated first.
|
||||||
|
export function getSessions() {
|
||||||
|
return load().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert or merge a session. Only defined fields overwrite existing values, so
|
||||||
|
// a sparse update (e.g. just a playerName at join time) won't clobber names
|
||||||
|
// filled in later from the full game state.
|
||||||
|
export function saveSession(fields) {
|
||||||
|
const { gameId, playerId } = fields;
|
||||||
|
if (!gameId || !playerId) return;
|
||||||
|
const list = load();
|
||||||
|
const idx = list.findIndex(s => sameSession(s, gameId, playerId));
|
||||||
|
const incoming = Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined && v !== null));
|
||||||
|
const merged = { ...(idx >= 0 ? list[idx] : {}), ...incoming, updatedAt: Date.now() };
|
||||||
|
if (idx >= 0) list[idx] = merged;
|
||||||
|
else list.push(merged);
|
||||||
|
persist(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSession(gameId, playerId) {
|
||||||
|
persist(load().filter(s => !sameSession(s, gameId, playerId)));
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { getSuggestion } from '../lib/suggestions';
|
import { getSuggestion } from '../lib/suggestions';
|
||||||
import { displayName } from '../lib/cards';
|
import { displayName } from '../lib/cards';
|
||||||
import { setPageTitle } from '../lib/title';
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { saveSession } from '../lib/sessions';
|
||||||
|
|
||||||
import LobbyPhase from '../components/LobbyPhase.svelte';
|
import LobbyPhase from '../components/LobbyPhase.svelte';
|
||||||
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
|
||||||
@@ -108,6 +109,18 @@
|
|||||||
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
|
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
|
||||||
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
|
$: setPageTitle(state ? displayName(state.player) : null, state?.game?.crew_name);
|
||||||
|
|
||||||
|
// Keep this browser's saved session up to date so the home page can rejoin
|
||||||
|
// with current crew/rat names (the rat name changes when a Name is earned)
|
||||||
|
$: if (state?.game && state?.player) {
|
||||||
|
saveSession({
|
||||||
|
gameId,
|
||||||
|
playerId,
|
||||||
|
crewName: state.game.crew_name,
|
||||||
|
playerName: state.player.player_name,
|
||||||
|
ratName: state.player.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
|
// Admins get the admin key in their state blob; stash it so the Admin page works for them too
|
||||||
$: if (state?.player?.is_admin && state?.game?.admin_key) {
|
$: if (state?.player?.is_admin && state?.game?.admin_key) {
|
||||||
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
|
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);
|
||||||
|
|||||||
@@ -3,13 +3,41 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { setPageTitle } from '../lib/title';
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { getSessions, saveSession, removeSession } from '../lib/sessions';
|
||||||
|
|
||||||
onMount(() => setPageTitle());
|
let sessions = [];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setPageTitle();
|
||||||
|
sessions = getSessions();
|
||||||
|
});
|
||||||
|
|
||||||
let crewName = '';
|
let crewName = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let creating = false;
|
let creating = false;
|
||||||
|
|
||||||
|
function sessionRat(s) {
|
||||||
|
if (s.ratName && s.playerName && s.ratName !== s.playerName) return `${s.ratName} (${s.playerName})`;
|
||||||
|
return s.ratName || s.playerName || 'Unknown rat';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete is a soft, undoable removal: drop it from localStorage right away
|
||||||
|
// (so a refresh finalizes it) but keep the greyed row visible with an Undo.
|
||||||
|
function deleteSession(s) {
|
||||||
|
removeSession(s.gameId, s.playerId);
|
||||||
|
s.deleted = true;
|
||||||
|
sessions = sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoDelete(s) {
|
||||||
|
saveSession({
|
||||||
|
gameId: s.gameId, playerId: s.playerId,
|
||||||
|
crewName: s.crewName, playerName: s.playerName, ratName: s.ratName,
|
||||||
|
});
|
||||||
|
s.deleted = false;
|
||||||
|
sessions = sessions;
|
||||||
|
}
|
||||||
|
|
||||||
async function createGame() {
|
async function createGame() {
|
||||||
if (!crewName.trim()) return;
|
if (!crewName.trim()) return;
|
||||||
creating = true;
|
creating = true;
|
||||||
@@ -62,7 +90,80 @@
|
|||||||
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
<p class="join-hint">Joining someone else's crew? Ask the game creator for the join link from their lobby.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if sessions.length > 0}
|
||||||
|
<div class="glass-panel creation-card saved-sessions">
|
||||||
|
<h2>Rejoin a Crew</h2>
|
||||||
|
<p class="info-text" style="margin-bottom: 1rem;">Games you've joined from this browser. Removing one only forgets it here — it stays in the game.</p>
|
||||||
|
<ul class="session-list">
|
||||||
|
{#each sessions as s (s.gameId + s.playerId)}
|
||||||
|
<li class="session-row {s.deleted ? 'is-deleted' : ''}">
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-crew">{s.crewName || 'Unnamed Crew'}</span>
|
||||||
|
<span class="session-rat text-muted">{sessionRat(s)}</span>
|
||||||
|
</div>
|
||||||
|
{#if s.deleted}
|
||||||
|
<div class="session-actions">
|
||||||
|
<span class="text-muted italic">Removed</span>
|
||||||
|
<button class="btn btn-secondary btn-small" on:click={() => undoDelete(s)}>Undo</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="session-actions">
|
||||||
|
<a href="#/game/{s.gameId}/player/{s.playerId}" class="btn btn-primary btn-small">Rejoin</a>
|
||||||
|
<button class="btn btn-secondary btn-small" title="Forget this session" on:click={() => deleteSession(s)}>✕</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="welcome-links">
|
<div class="welcome-links">
|
||||||
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.session-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.session-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
background: var(--well);
|
||||||
|
border: 1px solid var(--edge-soft);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.session-row.is-deleted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.session-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.session-crew {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-rat {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.session-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { setPageTitle } from '../lib/title';
|
import { setPageTitle } from '../lib/title';
|
||||||
|
import { saveSession } from '../lib/sessions';
|
||||||
export let params = {};
|
export let params = {};
|
||||||
|
|
||||||
onMount(() => setPageTitle('Join the Crew'));
|
onMount(() => setPageTitle('Join the Crew'));
|
||||||
@@ -21,6 +22,9 @@
|
|||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
const data = await apiRequest(`/game/${gameId}/join`, 'POST', { name: playerName });
|
||||||
|
// Remember this session so the home page can offer a rejoin link;
|
||||||
|
// the dashboard enriches it with crew/rat names once state loads.
|
||||||
|
saveSession({ gameId, playerId: data.id, playerName });
|
||||||
push(`/game/${gameId}/player/${data.id}`);
|
push(`/game/${gameId}/player/${data.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
|
|||||||
Reference in New Issue
Block a user