Sync source to CNB / Sync Git ref (push) Skipped
Bug1 远端公告:公告只在启动时拉取一次,且点铃铛不触发刷新。 - openCenter 时补调 refresh(),点铃铛即拉最新 - watch skinSiteStatus,登录皮肤站后自动重试拉取 Bug2 盔甲翻译:补 7 个缺失的 app.skins.armor.* i18n 键 (title/save/close/description/unsaved/reset-to-saved/restore-defaults), ui 包的 skin.preview.armor.* 46 个键已存在无需补。
426 lines
13 KiB
Vue
426 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Button,
|
|
commonMessages,
|
|
defineMessages,
|
|
NewModal,
|
|
useModalStack,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import { renderString } from '@modrinth/utils'
|
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
|
|
import {
|
|
announcementKey,
|
|
isAnnouncementActive,
|
|
mapBroadcastAnnouncements,
|
|
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
|
|
parseAnnouncements,
|
|
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
|
|
type RemoteAnnouncement,
|
|
safeAnnouncementUrl,
|
|
} from '@/helpers/remote-announcements'
|
|
|
|
import {
|
|
requestSkinSiteDownloadToken,
|
|
skinSiteStatus,
|
|
waitForSkinSiteSession,
|
|
} from '@/composables/skin-site-session.ts'
|
|
|
|
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
|
|
const { formatMessage } = useVIntl()
|
|
const { hasModal } = useModalStack()
|
|
const messages = defineMessages({
|
|
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
|
|
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
|
|
readAll: {
|
|
id: 'app.remote-announcements.read-all',
|
|
defaultMessage: 'Mark all announcements as read',
|
|
},
|
|
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
|
|
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
|
|
previewTitle: {
|
|
id: 'app.remote-announcements.preview-title',
|
|
defaultMessage: 'Announcement style preview',
|
|
},
|
|
previewSummary: {
|
|
id: 'app.remote-announcements.preview-summary',
|
|
defaultMessage:
|
|
'This is a local preview. Select View announcement to preview the full Markdown content.',
|
|
},
|
|
previewContent: {
|
|
id: 'app.remote-announcements.preview-content',
|
|
defaultMessage:
|
|
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
|
|
},
|
|
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
|
|
})
|
|
const modal = ref<InstanceType<typeof NewModal>>()
|
|
const selected = ref<RemoteAnnouncement | null>(null)
|
|
const active = ref(false)
|
|
const centerOpen = ref(false)
|
|
const startupNotice = ref<RemoteAnnouncement | null>(null)
|
|
const html = computed(() => renderString(selected.value?.content ?? ''))
|
|
const stateKey = 'starlight-remote-announcements-v2'
|
|
const read = reactive(new Set<string>())
|
|
const queuedThisSession = new Set<string>()
|
|
const startupNotified = new Set<string>()
|
|
let items: RemoteAnnouncement[] = []
|
|
let pending: RemoteAnnouncement[] = []
|
|
let cacheKey = 'starlight-remote-announcements-cache-v1'
|
|
let cacheLoaded = false
|
|
let disposed = false
|
|
let interval: ReturnType<typeof setInterval> | undefined
|
|
let advanceTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
function persist() {
|
|
if (props.previewOnly) return
|
|
try {
|
|
localStorage.setItem(
|
|
stateKey,
|
|
JSON.stringify({
|
|
read: [...read].slice(-1000),
|
|
}),
|
|
)
|
|
} catch {
|
|
// localStorage may be unavailable (private mode / quota)
|
|
}
|
|
}
|
|
function emitState() {
|
|
window.dispatchEvent(
|
|
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
|
|
detail: {
|
|
items,
|
|
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
function openCenter(item?: RemoteAnnouncement) {
|
|
if (!props.ready || disposed) return
|
|
void refresh()
|
|
centerOpen.value = true
|
|
if (item) {
|
|
startupNotice.value = null
|
|
selectAnnouncement(item)
|
|
} else if (!selected.value && items.length) {
|
|
selectAnnouncement(items[0])
|
|
}
|
|
void nextTick(() => modal.value?.show())
|
|
}
|
|
function handleOpenCenter() {
|
|
if (props.previewOnly) return
|
|
openCenter()
|
|
}
|
|
async function show(item: RemoteAnnouncement) {
|
|
if (
|
|
disposed ||
|
|
!props.ready ||
|
|
!isAnnouncementActive(item) ||
|
|
(hasModal.value && !active.value && !centerOpen.value)
|
|
)
|
|
return
|
|
centerOpen.value = true
|
|
active.value = true
|
|
selectAnnouncement(item)
|
|
await nextTick()
|
|
if (!disposed) modal.value?.show()
|
|
}
|
|
function selectAnnouncement(item: RemoteAnnouncement) {
|
|
selected.value = item
|
|
const key = announcementKey(item)
|
|
read.add(key)
|
|
pending = pending.filter((entry) => announcementKey(entry) !== key)
|
|
persist()
|
|
emitState()
|
|
}
|
|
function markAllRead() {
|
|
for (const item of items) {
|
|
read.add(announcementKey(item))
|
|
}
|
|
pending = []
|
|
persist()
|
|
emitState()
|
|
}
|
|
function advance() {
|
|
if (disposed || !props.ready || hasModal.value || active.value) return
|
|
const next = pending.shift()
|
|
if (next) {
|
|
void show(next)
|
|
return
|
|
}
|
|
for (const item of items) {
|
|
const key = announcementKey(item)
|
|
if (
|
|
item.type === 'notification' &&
|
|
!startupNotified.has(key) &&
|
|
!read.has(key) &&
|
|
isAnnouncementActive(item)
|
|
) {
|
|
startupNotice.value = item
|
|
startupNotified.add(key)
|
|
setTimeout(() => {
|
|
if (startupNotice.value === item) {
|
|
startupNotice.value = null
|
|
advance()
|
|
}
|
|
}, 8000)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
function closed() {
|
|
active.value = false
|
|
centerOpen.value = false
|
|
if (advanceTimer) clearTimeout(advanceTimer)
|
|
advanceTimer = setTimeout(advance, 350)
|
|
}
|
|
function sync(next: RemoteAnnouncement[], fresh: boolean) {
|
|
items = next.filter((item) => isAnnouncementActive(item))
|
|
const keys = new Set(items.map(announcementKey))
|
|
pending = pending.filter((item) => keys.has(announcementKey(item)))
|
|
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
|
|
emitState()
|
|
if (fresh) {
|
|
for (const item of items) {
|
|
const key = announcementKey(item)
|
|
if (
|
|
item.type === 'modal' &&
|
|
!queuedThisSession.has(key) &&
|
|
(!read.has(key) || item.priority === 'critical')
|
|
) {
|
|
queuedThisSession.add(key)
|
|
pending.push(item)
|
|
}
|
|
}
|
|
advance()
|
|
}
|
|
}
|
|
function loadCache() {
|
|
if (cacheLoaded || !cacheKey) return
|
|
cacheLoaded = true
|
|
try {
|
|
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
|
|
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
|
|
const parsed = parseAnnouncements(cached.items)
|
|
if (parsed) sync(parsed, false)
|
|
}
|
|
} catch {
|
|
// Ignore malformed or expired cache payloads
|
|
}
|
|
}
|
|
function saveCache(next: RemoteAnnouncement[]) {
|
|
if (!cacheKey) return
|
|
try {
|
|
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
|
|
} catch {
|
|
// localStorage may be unavailable (private mode / quota)
|
|
}
|
|
}
|
|
async function refresh() {
|
|
if (disposed) return
|
|
loadCache()
|
|
if (skinSiteStatus.value !== 'signed-in') return
|
|
try {
|
|
await waitForSkinSiteSession()
|
|
const token = await requestSkinSiteDownloadToken()
|
|
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
|
|
method: 'GET',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
if (!response.ok) return
|
|
const data = await response.json()
|
|
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
|
|
if (parsed && !disposed) {
|
|
sync(parsed, true)
|
|
saveCache(parsed)
|
|
}
|
|
} catch {
|
|
// Remote fetch failed; previously cached items remain displayed
|
|
}
|
|
}
|
|
async function openLink(value: unknown) {
|
|
const url = safeAnnouncementUrl(value)
|
|
if (!url) return
|
|
try {
|
|
await openUrl(url)
|
|
} catch {
|
|
// Opener may reject unknown schemes
|
|
}
|
|
}
|
|
function contentClick(event: MouseEvent) {
|
|
const link = event.target instanceof Element ? event.target.closest('a') : null
|
|
if (!link) return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
void openLink(link.getAttribute('href'))
|
|
}
|
|
function preview(type: RemoteAnnouncement['type'], withAction = false) {
|
|
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
|
|
const now = new Date().toISOString()
|
|
const item: RemoteAnnouncement = {
|
|
id: 'local-preview-' + type,
|
|
title: formatMessage(messages.previewTitle),
|
|
summary: formatMessage(messages.previewSummary),
|
|
content: formatMessage(messages.previewContent),
|
|
type,
|
|
priority: 'normal',
|
|
starts_at: now,
|
|
ends_at: null,
|
|
published_at: now,
|
|
action_label: withAction ? formatMessage(messages.previewAction) : null,
|
|
action_url: withAction ? 'https://skin.starlight.cool/' : null,
|
|
}
|
|
read.clear()
|
|
queuedThisSession.clear()
|
|
sync([item], true)
|
|
}
|
|
defineExpose({ preview })
|
|
onMounted(() => {
|
|
if (props.previewOnly) return
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
|
|
if (saved && Array.isArray(saved.read))
|
|
for (const key of saved.read) if (typeof key === 'string') read.add(key)
|
|
} catch {
|
|
// Ignore malformed local read-state
|
|
}
|
|
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
|
const start = () => {
|
|
if (disposed) return
|
|
void refresh()
|
|
interval = setInterval(() => {
|
|
sync(items, false)
|
|
advance()
|
|
}, 15000)
|
|
}
|
|
// Defer the startup request until after first paint.
|
|
if (typeof requestIdleCallback === 'function') {
|
|
requestIdleCallback(start, { timeout: 2000 })
|
|
} else {
|
|
setTimeout(start, 500)
|
|
}
|
|
})
|
|
watch(skinSiteStatus, (status) => {
|
|
if (disposed || props.previewOnly) return
|
|
if (status === 'signed-in') void refresh()
|
|
})
|
|
watch([() => props.ready, hasModal], () => {
|
|
if (advanceTimer) clearTimeout(advanceTimer)
|
|
advanceTimer = setTimeout(advance, 350)
|
|
})
|
|
onUnmounted(() => {
|
|
disposed = true
|
|
if (interval) clearInterval(interval)
|
|
if (advanceTimer) clearTimeout(advanceTimer)
|
|
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="startupNotice"
|
|
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
|
|
>
|
|
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
|
|
<div class="mb-1 text-xs font-semibold uppercase text-brand">
|
|
{{ formatMessage(messages.unread) }}
|
|
</div>
|
|
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
|
|
<div
|
|
v-if="startupNotice.summary"
|
|
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
|
|
>
|
|
{{ startupNotice.summary }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
<NewModal
|
|
ref="modal"
|
|
:header="formatMessage(messages.centerTitle)"
|
|
:on-hide="closed"
|
|
max-width="80vw"
|
|
width="80vw"
|
|
max-content-height="calc(80vh - 8rem)"
|
|
scrollable
|
|
>
|
|
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
|
|
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
|
|
<div class="mb-2 flex items-center justify-between">
|
|
<span class="font-semibold text-[var(--color-text-primary)]">{{
|
|
formatMessage(messages.centerTitle)
|
|
}}</span>
|
|
<Button
|
|
v-if="items.some((item) => !read.has(announcementKey(item)))"
|
|
@click="markAllRead"
|
|
>{{ formatMessage(messages.readAll) }}</Button
|
|
>
|
|
</div>
|
|
<div
|
|
v-if="!items.length"
|
|
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
|
|
>
|
|
{{ formatMessage(messages.empty) }}
|
|
</div>
|
|
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
|
|
<button
|
|
v-for="item in items"
|
|
:key="announcementKey(item)"
|
|
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
|
|
:class="{
|
|
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
|
|
}"
|
|
@click="selectAnnouncement(item)"
|
|
>
|
|
<span
|
|
class="mt-1.5 size-2 shrink-0 rounded-full"
|
|
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
|
|
/>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
|
|
item.title
|
|
}}</span>
|
|
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
|
|
item.summary || item.content
|
|
}}</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
|
|
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
|
|
{{ selected.title }}
|
|
</h2>
|
|
<div
|
|
class="markdown-body break-words"
|
|
@click="contentClick"
|
|
@auxclick="contentClick"
|
|
v-html="html"
|
|
/>
|
|
<div class="mt-4 flex justify-end">
|
|
<Button
|
|
v-if="selected.action_url && selected.action_label"
|
|
type="colored"
|
|
color="brand"
|
|
@click="openLink(selected.action_url)"
|
|
>{{ selected.action_label }}</Button
|
|
>
|
|
</div>
|
|
</section>
|
|
<div
|
|
v-else
|
|
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
|
|
>
|
|
{{ formatMessage(messages.empty) }}
|
|
</div>
|
|
</div>
|
|
<template #actions>
|
|
<div class="flex flex-wrap justify-end gap-2">
|
|
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
|
|
</div>
|
|
</template>
|
|
</NewModal>
|
|
</template>
|