feat(submissions): auto-replace player in scopes, cancel sync + info about the sync

This commit is contained in:
2026-10-16 01:49:53 +02:00
parent de5ee8ae7a
commit 13c09adff3
18 changed files with 1051 additions and 137 deletions
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import dayjs from "dayjs";
import {
submissionsApiStopSyncScopesMutation,
submissionsApiSyncScopesMutation,
submissionsApiSyncScopesStatusOptions,
submissionsApiSyncScopesStatusQueryKey,
} from "@/api/@tanstack/vue-query.gen";
interface Props {
eventPath: string;
gamePath: string;
gameName: string;
}
const props = defineProps<Props>();
const open = defineModel<boolean>("open", { required: true });
const { t } = useI18n();
const queryClient = useQueryClient();
const path = computed(() => ({ event_path: props.eventPath, game_path: props.gamePath }));
const statusKey = computed(() => submissionsApiSyncScopesStatusQueryKey({ path: path.value }));
// Polled while open, so admins see each run land (last/next run, errors).
const { data: status, isLoading } = useQuery(
computed(() => ({
...submissionsApiSyncScopesStatusOptions({ path: path.value }),
enabled: open.value,
refetchInterval: 5000,
})),
);
const apibotToken = ref("");
const message = ref<{ type: "success" | "info" | "error"; text: string } | null>(null);
watch(open, (isOpen) => {
if (!isOpen) {
apibotToken.value = "";
message.value = null;
}
});
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: statusKey.value });
const start = useMutation({
...submissionsApiSyncScopesMutation(),
onSuccess: (data) => {
apibotToken.value = "";
message.value = data.started
? { type: "success", text: t("scopeSync.started") }
: { type: "info", text: t("scopeSync.alreadyRunning") };
},
onError: () => {
message.value = { type: "error", text: t("scopeSync.startFailed") };
},
onSettled: refreshStatus,
});
const stop = useMutation({
...submissionsApiStopSyncScopesMutation(),
onSuccess: (data) => {
message.value = data.stopped
? { type: "success", text: t("scopeSync.stopped") }
: { type: "info", text: t("scopeSync.notRunning") };
},
onError: () => {
message.value = { type: "error", text: t("scopeSync.stopFailed") };
},
onSettled: refreshStatus,
});
const busy = computed(() => start.isPending.value || stop.isPending.value);
const hasToken = computed(() => status.value?.has_token ?? false);
const tokenTyped = computed(() => apibotToken.value.trim() !== "");
const submit = () => {
start.mutate({
path: path.value,
body: { apibot_token: tokenTyped.value ? apibotToken.value.trim() : null },
});
};
const AANDT_STATUSES: Record<string, string> = {
"0prep": "prep",
"1publ": "published",
"3insc": "registrations",
"5inscdone": "registrationsDone",
"7inprog": "inProgress",
"9done": "done",
"11reviewed": "reviewed",
};
const aandtStatusLabel = computed(() => {
const value = status.value?.aandt_status;
if (!value) return t("scopeSync.unknown");
return AANDT_STATUSES[value] ? t(`scopeSync.aandtStatus.${AANDT_STATUSES[value]}`) : value;
});
const formatDate = (value?: string | null) =>
value ? dayjs(value).format("DD/MM HH:mm:ss") : t("scopeSync.never");
</script>
<template>
<div :class="['modal', { 'modal-open': open }]">
<div class="modal-box w-11/12 max-w-4xl max-h-[90vh] overflow-y-auto">
<h3 class="font-bold text-xl flex items-center gap-2">
<i class="mdi mdi-sync"></i>
{{ t("scopeSync.title", { game: gameName }) }}
</h3>
<!-- What this actually does -->
<div class="mt-4 space-y-3 text-base-content/80">
<p>{{ t("scopeSync.intro") }}</p>
<ul class="list-disc pl-6 space-y-1">
<li>{{ t("scopeSync.whatScopes") }}</li>
<li>{{ t("scopeSync.whatPlayers") }}</li>
<li>{{ t("scopeSync.whatClose") }}</li>
<li>{{ t("scopeSync.whatLink") }}</li>
<li>{{ t("scopeSync.whatLive") }}</li>
</ul>
</div>
<div class="alert alert-info mt-4 items-start">
<i class="mdi mdi-autorenew text-2xl"></i>
<div>
<h4 class="font-semibold">{{ t("scopeSync.autoTitle") }}</h4>
<p class="text-sm">{{ t("scopeSync.autoInterval") }}</p>
<p class="text-sm">{{ t("scopeSync.autoStop") }}</p>
<p class="text-sm">{{ t("scopeSync.autoErrors") }}</p>
</div>
</div>
<!-- Current state -->
<div class="mt-6">
<h4 class="font-semibold mb-2">{{ t("scopeSync.statusTitle") }}</h4>
<div v-if="isLoading" class="flex justify-center py-6">
<span class="loading loading-spinner"></span>
</div>
<div v-else-if="status" class="stats stats-vertical md:stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">{{ t("scopeSync.loop") }}</div>
<div class="stat-value text-lg flex items-center gap-2">
<span :class="['badge', status.running ? 'badge-success' : 'badge-ghost']">
{{ status.running ? t("scopeSync.running") : t("scopeSync.notRunningShort") }}
</span>
</div>
<div class="stat-desc">
{{ t("scopeSync.nextRun") }}: {{ status.running ? formatDate(status.next_run_at) : "—" }}
</div>
</div>
<div class="stat">
<div class="stat-title">{{ t("scopeSync.aandt") }}</div>
<div class="stat-value text-lg">{{ aandtStatusLabel }}</div>
<div class="stat-desc">{{ t("scopeSync.lastRun") }}: {{ formatDate(status.last_run_at) }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ t("scopeSync.scopes") }}</div>
<div class="stat-value text-lg">{{ status.active_scope_count }} / {{ status.scope_count }}</div>
<div class="stat-desc">{{ t("scopeSync.activeOfTotal") }}</div>
</div>
</div>
<div v-if="status?.error" class="alert alert-error mt-3">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ t("scopeSync.lastError") }}: {{ status.error }}</span>
</div>
</div>
<!-- Token -->
<form class="mt-6" @submit.prevent="submit">
<label class="font-semibold block mb-1" for="apibot-token">{{ t("scopeSync.tokenTitle") }}</label>
<p class="text-sm text-base-content/70 mb-2">
{{ hasToken ? t("scopeSync.tokenReplace") : t("scopeSync.tokenMissing") }}
</p>
<input id="apibot-token" v-model="apibotToken" type="password" autocomplete="off"
:placeholder="hasToken ? t('scopeSync.tokenConfigured') : t('scopeSync.tokenPlaceholder')"
class="input input-bordered w-full" />
<div v-if="message" :class="['alert mt-4', `alert-${message.type}`]">{{ message.text }}</div>
<div class="modal-action flex-wrap">
<button type="button" @click="open = false" class="btn">{{ t("common.close") }}</button>
<button v-if="status?.running" type="button" :disabled="busy" class="btn btn-error"
@click="stop.mutate({ path })">
<span v-if="stop.isPending.value" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-stop"></i>
{{ t("scopeSync.stop") }}
</button>
<button type="submit" class="btn btn-primary"
:disabled="busy || (!hasToken && !tokenTyped) || (status?.running && !tokenTyped)">
<span v-if="start.isPending.value" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-play"></i>
{{ status?.running ? t("scopeSync.saveToken") : t("scopeSync.start") }}
</button>
</div>
</form>
</div>
<div class="modal-backdrop" @click="open = false"></div>
</div>
</template>