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:
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 { 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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user