fix(submissions): top-header icon for live connection (ws)

This commit is contained in:
2026-10-16 02:03:54 +02:00
parent a6ea3719b9
commit 3d040dd377
5 changed files with 81 additions and 26 deletions
+1 -9
View File
@@ -477,7 +477,7 @@ const loadUserSubmissions = async () => {
// Match over (or user moved off it): back to the picker, which auto-enters // Match over (or user moved off it): back to the picker, which auto-enters
// the next match when there's only one, so players follow along without a refresh. // the next match when there's only one, so players follow along without a refresh.
const { isConnected: wsConnected } = useSubmissionsWebSocket({ useSubmissionsWebSocket({
userSubmissions, userSubmissions,
onScopeUpdate: (scope) => { onScopeUpdate: (scope) => {
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return; if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
@@ -557,14 +557,6 @@ onUnmounted(() => {
<div class="flex-1 bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Main Content --> <!-- Main Content -->
<div class="container min-w-3/4 mx-auto px-4 py-8"> <div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- WebSocket Status Indicator -->
<div class="flex justify-end mb-4">
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Right Column: Upload --> <!-- Right Column: Upload -->
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2"> <div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
@@ -1,5 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { usePageHeaderStore } from '@/stores/pageHeader'
interface SubmissionUpdate { interface SubmissionUpdate {
id: string id: string
@@ -107,6 +108,10 @@ export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Opti
}, },
}) })
// Live badge in AppHeader (reset by the router on the next navigation)
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)
+54 -15
View File
@@ -11,7 +11,7 @@ const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const userInfo = useUser(); const userInfo = useUser();
const pageHeaderStore = usePageHeaderStore(); const pageHeaderStore = usePageHeaderStore();
const { title: dynamicTitle, backRoute: dynamicBackRoute } = storeToRefs(pageHeaderStore); const { title: dynamicTitle, backRoute: dynamicBackRoute, live } = storeToRefs(pageHeaderStore);
const title = computed(() => { const title = computed(() => {
if (dynamicTitle.value) return dynamicTitle.value; if (dynamicTitle.value) return dynamicTitle.value;
@@ -29,22 +29,61 @@ const goBack = () => {
<template> <template>
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar bg-base-100 shadow-lg">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4"> <!-- 3 columns so the live badge stays centered whatever the sides' width -->
<button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm"> <div class="container min-w-3/4 mx-auto w-full grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<i class="mdi mdi-arrow-left"></i> <div class="flex items-center gap-4 min-w-0">
{{ t("common.back") }} <button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm">
</button> <i class="mdi mdi-arrow-left"></i>
<h1 class="text-xl font-bold">{{ title }}</h1> {{ t("common.back") }}
<div class="flex-1"></div> </button>
<div v-if="userInfo.isAuthenticated" class="flex items-center gap-2"> <h1 class="text-xl font-bold truncate">{{ title }}</h1>
<div class="text-sm"> </div>
<span class="font-medium">{{ userInfo.username }}</span> <div>
<span v-if="userInfo.isStaff" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span> <!-- Hover (or tap, on mobile) to explain what live means for the player -->
<div v-if="live !== null" class="dropdown dropdown-hover dropdown-center">
<div tabindex="0" role="button" :class="['badge cursor-help', live ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', live ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ live ? t("submissions.live") : t("submissions.offline") }}
<i class="mdi mdi-information-outline ml-1 opacity-70"></i>
</div>
<div tabindex="0" class="dropdown-content card card-sm bg-base-200 shadow-xl z-50 w-80 mt-2">
<div class="card-body">
<template v-if="live">
<h3 class="card-title text-base">
<i class="mdi mdi-wifi text-success"></i>
{{ t("liveStatus.liveTitle") }}
</h3>
<p>{{ t("liveStatus.liveIntro") }}</p>
<ul class="list-disc pl-5 space-y-1">
<li>{{ t("liveStatus.liveSubmissions") }}</li>
<li>{{ t("liveStatus.liveMatches") }}</li>
</ul>
<p class="text-base-content/70">{{ t("liveStatus.liveNoRefresh") }}</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>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div> <div class="flex items-center justify-end gap-4">
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a> <div v-if="userInfo.isAuthenticated" class="flex items-center gap-2">
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a> <div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.isStaff" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
</div>
</div> </div>
</div> </div>
</template> </template>
+11
View File
@@ -260,6 +260,17 @@
"notRunning": "Sync wasn't running.", "notRunning": "Sync wasn't running.",
"stopFailed": "Failed to stop the sync." "stopFailed": "Failed to stop the sync."
}, },
"liveStatus": {
"liveTitle": "You're connected live",
"liveIntro": "This page updates on its own, you don't need to refresh it:",
"liveSubmissions": "Your submissions update as they're processed (uploaded files, text read from your screenshots).",
"liveMatches": "When your match ends, you're sent back to the match list, and straight into your next match if you have only one.",
"liveNoRefresh": "Just keep this page open between matches.",
"offlineTitle": "Live connection lost",
"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.",
"offlineReconnect": "It reconnects automatically, usually within a few seconds.",
"offlineRefresh": "Still offline after a while? Refresh the page. Anything you already submitted is saved."
},
"errorPage": { "errorPage": {
"notFound": { "notFound": {
"title": "Page not found", "title": "Page not found",
+9 -1
View File
@@ -11,6 +11,9 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
// Overrides route.meta.getBackRoute when the right target depends on // Overrides route.meta.getBackRoute when the right target depends on
// fetched data (e.g. skip the scope picker when it would auto-redirect). // fetched data (e.g. skip the scope picker when it would auto-redirect).
const backRoute = ref<RouteLocationRaw | null>(null); const backRoute = ref<RouteLocationRaw | null>(null);
// Live (websocket) connection state of the page, shown mid-header; null
// when the page has no live connection (no badge at all).
const live = ref<boolean | null>(null);
const setTitle = (value: string) => { const setTitle = (value: string) => {
title.value = value; title.value = value;
@@ -20,10 +23,15 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
backRoute.value = value; backRoute.value = value;
}; };
const setLive = (value: boolean | null) => {
live.value = value;
};
const clear = () => { const clear = () => {
title.value = ""; title.value = "";
backRoute.value = null; backRoute.value = null;
live.value = null;
}; };
return { title, backRoute, setTitle, setBackRoute, clear }; return { title, backRoute, live, setTitle, setBackRoute, setLive, clear };
}); });