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:
2026-06-12 21:14:27 -07:00
parent d68333ec05
commit 345ef4088f
5 changed files with 165 additions and 2 deletions

View File

@@ -5,6 +5,7 @@
import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import { setPageTitle } from '../lib/title';
import { saveSession } from '../lib/sessions';
import LobbyPhase from '../components/LobbyPhase.svelte';
import CharacterCreationPhase from '../components/CharacterCreationPhase.svelte';
@@ -108,6 +109,18 @@
// Distinguish tabs/history by the Pi-Rat (and crew) this dashboard is for
$: 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
$: if (state?.player?.is_admin && state?.game?.admin_key) {
localStorage.setItem(`admin_key_${gameId}`, state.game.admin_key);

View File

@@ -3,13 +3,41 @@
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { getSessions, saveSession, removeSession } from '../lib/sessions';
onMount(() => setPageTitle());
let sessions = [];
onMount(() => {
setPageTitle();
sessions = getSessions();
});
let crewName = '';
let error = '';
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() {
if (!crewName.trim()) return;
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>
</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">
<a href="/rules" target="_blank" rel="noopener" class="btn btn-secondary">📖 Read the Rules</a>
</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>

View File

@@ -3,6 +3,7 @@
import { push } from 'svelte-spa-router';
import { apiRequest } from '../lib/api';
import { setPageTitle } from '../lib/title';
import { saveSession } from '../lib/sessions';
export let params = {};
onMount(() => setPageTitle('Join the Crew'));
@@ -21,6 +22,9 @@
error = '';
try {
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}`);
} catch (err) {
error = err.message;