fix: move other (clock+place) live icon to top ehader too

This commit is contained in:
2026-10-16 02:06:33 +02:00
parent 3d040dd377
commit bfaef2e402
8 changed files with 119 additions and 53 deletions
+2 -4
View File
@@ -3,6 +3,7 @@ import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useClockSocket } from "@/composables/useClockSocket"; import { useClockSocket } from "@/composables/useClockSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
interface Props { interface Props {
eventPath: string; eventPath: string;
@@ -25,6 +26,7 @@ const {
cooldownDurationSeconds, cooldownDurationSeconds,
press, press,
} = useClockSocket(props.eventPath); } = useClockSocket(props.eventPath);
useLiveBadge("clock", connected);
const remainingDisplay = computed(() => { const remainingDisplay = computed(() => {
const totalSeconds = Math.floor(remainingMs.value / 1000); const totalSeconds = Math.floor(remainingMs.value / 1000);
@@ -38,10 +40,6 @@ const onCooldown = computed(() => cooldownRemainingMs.value > 0);
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8"> <div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("clock.connected") : t("clock.reconnecting") }}
</span>
<div class="font-bold text-7xl tabular-nums"> <div class="font-bold text-7xl tabular-nums">
{{ remainingDisplay }} {{ remainingDisplay }}
</div> </div>
+2 -4
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { usePlaceSocket } from "@/composables/usePlaceSocket"; import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
import { PALETTE } from "@/palette"; import { PALETTE } from "@/palette";
interface Props { interface Props {
@@ -47,6 +48,7 @@ const {
gridHeight, gridHeight,
place, place,
} = usePlaceSocket(props.eventPath, drawPixel); } = usePlaceSocket(props.eventPath, drawPixel);
useLiveBadge("place", connected);
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL); const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL); const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
@@ -131,10 +133,6 @@ function handleClick(event: MouseEvent) {
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6"> <div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("place.connected") : t("place.reconnecting") }}
</span>
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }"> <div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight" <canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{ class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
@@ -0,0 +1,10 @@
import { watch, type Ref } from "vue";
import { usePageHeaderStore, type LiveKind } from "@/stores/pageHeader";
// Shows the page's live (websocket) state as a badge mid-header, with a
// popover explaining what it means on this page (liveStatus.<kind> strings).
// Reset by the router on the next navigation.
export const useLiveBadge = (kind: LiveKind, connected: Ref<boolean>) => {
const pageHeaderStore = usePageHeaderStore();
watch(connected, (value) => pageHeaderStore.setLive(value, kind), { immediate: true });
};
@@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { placeApiCanvas } from '@/api' import { placeApiCanvas } from '@/api'
@@ -108,6 +108,15 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
const connected = computed(() => status.value === 'OPEN') const connected = computed(() => status.value === 'OPEN')
// Pixels placed while disconnected never reach us: catch up by reloading
// the whole canvas on every reconnect (the first load is done on mount).
let hasConnected = false
watch(connected, (isConnected) => {
if (!isConnected) return
if (hasConnected) loadCanvas()
hasConnected = true
})
async function loadCanvas() { async function loadCanvas() {
const response = await placeApiCanvas({ path: { event_path: eventPath } }) const response = await placeApiCanvas({ path: { event_path: eventPath } })
if (!response.data) return if (!response.data) return
@@ -1,6 +1,6 @@
import { ref, computed, watch } from 'vue' import { ref, computed } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { usePageHeaderStore } from '@/stores/pageHeader' import { useLiveBadge } from '@/composables/useLiveBadge'
interface SubmissionUpdate { interface SubmissionUpdate {
id: string id: string
@@ -108,9 +108,7 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
}, },
}) })
// Live badge in AppHeader (reset by the router on the next navigation) useLiveBadge('submissions', isConnected)
const pageHeaderStore = usePageHeaderStore()
watch(isConnected, (connected) => pageHeaderStore.setLive(connected), { immediate: true })
const handleSubmissionUpdate = (submission: SubmissionUpdate) => { const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id) const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
+21 -21
View File
@@ -8,10 +8,22 @@ import { usePageHeaderStore } from "@/stores/pageHeader";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t, tm, rt } = useI18n();
const userInfo = useUser(); const userInfo = useUser();
const pageHeaderStore = usePageHeaderStore(); const pageHeaderStore = usePageHeaderStore();
const { title: dynamicTitle, backRoute: dynamicBackRoute, live } = storeToRefs(pageHeaderStore); const { title: dynamicTitle, backRoute: dynamicBackRoute, live, liveKind } = storeToRefs(pageHeaderStore);
// Popover text of the live badge, per page (liveStatus.<kind>.live/offline)
const liveHelp = computed(() => {
if (!liveKind.value) return null;
const key = `liveStatus.${liveKind.value}.${live.value ? "live" : "offline"}`;
return {
title: t(`${key}.title`),
intro: t(`${key}.intro`),
points: (tm(`${key}.points`) as unknown[]).map((point) => rt(point as Parameters<typeof rt>[0])),
outro: t(`${key}.outro`),
};
});
const title = computed(() => { const title = computed(() => {
if (dynamicTitle.value) return dynamicTitle.value; if (dynamicTitle.value) return dynamicTitle.value;
@@ -47,28 +59,16 @@ const goBack = () => {
<i class="mdi mdi-information-outline ml-1 opacity-70"></i> <i class="mdi mdi-information-outline ml-1 opacity-70"></i>
</div> </div>
<div tabindex="0" class="dropdown-content card card-sm bg-base-200 shadow-xl z-50 w-80 mt-2"> <div tabindex="0" class="dropdown-content card card-sm bg-base-200 shadow-xl z-50 w-80 mt-2">
<div class="card-body"> <div v-if="liveHelp" class="card-body">
<template v-if="live">
<h3 class="card-title text-base"> <h3 class="card-title text-base">
<i class="mdi mdi-wifi text-success"></i> <i :class="['mdi', live ? 'mdi-wifi text-success' : 'mdi-wifi-off text-warning']"></i>
{{ t("liveStatus.liveTitle") }} {{ liveHelp.title }}
</h3> </h3>
<p>{{ t("liveStatus.liveIntro") }}</p> <p>{{ liveHelp.intro }}</p>
<ul class="list-disc pl-5 space-y-1"> <ul v-if="liveHelp.points.length" class="list-disc pl-5 space-y-1">
<li>{{ t("liveStatus.liveSubmissions") }}</li> <li v-for="point in liveHelp.points" :key="point">{{ point }}</li>
<li>{{ t("liveStatus.liveMatches") }}</li>
</ul> </ul>
<p class="text-base-content/70">{{ t("liveStatus.liveNoRefresh") }}</p> <p class="text-base-content/70">{{ liveHelp.outro }}</p>
</template>
<template v-else>
<h3 class="card-title text-base">
<i class="mdi mdi-wifi-off text-warning"></i>
{{ t("liveStatus.offlineTitle") }}
</h3>
<p>{{ t("liveStatus.offlineIntro") }}</p>
<p>{{ t("liveStatus.offlineReconnect") }}</p>
<p class="text-base-content/70">{{ t("liveStatus.offlineRefresh") }}</p>
</template>
</div> </div>
</div> </div>
</div> </div>
+60 -13
View File
@@ -80,8 +80,6 @@
"clock": { "clock": {
"pageTitle": "Beat the Clock", "pageTitle": "Beat the Clock",
"header": "Beat the Clock", "header": "Beat the Clock",
"connected": "connected",
"reconnecting": "reconnecting…",
"press": "PRESS", "press": "PRESS",
"nextPlayer": "▲ next player", "nextPlayer": "▲ next player",
"chasingYou": "▼ chasing you", "chasingYou": "▼ chasing you",
@@ -90,8 +88,6 @@
"place": { "place": {
"pageTitle": "Place", "pageTitle": "Place",
"header": "Place", "header": "Place",
"connected": "connected",
"reconnecting": "reconnecting…",
"showGrid": "Show grid", "showGrid": "Show grid",
"hideGrid": "Hide grid" "hideGrid": "Hide grid"
}, },
@@ -261,15 +257,66 @@
"stopFailed": "Failed to stop the sync." "stopFailed": "Failed to stop the sync."
}, },
"liveStatus": { "liveStatus": {
"liveTitle": "You're connected live", "submissions": {
"liveIntro": "This page updates on its own, you don't need to refresh it:", "live": {
"liveSubmissions": "Your submissions update as they're processed (uploaded files, text read from your screenshots).", "title": "You're connected live",
"liveMatches": "When your match ends, you're sent back to the match list, and straight into your next match if you have only one.", "intro": "This page updates on its own, you don't need to refresh it:",
"liveNoRefresh": "Just keep this page open between matches.", "points": [
"offlineTitle": "Live connection lost", "Your submissions update as they're processed (uploaded files, text read from your screenshots).",
"offlineIntro": "The page can't get updates right now, so it won't move you to your next match or show processing results on its own.", "When your match ends, you're sent back to the match list, and straight into your next match if you have only one."
"offlineReconnect": "It reconnects automatically, usually within a few seconds.", ],
"offlineRefresh": "Still offline after a while? Refresh the page. Anything you already submitted is saved." "outro": "Just keep this page open between matches."
},
"offline": {
"title": "Live connection lost",
"intro": "The page can't get updates right now:",
"points": [
"It won't move you to your next match on its own.",
"Processing results of your submissions won't show up by themselves."
],
"outro": "It reconnects automatically, usually within a few seconds. Still offline after a while? Refresh the page, anything you already submitted is saved."
}
},
"clock": {
"live": {
"title": "You're connected live",
"intro": "Everything here is real time, shared with every player:",
"points": [
"The countdown is the same for everyone, and moves as soon as someone presses.",
"Your points and rank update right after each of your presses."
],
"outro": "Presses go through this live connection, keep it green to play."
},
"offline": {
"title": "Live connection lost",
"intro": "You can't play right now:",
"points": [
"Your presses can't reach the server.",
"The countdown shown may be off, other players' presses aren't coming in."
],
"outro": "It reconnects automatically within a second or so, and your stats are refreshed when it does. Still offline? Refresh the page."
}
},
"place": {
"live": {
"title": "You're connected live",
"intro": "The canvas is shared with every player, in real time:",
"points": [
"Pixels placed by others show up as soon as they're placed.",
"Your pixels go through this live connection, then the cooldown starts."
],
"outro": "Just keep this page open, no refresh needed."
},
"offline": {
"title": "Live connection lost",
"intro": "You can't place pixels right now:",
"points": [
"Your clicks can't reach the server.",
"Pixels placed by others aren't coming in, the canvas may be out of date."
],
"outro": "It reconnects automatically within a second or so, and reloads the whole canvas to catch up. Still offline? Refresh the page."
}
}
}, },
"errorPage": { "errorPage": {
"notFound": { "notFound": {
+8 -2
View File
@@ -2,6 +2,9 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import type { RouteLocationRaw } from "vue-router"; import type { RouteLocationRaw } from "vue-router";
// Which page's explanation the live badge popover shows (liveStatus.<kind>)
export type LiveKind = "submissions" | "clock" | "place";
// Lets a routed page hand AppHeader a title built from data only that page // Lets a routed page hand AppHeader a title built from data only that page
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API // has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
// call keyed by the scopeId route param). Cleared on every navigation so a // call keyed by the scopeId route param). Cleared on every navigation so a
@@ -14,6 +17,7 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
// Live (websocket) connection state of the page, shown mid-header; null // Live (websocket) connection state of the page, shown mid-header; null
// when the page has no live connection (no badge at all). // when the page has no live connection (no badge at all).
const live = ref<boolean | null>(null); const live = ref<boolean | null>(null);
const liveKind = ref<LiveKind | null>(null);
const setTitle = (value: string) => { const setTitle = (value: string) => {
title.value = value; title.value = value;
@@ -23,15 +27,17 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
backRoute.value = value; backRoute.value = value;
}; };
const setLive = (value: boolean | null) => { const setLive = (value: boolean, kind: LiveKind) => {
live.value = value; live.value = value;
liveKind.value = kind;
}; };
const clear = () => { const clear = () => {
title.value = ""; title.value = "";
backRoute.value = null; backRoute.value = null;
live.value = null; live.value = null;
liveKind.value = null;
}; };
return { title, backRoute, live, setTitle, setBackRoute, setLive, clear }; return { title, backRoute, live, liveKind, setTitle, setBackRoute, setLive, clear };
}); });