You've already forked AstralRinth
forked from didirus/AstralRinth
fix: checklist fixes (#5103)
* fix: page switching only when stage actually changes * feat: preload improvements * fix: dont skip own locks
This commit is contained in:
@@ -479,8 +479,10 @@ import {
|
|||||||
type ProjectStatus,
|
type ProjectStatus,
|
||||||
renderHighlightedString,
|
renderHighlightedString,
|
||||||
} from '@modrinth/utils'
|
} from '@modrinth/utils'
|
||||||
import { computedAsync, useLocalStorage } from '@vueuse/core'
|
import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
|
||||||
|
|
||||||
|
import { useGeneratedState } from '~/composables/generated'
|
||||||
|
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||||
import { useModerationStore } from '~/store/moderation.ts'
|
import { useModerationStore } from '~/store/moderation.ts'
|
||||||
|
|
||||||
import KeybindsModal from './ChecklistKeybindsModal.vue'
|
import KeybindsModal from './ChecklistKeybindsModal.vue'
|
||||||
@@ -499,6 +501,8 @@ const props = defineProps<{
|
|||||||
const { projectV2, projectV3 } = injectProjectPageContext()
|
const { projectV2, projectV3 } = injectProjectPageContext()
|
||||||
|
|
||||||
const moderationStore = useModerationStore()
|
const moderationStore = useModerationStore()
|
||||||
|
const tags = useGeneratedState()
|
||||||
|
const auth = await useAuth()
|
||||||
|
|
||||||
const lockStatus = ref<{
|
const lockStatus = ref<{
|
||||||
locked: boolean
|
locked: boolean
|
||||||
@@ -513,14 +517,22 @@ const lockCountdownInterval = ref<ReturnType<typeof setInterval> | null>(null)
|
|||||||
const lockTimeRemaining = ref<string | null>(null)
|
const lockTimeRemaining = ref<string | null>(null)
|
||||||
const alreadyReviewed = ref(false)
|
const alreadyReviewed = ref(false)
|
||||||
|
|
||||||
// Prefetched next project data for instant navigation
|
// Prefetch queue for parallel lock checking and instant navigation
|
||||||
const prefetchedNextProject = ref<{
|
interface PrefetchedProject {
|
||||||
projectId: string
|
projectId: string
|
||||||
skippedCount: number
|
slug: string // For canonical URL navigation
|
||||||
skippedIds: string[]
|
projectType: string // For canonical URL navigation
|
||||||
} | null>(null)
|
validatedAt: number
|
||||||
|
skippedIds: string[] // IDs that were locked when this was prefetched
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefetchQueue = ref<PrefetchedProject[]>([])
|
||||||
const isPrefetching = ref(false)
|
const isPrefetching = ref(false)
|
||||||
|
|
||||||
|
const PREFETCH_STALE_MS = 30_000 // 30 seconds
|
||||||
|
const PREFETCH_TARGET_COUNT = 3 // Keep 3 unlocked projects ready
|
||||||
|
const PREFETCH_BATCH_SIZE = 5 // Check 5 at a time in parallel
|
||||||
|
|
||||||
const LOCK_EXPIRY_MINUTES = 15
|
const LOCK_EXPIRY_MINUTES = 15
|
||||||
|
|
||||||
function handleVisibilityChange() {
|
function handleVisibilityChange() {
|
||||||
@@ -528,6 +540,8 @@ function handleVisibilityChange() {
|
|||||||
// 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
|
||||||
moderationStore.refreshLock()
|
moderationStore.refreshLock()
|
||||||
|
// Refresh prefetch queue when tab becomes visible (not debounced)
|
||||||
|
maintainPrefetchQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +591,7 @@ function handleLockAcquired() {
|
|||||||
initializeAllStages()
|
initializeAllStages()
|
||||||
clearLockCountdown()
|
clearLockCountdown()
|
||||||
startLockHeartbeat()
|
startLockHeartbeat()
|
||||||
prefetchNextProject()
|
maintainPrefetchQueue() // Start prefetching immediately (not debounced)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLockUnavailable() {
|
function handleLockUnavailable() {
|
||||||
@@ -592,26 +606,58 @@ function handleLockUnavailable() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateToNextUnlockedProject(): boolean {
|
async function navigateToNextUnlockedProject(): Promise<boolean> {
|
||||||
if (!prefetchedNextProject.value) return false
|
// Remove stale entries first
|
||||||
|
const now = Date.now()
|
||||||
|
prefetchQueue.value = prefetchQueue.value.filter((p) => now - p.validatedAt < PREFETCH_STALE_MS)
|
||||||
|
|
||||||
const { projectId, skippedCount, skippedIds } = prefetchedNextProject.value
|
if (prefetchQueue.value.length === 0) return false
|
||||||
skippedIds.forEach((id) => moderationStore.completeCurrentProject(id, 'skipped'))
|
|
||||||
|
|
||||||
if (skippedCount > 0) {
|
const next = prefetchQueue.value[0]
|
||||||
|
|
||||||
|
// Quick re-check if close to expiry (last 5 seconds of TTL)
|
||||||
|
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
|
||||||
|
const recheck = await moderationStore.checkLock(next.projectId)
|
||||||
|
if (recheck.locked && !recheck.expired) {
|
||||||
|
// Project got locked, remove from queue and try next
|
||||||
|
prefetchQueue.value.shift()
|
||||||
|
return navigateToNextUnlockedProject() // Recurse to try next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from queue after validation
|
||||||
|
prefetchQueue.value.shift()
|
||||||
|
|
||||||
|
// Mark skipped projects as completed
|
||||||
|
next.skippedIds.forEach((id) => moderationStore.completeCurrentProject(id, 'skipped'))
|
||||||
|
|
||||||
|
if (next.skippedIds.length > 0) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Skipped locked projects',
|
title: 'Skipped locked projects',
|
||||||
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
|
text: `Skipped ${next.skippedIds.length} project(s) being moderated by others.`,
|
||||||
type: 'info',
|
type: 'info',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetchedNextProject.value = null
|
// Trigger prefetch replenishment in background (don't await)
|
||||||
navigateTo({
|
maintainPrefetchQueue()
|
||||||
name: 'type-id',
|
|
||||||
params: { type: 'project', id: projectId },
|
// Navigate to canonical URL if we have metadata (avoids middleware redirect)
|
||||||
state: { showChecklist: true },
|
if (next.slug && next.projectType) {
|
||||||
})
|
const urlType = getProjectTypeForUrlShorthand(next.projectType, [], tags.value)
|
||||||
|
|
||||||
|
navigateTo({
|
||||||
|
path: `/${urlType}/${next.slug}`,
|
||||||
|
state: { showChecklist: true },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Fallback: use project ID (will trigger middleware redirect)
|
||||||
|
navigateTo({
|
||||||
|
name: 'type-id',
|
||||||
|
params: { type: 'project', id: next.projectId },
|
||||||
|
state: { showChecklist: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,63 +731,144 @@ function reviewAnyway() {
|
|||||||
alreadyReviewed.value = false
|
alreadyReviewed.value = false
|
||||||
initializeAllStages()
|
initializeAllStages()
|
||||||
// Start prefetching the next project in the background
|
// Start prefetching the next project in the background
|
||||||
prefetchNextProject()
|
maintainPrefetchQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch the next unlocked project in the background
|
// Batch check locks and fetch project metadata in parallel
|
||||||
async function prefetchNextProject() {
|
interface LockCheckResult {
|
||||||
if (isPrefetching.value || !moderationStore.isQueueMode || moderationStore.queueLength <= 1) {
|
locked: boolean
|
||||||
return
|
expired?: boolean
|
||||||
}
|
isOwnLock?: boolean
|
||||||
|
slug?: string
|
||||||
|
projectType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchCheckLocksWithMetadata(
|
||||||
|
projectIds: string[],
|
||||||
|
): Promise<Map<string, LockCheckResult>> {
|
||||||
|
const results = new Map<string, LockCheckResult>()
|
||||||
|
const currentUserId = (auth.value?.user as { id?: string } | null)?.id
|
||||||
|
|
||||||
|
// Check locks and fetch minimal project data in parallel
|
||||||
|
const checks = await Promise.allSettled(
|
||||||
|
projectIds.map(async (id) => {
|
||||||
|
// Parallel: check lock AND fetch project metadata
|
||||||
|
const [lockStatus, projectData] = await Promise.all([
|
||||||
|
moderationStore.checkLock(id),
|
||||||
|
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Check if lock is by the current user (own lock = can acquire)
|
||||||
|
const isOwnLock = lockStatus.locked_by?.id === currentUserId
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
locked: lockStatus.locked,
|
||||||
|
expired: lockStatus.expired,
|
||||||
|
isOwnLock,
|
||||||
|
slug: (projectData as { slug?: string })?.slug,
|
||||||
|
projectType: (projectData as { project_type?: string })?.project_type,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use forEach with index to avoid indexOf bug on PromiseSettledResult
|
||||||
|
checks.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
results.set(result.value.id, result.value)
|
||||||
|
} else {
|
||||||
|
// On error, mark as needing fallback (no metadata)
|
||||||
|
results.set(projectIds[index], { locked: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintain a queue of prefetched unlocked projects for instant navigation
|
||||||
|
async function maintainPrefetchQueue() {
|
||||||
|
if (isPrefetching.value) return
|
||||||
|
if (!moderationStore.isQueueMode) return
|
||||||
|
|
||||||
isPrefetching.value = true
|
isPrefetching.value = true
|
||||||
prefetchedNextProject.value = null
|
|
||||||
|
|
||||||
const skippedIds: string[] = []
|
try {
|
||||||
let attempts = 0
|
// 1. Remove stale entries (validated > 30s ago)
|
||||||
|
const now = Date.now()
|
||||||
|
prefetchQueue.value = prefetchQueue.value.filter((p) => now - p.validatedAt < PREFETCH_STALE_MS)
|
||||||
|
|
||||||
// Get queue items excluding current project
|
// 2. Remove entries for current project
|
||||||
const queueItems = [...moderationStore.currentQueue.items]
|
prefetchQueue.value = prefetchQueue.value.filter((p) => p.projectId !== projectV2.value.id)
|
||||||
const currentIndex = queueItems.indexOf(projectV2.value.id)
|
|
||||||
const remainingItems =
|
|
||||||
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
|
|
||||||
|
|
||||||
for (const nextId of remainingItems) {
|
// 3. If queue is full enough, exit early
|
||||||
if (attempts >= MAX_SKIP_ATTEMPTS) break
|
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) {
|
||||||
attempts++
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lockStatusResult = await moderationStore.checkLock(nextId)
|
|
||||||
|
|
||||||
if (!lockStatusResult.locked || lockStatusResult.expired) {
|
|
||||||
// Found an unlocked project
|
|
||||||
prefetchedNextProject.value = {
|
|
||||||
projectId: nextId,
|
|
||||||
skippedCount: skippedIds.length,
|
|
||||||
skippedIds,
|
|
||||||
}
|
|
||||||
isPrefetching.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Project is locked, add to skipped list
|
|
||||||
skippedIds.push(nextId)
|
|
||||||
} catch {
|
|
||||||
// On error, assume unlocked and let navigation handle it
|
|
||||||
prefetchedNextProject.value = {
|
|
||||||
projectId: nextId,
|
|
||||||
skippedCount: skippedIds.length,
|
|
||||||
skippedIds,
|
|
||||||
}
|
|
||||||
isPrefetching.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No unlocked projects found
|
// 4. Get remaining queue items (excluding current and already prefetched)
|
||||||
isPrefetching.value = false
|
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId))
|
||||||
|
const queueItems = [...moderationStore.currentQueue.items]
|
||||||
|
const currentIndex = queueItems.indexOf(projectV2.value.id)
|
||||||
|
const remainingItems =
|
||||||
|
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
|
||||||
|
|
||||||
|
const candidateIds = remainingItems
|
||||||
|
.filter((id) => !prefetchedIds.has(id))
|
||||||
|
.slice(0, PREFETCH_BATCH_SIZE * 2) // Check up to 10 candidates
|
||||||
|
|
||||||
|
if (candidateIds.length === 0) return
|
||||||
|
|
||||||
|
// 5. Batch check locks AND fetch metadata in parallel
|
||||||
|
const skippedIds: string[] = []
|
||||||
|
let checkedCount = 0
|
||||||
|
|
||||||
|
while (
|
||||||
|
prefetchQueue.value.length < PREFETCH_TARGET_COUNT &&
|
||||||
|
checkedCount < candidateIds.length
|
||||||
|
) {
|
||||||
|
const batch = candidateIds.slice(checkedCount, checkedCount + PREFETCH_BATCH_SIZE)
|
||||||
|
checkedCount += batch.length
|
||||||
|
|
||||||
|
const results = await batchCheckLocksWithMetadata(batch)
|
||||||
|
|
||||||
|
for (const id of batch) {
|
||||||
|
const result = results.get(id)
|
||||||
|
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
|
||||||
|
if (!result?.locked || result?.expired || result?.isOwnLock) {
|
||||||
|
// Found unlocked project with metadata
|
||||||
|
if (result?.slug && result?.projectType) {
|
||||||
|
prefetchQueue.value.push({
|
||||||
|
projectId: id,
|
||||||
|
slug: result.slug,
|
||||||
|
projectType: result.projectType,
|
||||||
|
validatedAt: Date.now(),
|
||||||
|
skippedIds: [...skippedIds],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// No metadata - still add but will need fallback navigation
|
||||||
|
prefetchQueue.value.push({
|
||||||
|
projectId: id,
|
||||||
|
slug: '', // Empty = use fallback
|
||||||
|
projectType: '',
|
||||||
|
validatedAt: Date.now(),
|
||||||
|
skippedIds: [...skippedIds],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
|
||||||
|
} else {
|
||||||
|
skippedIds.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isPrefetching.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounced prefetch to prevent spam from rapid stage changes
|
||||||
|
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
|
||||||
|
|
||||||
const MAX_SKIP_ATTEMPTS = 10
|
const MAX_SKIP_ATTEMPTS = 10
|
||||||
|
|
||||||
async function skipToNextProject() {
|
async function skipToNextProject() {
|
||||||
@@ -756,29 +883,33 @@ async function skipToNextProject() {
|
|||||||
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
|
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
|
||||||
|
|
||||||
// Use prefetched data if available
|
// Use prefetched data if available
|
||||||
if (navigateToNextUnlockedProject()) {
|
if (await navigateToNextUnlockedProject()) {
|
||||||
debug('[skipToNextProject] Used prefetch, returning')
|
debug('[skipToNextProject] Used prefetch, returning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debug('[skipToNextProject] No prefetch, entering fallback loop')
|
debug('[skipToNextProject] No prefetch, entering fallback with batch checking')
|
||||||
|
|
||||||
// Fallback: find the next unlocked project (with a limit to prevent excessive API calls)
|
// Fallback: batch check remaining projects with metadata (excluding current)
|
||||||
let skippedCount = 0
|
const remainingIds: string[] = []
|
||||||
let attempts = 0
|
const queueItems = moderationStore.currentQueue.items
|
||||||
while (moderationStore.hasItems && attempts < MAX_SKIP_ATTEMPTS) {
|
|
||||||
const nextId = moderationStore.getCurrentProjectId()
|
|
||||||
debug('[skipToNextProject] Loop iteration. nextId:', nextId, 'attempts:', attempts)
|
|
||||||
if (!nextId) break
|
|
||||||
|
|
||||||
attempts++
|
// Build list of remaining projects, excluding current
|
||||||
|
for (const id of queueItems) {
|
||||||
|
if (id === currentProjectId) continue
|
||||||
|
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
|
||||||
|
remainingIds.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (remainingIds.length > 0) {
|
||||||
const lockStatusResult = await moderationStore.checkLock(nextId)
|
const results = await batchCheckLocksWithMetadata(remainingIds)
|
||||||
debug('[skipToNextProject] Lock check for', nextId, ':', lockStatusResult)
|
|
||||||
|
|
||||||
if (!lockStatusResult.locked || lockStatusResult.expired) {
|
let skippedCount = 0
|
||||||
// Found an unlocked project
|
for (const id of remainingIds) {
|
||||||
|
const result = results.get(id)
|
||||||
|
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
|
||||||
|
if (!result?.locked || result?.expired || result?.isOwnLock) {
|
||||||
|
// Found unlocked - skip the locked ones before it
|
||||||
if (skippedCount > 0) {
|
if (skippedCount > 0) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Skipped locked projects',
|
title: 'Skipped locked projects',
|
||||||
@@ -786,58 +917,37 @@ async function skipToNextProject() {
|
|||||||
type: 'info',
|
type: 'info',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
navigateTo({
|
|
||||||
name: 'type-id',
|
// Navigate to canonical URL if we have metadata
|
||||||
params: {
|
if (result?.slug && result?.projectType) {
|
||||||
type: 'project',
|
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
||||||
id: nextId,
|
navigateTo({
|
||||||
},
|
path: `/${urlType}/${result.slug}`,
|
||||||
state: {
|
state: { showChecklist: true },
|
||||||
showChecklist: true,
|
})
|
||||||
},
|
} else {
|
||||||
})
|
// Fallback: use project ID
|
||||||
|
navigateTo({
|
||||||
|
name: 'type-id',
|
||||||
|
params: { type: 'project', id },
|
||||||
|
state: { showChecklist: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
moderationStore.completeCurrentProject(id, 'skipped')
|
||||||
// Project is locked, skip it and try the next one
|
|
||||||
moderationStore.completeCurrentProject(nextId, 'skipped')
|
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} catch {
|
|
||||||
// On error, just try to navigate to the project
|
|
||||||
navigateTo({
|
|
||||||
name: 'type-id',
|
|
||||||
params: {
|
|
||||||
type: 'project',
|
|
||||||
id: nextId,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No unlocked projects found (or hit attempt limit)
|
// All checked were locked
|
||||||
debug(
|
debug('[skipToNextProject] All projects were locked, skippedCount:', skippedCount)
|
||||||
'[skipToNextProject] Loop exited. skippedCount:',
|
|
||||||
skippedCount,
|
|
||||||
'attempts:',
|
|
||||||
attempts,
|
|
||||||
'hasItems:',
|
|
||||||
moderationStore.hasItems,
|
|
||||||
)
|
|
||||||
if (skippedCount > 0 || attempts >= MAX_SKIP_ATTEMPTS) {
|
|
||||||
debug('[skipToNextProject] Showing "All projects locked" notification')
|
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'All projects locked',
|
title: 'All projects locked',
|
||||||
text:
|
text: 'All remaining projects are currently being moderated by others.',
|
||||||
attempts >= MAX_SKIP_ATTEMPTS
|
|
||||||
? 'Many projects are currently locked. Try again later.'
|
|
||||||
: 'All remaining projects are currently being moderated by others.',
|
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
debug('[skipToNextProject] Emitting exit')
|
debug('[skipToNextProject] Emitting exit')
|
||||||
emit('exit')
|
emit('exit')
|
||||||
}
|
}
|
||||||
@@ -1042,6 +1152,14 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger debounced prefetch when user progresses through stages
|
||||||
|
watch(currentStage, () => {
|
||||||
|
// Only prefetch if we're past the first stage (user is actively moderating)
|
||||||
|
if (currentStage.value > 0) {
|
||||||
|
debouncedPrefetch() // Use debounced version to prevent spam
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('keydown', handleKeybinds)
|
window.addEventListener('keydown', handleKeybinds)
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
@@ -1099,6 +1217,10 @@ onUnmounted(() => {
|
|||||||
clearInterval(lockCheckInterval.value)
|
clearInterval(lockCheckInterval.value)
|
||||||
}
|
}
|
||||||
clearLockCountdown()
|
clearLockCountdown()
|
||||||
|
|
||||||
|
// Clear prefetch state to prevent memory leaks
|
||||||
|
prefetchQueue.value = []
|
||||||
|
isPrefetching.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
function initializeAllStages() {
|
function initializeAllStages() {
|
||||||
@@ -1113,9 +1235,10 @@ function initializeCurrentStage() {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
currentStage,
|
currentStage,
|
||||||
(newIndex) => {
|
(newIndex, oldIndex) => {
|
||||||
const stage = checklist[newIndex]
|
const stage = checklist[newIndex]
|
||||||
if (stage?.navigate) {
|
// only navigate when the stage actually changes (not on initial mount/remount)
|
||||||
|
if (oldIndex !== undefined && newIndex !== oldIndex && stage?.navigate) {
|
||||||
router.push(`/${projectV2.value.project_type}/${projectV2.value.slug}${stage.navigate}`)
|
router.push(`/${projectV2.value.project_type}/${projectV2.value.slug}${stage.navigate}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1774,22 +1897,29 @@ async function endChecklist(status?: string) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use prefetched data if available for instant navigation
|
// Use prefetched data if available for instant navigation
|
||||||
if (!navigateToNextUnlockedProject()) {
|
if (!(await navigateToNextUnlockedProject())) {
|
||||||
// Fallback: find next unlocked project with lock checking
|
// Fallback: batch check remaining projects with metadata
|
||||||
|
const remainingIds: string[] = []
|
||||||
|
const currentProjectId = projectV2.value.id
|
||||||
|
const queueItems = moderationStore.currentQueue.items
|
||||||
|
|
||||||
|
// Build list of remaining projects, excluding current
|
||||||
|
for (const id of queueItems) {
|
||||||
|
if (id === currentProjectId) continue
|
||||||
|
if (remainingIds.length >= MAX_SKIP_ATTEMPTS) break
|
||||||
|
remainingIds.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
let foundUnlocked = false
|
let foundUnlocked = false
|
||||||
let skippedCount = 0
|
if (remainingIds.length > 0) {
|
||||||
let attempts = 0
|
const results = await batchCheckLocksWithMetadata(remainingIds)
|
||||||
|
|
||||||
while (moderationStore.hasItems && attempts < MAX_SKIP_ATTEMPTS) {
|
let skippedCount = 0
|
||||||
attempts++
|
for (const id of remainingIds) {
|
||||||
const nextId = moderationStore.getCurrentProjectId()
|
const result = results.get(id)
|
||||||
if (!nextId) break
|
// Treat as unlocked if: not locked, OR expired, OR it's our own lock
|
||||||
|
if (!result?.locked || result?.expired || result?.isOwnLock) {
|
||||||
try {
|
// Found unlocked - skip the locked ones before it
|
||||||
const lockStatus = await moderationStore.checkLock(nextId)
|
|
||||||
|
|
||||||
if (!lockStatus.locked || lockStatus.expired) {
|
|
||||||
// Found an unlocked project
|
|
||||||
if (skippedCount > 0) {
|
if (skippedCount > 0) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Skipped locked projects',
|
title: 'Skipped locked projects',
|
||||||
@@ -1797,49 +1927,41 @@ async function endChecklist(status?: string) {
|
|||||||
type: 'info',
|
type: 'info',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
navigateTo({
|
|
||||||
name: 'type-id',
|
// Navigate to canonical URL if we have metadata
|
||||||
params: {
|
if (result?.slug && result?.projectType) {
|
||||||
type: 'project',
|
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
|
||||||
id: nextId,
|
navigateTo({
|
||||||
},
|
path: `/${urlType}/${result.slug}`,
|
||||||
state: {
|
state: { showChecklist: true },
|
||||||
showChecklist: true,
|
})
|
||||||
},
|
} else {
|
||||||
})
|
// Fallback: use project ID
|
||||||
|
navigateTo({
|
||||||
|
name: 'type-id',
|
||||||
|
params: { type: 'project', id },
|
||||||
|
state: { showChecklist: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
foundUnlocked = true
|
foundUnlocked = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
moderationStore.completeCurrentProject(id, 'skipped')
|
||||||
// Project is locked, skip it
|
|
||||||
moderationStore.completeCurrentProject(nextId, 'skipped')
|
|
||||||
skippedCount++
|
skippedCount++
|
||||||
} catch {
|
|
||||||
// On error, try to navigate anyway
|
|
||||||
navigateTo({
|
|
||||||
name: 'type-id',
|
|
||||||
params: {
|
|
||||||
type: 'project',
|
|
||||||
id: nextId,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
showChecklist: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
foundUnlocked = true
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If no unlocked projects found, go back to moderation queue
|
// If no unlocked projects found, show notification
|
||||||
if (!foundUnlocked) {
|
if (!foundUnlocked && skippedCount > 0) {
|
||||||
if (skippedCount > 0) {
|
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'All projects locked',
|
title: 'All projects locked',
|
||||||
text: 'All remaining projects are currently being moderated by others.',
|
text: 'All remaining projects are currently being moderated by others.',
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no unlocked projects found, go back to moderation queue
|
||||||
|
if (!foundUnlocked) {
|
||||||
await navigateTo({
|
await navigateTo({
|
||||||
name: 'moderation',
|
name: 'moderation',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user