feat: offline resilience and connection status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
627a1a0d9e
commit
6e092ee7be
@@ -3,8 +3,10 @@ import { ref } from 'vue'
|
|||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import PixelGlove from './PixelGlove.vue'
|
import PixelGlove from './PixelGlove.vue'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
|
import { useOnlineStatus } from '../composables/useOnlineStatus'
|
||||||
|
|
||||||
const { bot, isLoggedIn } = useNostr()
|
const { bot, isLoggedIn } = useNostr()
|
||||||
|
const { isOnline } = useOnlineStatus()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const isMenuOpen = ref(false)
|
const isMenuOpen = ref(false)
|
||||||
|
|
||||||
@@ -37,6 +39,11 @@ const bottomNav = [
|
|||||||
<span class="font-display font-black text-neon-pink text-lg tracking-widest glow-pink">
|
<span class="font-display font-black text-neon-pink text-lg tracking-widest glow-pink">
|
||||||
BOTFIGHTS
|
BOTFIGHTS
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full transition-colors"
|
||||||
|
:class="isOnline ? 'bg-green-400' : 'bg-red-400 animate-pulse'"
|
||||||
|
:title="isOnline ? 'Online' : 'Offline'"
|
||||||
|
/>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<div class="hidden md:flex items-center gap-6">
|
<div class="hidden md:flex items-center gap-6">
|
||||||
@@ -100,6 +107,14 @@ const bottomNav = [
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Offline banner -->
|
||||||
|
<div
|
||||||
|
v-if="!isOnline"
|
||||||
|
class="fixed top-16 left-0 right-0 z-40 bg-red-500/90 backdrop-blur-sm text-center py-1.5 font-pixel text-[10px] text-white tracking-wider"
|
||||||
|
>
|
||||||
|
OFFLINE — RECONNECTING...
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Mobile bottom nav -->
|
<!-- Mobile bottom nav -->
|
||||||
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-surface/95 backdrop-blur-md safe-area-bottom">
|
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-surface/95 backdrop-blur-md safe-area-bottom">
|
||||||
<div class="flex items-stretch justify-around h-14">
|
<div class="flex items-stretch justify-around h-14">
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
const DB_NAME = 'botfights_cache'
|
||||||
|
const STORE_NAME = 'fights'
|
||||||
|
const MAX_CACHED = 5
|
||||||
|
|
||||||
|
function openDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1)
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache a fight replay for offline viewing */
|
||||||
|
export async function cacheFight(fightData: { id: string; [key: string]: any }): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readwrite')
|
||||||
|
const store = tx.objectStore(STORE_NAME)
|
||||||
|
|
||||||
|
// Add with timestamp
|
||||||
|
store.put({ ...fightData, _cachedAt: Date.now() })
|
||||||
|
|
||||||
|
// Evict oldest if over limit
|
||||||
|
const countReq = store.count()
|
||||||
|
countReq.onsuccess = () => {
|
||||||
|
if (countReq.result > MAX_CACHED) {
|
||||||
|
const cursor = store.openCursor()
|
||||||
|
let oldest: { key: IDBValidKey; time: number } | null = null
|
||||||
|
cursor.onsuccess = () => {
|
||||||
|
const c = cursor.result
|
||||||
|
if (c) {
|
||||||
|
const t = (c.value as any)._cachedAt || 0
|
||||||
|
if (!oldest || t < oldest.time) oldest = { key: c.key, time: t }
|
||||||
|
c.continue()
|
||||||
|
} else if (oldest) {
|
||||||
|
store.delete(oldest.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
} catch { /* IndexedDB not available */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a cached fight by ID */
|
||||||
|
export async function getCachedFight(id: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const tx = db.transaction(STORE_NAME, 'readonly')
|
||||||
|
const req = tx.objectStore(STORE_NAME).get(id)
|
||||||
|
req.onsuccess = () => {
|
||||||
|
db.close()
|
||||||
|
resolve(req.result || null)
|
||||||
|
}
|
||||||
|
req.onerror = () => {
|
||||||
|
db.close()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const isOnline = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
||||||
|
|
||||||
|
let listenerCount = 0
|
||||||
|
|
||||||
|
function handleOnline() { isOnline.value = true }
|
||||||
|
function handleOffline() { isOnline.value = false }
|
||||||
|
|
||||||
|
export function useOnlineStatus() {
|
||||||
|
onMounted(() => {
|
||||||
|
if (listenerCount === 0) {
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
listenerCount++
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
listenerCount--
|
||||||
|
if (listenerCount === 0) {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { isOnline }
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import FightViewer from '../components/FightViewer.vue'
|
|||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
import { useFightPolling } from '../composables/useFightPolling'
|
import { useFightPolling } from '../composables/useFightPolling'
|
||||||
import { useHumanChallenge } from '../composables/useHumanChallenge'
|
import { useHumanChallenge } from '../composables/useHumanChallenge'
|
||||||
|
import { cacheFight, getCachedFight } from '../composables/useFightCache'
|
||||||
import { createFightScene, type FightSceneController } from '../game/FightScene'
|
import { createFightScene, type FightSceneController } from '../game/FightScene'
|
||||||
import {
|
import {
|
||||||
fanfareRound,
|
fanfareRound,
|
||||||
@@ -434,7 +435,22 @@ watch(liveFightData, async (val) => {
|
|||||||
|
|
||||||
// --- Mount / Unmount ---
|
// --- Mount / Unmount ---
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const status = await loadFight()
|
let status = await loadFight()
|
||||||
|
|
||||||
|
// If fetch failed (offline), try IndexedDB cache
|
||||||
|
if (status === null && fight.value === null) {
|
||||||
|
const cached = await getCachedFight(fightId.value)
|
||||||
|
if (cached) {
|
||||||
|
fight.value = cached
|
||||||
|
status = 'finished'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache finished fights for offline replay
|
||||||
|
if (status === 'finished' && fight.value) {
|
||||||
|
cacheFight(fight.value).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
||||||
if (status === null || status !== 'finished') {
|
if (status === null || status !== 'finished') {
|
||||||
|
|||||||
Reference in New Issue
Block a user