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: [
|
modules: [
|
||||||
'@pinia/nuxt',
|
|
||||||
'floating-vue/nuxt',
|
'floating-vue/nuxt',
|
||||||
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
|
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
|
||||||
...(isProduction() ? ['@sentry/nuxt/module'] : []),
|
...(isProduction() ? ['@sentry/nuxt/module'] : []),
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
"@modrinth/moderation": "workspace:*",
|
"@modrinth/moderation": "workspace:*",
|
||||||
"@modrinth/ui": "workspace:*",
|
"@modrinth/ui": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
|
||||||
"@sentry/nuxt": "^10.33.0",
|
"@sentry/nuxt": "^10.33.0",
|
||||||
"@tanstack/vue-query": "^5.90.7",
|
"@tanstack/vue-query": "^5.90.7",
|
||||||
"@types/three": "^0.172.0",
|
"@types/three": "^0.172.0",
|
||||||
@@ -69,8 +68,6 @@
|
|||||||
"lru-cache": "^11.2.4",
|
"lru-cache": "^11.2.4",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^3.0.0",
|
|
||||||
"pinia-plugin-persistedstate": "^4.4.1",
|
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ async function closeReport(reply = false) {
|
|||||||
closed: true,
|
closed: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
updateThread(props.report.thread)
|
await refreshReportCaches()
|
||||||
didCloseReport.value = true
|
didCloseReport.value = true
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -292,7 +292,7 @@ async function reopenReport() {
|
|||||||
closed: false,
|
closed: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
updateThread(props.report.thread)
|
await refreshReportCaches()
|
||||||
didCloseReport.value = false
|
didCloseReport.value = false
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -309,6 +309,18 @@ const formatDateTime = useFormatDateTime({
|
|||||||
dateStyle: 'long',
|
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) {
|
function updateThread(newThread: any) {
|
||||||
if (props.report.thread) {
|
if (props.report.thread) {
|
||||||
Object.assign(props.report.thread, newThread)
|
Object.assign(props.report.thread, newThread)
|
||||||
|
|||||||
@@ -477,6 +477,8 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
|||||||
backToFileList()
|
backToFileList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit('refetch')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to batch update:', error)
|
console.error('Failed to batch update:', error)
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -549,6 +551,8 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
text: `This issue has been flagged as malicious.${otherText}`,
|
text: `This issue has been flagged as malicious.${otherText}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit('refetch')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update detail status:', error)
|
console.error('Failed to update detail status:', error)
|
||||||
addNotification({
|
addNotification({
|
||||||
|
|||||||
@@ -79,13 +79,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
|
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
|
||||||
color="brand"
|
color="brand"
|
||||||
@click="skipToNextProject"
|
@click="skipToNextProject"
|
||||||
>
|
>
|
||||||
<button>
|
<button>
|
||||||
<RightArrowIcon aria-hidden="true" />
|
<RightArrowIcon aria-hidden="true" />
|
||||||
Next project ({{ moderationStore.queueLength }} left)
|
Next project ({{ moderationQueue.queueLength }} left)
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,13 +112,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
|
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
|
||||||
color="brand"
|
color="brand"
|
||||||
@click="skipToNextProject"
|
@click="skipToNextProject"
|
||||||
>
|
>
|
||||||
<button>
|
<button>
|
||||||
<RightArrowIcon aria-hidden="true" />
|
<RightArrowIcon aria-hidden="true" />
|
||||||
Next project ({{ moderationStore.queueLength }} left)
|
Next project ({{ moderationQueue.queueLength }} left)
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,9 +131,9 @@
|
|||||||
<div v-if="done">
|
<div v-if="done">
|
||||||
<p>
|
<p>
|
||||||
You are done moderating this project!
|
You are done moderating this project!
|
||||||
<template v-if="moderationStore.hasItems">
|
<template v-if="moderationQueue.hasItems">
|
||||||
There are
|
There are
|
||||||
{{ moderationStore.queueLength }} left.
|
{{ moderationQueue.queueLength }} left.
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,6 +159,7 @@
|
|||||||
:disabled="false"
|
:disabled="false"
|
||||||
:heading-buttons="false"
|
:heading-buttons="false"
|
||||||
:on-image-upload="onUploadHandler"
|
:on-image-upload="onUploadHandler"
|
||||||
|
@input="persistGeneratedMessageState"
|
||||||
/>
|
/>
|
||||||
<StyledInput
|
<StyledInput
|
||||||
v-else
|
v-else
|
||||||
@@ -167,7 +168,7 @@
|
|||||||
placeholder="No message generated."
|
placeholder="No message generated."
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
input-class="h-[400px] font-mono"
|
input-class="h-[400px] font-mono"
|
||||||
@input="persistState"
|
@input="persistGeneratedMessageState"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
|
<ButtonStyled v-if="!done && !generatedMessage && moderationQueue.hasItems">
|
||||||
<button @click="skipCurrentProject">
|
<button @click="skipCurrentProject">
|
||||||
<XIcon aria-hidden="true" />
|
<XIcon aria-hidden="true" />
|
||||||
Skip ({{ moderationStore.queueLength }} left)
|
Skip ({{ moderationQueue.queueLength }} left)
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,7 +346,7 @@
|
|||||||
<button @click="endChecklist(undefined)">
|
<button @click="endChecklist(undefined)">
|
||||||
<template v-if="hasNextProject">
|
<template v-if="hasNextProject">
|
||||||
<RightArrowIcon aria-hidden="true" />
|
<RightArrowIcon aria-hidden="true" />
|
||||||
Next project ({{ moderationStore.queueLength }} left)
|
Next project ({{ moderationQueue.queueLength }} left)
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<CheckIcon aria-hidden="true" />
|
<CheckIcon aria-hidden="true" />
|
||||||
@@ -441,10 +442,10 @@ import {
|
|||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
type Action,
|
type Action,
|
||||||
|
type ActionState,
|
||||||
type ButtonAction,
|
type ButtonAction,
|
||||||
checklist,
|
checklist,
|
||||||
type ConditionalButtonAction,
|
type ConditionalButtonAction,
|
||||||
deserializeActionStates,
|
|
||||||
type DropdownAction,
|
type DropdownAction,
|
||||||
expandVariables,
|
expandVariables,
|
||||||
finalPermissionMessages,
|
finalPermissionMessages,
|
||||||
@@ -461,7 +462,6 @@ import {
|
|||||||
keybinds,
|
keybinds,
|
||||||
type MultiSelectChipsAction,
|
type MultiSelectChipsAction,
|
||||||
processMessage,
|
processMessage,
|
||||||
serializeActionStates,
|
|
||||||
type Stage,
|
type Stage,
|
||||||
type ToggleAction,
|
type ToggleAction,
|
||||||
} from '@modrinth/moderation'
|
} from '@modrinth/moderation'
|
||||||
@@ -486,13 +486,27 @@ import {
|
|||||||
type ProjectStatus,
|
type ProjectStatus,
|
||||||
renderHighlightedString,
|
renderHighlightedString,
|
||||||
} from '@modrinth/utils'
|
} 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 { useGeneratedState } from '~/composables/generated'
|
||||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||||
import type { LockAcquireResponse } from '~/store/moderation.ts'
|
import {
|
||||||
import { useModerationStore } from '~/store/moderation.ts'
|
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 KeybindsModal from './ChecklistKeybindsModal.vue'
|
||||||
import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
|
import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
|
||||||
@@ -508,9 +522,10 @@ const props = defineProps<{
|
|||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { projectV2, projectV3 } = injectProjectPageContext()
|
const { projectV2, projectV3, invalidate } = injectProjectPageContext()
|
||||||
|
|
||||||
const moderationStore = useModerationStore()
|
const moderationQueue = useModerationQueue()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const tags = useGeneratedState()
|
const tags = useGeneratedState()
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
|
|
||||||
@@ -548,7 +563,7 @@ async function handleVisibilityChange() {
|
|||||||
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
|
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
|
||||||
// Immediately refresh the lock when returning to the tab
|
// Immediately refresh the lock when returning to the tab
|
||||||
// This handles cases where the heartbeat was throttled while backgrounded
|
// This handles cases where the heartbeat was throttled while backgrounded
|
||||||
const refreshResult = await moderationStore.refreshLock()
|
const refreshResult = await moderationQueue.refreshLock()
|
||||||
if (!refreshResult.success) {
|
if (!refreshResult.success) {
|
||||||
handleLockLost(refreshResult)
|
handleLockLost(refreshResult)
|
||||||
return
|
return
|
||||||
@@ -594,7 +609,7 @@ function clearLockCountdown() {
|
|||||||
function startLockHeartbeat() {
|
function startLockHeartbeat() {
|
||||||
lockCheckInterval.value = setInterval(
|
lockCheckInterval.value = setInterval(
|
||||||
async () => {
|
async () => {
|
||||||
const result = await moderationStore.refreshLock()
|
const result = await moderationQueue.refreshLock()
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
handleLockLost(result)
|
handleLockLost(result)
|
||||||
}
|
}
|
||||||
@@ -667,7 +682,7 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
|||||||
|
|
||||||
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
||||||
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
|
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) {
|
if (recheck.locked && !recheck.expired) {
|
||||||
// Project got locked, remove from queue and try next
|
// Project got locked, remove from queue and try next
|
||||||
prefetchQueue.value.shift()
|
prefetchQueue.value.shift()
|
||||||
@@ -679,7 +694,9 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
|
|||||||
prefetchQueue.value.shift()
|
prefetchQueue.value.shift()
|
||||||
|
|
||||||
// Mark skipped projects as completed
|
// 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) {
|
if (next.skippedIds.length > 0) {
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -734,11 +751,32 @@ async function onUploadHandler(file: File) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useSimpleEditor = ref(false)
|
const useSimpleEditor = ref(false)
|
||||||
const message = ref('')
|
const checklistPersistenceProjectSlug = projectV2.value.slug
|
||||||
const generatedMessage = ref(false)
|
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 loadingMessage = ref(false)
|
||||||
const done = 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() {
|
function handleModpackPermissionsComplete() {
|
||||||
modpackPermissionsComplete.value = true
|
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)
|
// Release if we own the lock, or if there was an error checking (we might still own it)
|
||||||
const projectId = projectV2.value?.id
|
const projectId = projectV2.value?.id
|
||||||
if (projectId && (lockStatus.value?.isOwnLock || lockError.value)) {
|
if (projectId && (lockStatus.value?.isOwnLock || lockError.value)) {
|
||||||
const released = await moderationStore.releaseLock(projectId)
|
const released = await moderationQueue.releaseLock(projectId)
|
||||||
if (!released && lockStatus.value?.isOwnLock) {
|
if (!released && lockStatus.value?.isOwnLock) {
|
||||||
console.warn('Failed to release moderation lock for project:', projectId)
|
console.warn('Failed to release moderation lock for project:', projectId)
|
||||||
}
|
}
|
||||||
@@ -770,7 +808,7 @@ async function confirmTakeOverOverride() {
|
|||||||
console.warn('[confirmTakeOverOverride] No project ID available')
|
console.warn('[confirmTakeOverOverride] No project ID available')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await moderationStore.overrideLock(projectId)
|
const result = await moderationQueue.overrideLock(projectId)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -825,7 +863,7 @@ async function batchCheckLocksWithMetadata(
|
|||||||
projectIds.map(async (id) => {
|
projectIds.map(async (id) => {
|
||||||
// Parallel: check lock AND fetch project metadata
|
// Parallel: check lock AND fetch project metadata
|
||||||
const [lockResponse, projectData] = await Promise.all([
|
const [lockResponse, projectData] = await Promise.all([
|
||||||
moderationStore.checkLock(id),
|
moderationQueue.checkLock(id),
|
||||||
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
|
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -856,7 +894,7 @@ async function batchCheckLocksWithMetadata(
|
|||||||
// Maintain a queue of prefetched unlocked projects for instant navigation
|
// Maintain a queue of prefetched unlocked projects for instant navigation
|
||||||
async function maintainPrefetchQueue() {
|
async function maintainPrefetchQueue() {
|
||||||
if (isPrefetching.value) return
|
if (isPrefetching.value) return
|
||||||
if (!moderationStore.isQueueMode) return
|
if (!moderationQueue.isQueueMode) return
|
||||||
|
|
||||||
const currentProjectId = projectV2.value?.id
|
const currentProjectId = projectV2.value?.id
|
||||||
|
|
||||||
@@ -879,7 +917,7 @@ async function maintainPrefetchQueue() {
|
|||||||
|
|
||||||
// 4. Get remaining queue items (excluding current and already prefetched)
|
// 4. Get remaining queue items (excluding current and already prefetched)
|
||||||
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId))
|
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 currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
|
||||||
const remainingItems =
|
const remainingItems =
|
||||||
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
|
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
|
||||||
@@ -951,12 +989,12 @@ async function skipToNextProject() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
|
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] Queue after complete:', [...moderationQueue.currentQueue.items])
|
||||||
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
|
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
|
||||||
|
|
||||||
// Use prefetched data if available
|
// Use prefetched data if available
|
||||||
if (await navigateToNextUnlockedProject()) {
|
if (await navigateToNextUnlockedProject()) {
|
||||||
@@ -968,7 +1006,7 @@ async function skipToNextProject() {
|
|||||||
|
|
||||||
// Fallback: batch check remaining projects with metadata (excluding current)
|
// Fallback: batch check remaining projects with metadata (excluding current)
|
||||||
const remainingIds: string[] = []
|
const remainingIds: string[] = []
|
||||||
const queueItems = moderationStore.currentQueue.items
|
const queueItems = moderationQueue.currentQueue.items
|
||||||
|
|
||||||
// Build list of remaining projects, excluding current
|
// Build list of remaining projects, excluding current
|
||||||
for (const id of queueItems) {
|
for (const id of queueItems) {
|
||||||
@@ -1011,7 +1049,7 @@ async function skipToNextProject() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
moderationStore.completeCurrentProject(id, 'skipped')
|
await moderationQueue.completeCurrentProject(id, 'skipped')
|
||||||
skippedCount++
|
skippedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1034,8 +1072,7 @@ function resetProgress() {
|
|||||||
textInputValues.value = {}
|
textInputValues.value = {}
|
||||||
|
|
||||||
done.value = false
|
done.value = false
|
||||||
generatedMessage.value = false
|
clearGeneratedMessageState()
|
||||||
message.value = ''
|
|
||||||
loadingMessage.value = false
|
loadingMessage.value = false
|
||||||
|
|
||||||
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
||||||
@@ -1061,8 +1098,11 @@ function findFirstValidStage(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentStageObj = computed(() => checklist[currentStage.value])
|
const currentStageObj = computed(() => checklist[currentStage.value])
|
||||||
const currentStage = useLocalStorage(`moderation-stage-${projectV2.value.slug}`, () =>
|
const persistedStage = import.meta.client
|
||||||
findFirstValidStage(),
|
? await loadChecklistStage(checklistPersistenceProjectSlug)
|
||||||
|
: null
|
||||||
|
const currentStage = ref(
|
||||||
|
persistedStage !== null && checklist[persistedStage] ? persistedStage : findFirstValidStage(),
|
||||||
)
|
)
|
||||||
|
|
||||||
const stageTextExpanded = computedAsync(async () => {
|
const stageTextExpanded = computedAsync(async () => {
|
||||||
@@ -1081,37 +1121,27 @@ const stageTextExpanded = computedAsync(async () => {
|
|||||||
return null
|
return null
|
||||||
}, null)
|
}, null)
|
||||||
|
|
||||||
interface ActionState {
|
const persistedActionStates = import.meta.client
|
||||||
selected: boolean
|
? await loadChecklistActionStates(checklistPersistenceProjectSlug)
|
||||||
value?: any
|
: {}
|
||||||
}
|
|
||||||
|
|
||||||
const persistedActionStates = useLocalStorage(
|
|
||||||
`moderation-actions-${projectV2.value.slug}`,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
serializer: {
|
|
||||||
read: (v: any) => (v ? deserializeActionStates(v) : {}),
|
|
||||||
write: (v: any) => serializeActionStates(v),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const persistedTextInputs = useLocalStorage(
|
const persistedTextInputs = import.meta.client
|
||||||
`moderation-inputs-${projectV2.value.slug}`,
|
? await loadChecklistTextInputs(checklistPersistenceProjectSlug)
|
||||||
{} as Record<string, string>,
|
: {}
|
||||||
)
|
|
||||||
|
|
||||||
const actionStates = ref<Record<string, ActionState>>(persistedActionStates.value)
|
const actionStates = ref<Record<string, ActionState>>(persistedActionStates)
|
||||||
const textInputValues = ref<Record<string, string>>(persistedTextInputs.value)
|
const textInputValues = ref<Record<string, string>>(persistedTextInputs)
|
||||||
|
|
||||||
const persistState = () => {
|
const persistState = () => {
|
||||||
persistedActionStates.value = actionStates.value
|
void saveChecklistActionStates(checklistPersistenceProjectSlug, actionStates.value)
|
||||||
persistedTextInputs.value = textInputValues.value
|
void saveChecklistTextInputs(checklistPersistenceProjectSlug, textInputValues.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(currentStage, (stage) => {
|
||||||
|
void saveChecklistStage(checklistPersistenceProjectSlug, stage)
|
||||||
|
})
|
||||||
watch(actionStates, persistState, { deep: true })
|
watch(actionStates, persistState, { deep: true })
|
||||||
watch(textInputValues, persistState, { deep: true })
|
watch(textInputValues, persistState, { deep: true })
|
||||||
|
|
||||||
@@ -1141,7 +1171,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
isLoadingMessage: loadingMessage.value,
|
isLoadingMessage: loadingMessage.value,
|
||||||
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
isModpackPermissionsStage: isModpackPermissionsStage.value,
|
||||||
|
|
||||||
futureProjectCount: moderationStore.queueLength,
|
futureProjectCount: moderationQueue.queueLength,
|
||||||
visibleActionsCount: visibleActions.value.length,
|
visibleActionsCount: visibleActions.value.length,
|
||||||
|
|
||||||
focusedActionIndex: focusedActionIndex.value,
|
focusedActionIndex: focusedActionIndex.value,
|
||||||
@@ -1249,14 +1279,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to acquire lock
|
// Try to acquire lock
|
||||||
const result = await moderationStore.acquireLock(projectV2.value.id)
|
const result = await moderationQueue.acquireLock(projectV2.value.id)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
handleLockAcquired()
|
handleLockAcquired()
|
||||||
} else if (result.locked_by) {
|
} else if (result.locked_by) {
|
||||||
// Actually locked by another moderator
|
// Actually locked by another moderator
|
||||||
// In queue mode with more projects - auto-skip to next project
|
// In queue mode with more projects - auto-skip to next project
|
||||||
if (moderationStore.isQueueMode && moderationStore.queueLength > 1) {
|
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Project locked',
|
title: 'Project locked',
|
||||||
text: `Skipped project locked by @${result.locked_by.username}.`,
|
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)
|
// Release lock if we own it (navigation away without explicit exit)
|
||||||
const projectId = projectV2.value?.id
|
const projectId = projectV2.value?.id
|
||||||
if (projectId && lockStatus.value?.isOwnLock) {
|
if (projectId && lockStatus.value?.isOwnLock) {
|
||||||
moderationStore.releaseLock(projectId)
|
void moderationQueue.releaseLock(projectId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear prefetch state to prevent memory leaks
|
// Clear prefetch state to prevent memory leaks
|
||||||
@@ -1809,8 +1839,7 @@ function nextStage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBackToStages() {
|
function goBackToStages() {
|
||||||
generatedMessage.value = false
|
clearGeneratedMessageState()
|
||||||
message.value = ''
|
|
||||||
|
|
||||||
let targetStage = checklist.length - 1
|
let targetStage = checklist.length - 1
|
||||||
while (targetStage >= 0) {
|
while (targetStage >= 0) {
|
||||||
@@ -1923,6 +1952,16 @@ function generateModpackMessage(allFiles: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasNextProject = ref(false)
|
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) {
|
async function sendMessage(status: ProjectStatus) {
|
||||||
// Capture project data upfront to avoid null issues during async operations
|
// Capture project data upfront to avoid null issues during async operations
|
||||||
const projectId = projectV2.value?.id
|
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([
|
await Promise.race([
|
||||||
moderationStore.releaseLock(projectId),
|
moderationQueue.releaseLock(projectId),
|
||||||
new Promise((r) => setTimeout(r, 2000)),
|
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
|
// to avoid the race condition where done=true renders with hasNextProject=false
|
||||||
hasNextProject.value = willHaveNext
|
hasNextProject.value = willHaveNext
|
||||||
done.value = true
|
done.value = true
|
||||||
|
clearGeneratedMessageState()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error submitting moderation:', error)
|
console.error('Error submitting moderation:', error)
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -2000,7 +2042,7 @@ async function endChecklist(status?: string) {
|
|||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (moderationStore.currentQueue.total > 1) {
|
if (moderationQueue.currentQueue.total > 1) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Moderation completed',
|
title: 'Moderation completed',
|
||||||
text: `You have completed the moderation queue.`,
|
text: `You have completed the moderation queue.`,
|
||||||
@@ -2019,7 +2061,7 @@ async function endChecklist(status?: string) {
|
|||||||
// Fallback: batch check remaining projects with metadata
|
// Fallback: batch check remaining projects with metadata
|
||||||
const remainingIds: string[] = []
|
const remainingIds: string[] = []
|
||||||
const currentProjectId = projectV2.value?.id
|
const currentProjectId = projectV2.value?.id
|
||||||
const queueItems = moderationStore.currentQueue.items
|
const queueItems = moderationQueue.currentQueue.items
|
||||||
|
|
||||||
// Build list of remaining projects, excluding current
|
// Build list of remaining projects, excluding current
|
||||||
for (const id of queueItems) {
|
for (const id of queueItems) {
|
||||||
@@ -2064,7 +2106,7 @@ async function endChecklist(status?: string) {
|
|||||||
foundUnlocked = true
|
foundUnlocked = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
moderationStore.completeCurrentProject(id, 'skipped')
|
await moderationQueue.completeCurrentProject(id, 'skipped')
|
||||||
skippedCount++
|
skippedCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2100,11 +2142,11 @@ async function skipCurrentProject() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
moderationStore.releaseLock(projectId),
|
moderationQueue.releaseLock(projectId),
|
||||||
new Promise((r) => setTimeout(r, 2000)),
|
new Promise((r) => setTimeout(r, 2000)),
|
||||||
])
|
])
|
||||||
|
|
||||||
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
|
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped')
|
||||||
|
|
||||||
await endChecklist('skipped')
|
await endChecklist('skipped')
|
||||||
}
|
}
|
||||||
@@ -2112,15 +2154,15 @@ async function skipCurrentProject() {
|
|||||||
function clearProjectLocalStorage() {
|
function clearProjectLocalStorage() {
|
||||||
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
||||||
localStorage.removeItem(`modpack-permissions-index-${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-data-${projectV2.value.id}`)
|
||||||
sessionStorage.removeItem(`modpack-permissions-permanent-no-${projectV2.value.id}`)
|
sessionStorage.removeItem(`modpack-permissions-permanent-no-${projectV2.value.id}`)
|
||||||
sessionStorage.removeItem(`modpack-permissions-updated-${projectV2.value.id}`)
|
sessionStorage.removeItem(`modpack-permissions-updated-${projectV2.value.id}`)
|
||||||
|
|
||||||
|
void clearChecklistProgressState(checklistPersistenceProjectSlug)
|
||||||
actionStates.value = {}
|
actionStates.value = {}
|
||||||
|
textInputValues.value = {}
|
||||||
|
clearGeneratedMessageState()
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLastVisibleStage = computed(() => {
|
const isLastVisibleStage = computed(() => {
|
||||||
@@ -2169,7 +2211,7 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
|
|||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
|
|
||||||
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string }
|
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string; icon?: Component }
|
||||||
|
|
||||||
const stageOptionsForSlots = computed(() =>
|
const stageOptionsForSlots = computed(() =>
|
||||||
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),
|
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),
|
||||||
|
|||||||
@@ -217,6 +217,14 @@
|
|||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'withheld',
|
disabled: project.status === 'withheld',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'set-to-draft-reply',
|
||||||
|
action: () => {
|
||||||
|
sendReply('draft')
|
||||||
|
},
|
||||||
|
hoverFilled: true,
|
||||||
|
disabled: project.status === 'draft',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'send-to-review-reply',
|
id: 'send-to-review-reply',
|
||||||
action: () => {
|
action: () => {
|
||||||
@@ -236,6 +244,14 @@
|
|||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'withheld',
|
disabled: project.status === 'withheld',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'set-to-draft',
|
||||||
|
action: () => {
|
||||||
|
setStatus('draft')
|
||||||
|
},
|
||||||
|
hoverFilled: true,
|
||||||
|
disabled: project.status === 'draft',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'send-to-review',
|
id: 'send-to-review',
|
||||||
action: () => {
|
action: () => {
|
||||||
@@ -256,6 +272,14 @@
|
|||||||
<EyeOffIcon aria-hidden="true" />
|
<EyeOffIcon aria-hidden="true" />
|
||||||
Withhold
|
Withhold
|
||||||
</template>
|
</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>
|
<template #send-to-review-reply>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
Send to review with reply
|
Send to review with reply
|
||||||
@@ -280,6 +304,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
|
FileTextIcon,
|
||||||
ReplyIcon,
|
ReplyIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
@@ -417,7 +442,7 @@ async function sendReply(status = null, privateMessage = false) {
|
|||||||
|
|
||||||
await updateThreadLocal()
|
await updateThreadLocal()
|
||||||
if (status !== null) {
|
if (status !== null) {
|
||||||
props.setStatus(status)
|
await props.setStatus(status)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
|
|||||||
@@ -754,10 +754,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'moderation-checklist',
|
id: 'moderation-checklist',
|
||||||
action: () => {
|
action: openModerationChecklistFromMenu,
|
||||||
moderationStore.setSingleProject(project.id)
|
|
||||||
showModerationChecklist = true
|
|
||||||
},
|
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
hoverOnly: true,
|
hoverOnly: true,
|
||||||
shown:
|
shown:
|
||||||
@@ -1046,7 +1043,7 @@
|
|||||||
>
|
>
|
||||||
<ModerationChecklist
|
<ModerationChecklist
|
||||||
:collapsed="collapsedModerationChecklist"
|
:collapsed="collapsedModerationChecklist"
|
||||||
@exit="showModerationChecklist = false"
|
@exit="setModerationChecklistOpen(false)"
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1145,7 +1142,11 @@ import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
|||||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||||
import { versionQueryOptions } from '~/composables/queries/version'
|
import { versionQueryOptions } from '~/composables/queries/version'
|
||||||
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
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'
|
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -1156,7 +1157,7 @@ const data = useNuxtApp()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const signInRouteObj = computed(() => getSignInRouteObj(route))
|
const signInRouteObj = computed(() => getSignInRouteObj(route))
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const moderationStore = useModerationStore()
|
const moderationQueue = useModerationQueue()
|
||||||
const notifications = injectNotificationManager()
|
const notifications = injectNotificationManager()
|
||||||
const { addNotification } = notifications
|
const { addNotification } = notifications
|
||||||
|
|
||||||
@@ -2514,16 +2515,84 @@ async function copyPermalink() {
|
|||||||
|
|
||||||
const collapsedChecklist = ref(false)
|
const collapsedChecklist = ref(false)
|
||||||
|
|
||||||
const showModerationChecklist = useLocalStorage(
|
const showModerationChecklist = ref(false)
|
||||||
`show-moderation-checklist-${project.value?.id ?? 'unknown'}`,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false)
|
const collapsedModerationChecklist = useLocalStorage('collapsed-moderation-checklist', false)
|
||||||
|
|
||||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
function consumeShowChecklistHistoryState() {
|
||||||
showModerationChecklist.value = true
|
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) {
|
function closeDownloadModal(event) {
|
||||||
downloadModal.value.hide(event)
|
downloadModal.value.hide(event)
|
||||||
userSelectedPlatform.value = null
|
userSelectedPlatform.value = null
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
:set-status="setStatus"
|
:set-status="setStatus"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
:auth="auth"
|
:auth="auth"
|
||||||
@update-thread="(newThread) => (thread = newThread)"
|
@update-thread="updateThread"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,6 +132,13 @@ const { data: thread } = useQuery({
|
|||||||
enabled: computed(() => !!project.value?.thread_id),
|
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) {
|
async function setStatus(status) {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
|
|||||||
@@ -112,13 +112,13 @@ import ConfettiExplosion from 'vue-confetti-explosion'
|
|||||||
|
|
||||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||||
import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation.ts'
|
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' })
|
useHead({ title: 'Projects queue - Modrinth' })
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const moderationStore = useModerationStore()
|
const moderationQueue = useModerationQueue()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -331,18 +331,18 @@ function goToPage(page: number) {
|
|||||||
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
||||||
let skippedCount = 0
|
let skippedCount = 0
|
||||||
|
|
||||||
while (moderationStore.hasItems) {
|
while (moderationQueue.hasItems) {
|
||||||
const currentId = moderationStore.getCurrentProjectId()
|
const currentId = moderationQueue.getCurrentProjectId()
|
||||||
if (!currentId) return null
|
if (!currentId) return null
|
||||||
|
|
||||||
const project = filteredProjects.value.find((p) => p.project.id === currentId)
|
const project = filteredProjects.value.find((p) => p.project.id === currentId)
|
||||||
if (!project) {
|
if (!project) {
|
||||||
moderationStore.completeCurrentProject(currentId, 'skipped')
|
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lockStatus = await moderationStore.checkLock(currentId)
|
const lockStatus = await moderationQueue.checkLock(currentId)
|
||||||
|
|
||||||
if (!lockStatus.locked || lockStatus.expired) {
|
if (!lockStatus.locked || lockStatus.expired) {
|
||||||
if (skippedCount > 0) {
|
if (skippedCount > 0) {
|
||||||
@@ -356,7 +356,7 @@ async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Project is locked, skip it
|
// Project is locked, skip it
|
||||||
moderationStore.completeCurrentProject(currentId, 'skipped')
|
await moderationQueue.completeCurrentProject(currentId, 'skipped')
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} catch {
|
} catch {
|
||||||
return project
|
return project
|
||||||
@@ -371,7 +371,7 @@ async function moderateAllInFilter() {
|
|||||||
const startIndex = (currentPage.value - 1) * itemsPerPage
|
const startIndex = (currentPage.value - 1) * itemsPerPage
|
||||||
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
|
||||||
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
|
||||||
moderationStore.setQueue(projectIds)
|
await moderationQueue.setQueue(projectIds)
|
||||||
|
|
||||||
// Find first unlocked project
|
// Find first unlocked project
|
||||||
const targetProject = await findFirstUnlockedProject()
|
const targetProject = await findFirstUnlockedProject()
|
||||||
@@ -402,12 +402,12 @@ async function startFromProject(projectId: string) {
|
|||||||
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
|
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
|
||||||
if (projectIndex === -1) {
|
if (projectIndex === -1) {
|
||||||
// Project not found in filtered list, just moderate it alone
|
// Project not found in filtered list, just moderate it alone
|
||||||
moderationStore.setSingleProject(projectId)
|
await moderationQueue.setSingleProject(projectId)
|
||||||
} else {
|
} else {
|
||||||
// Start queue from this project onwards
|
// Start queue from this project onwards
|
||||||
const projectsFromHere = filteredProjects.value.slice(projectIndex)
|
const projectsFromHere = filteredProjects.value.slice(projectIndex)
|
||||||
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
|
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
|
||||||
moderationStore.setQueue(projectIds)
|
await moderationQueue.setQueue(projectIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find first unlocked project
|
// Find first unlocked project
|
||||||
|
|||||||
@@ -246,8 +246,14 @@ const reviewItem = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleMarkComplete(_projectId: string) {
|
async function handleMarkComplete(projectId: string) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['tech-reviews'] })
|
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>>()
|
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 type {
|
||||||
import { computed, ref } from 'vue'
|
LockAcquireResponse,
|
||||||
|
LockedByUser,
|
||||||
|
LockStatusResponse,
|
||||||
|
ModerationQueue,
|
||||||
|
ModerationQueueService,
|
||||||
|
} from '~/services/moderation-queue.ts'
|
||||||
|
import { useModerationQueue } from '~/services/moderation-queue.ts'
|
||||||
|
|
||||||
export interface ModerationQueue {
|
export type {
|
||||||
items: string[]
|
LockAcquireResponse,
|
||||||
total: number
|
LockedByUser,
|
||||||
completed: number
|
LockStatusResponse,
|
||||||
skipped: number
|
ModerationQueue,
|
||||||
lastUpdated: Date
|
ModerationQueueService,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LockedByUser {
|
export const useModerationStore = useModerationQueue
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
|||||||
import { LabrinthCollectionsModule } from './labrinth/collections'
|
import { LabrinthCollectionsModule } from './labrinth/collections'
|
||||||
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
|
||||||
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
|
||||||
|
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
|
||||||
import { LabrinthNotificationsV2Module } from './labrinth/notifications/v2'
|
import { LabrinthNotificationsV2Module } from './labrinth/notifications/v2'
|
||||||
import { LabrinthOAuthInternalModule } from './labrinth/oauth/internal'
|
import { LabrinthOAuthInternalModule } from './labrinth/oauth/internal'
|
||||||
import { LabrinthOrganizationsV3Module } from './labrinth/organizations/v3'
|
import { LabrinthOrganizationsV3Module } from './labrinth/organizations/v3'
|
||||||
@@ -73,6 +74,7 @@ export const MODULE_REGISTRY = {
|
|||||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||||
labrinth_collections: LabrinthCollectionsModule,
|
labrinth_collections: LabrinthCollectionsModule,
|
||||||
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
labrinth_globals_internal: LabrinthGlobalsInternalModule,
|
||||||
|
labrinth_moderation_internal: LabrinthModerationInternalModule,
|
||||||
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
labrinth_notifications_v2: LabrinthNotificationsV2Module,
|
||||||
labrinth_oauth_internal: LabrinthOAuthInternalModule,
|
labrinth_oauth_internal: LabrinthOAuthInternalModule,
|
||||||
labrinth_organizations_v3: LabrinthOrganizationsV3Module,
|
labrinth_organizations_v3: LabrinthOrganizationsV3Module,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from './billing/internal'
|
|||||||
export * from './collections'
|
export * from './collections'
|
||||||
export * from './globals/internal'
|
export * from './globals/internal'
|
||||||
export * from './limits/v3'
|
export * from './limits/v3'
|
||||||
|
export * from './moderation/internal'
|
||||||
export * from './notifications/v2'
|
export * from './notifications/v2'
|
||||||
export * from './oauth/internal'
|
export * from './oauth/internal'
|
||||||
export * from './organizations/v3'
|
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 Notifications {
|
||||||
export namespace v2 {
|
export namespace v2 {
|
||||||
export type NotificationAction = {
|
export type NotificationAction = {
|
||||||
|
|||||||
51
pnpm-lock.yaml
generated
51
pnpm-lock.yaml
generated
@@ -278,9 +278,6 @@ importers:
|
|||||||
'@modrinth/utils':
|
'@modrinth/utils':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/utils
|
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':
|
'@sentry/nuxt':
|
||||||
specifier: ^10.33.0
|
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))
|
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:
|
pathe:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 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:
|
prettier:
|
||||||
specifier: ^3.6.2
|
specifier: ^3.6.2
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
@@ -3321,11 +3312,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||||
engines: {node: '>= 10.0.0'}
|
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':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -7804,20 +7790,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
pinia@3.0.4:
|
||||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -9711,8 +9683,8 @@ packages:
|
|||||||
vue-component-type-helpers@3.2.4:
|
vue-component-type-helpers@3.2.4:
|
||||||
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
|
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
|
||||||
|
|
||||||
vue-component-type-helpers@3.2.6:
|
vue-component-type-helpers@3.2.7:
|
||||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
|
||||||
|
|
||||||
vue-confetti-explosion@1.0.2:
|
vue-confetti-explosion@1.0.2:
|
||||||
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
|
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
|
||||||
@@ -12448,13 +12420,6 @@ snapshots:
|
|||||||
'@parcel/watcher-win32-ia32': 2.5.6
|
'@parcel/watcher-win32-ia32': 2.5.6
|
||||||
'@parcel/watcher-win32-x64': 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':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
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)
|
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
|
type-fest: 2.19.0
|
||||||
vue: 3.5.27(typescript@5.9.3)
|
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': {}
|
'@stripe/stripe-js@7.9.0': {}
|
||||||
|
|
||||||
@@ -17973,14 +17938,6 @@ snapshots:
|
|||||||
|
|
||||||
pify@2.3.0: {}
|
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)):
|
pinia@3.0.4(typescript@5.9.3)(vue@3.5.27(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 7.7.9
|
'@vue/devtools-api': 7.7.9
|
||||||
@@ -19996,7 +19953,7 @@ snapshots:
|
|||||||
|
|
||||||
vue-component-type-helpers@3.2.4: {}
|
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)):
|
vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user