183 lines
6.6 KiB
Vue
183 lines
6.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import {
|
|
ANNOUNCEMENT_REFRESH_MS,
|
|
ANNOUNCEMENT_WINDOW_MS,
|
|
normalizeAnnouncementConfig,
|
|
prepareAnnouncement,
|
|
type AnnouncementCache,
|
|
type AnnouncementConfig,
|
|
} from '../editor/announcement'
|
|
|
|
const { t, locale } = useI18n()
|
|
const config = ref<AnnouncementConfig | null>(null)
|
|
const visible = ref(false)
|
|
const animationRun = ref(0)
|
|
const CACHE_KEY = 'SpineParticle.Announcement.v1'
|
|
let memoryCache: AnnouncementCache | null = null
|
|
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
let staticHideTimer: ReturnType<typeof setTimeout> | null = null
|
|
let refreshing = false
|
|
|
|
const text = computed(() => {
|
|
if (!config.value?.enabled) return ''
|
|
return String(config.value.messages[locale.value]
|
|
|| config.value.messages.en
|
|
|| config.value.messages['zh-CN']
|
|
|| '')
|
|
})
|
|
const duration = computed(() => {
|
|
return config.value?.scrollDurationSeconds || 24
|
|
})
|
|
|
|
function readCache(): AnnouncementCache | null {
|
|
if (memoryCache) return memoryCache
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null') as Partial<AnnouncementCache> | null
|
|
const cachedConfig = normalizeAnnouncementConfig(parsed?.config)
|
|
if (!parsed || !cachedConfig || typeof parsed.signature !== 'string'
|
|
|| typeof parsed.checkedAt !== 'number' || typeof parsed.lastShownAt !== 'number') return null
|
|
memoryCache = {
|
|
signature: parsed.signature,
|
|
checkedAt: parsed.checkedAt!,
|
|
lastShownAt: parsed.lastShownAt!,
|
|
config: cachedConfig,
|
|
}
|
|
return memoryCache
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
function saveCache(cache: AnnouncementCache) {
|
|
memoryCache = cache
|
|
try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) } catch { /* Keep the in-memory cache for this session. */ }
|
|
}
|
|
function showAnnouncement(next: AnnouncementConfig) {
|
|
config.value = next
|
|
animationRun.value++
|
|
visible.value = true
|
|
if (staticHideTimer) clearTimeout(staticHideTimer)
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
staticHideTimer = setTimeout(() => { visible.value = false }, 10000)
|
|
}
|
|
}
|
|
function hideAnnouncement() {
|
|
visible.value = false
|
|
}
|
|
async function refreshAnnouncement(initial = false) {
|
|
if (refreshing) return
|
|
refreshing = true
|
|
try {
|
|
const response = await fetch(`${import.meta.env.BASE_URL}announcement.json`, { cache: 'no-store' })
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
const next = normalizeAnnouncementConfig(await response.json())
|
|
if (!next) throw new Error('Invalid announcement config')
|
|
const now = Date.now()
|
|
const decision = prepareAnnouncement(next, readCache(), now)
|
|
saveCache(decision.cache)
|
|
if (!next.enabled) {
|
|
visible.value = false
|
|
config.value = next
|
|
} else if (decision.shouldShow && next.enabled) {
|
|
showAnnouncement(next)
|
|
}
|
|
} catch {
|
|
if (!initial) return
|
|
const cached = readCache()
|
|
if (!cached?.config.enabled) return
|
|
const dueForWindow = !cached.lastShownAt || Date.now() - cached.lastShownAt >= ANNOUNCEMENT_WINDOW_MS
|
|
if (!dueForWindow) return
|
|
showAnnouncement(cached.config)
|
|
saveCache({ ...cached, lastShownAt: Date.now() })
|
|
} finally {
|
|
refreshing = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void refreshAnnouncement(true)
|
|
refreshTimer = setInterval(() => { void refreshAnnouncement() }, ANNOUNCEMENT_REFRESH_MS)
|
|
})
|
|
onBeforeUnmount(() => {
|
|
if (refreshTimer) clearInterval(refreshTimer)
|
|
if (staticHideTimer) clearTimeout(staticHideTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Transition name="announcement-fade">
|
|
<div
|
|
v-if="visible && text"
|
|
class="announcement-bar"
|
|
:aria-label="text"
|
|
:style="{ '--announcement-duration': `${duration}s` }"
|
|
tabindex="0"
|
|
role="status"
|
|
>
|
|
<div class="announcement-viewport">
|
|
<div :key="animationRun" class="announcement-track" @animationend="hideAnnouncement">
|
|
<span>{{ text }}</span>
|
|
<span aria-hidden="true">{{ text }}</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="announcement-close"
|
|
:aria-label="t('announcement.close')"
|
|
:title="t('announcement.close')"
|
|
@click.stop="hideAnnouncement"
|
|
>
|
|
<svg viewBox="0 0 12 12" aria-hidden="true">
|
|
<path d="m3 3 6 6M9 3 3 9" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.announcement-bar {
|
|
position: absolute; top: 10px; left: 50%; z-index: 3;
|
|
width: min(60vw, 1100px); height: 27.2px; box-sizing: border-box;
|
|
overflow: hidden; border: 1px solid rgba(255, 189, 190, .34); border-radius: 999px;
|
|
background:
|
|
linear-gradient(90deg, rgba(120, 48, 55, .12), rgba(145, 57, 64, .72) 10%, rgba(145, 57, 64, .72) 90%, rgba(120, 48, 55, .12));
|
|
color: #ffe4e5; transform: translateX(-50%);
|
|
}
|
|
.announcement-viewport {
|
|
position: absolute; inset: 0 26px 0 0; overflow: hidden;
|
|
mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
|
-webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
|
|
}
|
|
.announcement-bar:focus-visible { outline: 2px solid #ff8d8d; outline-offset: 2px; }
|
|
.announcement-track {
|
|
display: flex; width: max-content; height: 100%; align-items: center;
|
|
white-space: nowrap; font-size: 12px; font-weight: 600;
|
|
animation: announcement-scroll var(--announcement-duration) linear 3 forwards;
|
|
}
|
|
.announcement-track span { padding-right: 96px; }
|
|
.announcement-bar:hover .announcement-track,
|
|
.announcement-bar:focus .announcement-track { animation-play-state: paused; }
|
|
.announcement-close {
|
|
position: absolute; top: 50%; right: 3px; display: grid; width: 22px; height: 22px; padding: 0;
|
|
place-items: center; border: 0; border-radius: 50%; background: transparent;
|
|
color: #ffd4d6; transform: translateY(-50%); cursor: pointer;
|
|
}
|
|
.announcement-close svg { display: block; width: 10px; height: 10px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; }
|
|
.announcement-close:hover { background: rgba(255, 255, 255, .12); color: #fff; }
|
|
.announcement-close:focus-visible { outline: 1px solid #ffb0b3; outline-offset: -2px; }
|
|
.announcement-fade-enter-active, .announcement-fade-leave-active { transition: opacity .5s ease; }
|
|
.announcement-fade-enter-from, .announcement-fade-leave-to { opacity: 0; }
|
|
@keyframes announcement-scroll {
|
|
to { transform: translateX(-50%); }
|
|
}
|
|
@media (max-width: 900px) {
|
|
.announcement-bar { width: calc(100% - 180px); }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.announcement-track { animation: none; justify-content: center; }
|
|
.announcement-track span:last-child { display: none; }
|
|
}
|
|
</style>
|