diff --git a/apps/server/src/routes/saves.ts b/apps/server/src/routes/saves.ts index 2117193..fa5850c 100644 --- a/apps/server/src/routes/saves.ts +++ b/apps/server/src/routes/saves.ts @@ -1,10 +1,12 @@ import { Hono } from 'hono'; -import { eq, and, desc } from 'drizzle-orm'; +import { eq, and, desc, notInArray } from 'drizzle-orm'; import { db } from '../db'; import { saves } from '../db/schema'; import { authMiddleware } from '../middleware/auth'; import type { AppEnv } from '../types'; +const MAX_SAVES_PER_USER = 10; + const savesRouter = new Hono(); savesRouter.use('*', authMiddleware); @@ -68,29 +70,6 @@ savesRouter.put('/', async (c) => { era: string; }>(); - const existing = await db - .select({ id: saves.id }) - .from(saves) - .where(eq(saves.userId, userId)) - .orderBy(desc(saves.updatedAt)) - .limit(1); - - if (existing.length > 0) { - await db - .update(saves) - .set({ - companyName: body.companyName, - saveVersion: body.saveVersion, - gameData: body.gameData, - tickCount: body.tickCount, - era: body.era, - updatedAt: new Date(), - }) - .where(eq(saves.id, existing[0].id)); - - return c.json({ id: existing[0].id, updated: true }); - } - const [newSave] = await db .insert(saves) .values({ @@ -103,6 +82,20 @@ savesRouter.put('/', async (c) => { }) .returning({ id: saves.id }); + const keepIds = await db + .select({ id: saves.id }) + .from(saves) + .where(eq(saves.userId, userId)) + .orderBy(desc(saves.updatedAt)) + .limit(MAX_SAVES_PER_USER); + + const keepSet = keepIds.map((r) => r.id); + if (keepSet.length === MAX_SAVES_PER_USER) { + await db + .delete(saves) + .where(and(eq(saves.userId, userId), notInArray(saves.id, keepSet))); + } + return c.json({ id: newSave.id, created: true }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fd8f266..fbaff8f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(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 ( + resolveConflict('local')} + onChooseCloud={() => resolveConflict('cloud')} + onNewGame={() => resolveConflict('new')} + /> + ); + } + if (!companyName) { return ; } diff --git a/apps/web/src/components/game/CloudSaveIndicator.tsx b/apps/web/src/components/game/CloudSaveIndicator.tsx new file mode 100644 index 0000000..8ee0979 --- /dev/null +++ b/apps/web/src/components/game/CloudSaveIndicator.tsx @@ -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 = { + 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 ( +
{config.label}
{timeLabel}
Click to save now
}> + +
+ ); +} diff --git a/apps/web/src/components/game/CloudSaveList.tsx b/apps/web/src/components/game/CloudSaveList.tsx new file mode 100644 index 0000000..09dd814 --- /dev/null +++ b/apps/web/src/components/game/CloudSaveList.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [loadConfirm, setLoadConfirm] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(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); + 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 ( +
+ Loading cloud saves... +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + if (saves.length === 0) { + return ( +
+ No cloud saves yet. +
+ ); + } + + return ( + <> +
+ {saves.map((save) => ( +
+
+
{save.companyName}
+
+ {save.era} · {formatDuration(save.tickCount)} · {timeAgo(save.updatedAt)} +
+
+
+ + + +
+
+ ))} +
+ + {loadConfirm && ( + handleLoad(loadConfirm)} + onCancel={() => setLoadConfirm(null)} + /> + )} + + {deleteConfirm && ( + handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} + /> + )} + + ); +} diff --git a/apps/web/src/components/game/SaveConflictDialog.tsx b/apps/web/src/components/game/SaveConflictDialog.tsx new file mode 100644 index 0000000..ede88df --- /dev/null +++ b/apps/web/src/components/game/SaveConflictDialog.tsx @@ -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 ( + + ); +} + +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 ( +
+
+

Save Conflict

+

+ Both a local save and a cloud save exist. Which would you like to continue with? +

+ +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/layout/TopBar.tsx b/apps/web/src/components/layout/TopBar.tsx index 4056996..ca272da 100644 --- a/apps/web/src/components/layout/TopBar.tsx +++ b/apps/web/src/components/layout/TopBar.tsx @@ -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() { ))} + {isRegistered() && ( + + )} + + {timeLabel && {timeLabel}} + + ); +} + function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: () => void }) { return (