Refactor connection indicator to patch with timestamp #11
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/ryanhamamura/games/connect4"
|
"github.com/ryanhamamura/games/connect4"
|
||||||
"github.com/ryanhamamura/games/db/repository"
|
"github.com/ryanhamamura/games/db/repository"
|
||||||
"github.com/ryanhamamura/games/features/c4game/pages"
|
"github.com/ryanhamamura/games/features/c4game/pages"
|
||||||
|
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||||
"github.com/ryanhamamura/games/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,7 +120,7 @@ func HandleGameEvents(store *connect4.Store, nc *nats.Conn, sm *scs.SessionManag
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendPing := func() error {
|
sendPing := func() error {
|
||||||
return sse.PatchSignals([]byte(fmt.Sprintf(`{"lastPing":%d}`, time.Now().UnixMilli())))
|
return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial render and ping
|
// Send initial render and ping
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ templ GamePage(g *connect4.Game, myColor int, messages []chat.Message, chatCfg c
|
|||||||
@layouts.Base("Connect 4") {
|
@layouts.Base("Connect 4") {
|
||||||
<main
|
<main
|
||||||
class="flex flex-col items-center gap-4 p-4"
|
class="flex flex-col items-center gap-4 p-4"
|
||||||
data-signals="{chatMsg: '', lastPing: 0}"
|
data-signals="{chatMsg: ''}"
|
||||||
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
data-init={ fmt.Sprintf("@get('/games/%s/events',{requestCancellation:'disabled'})", g.ID) }
|
||||||
>
|
>
|
||||||
@sharedcomponents.ConnectionIndicator()
|
@sharedcomponents.ConnectionIndicator(0)
|
||||||
@GameContent(g, myColor, messages, chatCfg)
|
@GameContent(g, myColor, messages, chatCfg)
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package components
|
package components
|
||||||
|
|
||||||
import "github.com/starfederation/datastar-go/datastar"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/starfederation/datastar-go/datastar"
|
||||||
|
)
|
||||||
|
|
||||||
templ BackToLobby() {
|
templ BackToLobby() {
|
||||||
<a class="link text-sm opacity-70" href="/">← Back</a>
|
<a class="link text-sm opacity-70" href="/">← Back</a>
|
||||||
@@ -44,19 +48,56 @@ templ NicknamePrompt(returnPath string) {
|
|||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isStale(lastPing int64) bool {
|
||||||
|
return lastPing == 0
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectionIndicator shows a small dot indicating SSE connection status.
|
// ConnectionIndicator shows a small dot indicating SSE connection status.
|
||||||
// It requires a `lastPing` signal (unix ms timestamp) to be set by the server.
|
// Server patches this with a timestamp; client JS detects staleness.
|
||||||
templ ConnectionIndicator() {
|
templ ConnectionIndicator(lastPing int64) {
|
||||||
<div
|
<div
|
||||||
id="connection-indicator"
|
id="connection-indicator"
|
||||||
class="fixed top-2 right-2 flex items-center gap-1 text-xs text-gray-500"
|
class="fixed top-2 right-2"
|
||||||
title="Connection status"
|
data-last-ping={ fmt.Sprintf("%d", lastPing) }
|
||||||
>
|
>
|
||||||
<span
|
<div class="inline-grid *:[grid-area:1/1]">
|
||||||
class="w-2 h-2 rounded-full transition-colors duration-300"
|
<div
|
||||||
data-class="{'bg-green-500': Date.now() - $lastPing < 20000, 'bg-red-500': Date.now() - $lastPing >= 20000}"
|
id="connection-ping"
|
||||||
></span>
|
class={
|
||||||
|
"status status-sm",
|
||||||
|
templ.KV("status-success animate-ping", !isStale(lastPing)),
|
||||||
|
templ.KV("status-error", isStale(lastPing)),
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
id="connection-dot"
|
||||||
|
class={
|
||||||
|
"status status-sm",
|
||||||
|
templ.KV("status-success", !isStale(lastPing)),
|
||||||
|
templ.KV("status-error", isStale(lastPing)),
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@connectionWatcher()
|
||||||
|
}
|
||||||
|
|
||||||
|
script connectionWatcher() {
|
||||||
|
setInterval(function() {
|
||||||
|
var el = document.getElementById('connection-indicator');
|
||||||
|
var dot = document.getElementById('connection-dot');
|
||||||
|
var ping = document.getElementById('connection-ping');
|
||||||
|
if (!el || !dot || !ping) return;
|
||||||
|
|
||||||
|
var lastPing = parseInt(el.dataset.lastPing, 10) || 0;
|
||||||
|
var stale = Date.now() - lastPing > 20000;
|
||||||
|
|
||||||
|
dot.classList.toggle('status-success', !stale);
|
||||||
|
dot.classList.toggle('status-error', stale);
|
||||||
|
ping.classList.toggle('status-success', !stale);
|
||||||
|
ping.classList.toggle('status-error', stale);
|
||||||
|
ping.classList.toggle('animate-ping', !stale);
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
|
templ GameJoinPrompt(loginURL string, registerURL string, gamePath string) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/ryanhamamura/games/chat"
|
"github.com/ryanhamamura/games/chat"
|
||||||
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
chatcomponents "github.com/ryanhamamura/games/chat/components"
|
||||||
|
sharedcomponents "github.com/ryanhamamura/games/features/common/components"
|
||||||
"github.com/ryanhamamura/games/features/snakegame/pages"
|
"github.com/ryanhamamura/games/features/snakegame/pages"
|
||||||
"github.com/ryanhamamura/games/sessions"
|
"github.com/ryanhamamura/games/sessions"
|
||||||
"github.com/ryanhamamura/games/snake"
|
"github.com/ryanhamamura/games/snake"
|
||||||
@@ -125,7 +126,7 @@ func HandleSnakeEvents(snakeStore *snake.SnakeStore, nc *nats.Conn, sm *scs.Sess
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendPing := func() error {
|
sendPing := func() error {
|
||||||
return sse.PatchSignals([]byte(fmt.Sprintf(`{"lastPing":%d}`, time.Now().UnixMilli())))
|
return sse.PatchElementTempl(sharedcomponents.ConnectionIndicator(time.Now().UnixMilli()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send initial render and ping
|
// Send initial render and ping
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ templ GamePage(sg *snake.SnakeGame, mySlot int, messages []chat.Message, chatCfg
|
|||||||
@layouts.Base("Snake") {
|
@layouts.Base("Snake") {
|
||||||
<main
|
<main
|
||||||
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
class="snake-wrapper flex flex-col items-center gap-4 p-4"
|
||||||
data-signals={ `{"chatMsg":"","lastPing":0}` }
|
data-signals={ `{"chatMsg":""}` }
|
||||||
data-init={ fmt.Sprintf("@get('/snake/%s/events',{requestCancellation:'disabled'})", gameID) }
|
data-init={ fmt.Sprintf("@get('/snake/%s/events',{requestCancellation:'disabled'})", gameID) }
|
||||||
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
|
data-on:keydown__throttle.100ms={ keydownScript(gameID) }
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
@components.ConnectionIndicator()
|
@components.ConnectionIndicator(0)
|
||||||
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
@GameContent(sg, mySlot, messages, chatCfg, gameID)
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user