fix: moderation locking fixes (#5843)
* fix: moderation locking fixes * fix: lint * wip: override always available * fix: newmodal base z * fix: cargo fmt
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
<template>
|
||||
<KeybindsModal ref="keybindsModal" />
|
||||
<ConfirmModal
|
||||
v-if="lockStatus?.locked && !lockStatus?.isOwnLock"
|
||||
ref="takeOverModal"
|
||||
title="Override moderation lock"
|
||||
description="Are you sure you want to override?"
|
||||
:has-to-type="false"
|
||||
:markdown="false"
|
||||
proceed-label="Take over"
|
||||
@proceed="confirmTakeOverOverride"
|
||||
/>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
|
||||
@@ -60,7 +70,7 @@
|
||||
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="lockStatus.expired" @click="retryAcquireLock">
|
||||
<ButtonStyled @click="openTakeOverModal">
|
||||
<button>
|
||||
<LockIcon aria-hidden="true" />
|
||||
Take over
|
||||
@@ -384,13 +394,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template
|
||||
v-for="opt in stageOptions.filter(
|
||||
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
|
||||
)"
|
||||
#[opt.id]
|
||||
:key="opt.id"
|
||||
>
|
||||
<template v-for="opt in stageOptionsForSlots" #[opt.id] :key="opt.id">
|
||||
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
|
||||
{{ opt.text }}
|
||||
</template>
|
||||
@@ -466,6 +470,7 @@ import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Collapsible,
|
||||
ConfirmModal,
|
||||
DropdownSelect,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
@@ -486,6 +491,7 @@ import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import type { LockAcquireResponse } from '~/store/moderation.ts'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
|
||||
import KeybindsModal from './ChecklistKeybindsModal.vue'
|
||||
@@ -496,6 +502,7 @@ const { addNotification } = notifications
|
||||
const debug = useDebugLogger('ModerationChecklist')
|
||||
|
||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>()
|
||||
const takeOverModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
|
||||
const props = defineProps<{
|
||||
collapsed: boolean
|
||||
@@ -511,6 +518,7 @@ const lockStatus = ref<{
|
||||
locked: boolean
|
||||
lockedBy?: { id: string; username: string; avatar_url?: string }
|
||||
lockedAt?: Date
|
||||
expiresAt?: Date
|
||||
expired?: boolean
|
||||
isOwnLock: boolean
|
||||
} | null>(null)
|
||||
@@ -536,13 +544,15 @@ 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
|
||||
|
||||
function handleVisibilityChange() {
|
||||
async function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
|
||||
// Immediately refresh the lock when returning to the tab
|
||||
// This handles cases where the heartbeat was throttled while backgrounded
|
||||
moderationStore.refreshLock()
|
||||
const refreshResult = await moderationStore.refreshLock()
|
||||
if (!refreshResult.success) {
|
||||
handleLockLost(refreshResult)
|
||||
return
|
||||
}
|
||||
// Refresh prefetch queue when tab becomes visible (not debounced)
|
||||
maintainPrefetchQueue()
|
||||
}
|
||||
@@ -555,7 +565,9 @@ function updateLockCountdown() {
|
||||
}
|
||||
|
||||
const lockedAt = new Date(lockStatus.value.lockedAt)
|
||||
const expiresAt = new Date(lockedAt.getTime() + LOCK_EXPIRY_MINUTES * 60 * 1000)
|
||||
const expiresAt = lockStatus.value.expiresAt
|
||||
? new Date(lockStatus.value.expiresAt)
|
||||
: new Date(lockedAt.getTime() + 15 * 60 * 1000)
|
||||
const now = new Date()
|
||||
const remainingMs = expiresAt.getTime() - now.getTime()
|
||||
|
||||
@@ -582,12 +594,47 @@ function clearLockCountdown() {
|
||||
function startLockHeartbeat() {
|
||||
lockCheckInterval.value = setInterval(
|
||||
async () => {
|
||||
await moderationStore.refreshLock()
|
||||
const result = await moderationStore.refreshLock()
|
||||
if (!result.success) {
|
||||
handleLockLost(result)
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
function handleLockLost(result: LockAcquireResponse) {
|
||||
clearInterval(lockCheckInterval.value!)
|
||||
lockCheckInterval.value = null
|
||||
clearLockCountdown()
|
||||
|
||||
lockStatus.value = {
|
||||
locked: result.locked_by != null,
|
||||
lockedBy: result.locked_by,
|
||||
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
|
||||
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
|
||||
expired: result.expired,
|
||||
isOwnLock: false,
|
||||
}
|
||||
lockError.value = false
|
||||
|
||||
if (result.locked_by) {
|
||||
addNotification({
|
||||
title: 'Lock taken over',
|
||||
text: `@${result.locked_by.username} is now moderating this project.`,
|
||||
type: 'warning',
|
||||
})
|
||||
updateLockCountdown()
|
||||
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Moderation lock lost',
|
||||
text: 'Your lock on this project has expired. Acquire the lock again to continue.',
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleLockAcquired() {
|
||||
lockStatus.value = { locked: false, isOwnLock: true }
|
||||
lockError.value = false
|
||||
@@ -713,28 +760,36 @@ async function handleExit() {
|
||||
emit('exit')
|
||||
}
|
||||
|
||||
async function retryAcquireLock() {
|
||||
function openTakeOverModal() {
|
||||
takeOverModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmTakeOverOverride() {
|
||||
const projectId = projectV2.value?.id
|
||||
if (!projectId) {
|
||||
console.warn('[retryAcquireLock] No project ID available')
|
||||
console.warn('[confirmTakeOverOverride] No project ID available')
|
||||
return
|
||||
}
|
||||
const result = await moderationStore.acquireLock(projectId)
|
||||
const result = await moderationStore.overrideLock(projectId)
|
||||
|
||||
if (result.success) {
|
||||
addNotification({
|
||||
title: 'Moderation lock overridden',
|
||||
text: 'You are now moderating this project.',
|
||||
type: 'success',
|
||||
})
|
||||
handleLockAcquired()
|
||||
} else if (result.locked_by) {
|
||||
// Still locked by another moderator, update status
|
||||
lockStatus.value = {
|
||||
locked: true,
|
||||
lockedBy: result.locked_by,
|
||||
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
|
||||
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
|
||||
expired: result.expired,
|
||||
isOwnLock: false,
|
||||
}
|
||||
lockError.value = false
|
||||
|
||||
// Restart countdown timer
|
||||
updateLockCountdown()
|
||||
if (!lockCountdownInterval.value) {
|
||||
lockCountdownInterval.value = setInterval(updateLockCountdown, 1000)
|
||||
@@ -764,25 +819,21 @@ 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([
|
||||
const [lockResponse, 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,
|
||||
locked: lockResponse.locked,
|
||||
expired: lockResponse.expired,
|
||||
isOwnLock: lockResponse.is_own_lock,
|
||||
slug: (projectData as { slug?: string })?.slug,
|
||||
projectType: (projectData as { project_type?: string })?.project_type,
|
||||
}
|
||||
@@ -1187,6 +1238,7 @@ watch(currentStage, () => {
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('keydown', handleKeybinds)
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
notifications.setNotificationLocation('left')
|
||||
|
||||
@@ -1220,6 +1272,7 @@ onMounted(async () => {
|
||||
locked: true,
|
||||
lockedBy: result.locked_by,
|
||||
lockedAt: result.locked_at ? new Date(result.locked_at) : undefined,
|
||||
expiresAt: result.expires_at ? new Date(result.expires_at) : undefined,
|
||||
expired: result.expired,
|
||||
isOwnLock: false,
|
||||
}
|
||||
@@ -1233,7 +1286,25 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function handleBeforeUnload() {
|
||||
const projectId = projectV2.value?.id
|
||||
if (!projectId || !lockStatus.value?.isOwnLock) return
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const base = config.public.apiBaseUrl.replace(/\/v\d\/?$/, '/_internal/')
|
||||
const token = (auth as unknown as { value?: { token?: string } }).value?.token
|
||||
if (!token) return
|
||||
|
||||
// sendBeacon is POST-only and cannot set Authorization. The internal POST /release endpoint
|
||||
// accepts the same token as text/plain (matches useBaseFetch's Authorization value).
|
||||
void navigator.sendBeacon(
|
||||
`${base}moderation/lock/${projectId}/release`,
|
||||
new Blob([token], { type: 'text/plain' }),
|
||||
)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
window.removeEventListener('keydown', handleKeybinds)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
notifications.setNotificationLocation('right')
|
||||
@@ -1243,6 +1314,12 @@ onUnmounted(() => {
|
||||
}
|
||||
clearLockCountdown()
|
||||
|
||||
// Release lock if we own it (navigation away without explicit exit)
|
||||
const projectId = projectV2.value?.id
|
||||
if (projectId && lockStatus.value?.isOwnLock) {
|
||||
moderationStore.releaseLock(projectId)
|
||||
}
|
||||
|
||||
// Clear prefetch state to prevent memory leaks
|
||||
prefetchQueue.value = []
|
||||
isPrefetching.value = false
|
||||
@@ -1891,9 +1968,10 @@ async function sendMessage(status: ProjectStatus) {
|
||||
|
||||
const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed')
|
||||
|
||||
moderationStore.releaseLock(projectId).catch((err) => {
|
||||
console.warn('Failed to release lock:', err)
|
||||
})
|
||||
await Promise.race([
|
||||
moderationStore.releaseLock(projectId),
|
||||
new Promise((r) => setTimeout(r, 2000)),
|
||||
])
|
||||
|
||||
// Set both states together - hasNextProject MUST be set before done
|
||||
// to avoid the race condition where done=true renders with hasNextProject=false
|
||||
@@ -2021,9 +2099,10 @@ async function skipCurrentProject() {
|
||||
return
|
||||
}
|
||||
|
||||
moderationStore.releaseLock(projectId).catch((err) => {
|
||||
console.warn('Failed to release lock:', err)
|
||||
})
|
||||
await Promise.race([
|
||||
moderationStore.releaseLock(projectId),
|
||||
new Promise((r) => setTimeout(r, 2000)),
|
||||
])
|
||||
|
||||
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
|
||||
|
||||
@@ -2089,6 +2168,12 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string }
|
||||
|
||||
const stageOptionsForSlots = computed(() =>
|
||||
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPinia, defineStore } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface ModerationQueue {
|
||||
items: string[]
|
||||
@@ -17,15 +17,19 @@ export interface LockedByUser {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -42,71 +46,68 @@ function createEmptyQueue(): ModerationQueue {
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue
|
||||
}
|
||||
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
export const useModerationStore = defineStore(
|
||||
'moderation',
|
||||
() => {
|
||||
const currentQueue = ref<ModerationQueue>(createEmptyQueue())
|
||||
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
|
||||
const isQueueMode = ref(false)
|
||||
|
||||
export const useModerationStore = defineStore('moderation', {
|
||||
state: () => ({
|
||||
currentQueue: createEmptyQueue(),
|
||||
currentLock: null as { projectId: string; lockedAt: Date } | null,
|
||||
isQueueMode: 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
|
||||
})
|
||||
|
||||
getters: {
|
||||
queueLength: (state) => state.currentQueue.items.length,
|
||||
hasItems: (state) => state.currentQueue.items.length > 0,
|
||||
progress: (state) => {
|
||||
if (state.currentQueue.total === 0) return 0
|
||||
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setQueue(projectIDs: string[]) {
|
||||
this.isQueueMode = true
|
||||
this.currentQueue = {
|
||||
function setQueue(projectIDs: string[]) {
|
||||
isQueueMode.value = true
|
||||
currentQueue.value = {
|
||||
items: [...projectIDs],
|
||||
total: projectIDs.length,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
setSingleProject(projectId: string) {
|
||||
this.isQueueMode = false
|
||||
this.currentQueue = {
|
||||
function setSingleProject(projectId: string) {
|
||||
isQueueMode.value = false
|
||||
currentQueue.value = {
|
||||
items: [projectId],
|
||||
total: 1,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
completeCurrentProject(projectId: string, status: 'completed' | 'skipped' = 'completed') {
|
||||
function completeCurrentProject(
|
||||
projectId: string,
|
||||
status: 'completed' | 'skipped' = 'completed',
|
||||
) {
|
||||
if (status === 'completed') {
|
||||
this.currentQueue.completed++
|
||||
currentQueue.value.completed++
|
||||
} else {
|
||||
this.currentQueue.skipped++
|
||||
currentQueue.value.skipped++
|
||||
}
|
||||
|
||||
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId)
|
||||
this.currentQueue.lastUpdated = new Date()
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id: string) => id !== projectId)
|
||||
currentQueue.value.lastUpdated = new Date()
|
||||
|
||||
return this.currentQueue.items.length > 0
|
||||
},
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
getCurrentProjectId(): string | null {
|
||||
return this.currentQueue.items[0] || null
|
||||
},
|
||||
function getCurrentProjectId(): string | null {
|
||||
return currentQueue.value.items[0] || null
|
||||
}
|
||||
|
||||
resetQueue() {
|
||||
this.isQueueMode = false
|
||||
this.currentQueue = createEmptyQueue()
|
||||
},
|
||||
function resetQueue() {
|
||||
isQueueMode.value = false
|
||||
currentQueue.value = createEmptyQueue()
|
||||
}
|
||||
|
||||
async acquireLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
try {
|
||||
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
|
||||
method: 'POST',
|
||||
@@ -114,35 +115,57 @@ export const useModerationStore = defineStore('moderation', {
|
||||
})) as LockAcquireResponse
|
||||
|
||||
if (response.success) {
|
||||
this.currentLock = { projectId, lockedAt: new Date() }
|
||||
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 a failed response so the UI can handle it gracefully
|
||||
return { success: false }
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async releaseLock(projectId: string): Promise<boolean> {
|
||||
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 (this.currentLock?.projectId === projectId) {
|
||||
this.currentLock = null
|
||||
if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async checkLock(projectId: string): Promise<LockStatusResponse> {
|
||||
async function checkLock(projectId: string): Promise<LockStatusResponse> {
|
||||
try {
|
||||
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
|
||||
method: 'GET',
|
||||
@@ -152,34 +175,58 @@ export const useModerationStore = defineStore('moderation', {
|
||||
} catch (error) {
|
||||
console.error('Failed to check moderation lock:', error)
|
||||
// Return unlocked status on error so moderation can proceed
|
||||
return { locked: false }
|
||||
return { locked: false, is_own_lock: false }
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async refreshLock(): Promise<boolean> {
|
||||
if (!this.currentLock) return false
|
||||
async function refreshLock(): Promise<LockAcquireResponse> {
|
||||
if (!currentLock.value) return { success: false, is_own_lock: false }
|
||||
|
||||
try {
|
||||
const response = await this.acquireLock(this.currentLock.projectId)
|
||||
return response.success
|
||||
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)
|
||||
return false
|
||||
currentLock.value = null
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
persist: {
|
||||
key: 'moderation-store',
|
||||
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
|
||||
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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user