diff --git a/front/src/components/common/ExpandableDiv.vue b/front/src/components/common/ExpandableDiv.vue new file mode 100644 index 0000000000000000000000000000000000000000..653286ad21019afcb0a17e6264b970ef730b12cb --- /dev/null +++ b/front/src/components/common/ExpandableDiv.vue @@ -0,0 +1,32 @@ +<template> + <div class="expandable-wrapper"> + <div :class="['expandable-content', {expandable: truncated.length < content.length}, {expanded: isExpanded}]"> + <slot>{{ content }}</slot> + </div> + <a v-if="truncated.length < content.length" role="button" @click.prevent="isExpanded = !isExpanded"> + <br> + <translate v-if="isExpanded" key="1" translate-context="*/*/Button,Label">Show less</translate> + <translate v-else key="2" translate-context="*/*/Button,Label">Show more</translate> + </a> + </div> +</template> +<script> +import sanitize from "@/sanitize" + +export default { + props: { + content: {type: String, required: true}, + length: {type: Number, default: 150, required: false}, + }, + data () { + return { + isExpanded: false, + } + }, + computed: { + truncated () { + return this.content.substring(0, this.length) + } + } +} +</script> diff --git a/front/src/components/globals.js b/front/src/components/globals.js index 711b227ae956a8943c8085087b0215029a50499d..4131c5d15980b70ed4bf1f56759e7cd184725900 100644 --- a/front/src/components/globals.js +++ b/front/src/components/globals.js @@ -48,4 +48,7 @@ import EmptyState from '@/components/common/EmptyState' Vue.component('empty-state', EmptyState) +import ExpandableDiv from '@/components/common/ExpandableDiv' + +Vue.component('expandable-div', ExpandableDiv) export default {} diff --git a/front/src/components/library/EditCard.vue b/front/src/components/library/EditCard.vue index bb8676ceebd7f5df507c494e6dd6882190752042..fc5efea55548fb41e3e04c1a0000bb449657e057 100644 --- a/front/src/components/library/EditCard.vue +++ b/front/src/components/library/EditCard.vue @@ -158,6 +158,9 @@ export default { }, updatedFields () { + if (!this.obj.target) { + return [] + } let payload = this.obj.payload let previousState = this.previousState let fields = Object.keys(payload) diff --git a/front/src/components/manage/moderation/ReportCard.vue b/front/src/components/manage/moderation/ReportCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..bbf475b887564fc83a07739befaa176743731ea2 --- /dev/null +++ b/front/src/components/manage/moderation/ReportCard.vue @@ -0,0 +1,281 @@ +<template> + <div class="ui fluid report card"> + <div class="content"> + <div class="header"> + <router-link :to="{name: 'manage.moderation.reports.detail', params: {id: obj.uuid}}"> + <translate translate-context="Content/Moderation/Card/Short" :translate-params="{id: obj.uuid.substring(0, 8)}">Report %{ id }</translate> + </router-link> + </div> + <div class="content"> + <div class="ui stackable two column grid"> + <div class="column"> + <table class="ui very basic unstackable table"> + <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/*">Submitted by</translate> + </td> + <td> + <div v-if="obj.submitter"> + <actor-link :actor="obj.submitter" /> + </div> + <div v-else="obj.submitter_email"> + {{ obj.submitter_email }} + </div> + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*">Category</translate> + </td> + <td> + <i class="tag icon"></i> + {{ obj.type }} + </td> + </tr> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Creation date</translate> + </td> + <td> + <human-date :date="obj.creation_date" :icon="true"></human-date> + </td> + </tr> + </tbody> + </table> + </div> + <div class="column"> + <table class="ui very basic unstackable table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*">Status</translate> + </td> + <td v-if="obj.is_handled"> + <span v-if="obj.is_handled"> + <i class="green check icon"></i> + <translate translate-context="Content/*/*/Short">Resolved</translate> + </span> + </td> + <td v-else> + <i class="red x icon"></i> + <translate translate-context="Content/*/*/Short">Unresolved</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/Moderation/*">Assignee</translate> + </td> + <td> + <div v-if="obj.assigned_to"> + <actor-link :actor="obj.assigned_to" /> + </div> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </tr> + <tr> + <td> + <translate translate-context="Content/*/*/Noun">Resolution date</translate> + </td> + <td> + <human-date v-if="obj.handled_date" :date="obj.handled_date" :icon="true"></human-date> + <translate v-else translate-context="*/*/*">N/A</translate> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + <div class="main content"> + <div class="ui stackable two column grid"> + <div class="column"> + <h3> + <translate translate-context="Content/*/*/Short">Message</translate> + </h3> + <expandable-div v-if="obj.summary" class="summary" :content="obj.summary"> + <div v-html="markdown.makeHtml(obj.summary)"></div> + </expandable-div> + </div> + <aside class="column"> + <h3> + <translate translate-context="Content/*/*/Short">Reported object</translate> + </h3> + <router-link class="ui basic button" v-if="configs[obj.target.type].urls.getAdminDetail" :to="configs[obj.target.type].urls.getAdminDetail(obj.target_state)"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + <table class="ui very basic unstackable table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*">Type</translate> + </td> + <td> + <i :class="[configs[obj.target.type].icon, 'icon']"></i> + <translate translate-context="*/*/*">{{ configs[obj.target.type].label }}</translate> + </td> + </tr> + <tr v-if="obj.target_state.is_local"> + <td> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </td> + <td> + <i class="home icon"></i> + <translate translate-context="Content/Moderation/*/Short, Noun">Local</translate> + </td> + </tr> + <tr v-else-if="obj.target_state.domain"> + <td> + <router-link :to="{name: 'manage.moderation.domains.detail', params: {id: obj.target_state.domain }}"> + <translate translate-context="Content/Moderation/*/Noun">Domain</translate> + </router-link> + </td> + <td> + {{ obj.target_state.domain }} + </td> + </tr> + <tr v-for="field in targetFields" :key="field.id"> + <td>{{ field.label }}</td> + <td>{{ field.repr }}</td> + </tr> + </tbody> + </table> + </aside> + </div> + </div> + <div class="ui bottom attached buttons"> + <button + v-if="obj.is_handled === false" + @click="resolve(true)" + :class="['ui', {loading: isLoading}, 'green', 'basic', 'button']"> + <translate translate-context="Content/*/Button.Label/Verb">Resolve</translate> + </button> + <button + v-if="obj.is_handled === true" + @click="resolve(false)" + :class="['ui', {loading: isLoading}, 'yellow', 'basic', 'button']"> + <translate translate-context="Content/*/Button.Label">Unresolve</translate> + </button> + <dangerous-button + :class="['ui', {loading: isLoading}, 'basic button']" + :action="remove"> + <translate translate-context="*/*/*/Verb">Delete</translate> + <p slot="modal-header"><translate translate-context="Popup/*/Title">Delete this report?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Popup/*/Paragraph">The report will be completely removed, this action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import { diffWordsWithSpace } from 'diff' + +import entities from '@/entities' +import showdown from 'showdown' + +function castValue (value) { + if (value === null || value === undefined) { + return '' + } + return String(value) +} + +export default { + props: { + obj: {required: true}, + currentState: {required: false} + }, + data () { + return { + markdown: new showdown.Converter(), + isLoading: false, + } + }, + computed: { + configs: entities.getConfigs, + previousState () { + if (this.obj.is_applied) { + // mutation was applied, we use the previous state that is stored + // on the mutation itself + return this.obj.previous_state + } + // mutation is not applied yet, so we use the current state that was + // passed to the component, if any + return this.currentState + }, + detailUrl () { + if (!this.obj.target) { + return '' + } + let namespace + let id = this.obj.target.id + if (this.obj.target.type === 'track') { + namespace = 'library.tracks.edit.detail' + } + if (this.obj.target.type === 'album') { + namespace = 'library.albums.edit.detail' + } + if (this.obj.target.type === 'artist') { + namespace = 'library.artists.edit.detail' + } + return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href + }, + + targetFields () { + let payload = this.obj.target_state + let fields = this.configs[this.obj.target.type].moderatedFields + let self = this + return fields.map((fieldConfig) => { + let dummyRepr = (v) => { return v } + let getValueRepr = fieldConfig.getValueRepr || dummyRepr + let d = { + id: fieldConfig.id, + label: fieldConfig.label, + value: payload[fieldConfig.id], + repr: castValue(getValueRepr(payload[fieldConfig.id])), + } + return d + }) + } + }, + methods: { + remove () { + let self = this + this.isLoading = true + axios.delete(`manage/moderation/reports/${this.obj.uuid}/`).then((response) => { + self.$emit('deleted') + self.isLoading = false + if (!self.obj.is_handled) { + self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewReports'}) + } + }, error => { + self.isLoading = false + }) + }, + resolve (v) { + let url = `manage/moderation/reports/${this.obj.uuid}/` + let self = this + this.isLoading = true + axios.patch(url, {is_handled: v}).then((response) => { + self.$emit('handled', v) + self.isLoading = false + let increment + if (v) { + increment = -1 + } else { + increment = 1 + } + self.$store.commit('ui/incrementNotifications', {count: increment, type: 'pendingReviewReports'}) + }, error => { + self.isLoading = false + }) + }, + } +} +</script> diff --git a/front/src/entities.js b/front/src/entities.js new file mode 100644 index 0000000000000000000000000000000000000000..48d39df77926a1d99fbb4a7f7591cc9669424c6b --- /dev/null +++ b/front/src/entities.js @@ -0,0 +1,201 @@ +function getTagsValueRepr (val) { + if (!val) { + return '' + } + return val.slice().sort().join('\n') +} + +export default { + getConfigs () { + return { + artist: { + label: this.$pgettext('*/*/*', 'Artist'), + icon: 'users', + urls: { + getAdminDetail: (obj) => { return {name: 'manage.library.artists.detail', params: {id: obj.id}}} + }, + moderatedFields: [ + { + id: 'name', + label: this.$pgettext('*/*/*/Noun', 'Name'), + getValue: (obj) => { return obj.name } + }, + { + id: 'creation_date', + label: this.$pgettext('*/*/*/Noun', 'Creation date'), + getValue: (obj) => { return obj.creation_date } + }, + { + id: 'tags', + type: 'tags', + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + }, + { + id: 'mbid', + label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'), + getValue: (obj) => { return obj.mbid } + }, + ] + }, + album: { + label: this.$pgettext('*/*/*', 'Album'), + icon: 'play', + urls: { + getAdminDetail: (obj) => { return {name: 'manage.library.albums.detail', params: {id: obj.id}}} + }, + moderatedFields: [ + { + id: 'title', + label: this.$pgettext('*/*/*/Noun', 'Title'), + getValue: (obj) => { return obj.title } + }, + { + id: 'creation_date', + label: this.$pgettext('*/*/*/Noun', 'Creation date'), + getValue: (obj) => { return obj.creation_date } + }, + { + id: 'release_date', + label: this.$pgettext('Content/*/*/Noun', 'Release date'), + getValue: (obj) => { return obj.release_date } + }, + { + id: 'tags', + type: 'tags', + required: true, + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + }, + { + id: 'mbid', + label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'), + getValue: (obj) => { return obj.mbid } + }, + ] + }, + track: { + label: this.$pgettext('*/*/*', 'Track'), + icon: 'music', + urls: { + getAdminDetail: (obj) => { return {name: 'manage.library.tracks.detail', params: {id: obj.id}}} + }, + moderatedFields: [ + { + id: 'title', + label: this.$pgettext('*/*/*/Noun', 'Title'), + getValue: (obj) => { return obj.title } + }, + { + id: 'position', + label: this.$pgettext('*/*/*/Short, Noun', 'Position'), + getValue: (obj) => { return obj.position } + }, + { + id: 'copyright', + label: this.$pgettext('Content/Track/*/Noun', 'Copyright'), + getValue: (obj) => { return obj.copyright } + }, + { + id: 'license', + label: this.$pgettext('Content/*/*/Noun', 'License'), + getValue: (obj) => { return obj.license }, + }, + { + id: 'tags', + label: this.$pgettext('*/*/*/Noun', 'Tags'), + getValue: (obj) => { return obj.tags }, + getValueRepr: getTagsValueRepr + }, + { + id: 'mbid', + label: this.$pgettext('*/*/*/Noun', 'MusicBrainz ID'), + getValue: (obj) => { return obj.mbid } + }, + ] + }, + library: { + label: this.$pgettext('*/*/*', 'Library'), + icon: 'book', + urls: { + getAdminDetail: (obj) => { return {name: 'manage.library.libraries.detail', params: {id: obj.uuid}}} + }, + moderatedFields: [ + { + id: 'name', + label: this.$pgettext('*/*/*/Noun', 'Name'), + getValue: (obj) => { return obj.name } + }, + { + id: 'description', + label: this.$pgettext('*/*/*/Noun', 'Description'), + getValue: (obj) => { return obj.position } + }, + { + id: 'privacy_level', + label: this.$pgettext('*/*/*', 'Visibility'), + getValue: (obj) => { return obj.privacy_level } + }, + ] + }, + playlist: { + label: this.$pgettext('*/*/*', 'Playlist'), + icon: 'list', + urls: { + // getAdminDetail: (obj) => { return {name: 'manage.playlists.detail', params: {id: obj.id}}} + }, + moderatedFields: [ + { + id: 'name', + label: this.$pgettext('*/*/*/Noun', 'Name'), + getValue: (obj) => { return obj.name } + }, + { + id: 'privacy_level', + label: this.$pgettext('*/*/*', 'Visibility'), + getValue: (obj) => { return obj.privacy_level } + }, + ] + }, + account: { + label: this.$pgettext('*/*/*', 'Account'), + icon: 'user', + urls: { + getAdminDetail: (obj) => { return {name: 'manage.moderation.accounts.detail', params: {id: `${obj.preferred_username}@${obj.domain}`}}} + }, + moderatedFields: [ + { + id: 'name', + label: this.$pgettext('*/*/*/Noun', 'Name'), + getValue: (obj) => { return obj.name } + }, + { + id: 'summary', + label: this.$pgettext('*/*/*/Noun', 'Bio'), + getValue: (obj) => { return obj.summary } + }, + ] + }, + } + }, + + getConfig () { + return this.configs[this.objectType] + }, + getFieldConfig (configs, type, fieldId) { + let c = configs[type] + return c.fields.filter((f) => { + return f.id == fieldId + })[0] + }, + getCurrentStateForObj (obj, config) { + let s = {} + config.fields.forEach(f => { + s[f.id] = {value: f.getValue(obj)} + }) + return s + }, + +} diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 3687b6e225e0a11a70031574458181a55fb73d61..cd863cb1743bde96e4401f9fd9de3d60b52d920a 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -368,5 +368,13 @@ input + .help { margin-top: 0.5em; } +.expandable { + &:not(.expanded) { + overflow: hidden; + max-height: 15vh; + background: linear-gradient(top, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0.3) 100%); + } +} + @import "./themes/_light.scss"; @import "./themes/_dark.scss"; diff --git a/front/src/views/admin/moderation/Base.vue b/front/src/views/admin/moderation/Base.vue index d1c3ae29fb8e7fb1a074f2dc65e0b30679e1c5f5..04753cd364ffb9b14e68c93c8a72f6e8deecacc8 100644 --- a/front/src/views/admin/moderation/Base.vue +++ b/front/src/views/admin/moderation/Base.vue @@ -1,6 +1,9 @@ <template> <div class="main pusher" v-title="labels.moderation"> <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu"> + <router-link + class="ui item" + :to="{name: 'manage.moderation.reports.list'}"><translate translate-context="*/Moderation/*/Noun">Reports</translate></router-link> <router-link class="ui item" :to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link> diff --git a/front/src/views/admin/moderation/ReportsList.vue b/front/src/views/admin/moderation/ReportsList.vue index d5781e1b41de9824058c4fc7b7870287a8e53cbd..96c5400c7e1ce3b8efce89864efe4ea18296e9a9 100644 --- a/front/src/views/admin/moderation/ReportsList.vue +++ b/front/src/views/admin/moderation/ReportsList.vue @@ -4,52 +4,61 @@ <h2 class="ui header"><translate translate-context="*/Moderation/Title,Name">Reports</translate></h2> <div class="ui hidden divider"></div> <div class="ui inline form"> - <div class="fields"> - <div class="ui field"> - <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> - <form @submit.prevent="search.query = $refs.search.value"> - <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> - </form> - </div> - <div class="field"> - <label><translate translate-context="Content/Search/Dropdown.Label (Value is All/Resolved/Unresolved)">Status</translate></label> - <select class="ui dropdown" @change="addSearchToken('resolved', $event.target.value)" :value="getTokenValue('resolved', '')"> - <option value=""> - <translate translate-context="Content/*/Dropdown">All</translate> - </option> - <option value="yes"> - <translate translate-context="Content/*/*/Short">Resolved</translate> - </option> - <option value="no"> - <translate translate-context="Content/*/*/Short">Unresolved</translate> - </option> - </select> - </div> - <div class="field"> - <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> - <select class="ui dropdown" v-model="ordering"> - <option v-for="option in orderingOptions" :value="option[0]"> - {{ sharedLabels.filters[option[1]] }} - </option> - </select> - </div> - <div class="field"> - <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label> - <select class="ui dropdown" v-model="orderingDirection"> - <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> - <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> - </select> + <div class="fields"> + <div class="ui field"> + <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> + <form @submit.prevent="search.query = $refs.search.value"> + <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> + </form> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label (Value is All/Resolved/Unresolved)">Status</translate></label> + <select class="ui dropdown" @change="addSearchToken('resolved', $event.target.value)" :value="getTokenValue('resolved', '')"> + <option value=""> + <translate translate-context="Content/*/Dropdown">All</translate> + </option> + <option value="yes"> + <translate translate-context="Content/*/*/Short">Resolved</translate> + </option> + <option value="no"> + <translate translate-context="Content/*/*/Short">Unresolved</translate> + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ sharedLabels.filters[option[1]] }} + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> + <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> + </select> + </div> </div> </div> - </div> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <div v-else-if="!result || result.count === 0"> + <empty-state @refresh="fetchData()" :refresh="true"></empty-state> + </div> + <div v-else-if="mode === 'card'"> + <report-card :obj="obj" v-for="obj in result.results" :key="obj.uuid" @handled="fetchData()" @deleted="fetchData()" /> + </div> <action-table - v-if="result" + v-else-if="mode === 'table'" :objects-data="result" :actions="actions" action-url="manage/moderation/reports/action/" :filters="[]"> <template slot="header-cells"> - <th><translate translate-context="*/*/*">Submitter</translate></th> + <th><translate translate-context="*/*/*">Submitted by</translate></th> <th><translate translate-context="Content/Moderation/*/Noun">Domain</translate></th> <th><translate translate-context="*/*/*">Category</translate></th> <th><translate translate-context="*/*/*">Status</translate></th> @@ -120,7 +129,7 @@ import time from '@/utils/time' import Pagination from '@/components/Pagination' import OrderingMixin from '@/components/mixins/Ordering' import TranslationsMixin from '@/components/mixins/Translations' -// import EditCard from '@/components/library/EditCard' +import ReportCard from '@/components/manage/moderation/ReportCard' import {normalizeQuery, parseTokens} from '@/search' import SmartSearchMixin from '@/components/mixins/SmartSearch' import ActionTable from '@/components/common/ActionTable' @@ -130,8 +139,11 @@ export default { mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], components: { Pagination, - ActionTable - // EditCard + ActionTable, + ReportCard, + }, + props: { + mode: {default: 'card'}, }, data () { let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')