fix: queue store stability + persistence (#5909)

* fix: queue store stability + persistence

* fix: lint

* feat: set to draft btn

* feat: migrate to indexed db rather than local storage for moderation checklist storage (keep session + perms alone)

* fix: storage cleanup + lint

* fix: invalidation fixes
This commit is contained in:
Calum H.
2026-04-27 17:39:32 +01:00
committed by GitHub
parent a2eed001b2
commit 3f8fd9cb56
19 changed files with 1548 additions and 387 deletions

View File

@@ -258,7 +258,6 @@ export default defineNuxtConfig({
},
},
modules: [
'@pinia/nuxt',
'floating-vue/nuxt',
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
...(isProduction() ? ['@sentry/nuxt/module'] : []),

View File

@@ -46,7 +46,6 @@
"@modrinth/moderation": "workspace:*",
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@pinia/nuxt": "^0.11.3",
"@sentry/nuxt": "^10.33.0",
"@tanstack/vue-query": "^5.90.7",
"@types/three": "^0.172.0",
@@ -69,8 +68,6 @@
"lru-cache": "^11.2.4",
"markdown-it": "14.1.0",
"pathe": "^1.1.2",
"pinia": "^3.0.0",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",

View File

@@ -273,7 +273,7 @@ async function closeReport(reply = false) {
closed: true,
},
})
updateThread(props.report.thread)
await refreshReportCaches()
didCloseReport.value = true
} catch (err: any) {
addNotification({
@@ -292,7 +292,7 @@ async function reopenReport() {
closed: false,
},
})
updateThread(props.report.thread)
await refreshReportCaches()
didCloseReport.value = false
} catch (err: any) {
addNotification({
@@ -309,6 +309,18 @@ const formatDateTime = useFormatDateTime({
dateStyle: 'long',
})
async function refreshReportCaches() {
await Promise.allSettled([refreshThread(), refreshNuxtData('new-moderation-reports')])
}
async function refreshThread() {
const threadId = props.report.thread?.id ?? props.report.thread_id
if (!threadId) return
const thread = await useBaseFetch(`thread/${threadId}`)
updateThread(thread)
}
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread)

View File

@@ -477,6 +477,8 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
backToFileList()
}
}
emit('refetch')
} catch (error) {
console.error('Failed to batch update:', error)
addNotification({
@@ -549,6 +551,8 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
text: `This issue has been flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update detail status:', error)
addNotification({

View File

@@ -79,13 +79,13 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
color="brand"
@click="skipToNextProject"
>
<button>
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
Next project ({{ moderationQueue.queueLength }} left)
</button>
</ButtonStyled>
</div>
@@ -112,13 +112,13 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
color="brand"
@click="skipToNextProject"
>
<button>
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
Next project ({{ moderationQueue.queueLength }} left)
</button>
</ButtonStyled>
</div>
@@ -131,9 +131,9 @@
<div v-if="done">
<p>
You are done moderating this project!
<template v-if="moderationStore.hasItems">
<template v-if="moderationQueue.hasItems">
There are
{{ moderationStore.queueLength }} left.
{{ moderationQueue.queueLength }} left.
</template>
</p>
</div>
@@ -159,6 +159,7 @@
:disabled="false"
:heading-buttons="false"
:on-image-upload="onUploadHandler"
@input="persistGeneratedMessageState"
/>
<StyledInput
v-else
@@ -167,7 +168,7 @@
placeholder="No message generated."
autocomplete="off"
input-class="h-[400px] font-mono"
@input="persistState"
@input="persistGeneratedMessageState"
/>
</div>
</div>
@@ -331,10 +332,10 @@
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
<ButtonStyled v-if="!done && !generatedMessage && moderationQueue.hasItems">
<button @click="skipCurrentProject">
<XIcon aria-hidden="true" />
Skip ({{ moderationStore.queueLength }} left)
Skip ({{ moderationQueue.queueLength }} left)
</button>
</ButtonStyled>
</div>
@@ -345,7 +346,7 @@
<button @click="endChecklist(undefined)">
<template v-if="hasNextProject">
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
Next project ({{ moderationQueue.queueLength }} left)
</template>
<template v-else>
<CheckIcon aria-hidden="true" />
@@ -441,10 +442,10 @@ import {
} from '@modrinth/assets'
import {
type Action,
type ActionState,
type ButtonAction,
checklist,
type ConditionalButtonAction,
deserializeActionStates,
type DropdownAction,
expandVariables,
finalPermissionMessages,
@@ -461,7 +462,6 @@ import {
keybinds,
type MultiSelectChipsAction,
processMessage,
serializeActionStates,
type Stage,
type ToggleAction,
} from '@modrinth/moderation'
@@ -486,13 +486,27 @@ import {
type ProjectStatus,
renderHighlightedString,
} from '@modrinth/utils'
import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
import { useQueryClient } from '@tanstack/vue-query'
import { computedAsync, useDebounceFn } from '@vueuse/core'
import type { Component } from 'vue'
import { useGeneratedState } from '~/composables/generated'
import { useImageUpload } from '~/composables/image-upload.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import type { LockAcquireResponse } from '~/store/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts'
import {
clearChecklistProgressState,
clearGeneratedMessageState as clearPersistedGeneratedMessageState,
createEmptyGeneratedMessageState,
loadChecklistActionStates,
loadChecklistStage,
loadChecklistTextInputs,
loadGeneratedMessageState,
saveChecklistActionStates,
saveChecklistStage,
saveChecklistTextInputs,
saveGeneratedMessageState,
} from '~/services/moderation-checklist-storage.ts'
import { type LockAcquireResponse, useModerationQueue } from '~/services/moderation-queue.ts'
import KeybindsModal from './ChecklistKeybindsModal.vue'
import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
@@ -508,9 +522,10 @@ const props = defineProps<{
collapsed: boolean
}>()
const { projectV2, projectV3 } = injectProjectPageContext()
const { projectV2, projectV3, invalidate } = injectProjectPageContext()
const moderationStore = useModerationStore()
const moderationQueue = useModerationQueue()
const queryClient = useQueryClient()
const tags = useGeneratedState()
const auth = await useAuth()
@@ -548,7 +563,7 @@ async function handleVisibilityChange() {
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
// Immediately refresh the lock when returning to the tab
// This handles cases where the heartbeat was throttled while backgrounded
const refreshResult = await moderationStore.refreshLock()
const refreshResult = await moderationQueue.refreshLock()
if (!refreshResult.success) {
handleLockLost(refreshResult)
return
@@ -594,7 +609,7 @@ function clearLockCountdown() {
function startLockHeartbeat() {
lockCheckInterval.value = setInterval(
async () => {
const result = await moderationStore.refreshLock()
const result = await moderationQueue.refreshLock()
if (!result.success) {
handleLockLost(result)
}
@@ -667,7 +682,7 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
// Quick re-check if close to expiry (last 5 seconds of TTL)
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
const recheck = await moderationStore.checkLock(next.projectId)
const recheck = await moderationQueue.checkLock(next.projectId)
if (recheck.locked && !recheck.expired) {
// Project got locked, remove from queue and try next
prefetchQueue.value.shift()
@@ -679,7 +694,9 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
prefetchQueue.value.shift()
// Mark skipped projects as completed
next.skippedIds.forEach((id) => moderationStore.completeCurrentProject(id, 'skipped'))
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
if (next.skippedIds.length > 0) {
addNotification({
@@ -734,11 +751,32 @@ async function onUploadHandler(file: File) {
}
const useSimpleEditor = ref(false)
const message = ref('')
const generatedMessage = ref(false)
const checklistPersistenceProjectSlug = projectV2.value.slug
const persistedGeneratedMessage = import.meta.client
? await loadGeneratedMessageState(checklistPersistenceProjectSlug)
: createEmptyGeneratedMessageState()
const message = ref(
typeof persistedGeneratedMessage.message === 'string' ? persistedGeneratedMessage.message : '',
)
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
const loadingMessage = ref(false)
const done = ref(false)
function persistGeneratedMessageState() {
void saveGeneratedMessageState(checklistPersistenceProjectSlug, {
generated: generatedMessage.value,
message: message.value,
})
}
function clearGeneratedMessageState() {
generatedMessage.value = false
message.value = ''
void clearPersistedGeneratedMessageState(checklistPersistenceProjectSlug)
}
watch([generatedMessage, message], persistGeneratedMessageState, { flush: 'sync' })
function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true
}
@@ -752,7 +790,7 @@ async function handleExit() {
// Release if we own the lock, or if there was an error checking (we might still own it)
const projectId = projectV2.value?.id
if (projectId && (lockStatus.value?.isOwnLock || lockError.value)) {
const released = await moderationStore.releaseLock(projectId)
const released = await moderationQueue.releaseLock(projectId)
if (!released && lockStatus.value?.isOwnLock) {
console.warn('Failed to release moderation lock for project:', projectId)
}
@@ -770,7 +808,7 @@ async function confirmTakeOverOverride() {
console.warn('[confirmTakeOverOverride] No project ID available')
return
}
const result = await moderationStore.overrideLock(projectId)
const result = await moderationQueue.overrideLock(projectId)
if (result.success) {
addNotification({
@@ -825,7 +863,7 @@ async function batchCheckLocksWithMetadata(
projectIds.map(async (id) => {
// Parallel: check lock AND fetch project metadata
const [lockResponse, projectData] = await Promise.all([
moderationStore.checkLock(id),
moderationQueue.checkLock(id),
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
])
@@ -856,7 +894,7 @@ async function batchCheckLocksWithMetadata(
// Maintain a queue of prefetched unlocked projects for instant navigation
async function maintainPrefetchQueue() {
if (isPrefetching.value) return
if (!moderationStore.isQueueMode) return
if (!moderationQueue.isQueueMode) return
const currentProjectId = projectV2.value?.id
@@ -879,7 +917,7 @@ async function maintainPrefetchQueue() {
// 4. Get remaining queue items (excluding current and already prefetched)
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId))
const queueItems = [...moderationStore.currentQueue.items]
const queueItems = [...moderationQueue.currentQueue.items]
const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
const remainingItems =
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
@@ -951,12 +989,12 @@ async function skipToNextProject() {
return
}
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
debug('[skipToNextProject] Queue before complete:', [...moderationStore.currentQueue.items])
debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items])
moderationStore.completeCurrentProject(currentProjectId, 'skipped')
await moderationQueue.completeCurrentProject(currentProjectId, 'skipped')
debug('[skipToNextProject] Queue after complete:', [...moderationStore.currentQueue.items])
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items])
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
// Use prefetched data if available
if (await navigateToNextUnlockedProject()) {
@@ -968,7 +1006,7 @@ async function skipToNextProject() {
// Fallback: batch check remaining projects with metadata (excluding current)
const remainingIds: string[] = []
const queueItems = moderationStore.currentQueue.items
const queueItems = moderationQueue.currentQueue.items
// Build list of remaining projects, excluding current
for (const id of queueItems) {
@@ -1011,7 +1049,7 @@ async function skipToNextProject() {
}
return
}
moderationStore.completeCurrentProject(id, 'skipped')
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
}
@@ -1034,8 +1072,7 @@ function resetProgress() {
textInputValues.value = {}
done.value = false
generatedMessage.value = false
message.value = ''
clearGeneratedMessageState()
loadingMessage.value = false
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
@@ -1061,8 +1098,11 @@ function findFirstValidStage(): number {
}
const currentStageObj = computed(() => checklist[currentStage.value])
const currentStage = useLocalStorage(`moderation-stage-${projectV2.value.slug}`, () =>
findFirstValidStage(),
const persistedStage = import.meta.client
? await loadChecklistStage(checklistPersistenceProjectSlug)
: null
const currentStage = ref(
persistedStage !== null && checklist[persistedStage] ? persistedStage : findFirstValidStage(),
)
const stageTextExpanded = computedAsync(async () => {
@@ -1081,37 +1121,27 @@ const stageTextExpanded = computedAsync(async () => {
return null
}, null)
interface ActionState {
selected: boolean
value?: any
}
const persistedActionStates = useLocalStorage(
`moderation-actions-${projectV2.value.slug}`,
{},
{
serializer: {
read: (v: any) => (v ? deserializeActionStates(v) : {}),
write: (v: any) => serializeActionStates(v),
},
},
)
const persistedActionStates = import.meta.client
? await loadChecklistActionStates(checklistPersistenceProjectSlug)
: {}
const router = useRouter()
const persistedTextInputs = useLocalStorage(
`moderation-inputs-${projectV2.value.slug}`,
{} as Record<string, string>,
)
const persistedTextInputs = import.meta.client
? await loadChecklistTextInputs(checklistPersistenceProjectSlug)
: {}
const actionStates = ref<Record<string, ActionState>>(persistedActionStates.value)
const textInputValues = ref<Record<string, string>>(persistedTextInputs.value)
const actionStates = ref<Record<string, ActionState>>(persistedActionStates)
const textInputValues = ref<Record<string, string>>(persistedTextInputs)
const persistState = () => {
persistedActionStates.value = actionStates.value
persistedTextInputs.value = textInputValues.value
void saveChecklistActionStates(checklistPersistenceProjectSlug, actionStates.value)
void saveChecklistTextInputs(checklistPersistenceProjectSlug, textInputValues.value)
}
watch(currentStage, (stage) => {
void saveChecklistStage(checklistPersistenceProjectSlug, stage)
})
watch(actionStates, persistState, { deep: true })
watch(textInputValues, persistState, { deep: true })
@@ -1141,7 +1171,7 @@ function handleKeybinds(event: KeyboardEvent) {
isLoadingMessage: loadingMessage.value,
isModpackPermissionsStage: isModpackPermissionsStage.value,
futureProjectCount: moderationStore.queueLength,
futureProjectCount: moderationQueue.queueLength,
visibleActionsCount: visibleActions.value.length,
focusedActionIndex: focusedActionIndex.value,
@@ -1249,14 +1279,14 @@ onMounted(async () => {
}
// Try to acquire lock
const result = await moderationStore.acquireLock(projectV2.value.id)
const result = await moderationQueue.acquireLock(projectV2.value.id)
if (result.success) {
handleLockAcquired()
} else if (result.locked_by) {
// Actually locked by another moderator
// In queue mode with more projects - auto-skip to next project
if (moderationStore.isQueueMode && moderationStore.queueLength > 1) {
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
addNotification({
title: 'Project locked',
text: `Skipped project locked by @${result.locked_by.username}.`,
@@ -1317,7 +1347,7 @@ onUnmounted(() => {
// Release lock if we own it (navigation away without explicit exit)
const projectId = projectV2.value?.id
if (projectId && lockStatus.value?.isOwnLock) {
moderationStore.releaseLock(projectId)
void moderationQueue.releaseLock(projectId)
}
// Clear prefetch state to prevent memory leaks
@@ -1809,8 +1839,7 @@ function nextStage() {
}
function goBackToStages() {
generatedMessage.value = false
message.value = ''
clearGeneratedMessageState()
let targetStage = checklist.length - 1
while (targetStage >= 0) {
@@ -1923,6 +1952,16 @@ function generateModpackMessage(allFiles: {
}
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
}
await Promise.allSettled(refreshes)
}
async function sendMessage(status: ProjectStatus) {
// Capture project data upfront to avoid null issues during async operations
const projectId = projectV2.value?.id
@@ -1966,10 +2005,12 @@ async function sendMessage(status: ProjectStatus) {
})
}
const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed')
await refreshModerationCaches(threadId)
const willHaveNext = await moderationQueue.completeCurrentProject(projectId, 'completed')
await Promise.race([
moderationStore.releaseLock(projectId),
moderationQueue.releaseLock(projectId),
new Promise((r) => setTimeout(r, 2000)),
])
@@ -1977,6 +2018,7 @@ async function sendMessage(status: ProjectStatus) {
// to avoid the race condition where done=true renders with hasNextProject=false
hasNextProject.value = willHaveNext
done.value = true
clearGeneratedMessageState()
} catch (error) {
console.error('Error submitting moderation:', error)
addNotification({
@@ -2000,7 +2042,7 @@ async function endChecklist(status?: string) {
await nextTick()
if (moderationStore.currentQueue.total > 1) {
if (moderationQueue.currentQueue.total > 1) {
addNotification({
title: 'Moderation completed',
text: `You have completed the moderation queue.`,
@@ -2019,7 +2061,7 @@ async function endChecklist(status?: string) {
// Fallback: batch check remaining projects with metadata
const remainingIds: string[] = []
const currentProjectId = projectV2.value?.id
const queueItems = moderationStore.currentQueue.items
const queueItems = moderationQueue.currentQueue.items
// Build list of remaining projects, excluding current
for (const id of queueItems) {
@@ -2064,7 +2106,7 @@ async function endChecklist(status?: string) {
foundUnlocked = true
break
}
moderationStore.completeCurrentProject(id, 'skipped')
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
}
@@ -2100,11 +2142,11 @@ async function skipCurrentProject() {
}
await Promise.race([
moderationStore.releaseLock(projectId),
moderationQueue.releaseLock(projectId),
new Promise((r) => setTimeout(r, 2000)),
])
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped')
await endChecklist('skipped')
}
@@ -2112,15 +2154,15 @@ async function skipCurrentProject() {
function clearProjectLocalStorage() {
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
localStorage.removeItem(`moderation-actions-${projectV2.value.slug}`)
localStorage.removeItem(`moderation-inputs-${projectV2.value.slug}`)
localStorage.removeItem(`moderation-stage-${projectV2.value.slug}`)
sessionStorage.removeItem(`modpack-permissions-data-${projectV2.value.id}`)
sessionStorage.removeItem(`modpack-permissions-permanent-no-${projectV2.value.id}`)
sessionStorage.removeItem(`modpack-permissions-updated-${projectV2.value.id}`)
void clearChecklistProgressState(checklistPersistenceProjectSlug)
actionStates.value = {}
textInputValues.value = {}
clearGeneratedMessageState()
}
const isLastVisibleStage = computed(() => {
@@ -2169,7 +2211,7 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
return options
})
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string }
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string; icon?: Component }
const stageOptionsForSlots = computed(() =>
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),

View File

@@ -217,6 +217,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'set-to-draft-reply',
action: () => {
sendReply('draft')
},
hoverFilled: true,
disabled: project.status === 'draft',
},
{
id: 'send-to-review-reply',
action: () => {
@@ -236,6 +244,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'set-to-draft',
action: () => {
setStatus('draft')
},
hoverFilled: true,
disabled: project.status === 'draft',
},
{
id: 'send-to-review',
action: () => {
@@ -256,6 +272,14 @@
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #set-to-draft-reply>
<FileTextIcon aria-hidden="true" />
Set to draft with reply
</template>
<template #set-to-draft>
<FileTextIcon aria-hidden="true" />
Set to draft
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
Send to review with reply
@@ -280,6 +304,7 @@ import {
CheckIcon,
DropdownIcon,
EyeOffIcon,
FileTextIcon,
ReplyIcon,
ScaleIcon,
SendIcon,
@@ -417,7 +442,7 @@ async function sendReply(status = null, privateMessage = false) {
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
await props.setStatus(status)
}
} catch (err) {
addNotification({

View File

@@ -754,10 +754,7 @@
},
{
id: 'moderation-checklist',
action: () => {
moderationStore.setSingleProject(project.id)
showModerationChecklist = true
},
action: openModerationChecklistFromMenu,
color: 'orange',
hoverOnly: true,
shown:
@@ -1046,7 +1043,7 @@
>
<ModerationChecklist
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@exit="setModerationChecklistOpen(false)"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
@@ -1145,7 +1142,11 @@ import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { useModerationStore } from '~/store/moderation.ts'
import {
loadChecklistOpenState,
saveChecklistOpenState,
} from '~/services/moderation-checklist-storage.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
definePageMeta({
@@ -1156,7 +1157,7 @@ const data = useNuxtApp()
const route = useRoute()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const config = useRuntimeConfig()
const moderationStore = useModerationStore()
const moderationQueue = useModerationQueue()
const notifications = injectNotificationManager()
const { addNotification } = notifications
@@ -2514,16 +2515,84 @@ async function copyPermalink() {
const collapsedChecklist = ref(false)
const showModerationChecklist = useLocalStorage(
`show-moderation-checklist-${project.value?.id ?? 'unknown'}`,
false,
)
const showModerationChecklist = ref(false)
const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false)
if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true
function consumeShowChecklistHistoryState() {
if (!import.meta.client) return false
if (!window.history?.state?.showChecklist) return false
const state = { ...window.history.state }
delete state.showChecklist
window.history.replaceState(state, '', window.location.href)
return true
}
function setModerationChecklistOpen(open, projectId = project.value?.id) {
showModerationChecklist.value = open
if (projectId) {
void saveChecklistOpenState(projectId, open)
}
}
function isProjectInActiveModerationQueue(projectId = project.value?.id) {
return (
!!projectId &&
moderationQueue.isQueueMode &&
moderationQueue.currentQueue.items.includes(projectId)
)
}
async function openModerationChecklistFromMenu() {
const projectId = project.value?.id
if (!projectId) return
await moderationQueue.ready
if (!isProjectInActiveModerationQueue(projectId)) {
await moderationQueue.setSingleProject(projectId)
}
setModerationChecklistOpen(true)
}
watch(
() => project.value?.id,
async (projectId, _previousProjectId, onCleanup) => {
if (!import.meta.client || !projectId) return
let cancelled = false
onCleanup(() => {
cancelled = true
})
const openedFromNavigation = consumeShowChecklistHistoryState()
await moderationQueue.ready
if (cancelled) return
if (openedFromNavigation) {
setModerationChecklistOpen(true)
return
}
const storedOpen = await loadChecklistOpenState(projectId)
if (cancelled) return
if (storedOpen !== null) {
showModerationChecklist.value = storedOpen
return
}
const shouldRecoverFromQueue =
moderationQueue.isQueueMode && moderationQueue.getCurrentProjectId() === projectId
showModerationChecklist.value = shouldRecoverFromQueue
if (shouldRecoverFromQueue) {
void saveChecklistOpenState(projectId, true)
}
},
{ immediate: true },
)
function closeDownloadModal(event) {
downloadModal.value.hide(event)
userSelectedPlatform.value = null

View File

@@ -93,7 +93,7 @@
:set-status="setStatus"
:current-member="currentMember"
:auth="auth"
@update-thread="(newThread) => (thread = newThread)"
@update-thread="updateThread"
/>
</section>
</div>
@@ -132,6 +132,13 @@ const { data: thread } = useQuery({
enabled: computed(() => !!project.value?.thread_id),
})
function updateThread(newThread) {
const threadId = newThread?.id ?? project.value?.thread_id
if (!threadId) return
queryClient.setQueryData(['thread', threadId], newThread)
}
async function setStatus(status) {
startLoading()

View File

@@ -112,13 +112,13 @@ import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const moderationStore = useModerationStore()
const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
@@ -331,18 +331,18 @@ function goToPage(page: number) {
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
let skippedCount = 0
while (moderationStore.hasItems) {
const currentId = moderationStore.getCurrentProjectId()
while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
moderationStore.completeCurrentProject(currentId, 'skipped')
await moderationQueue.completeCurrentProject(currentId, 'skipped')
continue
}
try {
const lockStatus = await moderationStore.checkLock(currentId)
const lockStatus = await moderationQueue.checkLock(currentId)
if (!lockStatus.locked || lockStatus.expired) {
if (skippedCount > 0) {
@@ -356,7 +356,7 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
}
// Project is locked, skip it
moderationStore.completeCurrentProject(currentId, 'skipped')
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return project
@@ -371,7 +371,7 @@ async function moderateAllInFilter() {
const startIndex = (currentPage.value - 1) * itemsPerPage
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
moderationStore.setQueue(projectIds)
await moderationQueue.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
@@ -402,12 +402,12 @@ async function startFromProject(projectId: string) {
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
moderationStore.setSingleProject(projectId)
await moderationQueue.setSingleProject(projectId)
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
moderationStore.setQueue(projectIds)
await moderationQueue.setQueue(projectIds)
}
// Find first unlocked project

View File

@@ -246,8 +246,14 @@ const reviewItem = computed(() => {
}
})
function handleMarkComplete(_projectId: string) {
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] })
async function handleMarkComplete(projectId: string) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] }),
queryClient.invalidateQueries({ queryKey: ['tech-review-project-report', projectId] }),
queryClient.invalidateQueries({ queryKey: ['project', projectId] }),
queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId] }),
queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId] }),
])
}
const maliciousSummaryModalRef = ref<InstanceType<typeof MaliciousSummaryModal>>()

View File

@@ -0,0 +1,596 @@
import {
type ActionState,
deserializeActionStates,
serializeActionStates,
} from '@modrinth/moderation'
interface PersistedChecklistValue<T> {
version: 1
savedAt: string
value: T
}
export interface ModerationChecklistGeneratedMessageState {
generated: boolean
message: string
}
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 1
const STORE_NAME = 'kv'
const CHECKLIST_OPEN_KEY_PREFIX = 'show-moderation-checklist-'
const STAGE_KEY_PREFIX = 'moderation-stage-'
const ACTION_STATES_KEY_PREFIX = 'moderation-actions-'
const TEXT_INPUTS_KEY_PREFIX = 'moderation-inputs-'
const GENERATED_MESSAGE_KEY_PREFIX = 'moderation-generated-message-'
const CHECKLIST_STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
const CHECKLIST_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const CHECKLIST_CLEANUP_LAST_RUN_KEY = 'moderation-checklist-cleanup:last-run'
const CHECKLIST_STATE_KEY_PREFIXES = [
CHECKLIST_OPEN_KEY_PREFIX,
STAGE_KEY_PREFIX,
ACTION_STATES_KEY_PREFIX,
TEXT_INPUTS_KEY_PREFIX,
GENERATED_MESSAGE_KEY_PREFIX,
]
const indexedDbSaveChains = new Map<string, Promise<void>>()
let checklistCleanupPromise: Promise<void> | null = null
let checklistCleanupLastRunAt = 0
export function createEmptyGeneratedMessageState(): ModerationChecklistGeneratedMessageState {
return {
generated: false,
message: '',
}
}
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
function wrapValue<T>(value: T, savedAt = new Date().toISOString()): PersistedChecklistValue<T> {
return {
version: 1,
savedAt,
value,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function isPersistedValue<T>(
value: unknown,
isValue: (value: unknown) => value is T,
): value is PersistedChecklistValue<T> {
if (!isRecord(value)) return false
if (value.version !== 1) return false
if (typeof value.savedAt !== 'string') return false
return isValue(value.value)
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean'
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function isGeneratedMessageState(
value: unknown,
): value is ModerationChecklistGeneratedMessageState {
if (!isRecord(value)) return false
return typeof value.generated === 'boolean' && typeof value.message === 'string'
}
function sanitizeStage(value: number): number {
return Math.max(0, Math.trunc(value))
}
function sanitizeTextInputs(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null
const result: Record<string, string> = {}
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'string') {
result[key] = entry
}
}
return result
}
function normalizeChecklistOpen(value: unknown): PersistedChecklistValue<boolean> | null {
if (isPersistedValue(value, isBoolean)) return value
if (isBoolean(value)) return wrapValue(value, '')
return null
}
function normalizeStage(value: unknown): PersistedChecklistValue<number> | null {
if (isPersistedValue(value, isNumber)) {
return {
...value,
value: sanitizeStage(value.value),
}
}
if (isNumber(value)) return wrapValue(sanitizeStage(value), '')
return null
}
function normalizeActionStates(
value: unknown,
): PersistedChecklistValue<Record<string, ActionState>> | null {
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
if (isString(value.value)) {
return {
version: 1,
savedAt: value.savedAt,
value: deserializeActionStates(value.value),
}
}
if (isRecord(value.value)) {
return {
version: 1,
savedAt: value.savedAt,
value: deserializeActionStates(JSON.stringify(value.value)),
}
}
}
if (isString(value)) return wrapValue(deserializeActionStates(value), '')
if (isRecord(value)) return wrapValue(deserializeActionStates(JSON.stringify(value)), '')
return null
}
function normalizeTextInputs(
value: unknown,
): PersistedChecklistValue<Record<string, string>> | null {
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
const textInputs = sanitizeTextInputs(value.value)
if (textInputs) {
return {
version: 1,
savedAt: value.savedAt,
value: textInputs,
}
}
}
const textInputs = sanitizeTextInputs(value)
return textInputs ? wrapValue(textInputs, '') : null
}
function normalizeGeneratedMessage(
value: unknown,
): PersistedChecklistValue<ModerationChecklistGeneratedMessageState> | null {
if (isPersistedValue(value, isGeneratedMessageState)) return value
if (isGeneratedMessageState(value)) return wrapValue(value, '')
return null
}
function savedAtTime<T>(state: PersistedChecklistValue<T>): number {
const time = Date.parse(state.savedAt)
return Number.isNaN(time) ? 0 : time
}
function newestState<T>(
first: PersistedChecklistValue<T> | null,
second: PersistedChecklistValue<T> | null,
): PersistedChecklistValue<T> | null {
if (!first) return second
if (!second) return first
return savedAtTime(second) > savedAtTime(first) ? second : first
}
function isChecklistStateKey(key: string): boolean {
return CHECKLIST_STATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
}
function isStaleState<T>(
state: PersistedChecklistValue<T>,
now = Date.now(),
maxAgeMs = CHECKLIST_STATE_MAX_AGE_MS,
): boolean {
const savedAt = savedAtTime(state)
if (savedAt === 0) return false
return now - savedAt > maxAgeMs
}
function isStaleRawState(value: unknown, now = Date.now()): boolean {
if (!isRecord(value)) return false
if (value.version !== 1 || typeof value.savedAt !== 'string') return false
const savedAt = Date.parse(value.savedAt)
if (Number.isNaN(savedAt)) return false
return now - savedAt > CHECKLIST_STATE_MAX_AGE_MS
}
async function loadFromIndexedDb<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
): Promise<PersistedChecklistValue<T> | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
return normalize(await requestToPromise(store.get(key)))
} finally {
db.close()
}
}
async function cleanupIndexedDb(now = Date.now()): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.openCursor()
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result
if (!cursor) return
const key = typeof cursor.key === 'string' ? cursor.key : null
if (key && isChecklistStateKey(key) && isStaleRawState(cursor.value, now)) {
cursor.delete()
}
cursor.continue()
}
request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed'))
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function saveToIndexedDb<T>(key: string, state: PersistedChecklistValue<T>): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(state, key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function clearIndexedDbKey(key: string): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function saveToIndexedDbInOrder<T>(
key: string,
state: PersistedChecklistValue<T>,
): Promise<void> {
const run = () => saveToIndexedDb(key, state)
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
indexedDbSaveChains.set(
key,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
async function clearIndexedDbKeyInOrder(key: string): Promise<void> {
const run = () => clearIndexedDbKey(key)
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
indexedDbSaveChains.set(
key,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
function loadFromLocalStorage<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
): PersistedChecklistValue<T> | null {
const storage = getLocalStorage()
if (!storage) return null
const raw = storage.getItem(key)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
const state = normalize(parsed)
if (state) return state
} catch (error) {
console.debug('Failed to parse moderation checklist state from localStorage:', error)
}
try {
storage.removeItem(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from localStorage:', error)
}
return null
}
function safeSaveLocalStorage<T>(key: string, state: PersistedChecklistValue<T>): void {
try {
getLocalStorage()?.setItem(key, JSON.stringify(state))
} catch (error) {
console.debug('Failed to save moderation checklist state to localStorage:', error)
}
}
function safeClearLocalStorage(key: string): void {
try {
getLocalStorage()?.removeItem(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from localStorage:', error)
}
}
function cleanupLocalStorage(now = Date.now()): void {
const storage = getLocalStorage()
if (!storage) return
const keysToRemove: string[] = []
for (let index = 0; index < storage.length; index++) {
const key = storage.key(index)
if (!key || !isChecklistStateKey(key)) continue
const raw = storage.getItem(key)
if (!raw) continue
try {
if (isStaleRawState(JSON.parse(raw), now)) {
keysToRemove.push(key)
}
} catch {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => safeClearLocalStorage(key))
}
function scheduleStaleChecklistCleanup(): void {
if (!import.meta.client || checklistCleanupPromise) return
const storage = getLocalStorage()
const now = Date.now()
const persistedLastRun = Number(storage?.getItem(CHECKLIST_CLEANUP_LAST_RUN_KEY) ?? 0)
const lastRun = Math.max(
checklistCleanupLastRunAt,
Number.isFinite(persistedLastRun) ? persistedLastRun : 0,
)
if (Number.isFinite(lastRun) && now - lastRun < CHECKLIST_CLEANUP_INTERVAL_MS) return
checklistCleanupLastRunAt = now
try {
storage?.setItem(CHECKLIST_CLEANUP_LAST_RUN_KEY, String(now))
} catch (error) {
console.debug('Failed to save moderation checklist cleanup timestamp:', error)
}
checklistCleanupPromise = (async () => {
cleanupLocalStorage(now)
try {
await cleanupIndexedDb(now)
} catch (error) {
console.debug('Failed to cleanup stale moderation checklist state from IndexedDB:', error)
}
})().finally(() => {
checklistCleanupPromise = null
})
}
async function loadState<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
touch = true,
): Promise<T | null> {
if (!import.meta.client) return null
scheduleStaleChecklistCleanup()
let indexedDbState: PersistedChecklistValue<T> | null = null
try {
indexedDbState = await loadFromIndexedDb(key, normalize)
} catch (error) {
console.debug('Failed to load moderation checklist state from IndexedDB:', error)
}
let localStorageState: PersistedChecklistValue<T> | null = null
try {
localStorageState = loadFromLocalStorage(key, normalize)
} catch (error) {
console.debug('Failed to load moderation checklist state from localStorage:', error)
}
const state = newestState(indexedDbState, localStorageState)
if (!state) return null
if (isStaleState(state)) {
await clearState(key)
return null
}
if (touch) {
void saveState(key, state.value)
}
return state.value
}
async function saveState<T>(key: string, value: T): Promise<void> {
if (!import.meta.client) return
scheduleStaleChecklistCleanup()
const state = wrapValue(value)
safeSaveLocalStorage(key, state)
if (hasIndexedDb()) {
try {
await saveToIndexedDbInOrder(key, state)
} catch (error) {
console.debug('Failed to save moderation checklist state to IndexedDB:', error)
}
}
}
async function clearState(key: string): Promise<void> {
if (!import.meta.client) return
safeClearLocalStorage(key)
if (hasIndexedDb()) {
try {
await clearIndexedDbKeyInOrder(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from IndexedDB:', error)
}
}
}
export async function loadChecklistOpenState(projectId: string): Promise<boolean | null> {
return loadState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, normalizeChecklistOpen, false)
}
export async function saveChecklistOpenState(projectId: string, open: boolean): Promise<void> {
await saveState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, open)
}
export async function loadChecklistStage(projectSlug: string): Promise<number | null> {
return loadState(`${STAGE_KEY_PREFIX}${projectSlug}`, normalizeStage)
}
export async function saveChecklistStage(projectSlug: string, stage: number): Promise<void> {
await saveState(`${STAGE_KEY_PREFIX}${projectSlug}`, sanitizeStage(stage))
}
export async function loadChecklistActionStates(
projectSlug: string,
): Promise<Record<string, ActionState>> {
const actionStates =
(await loadState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, normalizeActionStates, false)) ??
{}
if (Object.keys(actionStates).length > 0) {
void saveChecklistActionStates(projectSlug, actionStates)
}
return actionStates
}
export async function saveChecklistActionStates(
projectSlug: string,
actionStates: Record<string, ActionState>,
): Promise<void> {
await saveState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, serializeActionStates(actionStates))
}
export async function loadChecklistTextInputs(
projectSlug: string,
): Promise<Record<string, string>> {
return (await loadState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, normalizeTextInputs)) ?? {}
}
export async function saveChecklistTextInputs(
projectSlug: string,
textInputs: Record<string, string>,
): Promise<void> {
await saveState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, textInputs)
}
export async function clearChecklistProgressState(projectSlug: string): Promise<void> {
await Promise.all([
clearState(`${STAGE_KEY_PREFIX}${projectSlug}`),
clearState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`),
clearState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`),
])
}
export async function loadGeneratedMessageState(
projectSlug: string,
): Promise<ModerationChecklistGeneratedMessageState> {
return (
(await loadState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, normalizeGeneratedMessage)) ??
createEmptyGeneratedMessageState()
)
}
export async function saveGeneratedMessageState(
projectSlug: string,
state: ModerationChecklistGeneratedMessageState,
): Promise<void> {
await saveState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, state)
}
export async function clearGeneratedMessageState(projectSlug: string): Promise<void> {
await clearState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`)
}

View File

@@ -0,0 +1,241 @@
export interface PersistedModerationQueueState {
version: 1
savedAt: string
currentQueue: {
items: string[]
total: number
completed: number
skipped: number
lastUpdated: string
}
isQueueMode: boolean
}
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 1
const STORE_NAME = 'kv'
export const MODERATION_QUEUE_KEY = 'moderation-queue:v1'
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
}
function isPersistedStateCandidate(value: unknown): value is PersistedModerationQueueState {
if (!value || typeof value !== 'object') return false
const candidate = value as PersistedModerationQueueState
if (candidate.version !== 1) return false
if (typeof candidate.savedAt !== 'string') return false
if (typeof candidate.isQueueMode !== 'boolean') return false
const queue = candidate.currentQueue
if (!queue || typeof queue !== 'object') return false
if (!isStringArray(queue.items)) return false
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
if (typeof queue.lastUpdated !== 'string') return false
return true
}
function savedAtTime(state: PersistedModerationQueueState): number {
const time = Date.parse(state.savedAt)
return Number.isNaN(time) ? 0 : time
}
function newestState(
first: PersistedModerationQueueState | null,
second: PersistedModerationQueueState | null,
): PersistedModerationQueueState | null {
if (!first) return second
if (!second) return first
return savedAtTime(second) > savedAtTime(first) ? second : first
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
async function loadFromIndexedDb(): Promise<PersistedModerationQueueState | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const raw = await requestToPromise(store.get(MODERATION_QUEUE_KEY))
if (!isPersistedStateCandidate(raw)) return null
return raw
} finally {
db.close()
}
}
async function saveToIndexedDb(state: PersistedModerationQueueState): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(state, MODERATION_QUEUE_KEY)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function clearIndexedDb(): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(MODERATION_QUEUE_KEY)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
function loadFromLocalStorage(): PersistedModerationQueueState | null {
const storage = getLocalStorage()
if (!storage) return null
const raw = storage.getItem(MODERATION_QUEUE_KEY)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
if (isPersistedStateCandidate(parsed)) return parsed
} catch (error) {
console.debug('Failed to parse moderation queue from localStorage:', error)
}
safeClearLocalStorage()
return null
}
function saveToLocalStorage(state: PersistedModerationQueueState): void {
const storage = getLocalStorage()
if (!storage) return
storage.setItem(MODERATION_QUEUE_KEY, JSON.stringify(state))
}
function clearLocalStorage(): void {
const storage = getLocalStorage()
if (!storage) return
storage.removeItem(MODERATION_QUEUE_KEY)
}
function safeClearLocalStorage(): void {
try {
clearLocalStorage()
} catch (error) {
console.debug('Failed to clear moderation queue from localStorage:', error)
}
}
function safeSaveLocalStorage(state: PersistedModerationQueueState): void {
try {
saveToLocalStorage(state)
} catch (error) {
console.debug('Failed to save moderation queue to localStorage:', error)
}
}
export async function loadQueueState(): Promise<PersistedModerationQueueState | null> {
if (!import.meta.client) return null
let indexedDbState: PersistedModerationQueueState | null = null
try {
indexedDbState = await loadFromIndexedDb()
} catch (error) {
console.debug('Failed to load moderation queue from IndexedDB:', error)
}
let localStorageState: PersistedModerationQueueState | null = null
try {
localStorageState = loadFromLocalStorage()
} catch (error) {
console.debug('Failed to load moderation queue from localStorage:', error)
}
return newestState(indexedDbState, localStorageState)
}
export async function saveQueueState(state: PersistedModerationQueueState): Promise<void> {
if (!import.meta.client) return
if (hasIndexedDb()) {
try {
await saveToIndexedDb(state)
safeSaveLocalStorage(state)
return
} catch (error) {
console.debug(
'Failed to save moderation queue to IndexedDB, using localStorage fallback:',
error,
)
}
}
safeSaveLocalStorage(state)
}
export async function clearQueueState(): Promise<void> {
if (!import.meta.client) return
if (hasIndexedDb()) {
try {
await clearIndexedDb()
} catch (error) {
console.debug('Failed to clear moderation queue from IndexedDB:', error)
}
}
safeClearLocalStorage()
}

View File

@@ -0,0 +1,325 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { computed, proxyRefs, ref } from 'vue'
import {
loadQueueState,
type PersistedModerationQueueState,
saveQueueState,
} from './moderation-queue-storage.ts'
export interface ModerationQueue {
items: string[]
total: number
completed: number
skipped: number
lastUpdated: Date
}
export type LockedByUser = Labrinth.Moderation.Internal.LockedByUser
export type LockStatusResponse = Labrinth.Moderation.Internal.LockStatusResponse
export type LockAcquireResponse = Labrinth.Moderation.Internal.LockAcquireResponse
export interface ModerationQueueService {
currentQueue: ModerationQueue
currentLock: { projectId: string; lockedAt: Date } | null
isQueueMode: boolean
hydrated: boolean
ready: Promise<void>
queueLength: number
hasItems: boolean
progress: number
setQueue(projectIds: string[]): Promise<void>
setSingleProject(projectId: string): Promise<void>
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
getCurrentProjectId(): string | null
resetQueue(): Promise<void>
acquireLock(projectId: string): Promise<LockAcquireResponse>
overrideLock(projectId: string): Promise<LockAcquireResponse>
releaseLock(projectId: string): Promise<boolean>
checkLock(projectId: string): Promise<LockStatusResponse>
refreshLock(): Promise<LockAcquireResponse>
}
const EMPTY_QUEUE: ModerationQueue = {
items: [],
total: 0,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
}
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
const lastUpdated = new Date(raw.lastUpdated)
const items = raw.items.filter((id): id is string => typeof id === 'string')
const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0
const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
const minimumTotal = items.length + completed + skipped
const total = Number.isFinite(raw.total)
? Math.max(Math.trunc(raw.total), minimumTotal)
: minimumTotal
return {
items,
total,
completed,
skipped,
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
}
}
function persistedPayload(
queue: ModerationQueue,
isQueueMode: boolean,
): PersistedModerationQueueState {
return {
version: 1,
savedAt: new Date().toISOString(),
currentQueue: {
items: [...queue.items],
total: queue.total,
completed: queue.completed,
skipped: queue.skipped,
lastUpdated: queue.lastUpdated.toISOString(),
},
isQueueMode,
}
}
function createModerationQueueState(client: AbstractModrinthClient = injectModrinthClient()) {
const currentQueue = ref(createEmptyQueue())
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
const isQueueMode = ref(false)
const hydrated = ref(false)
const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0)
const progress = computed(() => {
if (currentQueue.value.total === 0) return 0
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
})
let mutationChain = Promise.resolve()
const ready = (async () => {
if (import.meta.server) {
hydrated.value = true
return
}
try {
const persisted = await loadQueueState()
if (persisted?.currentQueue) {
currentQueue.value = sanitizeQueue(persisted.currentQueue)
isQueueMode.value = persisted.isQueueMode
}
} catch {
currentQueue.value = createEmptyQueue()
isQueueMode.value = false
} finally {
hydrated.value = true
}
})()
async function persist(): Promise<void> {
if (import.meta.server) return
await saveQueueState(persistedPayload(currentQueue.value, isQueueMode.value))
}
async function withMutation<T>(callback: () => T): Promise<T> {
const run = async () => {
await ready
const value = callback()
await persist()
return value
}
const result = mutationChain.then(run, run)
mutationChain = result.then(
() => undefined,
() => undefined,
)
return result
}
function setQueueState(items: string[], mode: boolean) {
isQueueMode.value = mode
currentQueue.value = {
items: [...items],
total: items.length,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
}
async function setQueue(projectIds: string[]): Promise<void> {
await withMutation(() => {
setQueueState(projectIds, true)
})
}
async function setSingleProject(projectId: string): Promise<void> {
await withMutation(() => {
setQueueState([projectId], false)
})
}
async function completeCurrentProject(
projectId: string,
status: 'completed' | 'skipped' = 'completed',
): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
if (status === 'completed') {
currentQueue.value.completed++
} else {
currentQueue.value.skipped++
}
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
currentQueue.value.lastUpdated = new Date()
return currentQueue.value.items.length > 0
})
}
function getCurrentProjectId(): string | null {
return currentQueue.value.items[0] || null
}
async function resetQueue(): Promise<void> {
await withMutation(() => {
isQueueMode.value = false
currentQueue.value = createEmptyQueue()
})
}
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
await ready
try {
const response = await client.labrinth.moderation_internal.acquireLock(projectId)
if (response.success) {
currentLock.value = { projectId, lockedAt: new Date() }
} else if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response
} catch (error) {
console.error('Failed to acquire moderation lock:', error)
return { success: false, is_own_lock: false }
}
}
async function overrideLock(projectId: string): Promise<LockAcquireResponse> {
await ready
try {
const response = await client.labrinth.moderation_internal.overrideLock(projectId)
if (response.success) {
currentLock.value = { projectId, lockedAt: new Date() }
} else if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response
} catch (error) {
console.error('Failed to override moderation lock:', error)
return { success: false, is_own_lock: false }
}
}
async function releaseLock(projectId: string): Promise<boolean> {
await ready
try {
const response = await client.labrinth.moderation_internal.releaseLock(projectId)
if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response.success
} catch {
return false
}
}
async function checkLock(projectId: string): Promise<LockStatusResponse> {
await ready
try {
const response = await client.labrinth.moderation_internal.checkLock(projectId)
return response
} catch (error) {
console.error('Failed to check moderation lock:', error)
return { locked: false, is_own_lock: false }
}
}
async function refreshLock(): Promise<LockAcquireResponse> {
await ready
if (!currentLock.value) return { success: false, is_own_lock: false }
try {
const response = await acquireLock(currentLock.value.projectId)
return response
} catch (error) {
console.error('Failed to refresh moderation lock:', error)
currentLock.value = null
return { success: false, is_own_lock: false }
}
}
return proxyRefs({
currentQueue,
currentLock,
isQueueMode,
hydrated,
ready,
queueLength,
hasItems,
progress,
setQueue,
setSingleProject,
completeCurrentProject,
getCurrentProjectId,
resetQueue,
acquireLock,
overrideLock,
releaseLock,
checkLock,
refreshLock,
}) as ModerationQueueService
}
export const createModerationQueueService = createModerationQueueState
const moderationQueueServices = new WeakMap<object, ModerationQueueService>()
export function useModerationQueue(): ModerationQueueService {
const nuxtApp = useNuxtApp()
const existingService = moderationQueueServices.get(nuxtApp)
if (existingService) return existingService
const service = createModerationQueueService()
moderationQueueServices.set(nuxtApp, service)
return service
}

View File

@@ -1,232 +1,18 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type {
LockAcquireResponse,
LockedByUser,
LockStatusResponse,
ModerationQueue,
ModerationQueueService,
} from '~/services/moderation-queue.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
export interface ModerationQueue {
items: string[]
total: number
completed: number
skipped: number
lastUpdated: Date
export type {
LockAcquireResponse,
LockedByUser,
LockStatusResponse,
ModerationQueue,
ModerationQueueService,
}
export interface LockedByUser {
id: string
username: string
avatar_url?: string
}
export interface LockStatusResponse {
locked: boolean
is_own_lock: boolean
locked_by?: LockedByUser
locked_at?: string
expires_at?: string
expired?: boolean
}
export interface LockAcquireResponse {
success: boolean
is_own_lock: boolean
locked_by?: LockedByUser
locked_at?: string
expires_at?: string
expired?: boolean
}
const EMPTY_QUEUE: Partial<ModerationQueue> = {
items: [],
// TODO: Consider some form of displaying this in the checklist, maybe at the end
total: 0,
completed: 0,
skipped: 0,
}
function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue
}
export const useModerationStore = defineStore(
'moderation',
() => {
const currentQueue = ref<ModerationQueue>(createEmptyQueue())
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
const isQueueMode = ref(false)
const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0)
const progress = computed(() => {
if (currentQueue.value.total === 0) return 0
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
})
function setQueue(projectIDs: string[]) {
isQueueMode.value = true
currentQueue.value = {
items: [...projectIDs],
total: projectIDs.length,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
}
function setSingleProject(projectId: string) {
isQueueMode.value = false
currentQueue.value = {
items: [projectId],
total: 1,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
}
function completeCurrentProject(
projectId: string,
status: 'completed' | 'skipped' = 'completed',
) {
if (status === 'completed') {
currentQueue.value.completed++
} else {
currentQueue.value.skipped++
}
currentQueue.value.items = currentQueue.value.items.filter((id: string) => id !== projectId)
currentQueue.value.lastUpdated = new Date()
return currentQueue.value.items.length > 0
}
function getCurrentProjectId(): string | null {
return currentQueue.value.items[0] || null
}
function resetQueue() {
isQueueMode.value = false
currentQueue.value = createEmptyQueue()
}
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'POST',
internal: true,
})) as LockAcquireResponse
if (response.success) {
currentLock.value = { projectId, lockedAt: new Date() }
} else if (currentLock.value?.projectId === projectId) {
// We were outbid or our lock expired — clear stale state
currentLock.value = null
}
return response
} catch (error) {
console.error('Failed to acquire moderation lock:', error)
return { success: false, is_own_lock: false }
}
}
async function overrideLock(projectId: string): Promise<LockAcquireResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}/override`, {
method: 'POST',
internal: true,
})) as LockAcquireResponse
if (response.success) {
currentLock.value = { projectId, lockedAt: new Date() }
} else if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response
} catch (error) {
console.error('Failed to override moderation lock:', error)
return { success: false, is_own_lock: false }
}
}
async function releaseLock(projectId: string): Promise<boolean> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'DELETE',
internal: true,
})) as { success: boolean }
if (currentLock.value?.projectId === projectId) {
currentLock.value = null
}
return response.success
} catch {
return false
}
}
async function checkLock(projectId: string): Promise<LockStatusResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'GET',
internal: true,
})) as LockStatusResponse
return response
} catch (error) {
console.error('Failed to check moderation lock:', error)
// Return unlocked status on error so moderation can proceed
return { locked: false, is_own_lock: false }
}
}
async function refreshLock(): Promise<LockAcquireResponse> {
if (!currentLock.value) return { success: false, is_own_lock: false }
try {
const response = await acquireLock(currentLock.value.projectId)
// acquireLock already clears currentLock on failure
return response
} catch (error) {
console.error('Failed to refresh moderation lock:', error)
currentLock.value = null
return { success: false, is_own_lock: false }
}
}
return {
currentQueue,
currentLock,
isQueueMode,
queueLength,
hasItems,
progress,
setQueue,
setSingleProject,
completeCurrentProject,
getCurrentProjectId,
resetQueue,
acquireLock,
overrideLock,
releaseLock,
checkLock,
refreshLock,
}
},
{
persist: {
key: 'moderation-store',
// Only persist queue state — currentLock is always revalidated on mount
paths: ['currentQueue', 'isQueueMode'],
serializer: {
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value)
if (parsed.currentQueue?.lastUpdated) {
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated)
}
return parsed
},
},
},
},
)
export const useModerationStore = useModerationQueue

View File

@@ -18,6 +18,7 @@ import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
import { LabrinthNotificationsV2Module } from './labrinth/notifications/v2'
import { LabrinthOAuthInternalModule } from './labrinth/oauth/internal'
import { LabrinthOrganizationsV3Module } from './labrinth/organizations/v3'
@@ -73,6 +74,7 @@ export const MODULE_REGISTRY = {
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
labrinth_moderation_internal: LabrinthModerationInternalModule,
labrinth_notifications_v2: LabrinthNotificationsV2Module,
labrinth_oauth_internal: LabrinthOAuthInternalModule,
labrinth_organizations_v3: LabrinthOrganizationsV3Module,

View File

@@ -4,6 +4,7 @@ export * from './billing/internal'
export * from './collections'
export * from './globals/internal'
export * from './limits/v3'
export * from './moderation/internal'
export * from './notifications/v2'
export * from './oauth/internal'
export * from './organizations/v3'

View File

@@ -0,0 +1,60 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
export class LabrinthModerationInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_moderation_internal'
}
public async acquireLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
return this.client.request<Labrinth.Moderation.Internal.LockAcquireResponse>(
`/moderation/lock/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
public async overrideLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
return this.client.request<Labrinth.Moderation.Internal.LockAcquireResponse>(
`/moderation/lock/${projectId}/override`,
{
api: 'labrinth',
version: 'internal',
method: 'POST',
},
)
}
public async releaseLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.ReleaseLockResponse> {
return this.client.request<Labrinth.Moderation.Internal.ReleaseLockResponse>(
`/moderation/lock/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'DELETE',
},
)
}
public async checkLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockStatusResponse> {
return this.client.request<Labrinth.Moderation.Internal.LockStatusResponse>(
`/moderation/lock/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
}

View File

@@ -1301,6 +1301,38 @@ export namespace Labrinth {
}
}
export namespace Moderation {
export namespace Internal {
export type LockedByUser = {
id: string
username: string
avatar_url?: string
}
export type LockStatusResponse = {
locked: boolean
is_own_lock: boolean
locked_by?: LockedByUser
locked_at?: string
expires_at?: string
expired?: boolean
}
export type LockAcquireResponse = {
success: boolean
is_own_lock: boolean
locked_by?: LockedByUser
locked_at?: string
expires_at?: string
expired?: boolean
}
export type ReleaseLockResponse = {
success: boolean
}
}
}
export namespace Notifications {
export namespace v2 {
export type NotificationAction = {

51
pnpm-lock.yaml generated
View File

@@ -278,9 +278,6 @@ importers:
'@modrinth/utils':
specifier: workspace:*
version: link:../../packages/utils
'@pinia/nuxt':
specifier: ^0.11.3
version: 0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))
'@sentry/nuxt':
specifier: ^10.33.0
version: 10.38.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.211.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.39.0)(magicast@0.5.1)(nuxt@3.21.0(@parcel/watcher@2.5.6)(@types/node@20.19.31)(@vue/compiler-sfc@3.5.27)(cac@6.7.14)(db0@0.3.4)(eslint@9.39.2(jiti@2.6.1))(ioredis@5.9.2)(lightningcss@1.32.0)(magicast@0.5.1)(optionator@0.9.4)(rolldown@1.0.0-rc.12)(rollup@4.57.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@20.19.31)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue-tsc@2.2.12(typescript@5.9.3))(xml2js@0.6.2)(yaml@2.8.2))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))(rollup@4.57.1)(vue@3.5.27(typescript@5.9.3))
@@ -347,12 +344,6 @@ importers:
pathe:
specifier: ^1.1.2
version: 1.1.2
pinia:
specifier: ^3.0.0
version: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
pinia-plugin-persistedstate:
specifier: ^4.4.1
version: 4.7.1(@nuxt/kit@3.21.0(magicast@0.5.1))(@pinia/nuxt@0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))
prettier:
specifier: ^3.6.2
version: 3.8.1
@@ -3321,11 +3312,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'}
'@pinia/nuxt@0.11.3':
resolution: {integrity: sha512-7WVNHpWx4qAEzOlnyrRC88kYrwnlR/PrThWT0XI1dSNyUAXu/KBv9oR37uCgYkZroqP5jn8DfzbkNF3BtKvE9w==}
peerDependencies:
pinia: ^3.0.4
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -7804,20 +7790,6 @@ packages:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
pinia-plugin-persistedstate@4.7.1:
resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
'@pinia/nuxt': '>=0.10.0'
pinia: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@pinia/nuxt':
optional: true
pinia:
optional: true
pinia@3.0.4:
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
peerDependencies:
@@ -9711,8 +9683,8 @@ packages:
vue-component-type-helpers@3.2.4:
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-component-type-helpers@3.2.7:
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
vue-confetti-explosion@1.0.2:
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
@@ -12448,13 +12420,6 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6
'@pinia/nuxt@0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))':
dependencies:
'@nuxt/kit': 4.3.0(magicast@0.5.1)
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
transitivePeerDependencies:
- magicast
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -13122,7 +13087,7 @@ snapshots:
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.27(typescript@5.9.3)
vue-component-type-helpers: 3.2.6
vue-component-type-helpers: 3.2.7
'@stripe/stripe-js@7.9.0': {}
@@ -17973,14 +17938,6 @@ snapshots:
pify@2.3.0: {}
pinia-plugin-persistedstate@4.7.1(@nuxt/kit@3.21.0(magicast@0.5.1))(@pinia/nuxt@0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))))(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))):
dependencies:
defu: 6.1.4
optionalDependencies:
'@nuxt/kit': 3.21.0(magicast@0.5.1)
'@pinia/nuxt': 0.11.3(magicast@0.5.1)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)))
pinia: 3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3))
pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.9
@@ -19996,7 +19953,7 @@ snapshots:
vue-component-type-helpers@3.2.4: {}
vue-component-type-helpers@3.2.6: {}
vue-component-type-helpers@3.2.7: {}
vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
dependencies: