Modrinth/apps/frontend/src/components/ui/thread/ConversationThread.vue
Evan Song abec2e48d4
Add TailwindCSS (#1252)
* Setup TailwindCSS

* Fully setup configuration

* Refactor some tailwind variables
2024-07-06 20:57:32 -07:00

457 lines
12 KiB
Vue

<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>