370 lines
9.9 KiB
Vue
370 lines
9.9 KiB
Vue
<script setup lang="ts">
|
|
import type { ConfigField } from '~/composables/moderation/useEditConfigs'
|
|
import type { Review, ReviewState, ReviewStatePayload } from '~/types'
|
|
import type { Change } from 'diff'
|
|
|
|
import { diffWordsWithSpace } from 'diff'
|
|
import { useRouter } from 'vue-router'
|
|
import { computed, ref } from 'vue'
|
|
import { useStore } from '~/store'
|
|
|
|
import axios from 'axios'
|
|
|
|
import useEditConfigs from '~/composables/moderation/useEditConfigs'
|
|
import useErrorHandler from '~/composables/useErrorHandler'
|
|
|
|
interface Events {
|
|
(e: 'approved', isApproved: boolean): void
|
|
(e: 'deleted'): void
|
|
}
|
|
|
|
interface Props {
|
|
obj: Review
|
|
currentState?: ReviewState
|
|
}
|
|
|
|
const emit = defineEmits<Events>()
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
currentState: () => ({})
|
|
})
|
|
|
|
const configs = useEditConfigs()
|
|
const router = useRouter()
|
|
const store = useStore()
|
|
|
|
const canApprove = computed(() => props.obj.is_applied || store.state.auth.authenticated
|
|
? false
|
|
: store.state.auth.availablePermissions.library
|
|
)
|
|
|
|
const canDelete = computed(() => {
|
|
if (props.obj.is_applied || props.obj.is_approved) return false
|
|
if (!store.state.auth.authenticated) return false
|
|
|
|
return props.obj.created_by.full_username === store.state.auth.fullUsername
|
|
|| store.state.auth.availablePermissions.library
|
|
})
|
|
|
|
const previousState = computed(() => props.obj.is_applied
|
|
// mutation was applied, we use the previous state that is stored
|
|
// on the mutation itself
|
|
? props.obj.previous_state
|
|
// mutation is not applied yet, so we use the current state that was
|
|
// passed to the component, if any
|
|
: props.currentState
|
|
)
|
|
|
|
const detailUrl = computed(() => {
|
|
if (!props.obj.target) return ''
|
|
|
|
const name = props.obj.target.type === 'track'
|
|
? 'library.tracks.edit.detail'
|
|
: props.obj.target.type === 'album'
|
|
? 'library.albums.edit.detail'
|
|
: props.obj.target.type === 'artist'
|
|
? 'library.artists.edit.detail'
|
|
: undefined
|
|
|
|
return router.resolve({
|
|
name,
|
|
params: {
|
|
id: props.obj.target.id,
|
|
editId: props.obj.uuid
|
|
}
|
|
}).href
|
|
})
|
|
|
|
const updatedFields = computed(() => {
|
|
if (!props.obj?.target) return []
|
|
|
|
const payload = props.obj.payload
|
|
const fields = Object.keys(payload)
|
|
|
|
const state = previousState.value
|
|
|
|
return fields.map((id) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const config = configs[props.obj.target!.type].fields.find((field) => id === field.id)
|
|
const getValueRepr = config?.getValueRepr ?? (v => v)
|
|
|
|
const result = {
|
|
id,
|
|
config,
|
|
new: payload[id],
|
|
newRepr: getValueRepr(payload[id]) ?? '',
|
|
old: undefined,
|
|
oldRepr: '',
|
|
diff: []
|
|
} as {
|
|
id: string
|
|
config: ConfigField
|
|
old?: ReviewStatePayload
|
|
new: ReviewStatePayload
|
|
oldRepr: string
|
|
newRepr: string
|
|
diff: Change[]
|
|
}
|
|
|
|
if (state?.[id]) {
|
|
const oldState = state[id]
|
|
result.old = oldState
|
|
result.oldRepr = getValueRepr(('value' in oldState && oldState.value) ?? oldState) ?? ''
|
|
|
|
// we compute the diffs between the old and new values
|
|
result.diff = diffWordsWithSpace(result.oldRepr, result.newRepr)
|
|
}
|
|
|
|
return result
|
|
})
|
|
})
|
|
|
|
const isLoading = ref(false)
|
|
const remove = async () => {
|
|
isLoading.value = true
|
|
|
|
try {
|
|
await axios.delete(`mutations/${props.obj.uuid}/`)
|
|
emit('deleted')
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoading.value = false
|
|
}
|
|
|
|
const approve = async (approved: boolean) => {
|
|
const url = approved
|
|
? `mutations/${props.obj.uuid}/approve/`
|
|
: `mutations/${props.obj.uuid}/reject/`
|
|
|
|
isLoading.value = true
|
|
|
|
try {
|
|
await axios.post(url)
|
|
emit('approved', approved)
|
|
store.commit('ui/incrementNotifications', { count: -1, type: 'pendingReviewEdits' })
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoading.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="ui fluid card">
|
|
<div class="content">
|
|
<h4 class="header">
|
|
<router-link :to="detailUrl">
|
|
<translate
|
|
|
|
:translate-params="{id: obj.uuid.substring(0, 8)}"
|
|
>
|
|
Modification %{ id }
|
|
</translate>
|
|
</router-link>
|
|
</h4>
|
|
<div class="meta">
|
|
<router-link
|
|
v-if="obj.target && obj.target.type === 'track'"
|
|
:to="{name: 'library.tracks.detail', params: {id: obj.target.id }}"
|
|
>
|
|
<i class="music icon" />
|
|
<translate
|
|
|
|
:translate-params="{id: obj.target.id, name: obj.target.repr}"
|
|
>
|
|
Track #%{ id } - %{ name }
|
|
</translate>
|
|
</router-link>
|
|
<br>
|
|
<human-date
|
|
:date="obj.creation_date"
|
|
:icon="true"
|
|
/>
|
|
|
|
<span class="right floated">
|
|
<span v-if="obj.is_approved && obj.is_applied">
|
|
<i class="success check icon" />
|
|
<translate >Approved and applied</translate>
|
|
</span>
|
|
<span v-else-if="obj.is_approved">
|
|
<i class="success check icon" />
|
|
<translate >Approved</translate>
|
|
</span>
|
|
<span v-else-if="obj.is_approved === null">
|
|
<i class="warning hourglass icon" />
|
|
<translate >Pending review</translate>
|
|
</span>
|
|
<span v-else-if="obj.is_approved === false">
|
|
<i class="danger x icon" />
|
|
<translate >Rejected</translate>
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="obj.summary"
|
|
class="content"
|
|
>
|
|
{{ obj.summary }}
|
|
</div>
|
|
<div class="content">
|
|
<table
|
|
v-if="obj.type === 'update'"
|
|
class="ui celled very basic fixed stacking table"
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th>
|
|
<translate >
|
|
Field
|
|
</translate>
|
|
</th>
|
|
<th>
|
|
<translate >
|
|
Old value
|
|
</translate>
|
|
</th>
|
|
<th>
|
|
<translate >
|
|
New value
|
|
</translate>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="field in updatedFields"
|
|
:key="field.id"
|
|
>
|
|
<td>{{ field.id }}</td>
|
|
|
|
<td v-if="field.diff">
|
|
<template v-if="field.config?.type === 'attachment' && field.oldRepr">
|
|
<img
|
|
class="ui image"
|
|
alt=""
|
|
:src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.oldRepr}/proxy?next=medium_square_crop`)"
|
|
>
|
|
</template>
|
|
<template v-else>
|
|
<span
|
|
v-for="(part, key) in field.diff.filter(p => !p.added)"
|
|
:key="key"
|
|
:class="['diff', {removed: part.removed}]"
|
|
>
|
|
{{ part.value }}
|
|
</span>
|
|
</template>
|
|
</td>
|
|
<td v-else>
|
|
<translate >
|
|
N/A
|
|
</translate>
|
|
</td>
|
|
|
|
<td
|
|
v-if="field.diff"
|
|
:title="field.newRepr"
|
|
>
|
|
<template v-if="field.config?.type === 'attachment' && field.newRepr">
|
|
<img
|
|
class="ui image"
|
|
alt=""
|
|
:src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
|
|
>
|
|
</template>
|
|
<template v-else>
|
|
<span
|
|
v-for="(part, key) in field.diff.filter(p => !p.removed)"
|
|
:key="key"
|
|
:class="['diff', {added: part.added}]"
|
|
>
|
|
{{ part.value }}
|
|
</span>
|
|
</template>
|
|
</td>
|
|
<td
|
|
v-else
|
|
:title="field.newRepr"
|
|
>
|
|
<template v-if="field.config?.type === 'attachment' && field.newRepr">
|
|
<img
|
|
class="ui image"
|
|
alt=""
|
|
:src="$store.getters['instance/absoluteUrl'](`api/v1/attachments/${field.newRepr}/proxy?next=medium_square_crop`)"
|
|
>
|
|
</template>
|
|
<template v-else>
|
|
{{ field.newRepr }}
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div
|
|
v-if="obj.created_by"
|
|
class="extra content"
|
|
>
|
|
<actor-link :actor="obj.created_by" />
|
|
</div>
|
|
<div
|
|
v-if="canDelete || canApprove"
|
|
class="ui bottom attached buttons"
|
|
>
|
|
<button
|
|
v-if="canApprove && obj.is_approved !== true"
|
|
:class="['ui', {loading: isLoading}, 'success', 'basic', 'button']"
|
|
@click="approve(true)"
|
|
>
|
|
<translate >
|
|
Approve
|
|
</translate>
|
|
</button>
|
|
<button
|
|
v-if="canApprove && obj.is_approved === null"
|
|
:class="['ui', {loading: isLoading}, 'warning', 'basic', 'button']"
|
|
@click="approve(false)"
|
|
>
|
|
<translate >
|
|
Reject
|
|
</translate>
|
|
</button>
|
|
<dangerous-button
|
|
v-if="canDelete"
|
|
:class="['ui', {loading: isLoading}, 'basic danger button']"
|
|
:action="remove"
|
|
>
|
|
<translate >
|
|
Delete
|
|
</translate>
|
|
<template #modal-header>
|
|
<p>
|
|
<translate >
|
|
Delete this suggestion?
|
|
</translate>
|
|
</p>
|
|
</template>
|
|
<template #modal-content>
|
|
<div>
|
|
<p>
|
|
<translate >
|
|
The suggestion will be completely removed, this action is irreversible.
|
|
</translate>
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<template #modal-confirm>
|
|
<p>
|
|
<translate >
|
|
Delete
|
|
</translate>
|
|
</p>
|
|
</template>
|
|
</dangerous-button>
|
|
</div>
|
|
</div>
|
|
</template>
|