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:
@@ -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'] : []),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>>()
|
||||
|
||||
596
apps/frontend/src/services/moderation-checklist-storage.ts
Normal file
596
apps/frontend/src/services/moderation-checklist-storage.ts
Normal 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}`)
|
||||
}
|
||||
241
apps/frontend/src/services/moderation-queue-storage.ts
Normal file
241
apps/frontend/src/services/moderation-queue-storage.ts
Normal 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()
|
||||
}
|
||||
325
apps/frontend/src/services/moderation-queue.ts
Normal file
325
apps/frontend/src/services/moderation-queue.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
51
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user