Threads and more! (#1232)

* Begin UI for threads and moderation overhaul

* Hide close button on non-report threads

* Fix review age coloring

* Add project count

* Remove action buttons from queue page and add queued date to project page

* Hook up to actual data

* Remove unused icon

* Get up to 1000 projects in queue

* prettier

* more prettier

* Changed all the things

* lint

* rebuild

* Add omorphia

* Workaround formatjs bug in ThreadSummary.vue

* Fix notifications page on prod

* Fix a few notifications and threads bugs

* lockfile

* Fix duplicate button styles

* more fixes and polishing

* More fixes

* Remove legacy pages

* More bugfixes

* Add some error catching for reports and notifications

* More error handling

* fix lint

* Add inbox links

* Remove loading component and rename member header

* Rely on threads always existing

* Handle if project update notifs are not grouped

* oops

* Fix chips on notifications page

* Import ModalModeration

* finish threads

---------

Co-authored-by: triphora <emma@modrinth.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Prospector
2023-07-15 20:39:33 -07:00
committed by GitHub
parent 1a2b45eebd
commit a5613ebb10
67 changed files with 3613 additions and 776 deletions

View File

@@ -4,7 +4,7 @@
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
}`"
} ${raised ? 'raised' : ''}`"
:src="src"
:alt="alt"
:loading="loading"
@@ -12,7 +12,9 @@
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''}`"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
raised ? 'raised' : ''
}`"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@@ -47,7 +49,7 @@ export default {
type: String,
default: 'sm',
validator(value) {
return ['xs', 'sm', 'md', 'lg'].includes(value)
return ['xxs', 'xs', 'sm', 'md', 'lg'].includes(value)
},
},
circle: {
@@ -62,6 +64,10 @@ export default {
type: String,
default: 'eager',
},
raised: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -89,6 +95,12 @@ export default {
background-color: var(--color-button-bg);
object-fit: contain;
&.size-xxs {
--size: 1.25rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
@@ -122,5 +134,9 @@ export default {
&.pixelated {
image-rendering: pixelated;
}
&.raised {
background-color: var(--color-raised-bg);
}
}
</style>

View File

@@ -1,22 +1,23 @@
<template>
<span :class="'version-badge ' + color + ' type--' + type">
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }} </template>
<span :class="'badge ' + color + ' type--' + type">
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
<!-- User roles -->
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team </template>
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator </template>
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator</template>
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'"><ListIcon /> Listed</template>
<template v-else-if="type === 'approved-general'"><CheckIcon /> Approved</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 === 'scheduled'"> <CalendarIcon /> Scheduled </template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived </template>
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived</template>
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review </template>
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review</template>
<!-- Team members -->
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
@@ -25,6 +26,9 @@
<!-- Transaction statuses -->
<template v-else-if="type === 'success'"><CheckIcon /> Success</template>
<!-- Report status -->
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
</span>
@@ -43,6 +47,7 @@ import ProcessingIcon from '~/assets/images/utils/updated.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import LockIcon from '~/assets/images/utils/lock.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import CloseIcon from '~/assets/images/utils/check-circle.svg'
export default {
components: {
@@ -58,6 +63,7 @@ export default {
CheckIcon,
LockIcon,
CalendarIcon,
CloseIcon,
},
props: {
type: {
@@ -73,13 +79,12 @@ export default {
</script>
<style lang="scss" scoped>
.version-badge {
display: flex;
align-items: center;
.badge {
font-weight: bold;
width: fit-content;
--badge-color: var(--color-special-gray);
color: var(--badge-color);
white-space: nowrap;
.circle {
width: 0.5rem;
@@ -91,9 +96,12 @@ export default {
}
svg {
margin-right: 0.25rem;
vertical-align: -15%;
width: 1em;
height: 1em;
}
&.type--closed,
&.type--withheld,
&.type--rejected,
&.red {
@@ -111,6 +119,7 @@ export default {
&.type--accepted,
&.type--admin,
&.type--success,
&.type--approved-general,
&.green {
--badge-color: var(--color-special-green);
}

View File

@@ -4,7 +4,7 @@
v-for="item in items"
:key="item"
class="iconified-button"
:class="{ selected: selected === item }"
:class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
@@ -37,6 +37,10 @@ export default {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
},
emits: ['update:modelValue'],
computed: {
@@ -73,7 +77,9 @@ export default {
flex-wrap: wrap;
.iconified-button {
text-transform: capitalize;
&.capitalize {
text-transform: capitalize;
}
svg {
width: 1em;

View File

@@ -0,0 +1,21 @@
<template>
<nuxt-link v-if="isLink" :to="to">
<slot />
</nuxt-link>
<span v-else>
<slot />
</span>
</template>
<script setup>
defineProps({
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
})
</script>

View File

@@ -38,7 +38,7 @@ export default {
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: flex;
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);

View File

@@ -0,0 +1,33 @@
<template>
<div class="double-icon">
<slot name="primary" />
<div class="secondary">
<slot name="secondary" />
</div>
</div>
</template>
<style lang="scss" scoped>
.double-icon {
position: relative;
height: fit-content;
line-height: 0;
.secondary {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-bg);
padding: var(--spacing-card-xs);
border-radius: 50%;
aspect-ratio: 1 / 1;
width: fit-content;
height: fit-content;
line-height: 0;
svg {
width: 1rem;
height: 1rem;
}
}
}
</style>

View File

@@ -0,0 +1,537 @@
<template>
<div
class="notification"
:class="{
'has-body': hasBody,
compact: compact,
read: notification.read,
}"
>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<NotificationIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ModerationIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<InvitationIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<NotificationIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link
>, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link :to="`/user/${invitedBy.username}`" class="iconified-link title-link">
<Avatar :src="invitedBy.avatar_url" circle size="xxs" no-shadow :raised="raised" />
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="$tag.rejectedStatuses.includes(notification.body.new_status)">
has been <Badge :type="notification.body.new_status" />
</template>
<template v-else>
updated from
<Badge :type="notification.body.old_status" />
to
<Badge :type="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link
>, has received
<template v-if="notification.grouped_notifs"> messages </template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }} </nuxt-link
>.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<div
v-for="notif in notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]"
:key="notif.id"
class="version-link"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version.name)"
class="text-link no-underline"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="notif.extra_data.version.loaders"
:type="notif.extra_data.project.project_type"
class="categories"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span
v-tooltip="
$dayjs(notif.extra_data.version.date_published).format('MMMM D, YYYY [at] h:mm A')
"
class="date"
>
{{ fromNow(notif.extra_data.version.date_published) }}
</span>
</span>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge"> <ReadIcon /> Read </span>
<span v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')">
<CalendarIcon /> Received {{ fromNow(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite'">
<button
v-tooltip="`Accept`"
class="iconified-button square-button brand-button button-transparent"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
</button>
<button
v-tooltip="`Decline`"
class="iconified-button square-button danger-button button-transparent"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<CrossIcon />
</button>
</template>
<button
v-else-if="!notification.read"
v-tooltip="`Mark as read`"
class="iconified-button square-button button-transparent"
@click="read()"
>
<CrossIcon />
</button>
</div>
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template v-if="type === 'team_invite' && !notification.read">
<button
class="iconified-button brand-button"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon /> Accept
</button>
<button
class="iconified-button danger-button"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<CrossIcon /> Decline
</button>
</template>
<button
v-else-if="!notification.read"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="read()"
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="$cosmetics.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<nuxt-link
v-if="notification.link && notification.link !== '#'"
class="iconified-button"
:class="{ 'raised-button': raised }"
:to="notification.link"
target="_blank"
>
<ExternalIcon />
Open link
</nuxt-link>
<button
v-for="(action, actionIndex) in notification.actions"
:key="actionIndex"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="performAction(notification, actionIndex)"
>
<CheckIcon v-if="action.title === 'Accept'" />
<CrossIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
<button
v-if="notification.actions.length === 0 && !notification.read"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="performAction(notification, null)"
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="$cosmetics.developerMode" :text="notification.id" />
</div>
</div>
</div>
</template>
<script setup>
import InvitationIcon from '~/assets/images/utils/user-plus.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import NotificationIcon from '~/assets/images/sidebar/notifications.svg'
import ReadIcon from '~/assets/images/utils/check-circle.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import VersionIcon from '~/assets/images/utils/version.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import ExternalIcon from '~/assets/images/utils/external.svg'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { getProjectLink, getVersionLink } from '~/helpers/projects.js'
import { getUserLink } from '~/helpers/users.js'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
import { markAsRead } from '~/helpers/notifications.js'
import { renderString } from '~/helpers/parse.js'
import DoubleIcon from '~/components/ui/DoubleIcon.vue'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import Categories from '~/components/ui/search/Categories.vue'
const app = useNuxtApp()
const emit = defineEmits(['update:notifications'])
const props = defineProps({
notification: {
type: Object,
required: true,
},
notifications: {
type: Array,
required: true,
},
raised: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
})
const type = computed(() =>
!props.notification.body || props.notification.body.type === 'legacy_markdown'
? null
: props.notification.body.type
)
const thread = computed(() => props.notification.extra_data.thread)
const report = computed(() => props.notification.extra_data.report)
const project = computed(() => props.notification.extra_data.project)
const version = computed(() => props.notification.extra_data.version)
const user = computed(() => props.notification.extra_data.user)
const invitedBy = computed(() => props.notification.extra_data.invited_by)
const threadLink = computed(() => {
if (report.value) {
return `/dashboard/report/${report.value.id}`
} else if (project.value) {
return `${getProjectLink(project.value)}/moderation#messages`
}
return '#'
})
const hasBody = computed(() => !type.value || thread.value || type.value === 'project_update')
async function read() {
try {
const ids = [
props.notification.id,
...(props.notification.grouped_notifs
? props.notification.grouped_notifs.map((notif) => notif.id)
: []),
]
const updateNotifs = await markAsRead(ids)
const newNotifs = updateNotifs(props.notifications)
emit('update:notifications', newNotifs)
} catch (err) {
app.$notify({
group: 'main',
title: 'Error marking notification as read',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function performAction(notification, actionIndex) {
startLoading()
try {
await read()
await userDeleteNotification(notification.id)
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
...app.$defaultHeaders(),
})
}
} catch (err) {
app.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
function getMessages() {
const messages = []
if (props.notification.body.message_id) {
messages.push(props.notification.body.message_id)
}
if (props.notification.grouped_notifs) {
for (const notif of props.notification.grouped_notifs) {
if (notif.body.message_id) {
messages.push(notif.body.message_id)
}
}
}
return messages
}
</script>
<style lang="scss" scoped>
.notification {
display: grid;
grid-template:
'icon title'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content min-content;
gap: var(--spacing-card-sm);
&.compact {
grid-template:
'icon title actions'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: auto min-content;
}
&.has-body {
grid-template:
'icon title'
'body body'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content auto auto min-content;
&.compact {
grid-template:
'icon title actions'
'body body body'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content auto min-content;
}
}
.label__title,
.label__description,
h1,
h2,
h3,
h4,
:deep(p) {
margin: 0 !important;
}
.notification__icon {
grid-area: icon;
}
.notification__title {
grid-area: title;
color: var(--color-heading);
margin-block: auto;
display: inline-block;
vertical-align: middle;
line-height: 1.25rem;
.iconified-link {
display: inline;
img {
vertical-align: middle;
position: relative;
top: -2px;
}
}
}
.notification__body {
grid-area: body;
.version-list {
margin: 0;
padding: 0;
list-style-type: none;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--spacing-card-sm);
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
.version-link {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
.version-info {
display: contents;
:deep(span) {
color: var(--color-text);
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
}
}
}
.notification__date {
grid-area: date;
color: var(--color-text-secondary);
svg {
vertical-align: top;
}
.read-badge {
font-weight: bold;
color: var(--color-text);
margin-right: var(--spacing-card-xs);
}
}
.notification__actions {
grid-area: actions;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
}
.unknown-type {
color: var(--color-special-red);
}
.title-link {
&:not(:hover) {
text-decoration: none;
}
font-weight: bold;
}
.moderation-color {
color: var(--color-special-orange);
}
.creator-color {
color: var(--color-special-blue);
}
}
</style>

View File

@@ -141,15 +141,6 @@ a {
filter: grayscale(50%);
opacity: 0.5;
}
&:hover:not(&:disabled) {
filter: brightness(0.85);
}
&:active:not(&:disabled) {
transform: scale(0.95);
filter: brightness(0.8);
}
}
.has-icon {

View File

@@ -70,7 +70,7 @@
</div>
<div
v-if="showUpdatedDate"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm:ss A')"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<EditIcon aria-hidden="true" />
@@ -78,7 +78,7 @@
</div>
<div
v-else
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm:ss A')"
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<CalendarIcon aria-hidden="true" />

View File

@@ -1,4 +1,18 @@
<template>
<div v-if="$auth.user && showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
</p>
<div class="input-group">
<button class="iconified-button brand-button" @click="acceptInvite()">
<CheckIcon />Accept
</button>
<button class="iconified-button danger-button" @click="declineInvite()">
<CrossIcon />Decline
</button>
</div>
</div>
<div
v-if="
$auth.user &&
@@ -63,17 +77,6 @@
/>{{ nag.title }}</span
>
{{ nag.description }}
<Checkbox
v-if="
nag.status === 'review' &&
project.moderator_message &&
$tag.rejectedStatuses.includes(project.status)
"
v-model="acknowledgedMessage"
description="Acknowledge staff message in sidebar"
>
I acknowledge that I have addressed the staff's message on the sidebar
</Checkbox>
<NuxtLink
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
@@ -103,18 +106,19 @@
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import DropdownIcon from '~/assets/images/utils/dropdown.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import RequiredIcon from '~/assets/images/utils/asterisk.svg'
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import SendIcon from '~/assets/images/utils/send.svg'
import Checkbox from '~/components/ui/Checkbox.vue'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
export default {
components: {
Checkbox,
ChevronRightIcon,
DropdownIcon,
CheckIcon,
CrossIcon,
RequiredIcon,
SuggestionIcon,
ModerationIcon,
@@ -135,6 +139,10 @@ export default {
type: Object,
default: null,
},
allMembers: {
type: Object,
default: null,
},
isSettings: {
type: Boolean,
default: false,
@@ -173,11 +181,19 @@ export default {
}
},
},
},
data() {
return {
acknowledgedMessage: !this.project.moderator_message,
}
updateMembers: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'updateMembers function not found',
type: 'error',
})
}
},
},
},
computed: {
featuredGalleryImage() {
@@ -326,13 +342,10 @@ export default {
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: 'review',
link: null,
action: {
onClick: this.submitForReview,
title: 'Resubmit for review',
disabled: () =>
!this.acknowledgedMessage ||
this.nags.filter((x) => x.condition && x.status === 'required').length > 0,
link: {
path: 'moderation',
title: 'Visit moderation page',
hide: this.routeName === 'type-id-moderation',
},
},
]
@@ -349,8 +362,23 @@ export default {
)
)
},
showInvitation() {
if (this.allMembers && this.$auth) {
const member = this.allMembers.find((x) => x.user.id === this.$auth.user.id)
return member && !member.accepted
}
return false
},
},
methods: {
acceptInvite() {
acceptTeamInvite(this.project.team)
this.updateMembers()
},
declineInvite() {
removeSelfFromTeam(this.project.team)
this.updateMembers()
},
sortByTrue(a, b, ifEqual = 0) {
if (a === b) {
return ifEqual
@@ -382,6 +410,9 @@ export default {
</script>
<style lang="scss" scoped>
.invited {
}
.author-actions {
&:empty {
display: none;

View File

@@ -0,0 +1,188 @@
<template>
<div class="report">
<div v-if="report.item_type === 'project'" class="item-info">
<nuxt-link
:to="`/${$getProjectTypeForUrl(report.project.project_type, report.project.loaders)}/${
report.project.slug
}`"
class="iconified-stacked-link"
>
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders)
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'user'" class="item-info">
<nuxt-link :to="`/user/${report.user.username}`" class="iconified-stacked-link">
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.user.username }}</span>
<span>User</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }"><VersionIcon /></div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders)
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
<span>Unknown report type</span>
</div>
<div class="report-type">
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<ThreadSummary
v-if="thread"
:thread="thread"
class="thread-summary"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
/>
<div class="reporter-info">
<ReportIcon class="inline-svg" /> Reported by
<span v-if="$auth.user.id === report.reporterUser.id">you</span>
<nuxt-link v-else :to="`/user/${report.reporterUser.username}`" class="iconified-link">
<Avatar
:src="report.reporterUser.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
/>
<span>{{ report.reporterUser.username }}</span>
</nuxt-link>
<span>&nbsp;</span>
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
fromNow(report.created)
}}</span>
<CopyCode v-if="$cosmetics.developerMode" :text="report.id" class="report-id" />
</div>
</div>
</template>
<script setup>
import { renderHighlightedString } from '~/helpers/highlight.js'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import ReportIcon from '~/assets/images/utils/report.svg'
import UnknownIcon from '~/assets/images/utils/unknown.svg'
import VersionIcon from '~/assets/images/utils/version.svg'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
defineProps({
report: {
type: Object,
required: true,
},
raised: {
type: Boolean,
default: false,
},
thread: {
type: Object,
default: null,
},
showMessage: {
type: Boolean,
default: true,
},
moderation: {
type: Boolean,
default: false,
},
})
</script>
<style lang="scss" scoped>
.report {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
flex-wrap: wrap;
.report-type {
grid-area: type;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
margin-top: var(--spacing-card-xs);
}
.item-info {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
color: var(--color-heading);
grid-area: title;
img,
.backed-svg {
margin-right: var(--spacing-card-xs);
}
}
.markdown-body {
grid-area: body;
}
.reporter-info {
grid-area: reporter;
gap: var(--spacing-card-xs);
color: var(--color-text-secondary);
img {
vertical-align: middle;
position: relative;
top: -1px;
margin-right: var(--spacing-card-xs);
}
a {
gap: var(--spacing-card-xs);
}
}
.action {
grid-area: action;
}
.thread-summary {
grid-area: summary;
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
.report-id {
margin-left: var(--spacing-card-sm);
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="breadcrumbsStack"
:current-title="`Report ${reportId}`"
:link-stack="breadcrumbsStack"
/>
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" />
</section>
<section class="universal-card">
<h2>Messages</h2>
<ConversationThread :thread="thread" :report="report" :update-thread="updateThread" />
</section>
</div>
</template>
<script setup>
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
const props = defineProps({
reportId: {
type: String,
required: true,
},
breadcrumbsStack: {
type: Array,
default: null,
},
})
const app = useNuxtApp()
const report = ref(null)
await fetchReport().then((result) => {
report.value = result
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`, app.$defaultHeaders())
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
async function updateThread(newThread) {
rawThread.value = newThread
report.value = await fetchReport()
}
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`, app.$defaultHeaders())
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
const userIds = []
userIds.push(rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
userIds.push(rawReport.value.item_id)
}
const versionId = rawReport.value.item_type === 'version' ? rawReport.value.item_id : null
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${JSON.stringify(userIds)}`, app.$defaultHeaders())
)
users = usersVal.value
}
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`, app.$defaultHeaders())
)
version = versionVal.value
}
const projectId = version
? version.project_id
: rawReport.value.item_type === 'project'
? rawReport.value.item_id
: null
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`, app.$defaultHeaders())
)
project = projectVal.value
}
const reportData = rawReport.value
reportData.project = project
reportData.version = version
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
reportData.user = users.find((user) => user.id === rawReport.value.item_id)
}
return reportData
}
</script>
<style lang="scss" scoped>
.stacked {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === $auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open)
)"
:key="report.id"
:report="report"
:thread="report.thread"
:moderation="moderation"
raised
class="universal-card recessed"
/>
</template>
<script setup>
import Chips from '~/components/ui/Chips.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
defineProps({
moderation: {
type: Boolean,
default: false,
},
})
const app = useNuxtApp()
const viewMode = ref('open')
const reports = ref([])
let { data: rawReports } = await useAsyncData('report', () =>
useBaseFetch('report', app.$defaultHeaders())
)
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, '')
return report
})
const reporterUsers = rawReports.map((report) => report.reporter)
const reportedUsers = rawReports
.filter((report) => report.item_type === 'user')
.map((report) => report.item_id)
const versionReports = rawReports.filter((report) => report.item_type === 'version')
const versionIds = [...new Set(versionReports.map((report) => report.item_id))]
const userIds = [...new Set(reporterUsers.concat(reportedUsers))]
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
]
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${JSON.stringify(userIds)}`, app.$defaultHeaders())
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
useBaseFetch(`versions?ids=${JSON.stringify(versionIds)}`, app.$defaultHeaders())
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
useBaseFetch(`threads?ids=${JSON.stringify(threadIds)}`, app.$defaultHeaders())
),
])
const reportedProjects = rawReports
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id)
const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
useBaseFetch(`projects?ids=${JSON.stringify(projectIds)}`, app.$defaultHeaders())
)
reports.value = rawReports.map((report) => {
report.reporterUser = users.value.find((user) => user.id === report.reporter)
if (report.item_type === 'user') {
report.user = users.value.find((user) => user.id === report.item_id)
} else if (report.item_type === 'project') {
report.project = projects.value.find((project) => project.id === report.item_id)
} else if (report.item_type === 'version') {
report.version = versions.value.find((version) => version.id === report.item_id)
report.project = projects.value.find((project) => project.id === report.version.project_id)
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report
)
}
report.open = true
return report
})
</script>

View File

@@ -46,9 +46,12 @@ export default {
display: flex;
align-items: center;
flex-direction: row;
margin-right: var(--spacing-card-md);
&:not(.version-badge) {
&:not(:last-child) {
margin-right: var(--spacing-card-md);
}
&:not(.badge) {
color: var(--color-icon);
}

View File

@@ -0,0 +1,381 @@
<template>
<div>
<Modal
ref="modalSubmit"
:header="isRejected(project) ? 'Resubmit for review' : 'Submit for review'"
>
<div class="modal-submit universal-body">
<span>
You're submitting <span class="project-title">{{ project.title }}</span> to be reviewed
again by the moderators.
</span>
<span>
Make sure you have addressed the comments from the moderation team.
<span class="known-errors">
Repeated submissions without addressing the moderators' comments may result in an
account suspension.
</span>
</span>
<Checkbox
v-model="submissionConfirmation"
description="Confirm I have addressed the messages from the moderators"
>
I confirm that I have properly addressed the moderators' comments.
</Checkbox>
<div class="input-group push-right">
<button
class="iconified-button moderation-button"
:disabled="!submissionConfirmation"
@click="resubmit()"
>
<ModerationIcon /> Resubmit for review
</button>
</div>
</div>
</Modal>
<div v-if="$cosmetics.developerMode" class="thread-id">
Thread ID: <CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="messages universal-card recessed">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
raised
/>
</div>
<span v-if="report && report.closed">
This thread is closed and new messages cannot be sent to it.
</span>
<template v-else-if="!report || !report.closed">
<div class="resizable-textarea-wrapper">
<Chips v-model="replyViewMode" class="chips" :items="['source', 'preview']" />
<textarea
v-if="replyViewMode === 'source'"
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
/>
<div v-else class="markdown-body preview" v-html="renderString(replyBody)" />
</div>
<div class="input-group">
<button
v-if="sortedMessages.length > 0"
class="iconified-button brand-button"
:disabled="!replyBody"
@click="sendReply()"
>
<ReplyIcon /> Reply
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="!replyBody"
@click="sendReply()"
>
<SendIcon /> Send
</button>
<template v-if="currentMember && !isStaff($auth.user)">
<template v-if="isRejected(project)">
<button
v-if="replyBody"
class="iconified-button moderation-button"
@click="openResubmitModal(true)"
>
<ModerationIcon /> Resubmit for review with reply
</button>
<button
v-else
class="iconified-button moderation-button"
@click="openResubmitModal(false)"
>
<ModerationIcon /> Resubmit for review
</button>
</template>
</template>
<div class="spacer"></div>
<div class="input-group extra-options">
<template v-if="report">
<template v-if="isStaff($auth.user)">
<button
v-if="replyBody"
class="iconified-button danger-button"
@click="closeReport(true)"
>
<CloseIcon /> Close with reply
</button>
<button v-else class="iconified-button danger-button" @click="closeReport()">
<CloseIcon /> Close thread
</button>
</template>
</template>
<template v-if="project">
<template v-if="isStaff($auth.user)">
<button
v-if="replyBody"
class="iconified-button brand-button"
@click="sendReply(requestedStatus)"
>
<CheckIcon /> Approve with reply
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="isApproved(project)"
@click="setStatus(requestedStatus)"
>
<CheckIcon /> Approve project
</button>
<button
v-if="replyBody"
class="iconified-button moderation-button"
:disabled="project.status === 'withheld'"
@click="sendReply('withheld')"
>
<EyeOffIcon /> Withhold with reply
</button>
<button
v-else
class="iconified-button moderation-button"
:disabled="project.status === 'withheld'"
@click="setStatus('withheld')"
>
<EyeOffIcon /> Withhold project
</button>
<button
v-if="replyBody"
class="iconified-button danger-button"
:disabled="project.status === 'rejected'"
@click="sendReply('rejected')"
>
<CrossIcon /> Reject with reply
</button>
<button
v-else
class="iconified-button danger-button"
:disabled="project.status === 'rejected'"
@click="setStatus('rejected')"
>
<CrossIcon /> Reject project
</button>
</template>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import Chips from '~/components/ui/Chips.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import ReplyIcon from '~/assets/images/utils/reply.svg'
import SendIcon from '~/assets/images/utils/send.svg'
import CloseIcon from '~/assets/images/utils/check-circle.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import EyeOffIcon from '~/assets/images/utils/eye-off.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import { renderString } from '~/helpers/parse.js'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
import { isStaff } from '~/helpers/users.js'
import { isApproved, isRejected } from '~/helpers/projects.js'
import Modal from '~/components/ui/Modal.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
project: {
type: Object,
required: false,
default: null,
},
updateThread: {
type: Function,
required: false,
default: () => {},
},
setStatus: {
type: Function,
required: false,
default: () => {},
},
currentMember: {
type: Object,
default() {
return null
},
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
return members
})
const replyViewMode = ref('source')
const replyBody = ref('')
const sortedMessages = computed(() => {
if (props.thread !== null) {
return props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
}
return []
})
const modalSubmit = ref(null)
async function updateThreadLocal() {
let threadId = null
if (props.project) {
threadId = props.project.thread_id
} else if (props.report) {
threadId = props.report.thread_id
}
let thread = null
if (threadId) {
thread = await useBaseFetch(`thread/${threadId}`, app.$defaultHeaders())
}
props.updateThread(thread)
}
async function sendReply(status = null) {
try {
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body: {
body: {
type: 'text',
body: replyBody.value,
},
},
...app.$defaultHeaders(),
})
replyBody.value = ''
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
}
} catch (err) {
app.$notify({
group: 'main',
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function closeReport(reply) {
if (reply) {
await sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
...app.$defaultHeaders(),
})
await updateThreadLocal()
} catch (err) {
app.$notify({
group: 'main',
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const replyWithSubmission = ref(false)
const submissionConfirmation = ref(false)
function openResubmitModal(reply) {
submissionConfirmation.value = false
replyWithSubmission.value = reply
modalSubmit.value.show()
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply('processing')
} else {
await props.setStatus('processing')
}
modalSubmit.value.hide()
}
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
</script>
<style lang="scss" scoped>
.messages {
display: flex;
flex-direction: column;
padding: var(--spacing-card-md);
}
.resizable-textarea-wrapper {
margin-bottom: var(--spacing-card-sm);
textarea {
padding: var(--spacing-card-bg);
width: 100%;
}
.chips {
margin-bottom: var(--spacing-card-md);
}
.preview {
overflow-y: auto;
}
}
.thread-id {
margin-bottom: var(--spacing-card-md);
font-weight: bold;
color: var(--color-heading);
}
.input-group {
.spacer {
flex-grow: 1;
flex-shrink: 1;
}
.extra-options {
flex-basis: fit-content;
}
}
.modal-submit {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
gap: var(--spacing-card-lg);
.project-title {
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div
class="message"
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: message.body.private,
}"
>
<template v-if="members[message.author_id]">
<ConditionalNuxtLink
class="message__icon"
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
tabindex="-1"
aria-hidden="true"
>
<Avatar
class="message__icon"
:src="members[message.author_id].avatar_url"
circle
:raised="raised"
/>
</ConditionalNuxtLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<PrivateIcon
v-if="message.body.private"
v-tooltip="'Only visible by moderators'"
class="private-icon"
/>
<ConditionalNuxtLink
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
>
{{ members[message.author_id].username }}
</ConditionalNuxtLink>
<ModeratorIcon
v-if="members[message.author_id].role === 'moderator'"
v-tooltip="'Moderator'"
/>
<ModrinthIcon
v-else-if="members[message.author_id].role === 'admin'"
v-tooltip="'Modrinth Team'"
/>
<MicIcon
v-if="report && message.author_id === report.reporterUser.id"
v-tooltip="'Reporter'"
class="reporter-icon"
/>
</span>
</template>
<template v-else>
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
<ModeratorIcon />
</div>
<span class="message__author moderation-color">
Moderator
<ModeratorIcon v-tooltip="'Moderator'" />
</span>
</template>
<div
v-if="message.body.type === 'text'"
class="message__body markdown-body"
v-html="formattedMessage"
/>
<div v-else class="message__body status-message">
<span v-if="message.body.type === 'deleted'"> posted a message that has been deleted. </span>
<template v-else-if="message.body.type === 'status_change'">
<span v-if="message.body.new_status === 'processing'">
submitted the project for review.
</span>
<span v-else>
changed the project's status from <Badge :type="message.body.old_status" /> to
<Badge :type="message.body.new_status" />.
</span>
</template>
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
</div>
<span class="message__date">
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
{{ timeSincePosted }}
</span>
</span>
<!-- <div class="message__actions">-->
<!-- <Button icon-only><MoreIcon /></Button>-->
<!-- </div>-->
</div>
</template>
<script setup>
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import ModeratorIcon from '~/assets/images/sidebar/admin.svg'
import ModrinthIcon from '~/assets/images/utils/modrinth.svg'
import MicIcon from '~/assets/images/utils/mic.svg'
import PrivateIcon from '~/assets/images/utils/lock.svg'
import { renderString } from '~/helpers/parse.js'
import ConditionalNuxtLink from '~/components/ui/ConditionalNuxtLink.vue'
const props = defineProps({
message: {
type: Object,
required: true,
},
report: {
type: Object,
default: null,
},
members: {
type: Object,
default: () => {},
},
forceCompact: {
type: Boolean,
default: false,
},
noLinks: {
type: Boolean,
default: false,
},
raised: {
type: Boolean,
default: false,
},
})
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
if (props.forceCompact) {
const hasImage = body.includes('<img')
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, '')
if (noHtml.trim()) {
return noHtml
} else if (hasImage) {
return 'sent an image.'
} else {
return 'sent a message.'
}
}
return body
})
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
</script>
<style lang="scss" scoped>
.message {
--gap-size: var(--spacing-card-xs);
display: flex;
flex-direction: row;
gap: var(--gap-size);
flex-wrap: wrap;
align-items: center;
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-md);
word-break: break-word;
.avatar,
.backed-svg {
--size: 1.5rem;
}
&.has-body {
--gap-size: var(--spacing-card-sm);
display: grid;
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
column-gap: var(--gap-size);
row-gap: var(--spacing-card-xs);
.message__icon {
margin-bottom: auto;
}
.avatar,
.backed-svg {
--size: 3rem;
}
}
&:not(.no-actions):hover,
&:not(.no-actions):focus-within {
background-color: var(--color-table-alternate-row);
.message__actions {
opacity: 1;
}
}
&.no-actions {
padding: 0;
.message__actions {
display: none;
}
}
}
.message__icon {
grid-area: icon;
}
.message__author {
grid-area: author;
font-weight: bold;
display: flex;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
flex-shrink: 0;
}
.message__date {
grid-area: date;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.message__actions {
grid-area: actions;
margin-left: auto;
opacity: 0;
}
.message__body {
grid-area: body;
}
.status-message > span {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
}
a {
display: flex;
align-items: center;
text-decoration: none;
}
a:focus-visible + .message__author a,
a:hover + .message__author a,
.message__author a:focus-visible,
.message__author a:hover {
text-decoration: underline;
filter: var(--hover-filter);
}
a:active + .message__author a,
.message__author a:active {
filter: var(--active-filter);
}
.moderation-color,
role-moderator {
color: var(--color-special-orange);
}
.role-admin {
color: var(--color-brand-green);
}
.reporter-icon {
color: var(--color-special-purple);
}
.private-icon {
color: var(--color-special-gray);
}
@media screen and (min-width: 600px) {
.message {
//grid-template:
// 'icon author body'
// 'date date date';
//grid-template-columns: min-content auto 1fr;
&.has-body {
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
}
}
}
@media screen and (min-width: 1024px) {
.message {
//grid-template: 'icon author body date';
//grid-template-columns: min-content auto 1fr auto;
&.has-body {
grid-template:
'icon author date actions'
'icon body body actions';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr auto;
}
}
}
.private {
color: var(--color-icon);
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<nuxt-link :to="link" class="thread-summary" :class="{ raised: raised }">
<div class="thread-title-row">
<span v-if="report" class="thread-title">Report thread</span>
<span v-else class="thread-title">Thread</span>
<span class="thread-messages"
>{{ props.thread.messages.length }} messages <ChevronRightIcon
/></span>
</div>
<template v-if="displayMessages.length > 0">
<ThreadMessage
v-for="message in displayMessages"
:key="message.id"
:message="message"
:report="report"
:members="members"
force-compact
no-links
/>
</template>
<span v-else>There are no messages in this thread yet.</span>
</nuxt-link>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
raised: {
type: Boolean,
default: false,
},
link: {
type: String,
required: true,
},
messages: {
type: Array,
required: false,
default() {
return []
},
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
members[app.$auth.user.id] = app.$auth.user
return members
})
const displayMessages = computed(() => {
const sortedMessages = props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
if (props.messages.length > 0) {
return sortedMessages.filter((msg) => props.messages.includes(msg.id))
} else {
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []
}
})
</script>
<style lang="scss" scoped>
.thread-summary {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
padding: var(--spacing-card-bg);
border-radius: var(--size-rounded-card);
border: 1px solid var(--color-divider-dark);
gap: var(--spacing-card-sm);
.thread-title-row {
display: flex;
flex-direction: row;
align-items: center;
.thread-title {
font-weight: bold;
color: var(--color-heading);
}
.thread-messages {
margin-left: auto;
color: var(--color-link);
svg {
vertical-align: top;
}
}
}
.thread-message {
.user {
font-weight: bold;
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
.thread-message,
.thread-message > span {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
}
&.raised {
background-color: var(--color-raised-bg);
}
&:hover .thread-title-row,
&:focus-visible .thread-title-row {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active .thread-title-row {
filter: var(--active-filter);
}
}
</style>