Overhaul cloud save system: fix destructive bugs, add save management UI
CI / build-and-push (push) Successful in 57s

Stop 401 responses from wiping local saves and force-reloading. Fix logout
race condition with final cloud save before token invalidation. Replace
hard 3-failure cap with exponential backoff (2min to 30min). Switch cloud
save interval from tick-based (30s) to wall-clock (5min). Add cloud save
status indicator and Force Save button in TopBar. Show save conflict
dialog on login when both local and cloud saves exist. Add cloud save
list, download, and delete in Settings. Server now keeps 10 save snapshots
per user instead of overwriting a single save.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:43:00 -04:00
parent 8ef1226755
commit 6ea136083a
10 changed files with 559 additions and 64 deletions
+17 -2
View File
@@ -4,6 +4,7 @@ import { MainLayout } from '@/components/layout/MainLayout';
import { NewGameScreen } from '@/components/game/NewGameScreen';
import { OfflineCatchUp } from '@/components/game/OfflineCatchUp';
import { InviteGateScreen } from '@/components/game/InviteGateScreen';
import { SaveConflictDialog } from '@/components/game/SaveConflictDialog';
import { useGameLoop } from '@/hooks/useGameLoop';
import { useAuthGate } from '@/hooks/useAuthGate';
import { useCloudSave } from '@/hooks/useCloudSave';
@@ -54,8 +55,10 @@ function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () =>
}
export function App() {
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, loadCloudSave, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const { loading: authLoading, backendError, needsInvite, needsPasswordReset, cloudSave, hasConflict, loadCloudSave, resolveConflict, setRegistered, setNeedsPasswordReset, retry } = useAuthGate();
const companyName = useGameStore((s) => s.meta.companyName);
const currentEra = useGameStore((s) => s.meta.currentEra);
const tickCount = useGameStore((s) => s.meta.tickCount);
const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp);
const [catchUpTicks, setCatchUpTicks] = useState<number | null>(null);
const [catchUpDone, setCatchUpDone] = useState(false);
@@ -71,7 +74,7 @@ export function App() {
}
}, [companyName, lastTickTimestamp, catchUpDone]);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset);
useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset || hasConflict);
useCloudSave();
if (authLoading) {
@@ -93,6 +96,18 @@ export function App() {
);
}
if (hasConflict && cloudSave && companyName) {
return (
<SaveConflictDialog
localSave={{ companyName, era: currentEra, tickCount, lastTickTimestamp }}
cloudSave={cloudSave}
onChooseLocal={() => resolveConflict('local')}
onChooseCloud={() => resolveConflict('cloud')}
onNewGame={() => resolveConflict('new')}
/>
);
}
if (!companyName) {
return <NewGameScreen cloudSave={cloudSave} onContinue={loadCloudSave} />;
}
@@ -0,0 +1,41 @@
import { Cloud, CloudOff, Check, AlertTriangle, Loader2 } from 'lucide-react';
import { useCloudSaveStore, type CloudSaveStatus } from '@/hooks/useCloudSave';
import { Tooltip } from '@/components/common/Tooltip';
function formatTimeAgo(ms: number): string {
const seconds = Math.floor((Date.now() - ms) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
return `${hours}h ago`;
}
const STATUS_CONFIG: Record<CloudSaveStatus, { icon: typeof Cloud; color: string; label: string }> = {
idle: { icon: Cloud, color: 'text-surface-500', label: 'Cloud save idle' },
saving: { icon: Loader2, color: 'text-accent', label: 'Saving to cloud...' },
success: { icon: Check, color: 'text-success', label: 'Saved to cloud' },
error: { icon: AlertTriangle, color: 'text-warning', label: 'Cloud save failed' },
offline: { icon: CloudOff, color: 'text-surface-500', label: 'Cloud save offline' },
};
export function CloudSaveIndicator({ onForceSave }: { onForceSave: () => void }) {
const status = useCloudSaveStore((s) => s.status);
const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime);
const config = STATUS_CONFIG[status];
const Icon = config.icon;
const timeLabel = lastSaveTime ? `Last saved: ${formatTimeAgo(lastSaveTime)}` : 'Not yet saved';
return (
<Tooltip content={<div className="space-y-1"><div>{config.label}</div><div className="text-surface-400">{timeLabel}</div><div className="text-surface-500 text-xs">Click to save now</div></div>}>
<button
onClick={onForceSave}
className={`p-2 rounded hover:bg-surface-800 transition-colors ${config.color}`}
aria-label="Cloud save"
>
<Icon size={18} className={status === 'saving' ? 'animate-spin' : ''} />
</button>
</Tooltip>
);
}
@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react';
import { Download, Trash2, Upload, RefreshCw, Cloud } from 'lucide-react';
import { api } from '@/lib/api';
import { useGameStore } from '@/store';
import { formatDuration } from '@token-empire/shared';
import { ConfirmModal } from '@/components/common/ConfirmModal';
interface SaveEntry {
id: string;
companyName: string;
era: string;
tickCount: number;
updatedAt: string;
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function CloudSaveList() {
const [saves, setSaves] = useState<SaveEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loadConfirm, setLoadConfirm] = useState<SaveEntry | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<SaveEntry | null>(null);
const addNotification = useGameStore((s) => s.addNotification);
async function fetchSaves() {
setLoading(true);
setError(null);
try {
const { saves: list } = await api.saves.list();
setSaves(list);
} catch {
setError('Failed to load cloud saves');
} finally {
setLoading(false);
}
}
useEffect(() => { fetchSaves(); }, []);
async function handleLoad(save: SaveEntry) {
try {
const { save: full } = await api.saves.get(save.id);
if (full?.gameData) {
useGameStore.setState(full.gameData as Record<string, unknown>);
addNotification({
title: 'Cloud Save Loaded',
message: `Loaded "${save.companyName}" from cloud.`,
type: 'success',
tick: useGameStore.getState().meta.tickCount,
});
}
} catch {
addNotification({
title: 'Load Failed',
message: 'Could not load cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
setLoadConfirm(null);
}
async function handleDownload(save: SaveEntry) {
try {
const { save: full } = await api.saves.get(save.id);
if (full?.gameData) {
const blob = new Blob([JSON.stringify(full.gameData)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `token-empire-cloud-${save.companyName.replace(/\s+/g, '-').toLowerCase()}.json`;
a.click();
URL.revokeObjectURL(url);
}
} catch {
addNotification({
title: 'Download Failed',
message: 'Could not download cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
}
async function handleDelete(save: SaveEntry) {
try {
await api.saves.delete(save.id);
setSaves((prev) => prev.filter((s) => s.id !== save.id));
} catch {
addNotification({
title: 'Delete Failed',
message: 'Could not delete cloud save.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
setDeleteConfirm(null);
}
if (loading) {
return (
<div className="flex items-center gap-2 text-sm text-surface-400 py-2">
<RefreshCw size={14} className="animate-spin" /> Loading cloud saves...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-between text-sm text-surface-400 py-2">
<span>{error}</span>
<button onClick={fetchSaves} className="text-accent hover:text-accent-light text-xs">Retry</button>
</div>
);
}
if (saves.length === 0) {
return (
<div className="flex items-center gap-2 text-sm text-surface-400 py-2">
<Cloud size={14} /> No cloud saves yet.
</div>
);
}
return (
<>
<div className="space-y-2">
{saves.map((save) => (
<div key={save.id} className="flex items-center justify-between bg-surface-800 rounded-lg px-3 py-2 border border-surface-700">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{save.companyName}</div>
<div className="text-xs text-surface-400">
{save.era} &middot; {formatDuration(save.tickCount)} &middot; {timeAgo(save.updatedAt)}
</div>
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
<button
onClick={() => setLoadConfirm(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-accent transition-colors"
title="Load this save"
>
<Upload size={14} />
</button>
<button
onClick={() => handleDownload(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-surface-200 transition-colors"
title="Download as JSON"
>
<Download size={14} />
</button>
<button
onClick={() => setDeleteConfirm(save)}
className="p-1.5 rounded hover:bg-surface-700 text-surface-400 hover:text-danger transition-colors"
title="Delete this save"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
{loadConfirm && (
<ConfirmModal
title="Load Cloud Save"
message={`Load "${loadConfirm.companyName}"? This will replace your current local game.`}
confirmLabel="Load"
onConfirm={() => handleLoad(loadConfirm)}
onCancel={() => setLoadConfirm(null)}
/>
)}
{deleteConfirm && (
<ConfirmModal
title="Delete Cloud Save"
message={`Delete "${deleteConfirm.companyName}" from the cloud? This cannot be undone.`}
confirmLabel="Delete"
danger
onConfirm={() => handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
)}
</>
);
}
@@ -0,0 +1,103 @@
import { useEffect } from 'react';
import { Cloud, HardDrive, Plus } from 'lucide-react';
import { formatDuration } from '@token-empire/shared';
import type { CloudSaveInfo } from '@/hooks/useAuthGate';
interface LocalSaveInfo {
companyName: string;
era: string;
tickCount: number;
lastTickTimestamp: number;
}
function SaveCard({ icon: Icon, label, companyName, era, tickCount, timestamp, selected, onClick }: {
icon: typeof Cloud;
label: string;
companyName: string;
era: string;
tickCount: number;
timestamp: string;
selected?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex-1 p-4 rounded-lg border text-left transition-colors ${
selected
? 'border-accent bg-accent/10'
: 'border-surface-600 bg-surface-800 hover:border-surface-500'
}`}
>
<div className="flex items-center gap-2 mb-3">
<Icon size={16} className={selected ? 'text-accent' : 'text-surface-400'} />
<span className="text-sm font-medium">{label}</span>
</div>
<div className="space-y-1.5">
<div className="text-base font-semibold">{companyName}</div>
<div className="text-xs text-surface-400">Era: {era}</div>
<div className="text-xs text-surface-400">Time: {formatDuration(tickCount)}</div>
<div className="text-xs text-surface-500">{timestamp}</div>
</div>
</button>
);
}
export function SaveConflictDialog({ localSave, cloudSave, onChooseLocal, onChooseCloud, onNewGame }: {
localSave: LocalSaveInfo;
cloudSave: CloudSaveInfo;
onChooseLocal: () => void;
onChooseCloud: () => void;
onNewGame: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onChooseLocal();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onChooseLocal]);
const localDate = new Date(localSave.lastTickTimestamp).toLocaleString();
const cloudDate = new Date(cloudSave.updatedAt).toLocaleString();
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl">
<h3 className="text-lg font-bold mb-2">Save Conflict</h3>
<p className="text-sm text-surface-400 mb-5">
Both a local save and a cloud save exist. Which would you like to continue with?
</p>
<div className="flex gap-3 mb-5">
<SaveCard
icon={HardDrive}
label="Local Save"
companyName={localSave.companyName}
era={localSave.era}
tickCount={localSave.tickCount}
timestamp={localDate}
onClick={onChooseLocal}
/>
<SaveCard
icon={Cloud}
label="Cloud Save"
companyName={cloudSave.companyName}
era={cloudSave.era}
tickCount={cloudSave.tickCount}
timestamp={cloudDate}
onClick={onChooseCloud}
/>
</div>
<button
onClick={onNewGame}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200 hover:bg-surface-800 transition-colors"
>
<Plus size={14} />
Start New Game
</button>
</div>
</div>
);
}
@@ -1,8 +1,11 @@
import { type ReactNode, useState } from 'react';
import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { CloudSaveIndicator } from '@/components/game/CloudSaveIndicator';
import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store';
import { isRegistered } from '@/lib/api';
import { performCloudSave } from '@/hooks/useCloudSave';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@token-empire/shared';
import type { GameSpeed } from '@token-empire/shared';
import { Tooltip } from '@/components/common/Tooltip';
@@ -97,6 +100,10 @@ export function TopBar() {
))}
</div>
{isRegistered() && (
<CloudSaveIndicator onForceSave={performCloudSave} />
)}
<button
onClick={() => setShowStats(true)}
className="p-2 rounded hover:bg-surface-800 transition-colors"
+21 -1
View File
@@ -20,7 +20,9 @@ interface AuthGateState {
isAdmin: boolean;
config: { requireInvite: boolean; userInvitations: number } | null;
cloudSave: CloudSaveInfo | null;
hasConflict: boolean;
loadCloudSave: () => Promise<void>;
resolveConflict: (choice: 'local' | 'cloud' | 'new') => Promise<void>;
setRegistered: (value: boolean) => void;
setNeedsPasswordReset: (value: boolean) => void;
retry: () => void;
@@ -34,6 +36,7 @@ export function useAuthGate(): AuthGateState {
const [passwordReset, setPasswordReset] = useState(false);
const [admin, setAdmin] = useState(false);
const [cloudSave, setCloudSave] = useState<CloudSaveInfo | null>(null);
const [hasConflict, setHasConflict] = useState(false);
const [initCount, setInitCount] = useState(0);
const init = useCallback(async () => {
@@ -111,6 +114,17 @@ export function useAuthGate(): AuthGateState {
}
}, []);
const resolveConflict = useCallback(async (choice: 'local' | 'cloud' | 'new') => {
if (choice === 'cloud') {
await loadCloudSave();
} else if (choice === 'new') {
localStorage.removeItem('token-empire-save');
window.location.reload();
return;
}
setHasConflict(false);
}, [loadCloudSave]);
const handleSetRegistered = useCallback(async (value: boolean) => {
setRegistered(value);
const payload = getTokenPayload();
@@ -130,7 +144,11 @@ export function useAuthGate(): AuthGateState {
tickCount: save.tickCount,
updatedAt: save.updatedAt,
});
if (save.gameData) {
const localCompany = useGameStore.getState().meta.companyName;
if (localCompany) {
setHasConflict(true);
} else {
useGameStore.setState(save.gameData as Record<string, unknown>);
}
}
@@ -157,7 +175,9 @@ export function useAuthGate(): AuthGateState {
isAdmin: admin,
config,
cloudSave,
hasConflict,
loadCloudSave,
resolveConflict,
setRegistered: handleSetRegistered,
setNeedsPasswordReset: handleSetPasswordReset,
retry,
+99 -31
View File
@@ -1,50 +1,118 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useCallback } from 'react';
import { create } from 'zustand';
import { useGameStore } from '@/store';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api';
import { AUTO_SAVE_INTERVAL_TICKS } from '@token-empire/shared';
import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload, isRegistered } from '@/lib/api';
const MAX_CONSECUTIVE_FAILURES = 3;
const CLOUD_SAVE_INTERVAL_MS = 5 * 60 * 1000;
const BASE_BACKOFF_MS = 2 * 60 * 1000;
const MAX_BACKOFF_MS = 30 * 60 * 1000;
export type CloudSaveStatus = 'idle' | 'saving' | 'success' | 'error' | 'offline';
interface CloudSaveState {
status: CloudSaveStatus;
lastSaveTime: number | null;
failureCount: number;
setStatus: (status: CloudSaveStatus) => void;
setLastSaveTime: (time: number) => void;
setFailureCount: (count: number) => void;
}
export const useCloudSaveStore = create<CloudSaveState>((set) => ({
status: 'idle',
lastSaveTime: null,
failureCount: 0,
setStatus: (status) => set({ status }),
setLastSaveTime: (time) => set({ lastSaveTime: time }),
setFailureCount: (count) => set({ failureCount: count }),
}));
function buildSavePayload() {
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
return {
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
};
}
export async function performCloudSave(): Promise<boolean> {
const token = getAuthToken();
if (!token || !isRegistered()) return false;
const store = useCloudSaveStore.getState();
store.setStatus('saving');
try {
await api.saves.put(buildSavePayload());
store.setStatus('success');
store.setLastSaveTime(Date.now());
if (store.failureCount > 0) {
useGameStore.getState().addNotification({
title: 'Cloud Save Reconnected',
message: 'Your game is syncing to the cloud again.',
type: 'success',
tick: useGameStore.getState().meta.tickCount,
});
}
store.setFailureCount(0);
return true;
} catch {
const newCount = store.failureCount + 1;
store.setFailureCount(newCount);
store.setStatus('error');
if (newCount === 1) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Progress is saved locally. Retrying automatically.',
type: 'warning',
tick: useGameStore.getState().meta.tickCount,
});
} else if (newCount === 5) {
useGameStore.getState().addNotification({
title: 'Cloud Save Unavailable',
message: 'Use Settings → Export Save to back up manually.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
return false;
}
}
export function useCloudSave() {
const tickCount = useGameStore((s) => s.meta.tickCount);
const companyName = useGameStore((s) => s.meta.companyName);
const lastSaveTick = useRef(0);
const failureCount = useRef(0);
const lastAttemptTime = useRef(Date.now());
useEffect(() => {
if (!companyName) return;
if (tickCount - lastSaveTick.current < AUTO_SAVE_INTERVAL_TICKS) return;
const token = getAuthToken();
if (!token) return;
if (!token || !isRegistered()) return;
if (failureCount.current >= MAX_CONSECUTIVE_FAILURES) return;
const now = Date.now();
const { failureCount, lastSaveTime } = useCloudSaveStore.getState();
lastSaveTick.current = tickCount;
const backoffMs = failureCount > 0
? Math.min(BASE_BACKOFF_MS * Math.pow(2, failureCount - 1), MAX_BACKOFF_MS)
: CLOUD_SAVE_INTERVAL_MS;
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
const timeSinceLastAttempt = now - lastAttemptTime.current;
if (timeSinceLastAttempt < backoffMs) return;
api.saves.put({
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
}).then(() => {
failureCount.current = 0;
}).catch(() => {
failureCount.current++;
if (failureCount.current === MAX_CONSECUTIVE_FAILURES) {
useGameStore.getState().addNotification({
title: 'Cloud Save Failed',
message: 'Unable to save to cloud. Your progress is still saved locally.',
type: 'danger',
tick: useGameStore.getState().meta.tickCount,
});
}
});
lastAttemptTime.current = now;
performCloudSave();
}, [tickCount, companyName]);
const forceSave = useCallback(() => performCloudSave(), []);
return { forceSave };
}
export async function ensureAuth(): Promise<string | null> {
-3
View File
@@ -14,7 +14,6 @@ export function getAuthToken() {
export function clearAuthToken() {
authToken = null;
localStorage.removeItem('token-empire-auth-token');
localStorage.removeItem('token-empire-refresh-token');
}
export interface TokenPayload {
@@ -91,8 +90,6 @@ async function request<T>(path: string, options: RequestInit & { timeoutMs?: num
if (!res.ok) {
if (res.status === 401 && authToken && !AUTH_PATHS.includes(path)) {
clearAuthToken();
localStorage.removeItem('token-empire-save');
window.location.reload();
}
const body = await res.json().catch(() => null);
throw new Error(body?.error || `HTTP ${res.status} ${res.statusText}`);
+59 -3
View File
@@ -1,8 +1,10 @@
import { useRef, useState } from 'react';
import { Pencil, Check, X, LogOut } from 'lucide-react';
import { Pencil, Check, X, LogOut, Cloud, Loader2 } from 'lucide-react';
import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal';
import { CloudSaveList } from '@/components/game/CloudSaveList';
import { api, setAuthToken, getTokenPayload, isRegistered, isAdmin, clearAuthToken } from '@/lib/api';
import { performCloudSave, useCloudSaveStore } from '@/hooks/useCloudSave';
export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings);
@@ -277,7 +279,8 @@ export function SettingsPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Save Data</h3>
<div className="flex gap-3">
<div className="flex flex-wrap gap-3">
{registered && <SaveToCloudButton />}
<button
onClick={handleExport}
className="px-4 py-2 rounded bg-surface-800 hover:bg-surface-700 border border-surface-600 text-sm"
@@ -306,6 +309,13 @@ export function SettingsPage() {
</div>
</div>
{registered && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-4">
<h3 className="font-semibold">Cloud Saves</h3>
<CloudSaveList />
</div>
)}
{showResetConfirm && (
<ConfirmModal
title="Reset All Progress"
@@ -336,9 +346,24 @@ export function SettingsPage() {
confirmLabel={registered ? 'Log Out' : 'Sign Out'}
danger={!registered}
onConfirm={async () => {
if (registered) {
try {
const state = useGameStore.getState();
const { activePage, notifications, infraNav, modelsTab, ...gameState } = state;
await api.saves.put({
companyName: state.meta.companyName,
saveVersion: state.meta.saveVersion,
gameData: gameState,
tickCount: state.meta.tickCount,
era: state.meta.currentEra,
});
} catch {}
}
try { await api.auth.logout(); } catch {}
clearAuthToken();
localStorage.removeItem('token-empire-save');
if (!registered) {
localStorage.removeItem('token-empire-save');
}
window.location.reload();
}}
onCancel={() => setShowLogoutConfirm(false)}
@@ -348,6 +373,37 @@ export function SettingsPage() {
);
}
function SaveToCloudButton() {
const status = useCloudSaveStore((s) => s.status);
const lastSaveTime = useCloudSaveStore((s) => s.lastSaveTime);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
await performCloudSave();
setSaving(false);
};
const isBusy = saving || status === 'saving';
const timeLabel = lastSaveTime
? `Last saved ${Math.floor((Date.now() - lastSaveTime) / 60000)}m ago`
: null;
return (
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={isBusy}
className="inline-flex items-center gap-2 px-4 py-2 rounded bg-accent/20 hover:bg-accent/30 border border-accent/50 text-accent text-sm disabled:opacity-50 transition-colors"
>
{isBusy ? <Loader2 size={14} className="animate-spin" /> : <Cloud size={14} />}
Save to Cloud
</button>
{timeLabel && <span className="text-xs text-surface-500">{timeLabel}</span>}
</div>
);
}
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: () => void }) {
return (
<button