Improve moderation messages and add moderation UI on projects. (#889)

This commit is contained in:
Prospector
2023-01-11 13:05:11 -08:00
committed by GitHub
parent 8fff3e5389
commit bb80dcb4e4
7 changed files with 325 additions and 165 deletions

View File

@@ -200,6 +200,10 @@
text-decoration: underline; text-decoration: underline;
} }
} }
&.moderation-card {
background-color: var(--color-banner-bg);
}
} }
.universal-labels { .universal-labels {
@@ -1458,7 +1462,7 @@ h3 {
} }
} }
.push-right { .push-right:not(.input-group), .push-right.input-group > :first-child {
margin-left: auto; margin-left: auto;
margin-right: 0; margin-right: 0;
} }

View File

@@ -15,8 +15,8 @@
<!-- Project statuses --> <!-- Project statuses -->
<template v-else-if="type === 'approved'"><ListIcon /> Listed</template> <template v-else-if="type === 'approved'"><ListIcon /> Listed</template>
<template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template>
<template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template> <template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template>
<template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template>
<template v-else-if="type === 'private'"><LockIcon /> Private</template> <template v-else-if="type === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <template v-else-if="type === 'scheduled'">
<CalendarIcon /> Scheduled <CalendarIcon /> Scheduled
@@ -106,6 +106,7 @@ export default {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
&.type--withheld,
&.type--rejected, &.type--rejected,
&.red { &.red {
--badge-color: var(--color-special-red); --badge-color: var(--color-special-red);
@@ -131,7 +132,6 @@ export default {
color: var(--color-special-blue); color: var(--color-special-blue);
} }
&.type--withheld,
&.type--unlisted, &.type--unlisted,
&.purple { &.purple {
color: var(--color-special-purple); color: var(--color-special-purple);

View File

@@ -0,0 +1,193 @@
<template>
<Modal ref="modal" header="Project moderation">
<div v-if="project !== null" class="moderation-modal universal-body">
<p>
A moderation message is optional, but it can be used to communicate
problems with a project's team members. The body is also optional and
supports markdown formatting!
</p>
<div v-if="status" class="status">
<span>New project status: </span>
<Badge :type="status" />
</div>
<h3>Message title</h3>
<input
v-model="moderationMessage"
type="text"
placeholder="Enter the message..."
/>
<h3>Message body</h3>
<div class="textarea-wrapper">
<Chips
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<textarea
v-if="bodyViewMode === 'source'"
id="body"
v-model="moderationMessageBody"
:disabled="!moderationMessage"
:placeholder="
moderationMessage
? 'Type a body to your moderation message here...'
: 'You must add a title before you add a body.'
"
/>
<div
v-else
v-highlightjs
class="markdown-body preview"
v-html="$xss($md.render(moderationMessageBody))"
></div>
</div>
<div class="push-right input-group">
<button
v-if="moderationMessage || moderationMessageBody"
class="iconified-button"
@click="
moderationMessage = ''
moderationMessageBody = ''
"
>
<TrashIcon />
Clear message
</button>
<button class="iconified-button" @click="$refs.modal.hide()">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="saveProject">
<CheckIcon />
Confirm
</button>
</div>
</div>
</Modal>
</template>
<script>
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips'
import Badge from '~/components/ui/Badge'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
export default {
name: 'ModalModeration',
components: {
TrashIcon,
CrossIcon,
CheckIcon,
Modal,
Chips,
Badge,
},
props: {
project: {
type: Object,
default: null,
},
status: {
type: String,
default: null,
},
onClose: {
type: Function,
default: null,
},
},
data() {
return {
bodyViewMode: 'source',
moderationMessage:
this.project && this.project.moderation_message
? this.project.moderation_message
: '',
moderationMessageBody:
this.project && this.project.moderation_message_body
? this.project.moderation_message_body
: '',
}
},
methods: {
async saveProject() {
this.$nuxt.$loading.start()
try {
const data = {
moderation_message: this.moderationMessage
? this.moderationMessage
: null,
moderation_message_body: this.moderationMessageBody
? this.moderationMessageBody
: null,
}
if (this.status) {
data.status = this.status
}
await this.$axios.patch(
`project/${this.project.id}`,
data,
this.$defaultHeaders()
)
if (this.onClose !== null) {
this.onClose()
}
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
show() {
this.$refs.modal.show()
this.moderationMessage =
this.project && this.project.moderator_message.message
? this.project.moderator_message.message
: ''
this.moderationMessageBody =
this.project && this.project.moderator_message.body
? this.project.moderator_message.body
: ''
},
},
}
</script>
<style scoped lang="scss">
.moderation-modal {
padding: var(--spacing-card-lg);
.status {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
span {
margin-right: 0.5rem;
}
}
.textarea-wrapper {
margin-top: 0.5rem;
height: 15rem;
.preview {
overflow-y: auto;
}
}
.separator {
margin: var(--spacing-card-sm) 0;
}
}
</style>

View File

@@ -106,6 +106,12 @@
</div> </div>
</div> </div>
<div v-else> <div v-else>
<ModalModeration
ref="modal_moderation"
:project="project"
:status="moderationStatus"
:on-close="resetProject"
/>
<Modal <Modal
ref="modal_license" ref="modal_license"
:header="project.license.name ? project.license.name : 'License'" :header="project.license.name ? project.license.name : 'License'"
@@ -275,20 +281,20 @@
v-if=" v-if="
currentMember && currentMember &&
((project.status !== 'approved' && ((project.status !== 'approved' &&
project.status !== 'unlisted' &&
project.status !== 'draft' && project.status !== 'draft' &&
project.status !== 'processing') || project.status !== 'processing') ||
(project.moderator_message && (project.moderator_message &&
(project.moderator_message.message || (project.moderator_message.message ||
project.moderator_message.body))) project.moderator_message.body)))
" "
class="project-status card" class="universal-card"
> >
<h3 class="card-header">Project status</h3> <h3 class="card-header">Moderation status</h3>
<div class="status-info"></div> <div class="current-status">
<p> Project status:
Your project is currently:
<Badge :type="project.status" /> <Badge :type="project.status" />
</p> </div>
<div class="message"> <div class="message">
<p v-if="project.status === 'rejected'"> <p v-if="project.status === 'rejected'">
Your project has been rejected by Modrinth's staff. In most cases, Your project has been rejected by Modrinth's staff. In most cases,
@@ -299,9 +305,7 @@
<div v-if="project.moderator_message"> <div v-if="project.moderator_message">
<hr class="card-divider" /> <hr class="card-divider" />
<div v-if="project.moderator_message.body"> <div v-if="project.moderator_message.body">
<h3 class="card-header"> <h3 class="card-header">Message from the moderators:</h3>
Message from the Modrinth moderators:
</h3>
<p <p
v-if="project.moderator_message.message" v-if="project.moderator_message.message"
class="mod-message__title" class="mod-message__title"
@@ -315,18 +319,16 @@
/> />
</div> </div>
<div v-else> <div v-else>
<h3 class="card-header"> <h3 class="card-header">Message from the moderators:</h3>
Message from the Modrinth moderators:
</h3>
<p>{{ project.moderator_message.message }}</p> <p>{{ project.moderator_message.message }}</p>
</div> </div>
<hr class="card-divider" />
</div> </div>
</div> </div>
<div class="buttons status-buttons"> <div class="buttons status-buttons">
<button <button
v-if=" v-if="
project.status === 'rejected' || project.status === 'withheld' !$tag.approvedStatuses.includes(project.status) &&
project.status !== 'processing'
" "
class="iconified-button brand-button" class="iconified-button brand-button"
@click="submitForReview" @click="submitForReview"
@@ -335,7 +337,7 @@
Resubmit for review Resubmit for review
</button> </button>
<button <button
v-if="project.status === 'approved'" v-if="$tag.approvedStatuses.includes(project.status)"
class="iconified-button" class="iconified-button"
@click="clearMessage" @click="clearMessage"
> >
@@ -363,6 +365,64 @@
</ul> </ul>
</div> </div>
</div> </div>
<div
v-if="
$auth.user &&
($auth.user.role === 'admin' || $auth.user.role === 'moderator')
"
class="universal-card moderation-card"
>
<h3>Moderation actions</h3>
<div class="input-stack">
<button
v-if="
!$tag.approvedStatuses.includes(project.status) ||
project.status === 'processing'
"
class="iconified-button brand-button"
@click="
openModerationModal(
project.requested_status
? project.requested_status
: 'approved'
)
"
>
<CheckIcon />
Approve
</button>
<button
v-if="
$tag.approvedStatuses.includes(project.status) ||
project.status === 'processing'
"
class="iconified-button danger-button"
@click="openModerationModal('withheld')"
>
<EyeIcon />
Withhold
</button>
<button
v-if="
$tag.approvedStatuses.includes(project.status) ||
project.status === 'processing'
"
class="iconified-button danger-button"
@click="openModerationModal('rejected')"
>
<CrossIcon />
Reject
</button>
<button class="iconified-button" @click="openModerationModal(null)">
<EditIcon />
Edit message
</button>
<nuxt-link class="iconified-button" to="/moderation">
<ModerationIcon />
Visit moderation queue
</nuxt-link>
</div>
</div>
</div> </div>
<div class="card normal-page__info"> <div class="card normal-page__info">
<template <template
@@ -680,11 +740,7 @@
>. >.
</div> </div>
<Advertisement <Advertisement
v-if=" v-if="$tag.approvedStatuses.includes(project.status)"
['approved', 'unlisted', 'archived', 'private'].includes(
project.status
)
"
type="banner" type="banner"
small-screen="square" small-screen="square"
/> />
@@ -769,6 +825,7 @@ import Categories from '~/components/ui/search/Categories'
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator' import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import ModalReport from '~/components/ui/ModalReport' import ModalReport from '~/components/ui/ModalReport'
import ModalModeration from '~/components/ui/ModalModeration'
import NavRow from '~/components/ui/NavRow' import NavRow from '~/components/ui/NavRow'
import CopyCode from '~/components/ui/CopyCode' import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar' import Avatar from '~/components/ui/Avatar'
@@ -783,6 +840,9 @@ import LinksIcon from '~/assets/images/utils/link.svg?inline'
import LicenseIcon from '~/assets/images/utils/copyright.svg?inline' import LicenseIcon from '~/assets/images/utils/copyright.svg?inline'
import GalleryIcon from '~/assets/images/utils/image.svg?inline' import GalleryIcon from '~/assets/images/utils/image.svg?inline'
import VersionIcon from '~/assets/images/utils/version.svg?inline' import VersionIcon from '~/assets/images/utils/version.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?inline'
export default { export default {
components: { components: {
@@ -793,6 +853,7 @@ export default {
Advertisement, Advertisement,
Modal, Modal,
ModalReport, ModalReport,
ModalModeration,
ProjectPublishingChecklist, ProjectPublishingChecklist,
EnvironmentIndicator, EnvironmentIndicator,
IssuesIcon, IssuesIcon,
@@ -818,6 +879,9 @@ export default {
NavStackItem, NavStackItem,
SettingsIcon, SettingsIcon,
EyeIcon, EyeIcon,
CrossIcon,
EditIcon,
ModerationIcon,
GalleryIcon, GalleryIcon,
VersionIcon, VersionIcon,
UsersIcon, UsersIcon,
@@ -968,6 +1032,7 @@ export default {
routeName: '', routeName: '',
from: '', from: '',
collapsedChecklist: false, collapsedChecklist: false,
moderationStatus: null,
} }
}, },
fetch() { fetch() {
@@ -1291,6 +1356,11 @@ export default {
) )
this.project.icon_url = response.data.icon_url this.project.icon_url = response.data.icon_url
}, },
openModerationModal(status) {
this.moderationStatus = status
this.$refs.modal_moderation.show()
},
}, },
} }
</script> </script>
@@ -1577,4 +1647,15 @@ export default {
} }
} }
} }
.current-status {
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
margin-top: var(--spacing-card-md);
}
.normal-page__sidebar .mod-button {
margin-top: var(--spacing-card-sm);
}
</style> </style>

View File

@@ -161,7 +161,7 @@
id="project-visibility" id="project-visibility"
v-model="visibility" v-model="visibility"
placeholder="Select one" placeholder="Select one"
:options="statusOptions" :options="$tag.approvedStatuses"
:custom-label="(value) => $formatProjectStatus(value)" :custom-label="(value) => $formatProjectStatus(value)"
:searchable="false" :searchable="false"
:close-on-select="true" :close-on-select="true"
@@ -298,7 +298,7 @@ export default {
this.summary = this.project.description this.summary = this.project.description
this.clientSide = this.project.client_side this.clientSide = this.project.client_side
this.serverSide = this.project.server_side this.serverSide = this.project.server_side
this.visibility = this.statusOptions.includes(this.project.status) this.visibility = this.$tag.approvedStatuses.includes(this.project.status)
? this.project.status ? this.project.status
: this.project.requested_status : this.project.requested_status
}, },
@@ -316,9 +316,6 @@ export default {
sideTypes() { sideTypes() {
return ['required', 'optional', 'unsupported'] return ['required', 'optional', 'unsupported']
}, },
statusOptions() {
return ['approved', 'archived', 'unlisted', 'private']
},
patchData() { patchData() {
const data = {} const data = {}
@@ -337,12 +334,12 @@ export default {
if (this.serverSide !== this.project.server_side) { if (this.serverSide !== this.project.server_side) {
data.server_side = this.serverSide data.server_side = this.serverSide
} }
if (this.visibility !== this.project.requested_status) { if (this.$tag.approvedStatuses.includes(this.project.status)) {
if (!this.statusOptions.includes(this.project.status)) { if (this.visibility !== this.project.status) {
data.requested_status = this.visibility
} else {
data.status = this.visibility data.status = this.visibility
} }
} else if (this.visibility !== this.project.requested_status) {
data.requested_status = this.visibility
} }
return data return data

View File

@@ -1,58 +1,11 @@
<template> <template>
<div> <div>
<Modal ref="modal" header="Moderation Form"> <ModalModeration
<div v-if="currentProject !== null" class="moderation-modal"> ref="modal"
<p> :project="currentProject"
Both of these fields are optional, but can be used to communicate :status="currentStatus"
problems with a project's team members. The body supports markdown :on-close="onModalClose"
formatting! />
</p>
<div class="status">
<span>New project status: </span>
<Badge :type="currentProject.newStatus" />
</div>
<input
v-model="currentProject.moderation_message"
type="text"
placeholder="Enter the message..."
/>
<h3>Body</h3>
<div class="textarea-wrapper">
<Chips
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<textarea
v-if="bodyViewMode === 'source'"
id="body"
v-model="currentProject.moderation_message_body"
/>
<div
v-else
v-highlightjs
class="markdown-body preview"
v-html="$xss($md.render(currentProject.moderation_message_body))"
></div>
</div>
<div class="buttons">
<button
class="iconified-button"
@click="
$refs.modal.hide()
currentProject = null
"
>
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="saveProject">
<CheckIcon />
Confirm
</button>
</div>
</div>
</Modal>
<div class="normal-page"> <div class="normal-page">
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<aside class="universal-card"> <aside class="universal-card">
@@ -175,9 +128,7 @@
</template> </template>
<script> <script>
import Chips from '~/components/ui/Chips'
import ProjectCard from '~/components/ui/ProjectCard' import ProjectCard from '~/components/ui/ProjectCard'
import Modal from '~/components/ui/Modal'
import Badge from '~/components/ui/Badge' import Badge from '~/components/ui/Badge'
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg?inline'
@@ -188,18 +139,18 @@ import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import Security from '~/assets/images/illustrations/security.svg?inline' import Security from '~/assets/images/illustrations/security.svg?inline'
import NavStack from '~/components/ui/NavStack' import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem' import NavStackItem from '~/components/ui/NavStackItem'
import ModalModeration from '~/components/ui/ModalModeration'
export default { export default {
name: 'Moderation', name: 'Moderation',
components: { components: {
ModalModeration,
NavStack, NavStack,
NavStackItem, NavStackItem,
Chips,
ProjectCard, ProjectCard,
CheckIcon, CheckIcon,
CrossIcon, CrossIcon,
UnlistIcon, UnlistIcon,
Modal,
Badge, Badge,
Security, Security,
TrashIcon, TrashIcon,
@@ -287,8 +238,8 @@ export default {
}, },
data() { data() {
return { return {
bodyViewMode: 'source',
currentProject: null, currentProject: null,
currentStatus: null,
} }
}, },
head: { head: {
@@ -311,47 +262,17 @@ export default {
}, },
methods: { methods: {
setProjectStatus(project, status) { setProjectStatus(project, status) {
project.moderation_message = ''
project.moderation_message_body = ''
project.newStatus = status
this.currentProject = project this.currentProject = project
this.currentStatus = status
this.$refs.modal.show() this.$refs.modal.show()
}, },
async saveProject() { onModalClose() {
this.$nuxt.$loading.start() this.projects.splice(
this.projects.findIndex((x) => this.project.id === x.id),
try { 1
await this.$axios.patch( )
`project/${this.currentProject.id}`, this.project = null
{
moderation_message: this.currentProject.moderation_message
? this.currentProject.moderation_message
: null,
moderation_message_body: this.currentProject.moderation_message_body
? this.currentProject.moderation_message_body
: null,
status: this.currentProject.newStatus,
},
this.$defaultHeaders()
)
this.projects.splice(
this.projects.findIndex((x) => this.currentProject.id === x.id),
1
)
this.currentProject = null
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
}, },
async deleteReport(index) { async deleteReport(index) {
this.$nuxt.$loading.start() this.$nuxt.$loading.start()
@@ -379,43 +300,6 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.moderation-modal {
width: auto;
padding: var(--spacing-card-md) var(--spacing-card-lg);
.status {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
span {
margin-right: 0.5rem;
}
}
.textarea-wrapper {
margin-top: 0.5rem;
height: 15rem;
.preview {
overflow-y: auto;
}
}
.separator {
margin: var(--spacing-card-sm) 0;
}
.buttons {
display: flex;
margin-top: 0.5rem;
:first-child {
margin-left: auto;
}
}
}
h1 { h1 {
color: var(--color-text-dark); color: var(--color-text-dark);
} }

View File

@@ -55,4 +55,5 @@ export const state = () => ({
modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift'], modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift'],
}, },
projectViewModes: ['list', 'grid', 'gallery'], projectViewModes: ['list', 'grid', 'gallery'],
approvedStatuses: ['approved', 'archived', 'unlisted', 'private'],
}) })