Files
pleno-vue/src/components/global/FrontendMaintenanceMenu.vue
T

218 lines
5.4 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const SHIFT_PRESS_WINDOW_MS = 1200;
const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit || releaseUpdateState.currentCommit));
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
}
};
const handleKeydown = (event) => {
if (event.key !== "Shift" || event.repeat) {
return;
}
const now = Date.now();
shiftPresses.value = [...shiftPresses.value.filter((pressedAt) => now - pressedAt <= SHIFT_PRESS_WINDOW_MS), now];
if (shiftPresses.value.length >= 3) {
shiftPresses.value = [];
isOpen.value = true;
}
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value || !window.confirm(t("maintenance_menu.confirm_clear_local"))) {
return;
}
isBusy.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isBusy.value = false;
console.error("Failed to force frontend update and clear local data:", error);
}
};
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener(CLOSE_EVENT, closeMenu);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(CLOSE_EVENT, closeMenu);
});
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="frontend-maintenance-menu" data-testid="frontend-maintenance-menu">
<button
type="button"
class="frontend-maintenance-menu__backdrop"
:aria-label="t('common.close')"
@click="closeMenu"
></button>
<section class="frontend-maintenance-menu__panel" role="dialog" aria-modal="true" :aria-label="t('maintenance_menu.title')">
<header class="frontend-maintenance-menu__header">
<div>
<p>{{ t("maintenance_menu.title") }}</p>
<span>{{ t("maintenance_menu.version", { current: currentVersion, latest: latestVersion }) }}</span>
</div>
<button type="button" class="frontend-maintenance-menu__close" :aria-label="t('common.close')" @click="closeMenu">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<button
type="button"
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="forceUpdateAndClearLocal"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
<strong>{{ t("maintenance_menu.force_update_clear") }}</strong>
<small>{{ t("maintenance_menu.force_update_clear_hint") }}</small>
</span>
</button>
</section>
</div>
</Teleport>
</template>
<style scoped>
.frontend-maintenance-menu {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: end center;
padding: 16px;
pointer-events: none;
}
.frontend-maintenance-menu__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(12, 22, 34, 0.32);
pointer-events: auto;
}
.frontend-maintenance-menu__panel {
position: relative;
width: min(420px, calc(100vw - 32px));
border: 1px solid #d6dde6;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
color: #111827;
pointer-events: auto;
}
.frontend-maintenance-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 10px;
border-bottom: 1px solid #edf1f5;
}
.frontend-maintenance-menu__header p {
margin: 0;
color: #172033;
font-size: 0.96rem;
font-weight: 800;
}
.frontend-maintenance-menu__header span {
display: block;
margin-top: 3px;
color: #667085;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.72rem;
font-weight: 700;
}
.frontend-maintenance-menu__close {
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid #d9e0e8;
border-radius: 6px;
background: #ffffff;
color: #334155;
cursor: pointer;
}
.frontend-maintenance-menu__danger {
width: calc(100% - 28px);
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 14px;
padding: 10px 12px;
border: 1px solid #f2b8b5;
border-radius: 6px;
background: #fff7f6;
color: #981b1b;
cursor: pointer;
text-align: left;
}
.frontend-maintenance-menu__danger:disabled {
cursor: wait;
opacity: 0.7;
}
.frontend-maintenance-menu__danger i {
width: 20px;
flex: 0 0 auto;
text-align: center;
}
.frontend-maintenance-menu__danger strong,
.frontend-maintenance-menu__danger small {
display: block;
}
.frontend-maintenance-menu__danger strong {
font-size: 0.86rem;
font-weight: 800;
}
.frontend-maintenance-menu__danger small {
margin-top: 2px;
color: #9f1c1c;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.25;
}
@media (min-width: 720px) {
.frontend-maintenance-menu {
place-items: end end;
padding: 22px;
}
}
</style>