You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
456
apps/frontend/src/components/ui/thread/ConversationThread.vue
Normal file
456
apps/frontend/src/components/ui/thread/ConversationThread.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<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="flags.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"
|
||||
:auth="auth"
|
||||
raised
|
||||
@update-thread="() => updateThreadLocal()"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="report && report.closed">
|
||||
<p>This thread is closed and new messages cannot be sent to it.</p>
|
||||
<button v-if="isStaff(auth.user)" class="iconified-button" @click="reopenReport()">
|
||||
<CloseIcon /> Reopen thread
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="!report || !report.closed">
|
||||
<div class="markdown-editor-spacing">
|
||||
<MarkdownEditor
|
||||
v-model="replyBody"
|
||||
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||
:on-image-upload="onUploadImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-if="sortedMessages.length > 0"
|
||||
class="btn btn-primary"
|
||||
:disabled="!replyBody"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon /> Reply
|
||||
</button>
|
||||
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
|
||||
<SendIcon /> Send
|
||||
</button>
|
||||
<button
|
||||
v-if="isStaff(auth.user)"
|
||||
class="btn"
|
||||
:disabled="!replyBody"
|
||||
@click="sendReply(null, true)"
|
||||
>
|
||||
<ModerationIcon /> Add private note
|
||||
</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="btn btn-green"
|
||||
:disabled="isApproved(project)"
|
||||
@click="sendReply(requestedStatus)"
|
||||
>
|
||||
<CheckIcon /> Approve with reply
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-green"
|
||||
:disabled="isApproved(project)"
|
||||
@click="setStatus(requestedStatus)"
|
||||
>
|
||||
<CheckIcon /> Approve
|
||||
</button>
|
||||
<div class="joined-buttons">
|
||||
<button
|
||||
v-if="replyBody"
|
||||
class="btn btn-danger"
|
||||
:disabled="project.status === 'rejected'"
|
||||
@click="sendReply('rejected')"
|
||||
>
|
||||
<CrossIcon /> Reject with reply
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-danger"
|
||||
:disabled="project.status === 'rejected'"
|
||||
@click="setStatus('rejected')"
|
||||
>
|
||||
<CrossIcon /> Reject
|
||||
</button>
|
||||
<OverflowMenu
|
||||
class="btn btn-danger btn-dropdown-animation icon-only"
|
||||
position="top"
|
||||
direction="left"
|
||||
:options="
|
||||
replyBody
|
||||
? [
|
||||
{
|
||||
id: 'withhold-reply',
|
||||
color: 'danger',
|
||||
action: () => {
|
||||
sendReply('withheld')
|
||||
},
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'withheld',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'withhold',
|
||||
color: 'danger',
|
||||
action: () => {
|
||||
setStatus('withheld')
|
||||
},
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'withheld',
|
||||
},
|
||||
]
|
||||
"
|
||||
>
|
||||
<DropdownIcon style="rotate: 180deg" />
|
||||
<template #withhold-reply> <EyeOffIcon /> Withhold with reply </template>
|
||||
<template #withhold> <EyeOffIcon /> Withhold </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { OverflowMenu, MarkdownEditor } from '@modrinth/ui'
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import ReplyIcon from '~/assets/images/utils/reply.svg?component'
|
||||
import SendIcon from '~/assets/images/utils/send.svg?component'
|
||||
import CloseIcon from '~/assets/images/utils/check-circle.svg?component'
|
||||
import CrossIcon from '~/assets/images/utils/x.svg?component'
|
||||
import EyeOffIcon from '~/assets/images/utils/eye-off.svg?component'
|
||||
import CheckIcon from '~/assets/images/utils/check.svg?component'
|
||||
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
|
||||
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,
|
||||
},
|
||||
setStatus: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: () => {},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-thread'])
|
||||
|
||||
const app = useNuxtApp()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const members = computed(() => {
|
||||
const members = {}
|
||||
for (const member of props.thread.members) {
|
||||
members[member.id] = member
|
||||
}
|
||||
return members
|
||||
})
|
||||
|
||||
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}`)
|
||||
}
|
||||
emit('update-thread', thread)
|
||||
}
|
||||
|
||||
const imageIDs = ref([])
|
||||
|
||||
async function onUploadImage(file) {
|
||||
const response = await useImageUpload(file, { context: 'thread_message' })
|
||||
|
||||
imageIDs.value.push(response.id)
|
||||
// Keep the last 10 entries of image IDs
|
||||
imageIDs.value = imageIDs.value.slice(-10)
|
||||
|
||||
return response.url
|
||||
}
|
||||
|
||||
async function sendReply(status = null, privateMessage = false) {
|
||||
try {
|
||||
const body = {
|
||||
body: {
|
||||
type: 'text',
|
||||
body: replyBody.value,
|
||||
private: privateMessage,
|
||||
},
|
||||
}
|
||||
|
||||
if (imageIDs.value.length > 0) {
|
||||
body.body = {
|
||||
...body.body,
|
||||
uploaded_images: imageIDs.value,
|
||||
}
|
||||
}
|
||||
|
||||
await useBaseFetch(`thread/${props.thread.id}`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
await updateThreadLocal()
|
||||
} catch (err) {
|
||||
app.$notify({
|
||||
group: 'main',
|
||||
title: 'Error closing report',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenReport() {
|
||||
try {
|
||||
await useBaseFetch(`report/${props.report.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
closed: false,
|
||||
},
|
||||
})
|
||||
await updateThreadLocal()
|
||||
} catch (err) {
|
||||
app.$notify({
|
||||
group: 'main',
|
||||
title: 'Error reopening 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>
|
||||
.markdown-editor-spacing {
|
||||
margin-bottom: var(--gap-md);
|
||||
}
|
||||
|
||||
.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>
|
||||
341
apps/frontend/src/components/ui/thread/ThreadMessage.vue
Normal file
341
apps/frontend/src/components/ui/thread/ThreadMessage.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<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}`">
|
||||
<LockIcon
|
||||
v-if="message.body.private"
|
||||
v-tooltip="'Only visible to moderators'"
|
||||
class="private-icon"
|
||||
/>
|
||||
<ConditionalNuxtLink
|
||||
:is-link="!noLinks"
|
||||
:to="`/user/${members[message.author_id].username}`"
|
||||
>
|
||||
{{ members[message.author_id].username }}
|
||||
</ConditionalNuxtLink>
|
||||
<ScaleIcon 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'"
|
||||
/>
|
||||
<MicrophoneIcon
|
||||
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 }">
|
||||
<ScaleIcon />
|
||||
</div>
|
||||
<span class="message__author moderation-color">
|
||||
Moderator
|
||||
<ScaleIcon 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>
|
||||
<span v-else-if="message.body.type === 'thread_reopen'">reopened 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 v-if="isStaff(auth.user) && message.author_id === auth.user.id" class="message__actions">
|
||||
<OverflowMenu
|
||||
class="btn btn-transparent icon-only"
|
||||
:options="[
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => deleteMessage(),
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
MicrophoneIcon,
|
||||
LockIcon,
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { OverflowMenu, ConditionalNuxtLink } from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
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,
|
||||
},
|
||||
auth: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-thread'])
|
||||
|
||||
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))
|
||||
|
||||
async function deleteMessage() {
|
||||
await useBaseFetch(`message/${props.message.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
emit('update-thread')
|
||||
}
|
||||
</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;
|
||||
|
||||
@media (hover: hover) {
|
||||
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-orange);
|
||||
}
|
||||
|
||||
.role-admin {
|
||||
color: var(--color-brand-green);
|
||||
}
|
||||
|
||||
.reporter-icon {
|
||||
color: var(--color-purple);
|
||||
}
|
||||
|
||||
.private-icon {
|
||||
color: var(--color-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>
|
||||
147
apps/frontend/src/components/ui/thread/ThreadSummary.vue
Normal file
147
apps/frontend/src/components/ui/thread/ThreadSummary.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<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"
|
||||
:auth="auth"
|
||||
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?component'
|
||||
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 []
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const members = computed(() => {
|
||||
const members = {}
|
||||
for (const member of props.thread.members) {
|
||||
members[member.id] = member
|
||||
}
|
||||
members[props.auth.user.id] = props.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>
|
||||
Reference in New Issue
Block a user