Compare commits

..
Author SHA1 Message Date
François-X. T. 13b6b83f6a fix app build 2026-05-12 16:28:36 -04:00
53 changed files with 3498 additions and 2078 deletions
+1 -1
View File
@@ -164,7 +164,7 @@ jobs:
with:
context: ./apps/labrinth/docker-stage
file: ./apps/labrinth/Dockerfile
push: true
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
annotations: ${{ steps.docker-meta.outputs.annotations }}
Generated
+24
View File
@@ -3059,6 +3059,29 @@ dependencies = [
"serde",
]
[[package]]
name = "elasticsearch"
version = "9.1.0-alpha.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bb303aa6e1d28c0c86b6fbfe484fd0fd3f512629aeed1ac4f6b85f81d9834a"
dependencies = [
"base64 0.22.1",
"bytes",
"dyn-clone",
"flate2",
"lazy_static",
"parking_lot",
"percent-encoding",
"reqwest 0.12.24",
"rustc_version",
"serde",
"serde_json",
"serde_with",
"tokio",
"url",
"void",
]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
@@ -5252,6 +5275,7 @@ dependencies = [
"dotenv-build",
"dotenvy",
"either",
"elasticsearch",
"eyre",
"futures",
"futures-util",
+1
View File
@@ -77,6 +77,7 @@ dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
elasticsearch = "9.1.0-alpha.1"
encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
@@ -303,7 +303,7 @@
"message": "This project is already installed"
},
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
"message": "Back to browse"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
@@ -334,7 +334,7 @@ const { formatMessage } = useVIntl()
const messages = defineMessages({
backToBrowse: {
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to discover',
defaultMessage: 'Back to browse',
},
installContentToInstance: {
id: 'app.project.install-context.install-content-to-instance',
@@ -415,7 +415,7 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
const type = data.value?.project_type ? `${data.value.project_type}s` : 'mods'
return `/browse/${type}`
})
@@ -1,12 +1,5 @@
<script setup lang="ts">
import {
BlueskyIcon,
DiscordIcon,
GithubIcon,
MastodonIcon,
ToggleRightIcon,
TwitterIcon,
} from '@modrinth/assets'
import { BlueskyIcon, DiscordIcon, GithubIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
import {
AutoLink,
ButtonStyled,
@@ -17,7 +10,6 @@ import {
type MessageDescriptor,
useVIntl,
} from '@modrinth/ui'
import { commonSettingsMessages } from '@modrinth/ui/src/utils/common-messages.js'
import TextLogo from '~/components/brand/TextLogo.vue'
@@ -241,21 +233,11 @@ function developerModeIncrement() {
role="region"
:aria-label="formatMessage(messages.modrinthInformation)"
>
<div class="flex items-center gap-2">
<TextLogo
aria-hidden="true"
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
@click="developerModeIncrement()"
/>
<ButtonStyled v-if="flags.developerMode" circular type="transparent" color="brand">
<nuxt-link
v-tooltip="formatMessage(commonSettingsMessages.featureFlags)"
to="/settings/flags"
>
<ToggleRightIcon />
</nuxt-link>
</ButtonStyled>
</div>
<TextLogo
aria-hidden="true"
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
@click="developerModeIncrement()"
/>
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
<ButtonStyled
v-for="(social, index) in socialLinks"
@@ -1,11 +1,7 @@
<script setup lang="ts">
import { XCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const tempIgnored = ref(false)
const messages = defineMessages({
title: {
@@ -17,34 +13,16 @@ const messages = defineMessages({
defaultMessage:
"This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}",
},
ignoreErrors: {
id: 'layout.banner.build-fail.ignore',
defaultMessage: 'Ignore',
},
alwaysIgnore: {
id: 'layout.banner.build-fail.always-ignore',
defaultMessage: 'Always ignore',
},
})
defineProps<{
errors: any[] | undefined
apiUrl: string
}>()
function alwaysIgnoreBanner() {
flags.value.alwaysIgnoreErrorBanner = true
saveFeatureFlags()
}
</script>
<template>
<PagewideBanner
v-if="
flags.showAllBanners || (errors?.length && !tempIgnored && !flags.alwaysIgnoreErrorBanner)
"
variant="error"
>
<PagewideBanner v-if="errors?.length" variant="error">
<template #title>
<span>{{ formatMessage(messages.title) }}</span>
</template>
@@ -56,19 +34,5 @@ function alwaysIgnoreBanner() {
})
}}
</template>
<template #actions_right>
<ButtonStyled color="red" type="transparent" hover-color-fill="background">
<button @click="alwaysIgnoreBanner">
<XCircleIcon />
{{ formatMessage(messages.alwaysIgnore) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="tempIgnored = true">
<XIcon />
{{ formatMessage(messages.ignoreErrors) }}
</button>
</ButtonStyled>
</template>
</PagewideBanner>
</template>
@@ -13,7 +13,6 @@ import {
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const config = useRuntimeConfig()
const route = useRoute()
const messages = defineMessages({
title: {
@@ -22,7 +21,7 @@ const messages = defineMessages({
},
description: {
id: 'layout.banner.preview.description',
defaultMessage: `If you meant to access the official Modrinth website, visit {url}. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}.`,
defaultMessage: `If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}.`,
},
})
@@ -30,12 +29,10 @@ function hidePreviewBanner() {
flags.value.hidePreviewBanner = true
saveFeatureFlags()
}
const url = computed(() => `https://modrinth.com${route.fullPath}`)
</script>
<template>
<PagewideBanner v-if="!flags.hidePreviewBanner || flags.showAllBanners" variant="info">
<PagewideBanner v-if="!flags.hidePreviewBanner" variant="info">
<template #title>
<span>{{ formatMessage(messages.title) }}</span>
</template>
@@ -48,9 +45,9 @@ const url = computed(() => `https://modrinth.com${route.fullPath}`)
branch: config.public.branch,
}"
>
<template #url>
<a :href="url" target="_blank" rel="noopener" class="text-link">
{{ url }}
<template #link="{ children }">
<a href="https://modrinth.com" target="_blank" rel="noopener" class="text-link">
<component :is="() => normalizeChildren(children)" />
</a>
</template>
<template #branch-link="{ children }">
@@ -78,7 +75,7 @@ const url = computed(() => `https://modrinth.com${route.fullPath}`)
</IntlFormatted>
</span>
</template>
<template #actions_top_right>
<template #actions_right>
<ButtonStyled type="transparent" circular>
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
<XIcon aria-hidden="true" />
@@ -47,7 +47,7 @@ function hideRussiaCensorshipBanner() {
<span class="text-xs font-medium">(Перевод на русский)</span>
</nuxt-link>
</ButtonStyled>
<ButtonStyled type="transparent" hover-color-fill="background">
<ButtonStyled>
<nuxt-link to="/news/article/standing-by-our-values">
<BookTextIcon /> Read our full statement
<span class="text-xs font-medium">(English)</span>
@@ -55,7 +55,7 @@ function hideRussiaCensorshipBanner() {
</ButtonStyled>
</div>
</template>
<template #actions_top_right>
<template #actions_right>
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(commonMessages.closeButton)"
@@ -10,7 +10,6 @@ import {
const { formatMessage } = useVIntl()
const cosmetics = useCosmetics()
const flags = useFeatureFlags()
const messages = defineMessages({
title: {
@@ -30,14 +29,14 @@ function hideStagingBanner() {
</script>
<template>
<PagewideBanner v-if="flags.showAllBanners || !cosmetics.hideStagingBanner" variant="warning">
<PagewideBanner v-if="!cosmetics.hideStagingBanner" variant="warning">
<template #title>
<span>{{ formatMessage(messages.title) }}</span>
</template>
<template #description>
{{ formatMessage(messages.description) }}
</template>
<template #actions_top_right>
<template #actions_right>
<ButtonStyled type="transparent" circular>
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hideStagingBanner">
<XIcon aria-hidden="true" />
@@ -29,8 +29,8 @@ const messages = defineMessages({
<template #description>
<span>{{ formatMessage(messages.description) }}</span>
</template>
<template #actions_right>
<ButtonStyled color="red">
<template #actions>
<ButtonStyled>
<nuxt-link to="/settings/billing">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(messages.action) }}
@@ -55,7 +55,7 @@ function openTaxForm(e: MouseEvent) {
formatMessage(messages.description, { threshold: formatMoney(taxThreshold) })
}}</span>
</template>
<template #actions_right>
<template #actions>
<ButtonStyled color="orange">
<button @click="openTaxForm"><FileTextIcon /> {{ formatMessage(messages.action) }}</button>
</ButtonStyled>
@@ -29,7 +29,7 @@ const messages = defineMessages({
<template #description>
<span>{{ formatMessage(messages.description) }}</span>
</template>
<template #actions_right>
<template #actions>
<div class="flex w-fit flex-row">
<ButtonStyled color="red">
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
@@ -96,12 +96,14 @@ async function handleResendEmailVerification() {
}}
</span>
</template>
<template #actions_right>
<ButtonStyled color="orange">
<button v-if="hasEmail" @click="handleResendEmailVerification">
<template #actions>
<ButtonStyled v-if="hasEmail">
<button @click="handleResendEmailVerification">
{{ formatMessage(verifyEmailBannerMessages.action) }}
</button>
<nuxt-link v-else to="/settings/account">
</ButtonStyled>
<ButtonStyled v-else>
<nuxt-link to="/settings/account">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(addEmailBannerMessages.action) }}
</nuxt-link>
@@ -1,41 +0,0 @@
<script setup lang="ts">
import { PagewideBanner } from '@modrinth/ui'
const flags = useFeatureFlags()
const route = useRoute()
const url = computed(() => `https://modrinth.com${route.fullPath}`)
const bannerRoot = ref<HTMLElement | null>(null)
function onProdLinkClick(e: MouseEvent) {
e.preventDefault()
const el = bannerRoot.value
if (el) {
const { height } = el.getBoundingClientRect()
window.scrollBy({ top: Math.ceil(height), behavior: 'auto' })
}
window.open(url.value, '_blank', 'noopener,noreferrer')
}
</script>
<template>
<div v-if="flags.showViewProdRouteBanner || flags.showAllBanners" ref="bannerRoot">
<PagewideBanner variant="info" slim>
<template #description>
<span>
View route on production:
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
class="text-link"
@click="onProdLinkClick"
>
{{ url }}
</a>
</span>
</template>
</PagewideBanner>
</div>
</template>
@@ -1,5 +1,5 @@
<template>
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="shadow-card rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink
@@ -107,8 +107,8 @@
<LinkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular color="orange">
<button v-tooltip="'Begin review'" @click="openProjectForReview">
<ButtonStyled v-tooltip="'Begin review'" circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon />
</button>
</ButtonStyled>
@@ -143,7 +143,7 @@
:expand-text="expandText"
collapse-text="Collapse thread"
>
<div class="bg-surface-2 pt-2">
<div class="bg-surface-2 p-4 pt-2">
<ThreadView
v-if="threadWithReportBody"
ref="reportThread"
@@ -1075,7 +1075,6 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
mode="local"
:links="navTabsLinks"
:active-index="activeTabIndex"
class="bg-surface-3! shadow-none!"
@tab-click="handleTabClick"
/>
</div>
@@ -1088,7 +1087,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
collapse-text="Collapse thread"
class="border-x border-b border-solid border-surface-3"
>
<div class="bg-surface-2 pt-0">
<div class="bg-surface-2 p-4 pt-0">
<!-- DEV-531 -->
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
<ThreadView
@@ -358,44 +358,26 @@
<div v-else-if="generatedMessage" class="flex items-center gap-2">
<ButtonStyled>
<button :disabled="loadingModerationDecision" @click="goBackToStages">
<button @click="goBackToStages">
<LeftArrowIcon aria-hidden="true" />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="loadingModerationDecision" @click="sendMessage('rejected')">
<SpinnerIcon
v-if="moderationDecision === 'rejected'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
<button @click="sendMessage('rejected')">
<XIcon aria-hidden="true" />
Reject
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="loadingModerationDecision" @click="sendMessage('withheld')">
<SpinnerIcon
v-if="moderationDecision === 'withheld'"
class="animate-spin"
aria-hidden="true"
/>
<LinkIcon v-else aria-hidden="true" />
<button @click="sendMessage('withheld')">
<EyeOffIcon aria-hidden="true" />
Withhold
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button
:disabled="loadingModerationDecision"
@click="sendMessage(approveSendStatus)"
>
<SpinnerIcon
v-if="moderationDecision === approveSendStatus"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
<button @click="sendMessage(projectV2.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" />
Approve
</button>
</ButtonStyled>
@@ -446,15 +428,14 @@ import {
BrushCleaningIcon,
CheckIcon,
DropdownIcon,
EyeOffIcon,
FileTextIcon,
KeyboardIcon,
LeftArrowIcon,
LinkIcon,
ListBulletedIcon,
LockIcon,
RightArrowIcon,
ScaleIcon,
SpinnerIcon,
ToggleLeftIcon,
ToggleRightIcon,
XIcon,
@@ -779,12 +760,6 @@ const message = ref(
)
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
const loadingMessage = ref(false)
const moderationDecision = ref<ProjectStatus | null>(null)
const loadingModerationDecision = computed(() => moderationDecision.value !== null)
const approveSendStatus = computed<ProjectStatus>(() => {
const requested = projectV2.value.requested_status
return requested ?? 'approved'
})
const done = ref(false)
function persistGeneratedMessageState() {
@@ -1099,7 +1074,6 @@ function resetProgress() {
done.value = false
clearGeneratedMessageState()
loadingMessage.value = false
moderationDecision.value = null
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
@@ -1216,7 +1190,7 @@ function handleKeybinds(event: KeyboardEvent) {
tryResetProgress: resetProgress,
tryExitModeration: handleExit,
tryApprove: () => sendMessage(approveSendStatus.value),
tryApprove: () => sendMessage(projectV2.value.requested_status ?? 'approved'),
tryReject: () => sendMessage('rejected'),
tryWithhold: () => sendMessage('withheld'),
tryEditMessage: goBackToStages,
@@ -2003,7 +1977,6 @@ async function sendMessage(status: ProjectStatus) {
return
}
moderationDecision.value = status
try {
await useBaseFetch(`project/${projectId}`, {
method: 'PATCH',
@@ -2053,8 +2026,6 @@ async function sendMessage(status: ProjectStatus) {
text: 'Failed to submit moderation decision. Please try again.',
type: 'error',
})
} finally {
moderationDecision.value = null
}
}
@@ -1,31 +1,17 @@
<template>
<div>
<section>
<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"
:auth="auth"
class="card-shadow mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-2 p-4"
/>
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
</section>
<section
v-if="report && thread"
class="card-shadow rounded-2xl border border-solid border-surface-4 bg-surface-3"
>
<h2 class="m-4 mb-2 text-xl font-semibold text-contrast">Messages with the moderators</h2>
<p class="mx-4 mt-0">
Make sure to include evidence of all claims you make, or your report may be closed without
action.
</p>
<section v-if="report && thread" class="universal-card">
<h2>Messages</h2>
<ConversationThread
class="overflow-clip rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2"
:thread="thread"
:report="report"
:auth="auth"
@@ -1,45 +1,28 @@
<template>
<div>
<NewModal
<Modal
ref="modalSubmit"
:header="
formatMessage(
isRejected(project)
? messages.resubmitModalHeaderResubmitting
: messages.resubmitModalHeaderSubmitting,
)
"
:header="isRejected(project) ? 'Resubmit for review' : 'Submit for review'"
>
<div class="flex max-w-[35rem] flex-col gap-3">
<p class="m-0">
<IntlFormatted
:message-id="messages.resubmitModalDescription"
:message-values="{ projectTitle: project.title }"
>
<template #project-title="{ children }">
<span class="font-semibold text-contrast">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</p>
<p class="m-0">{{ formatMessage(messages.resubmitModalReminder) }}</p>
<p class="m-0 font-semibold text-red">
{{ formatMessage(messages.resubmitModalWarning) }}
</p>
<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="formatMessage(messages.resubmitModalConfirmationDescription)"
description="Confirm I have addressed the messages from the moderators"
>
{{ formatMessage(messages.resubmitModalConfirmationLabel) }}
I confirm that I have properly addressed the moderators' comments.
</Checkbox>
<div class="flex flex-wrap items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modalSubmit.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<div class="input-group push-right">
<ButtonStyled color="orange">
<button
:disabled="!submissionConfirmation || isLoading"
@@ -51,37 +34,33 @@
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionResubmitForReview) }}
Resubmit for review
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="modalReply" :header="formatMessage(messages.replyModalHeader)">
<div class="flex max-w-[45rem] flex-col gap-3">
<p class="m-0">{{ formatMessage(messages.replyModalDescription) }}</p>
<p class="m-0">
<IntlFormatted :message-id="messages.replyModalHelpCenterNote">
<template #help-center-link="{ children }">
<a class="text-link" href="https://support.modrinth.com" target="_blank">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
</Modal>
<Modal ref="modalReply" header="Reply to thread">
<div class="modal-submit universal-body">
<span>
Your project is already approved. As such, the moderation team does not actively monitor
this thread. However, they may still see your message if there is a problem with your
project.
</span>
<span>
If you need to get in contact with the moderation team, please use the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</span>
<Checkbox
v-model="replyConfirmation"
:description="formatMessage(messages.replyModalConfirmationDescription)"
description="Confirm moderators do not actively monitor this"
>
{{ formatMessage(messages.replyModalConfirmationLabel) }}
I acknowledge that the moderators do not actively monitor the thread.
</Checkbox>
<div class="flex flex-wrap items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modalReply.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<div class="input-group push-right">
<ButtonStyled color="brand">
<button
:disabled="!replyConfirmation || isLoading"
@@ -93,318 +72,289 @@
aria-hidden="true"
/>
<ReplyIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionReplyToThread) }}
Reply to thread
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div v-if="flags.developerMode" class="mx-4 mb-3 font-semibold">
</Modal>
<div v-if="flags.developerMode" class="thread-id">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div v-bind="$attrs" class="flex flex-col">
<div v-if="sortedMessages.length > 0" class="flex flex-col pt-2">
<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 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>
<ButtonStyled v-if="isStaff(auth.user)">
<button :disabled="isLoading" @click="runBlockingAction('reopen', () => reopenReport())">
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
<CheckCircleIcon v-else aria-hidden="true" />
Reopen thread
</button>
</ButtonStyled>
</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>
<template v-if="report && report.closed">
<p>{{ formatMessage(messages.closedThreadDescription) }}</p>
<div class="input-group">
<ButtonStyled color="brand">
<button
v-if="sortedMessages.length > 0"
:disabled="!replyBody || isLoading"
@click="
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('reply', () => sendReply())
"
>
<SpinnerIcon v-if="loadingAction === 'reply'" class="animate-spin" aria-hidden="true" />
<ReplyIcon v-else aria-hidden="true" />
Reply
</button>
<button
v-else
:disabled="!replyBody || isLoading"
@click="
isApproved(project) && !isStaff(auth.user)
? openReplyModal()
: runBlockingAction('send', () => sendReply())
"
>
<SpinnerIcon v-if="loadingAction === 'send'" class="animate-spin" aria-hidden="true" />
<SendIcon v-else aria-hidden="true" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)">
<button :disabled="isLoading" @click="runBlockingAction('reopen', () => reopenReport())">
<button
:disabled="!replyBody || isLoading"
@click="runBlockingAction('private-note', () => sendReply(null, true))"
>
<SpinnerIcon
v-if="loadingAction === 'reopen'"
v-if="loadingAction === 'private-note'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionReopenThread) }}
<ScaleIcon v-else aria-hidden="true" />
Add private note
</button>
</ButtonStyled>
</template>
<template v-else-if="!report || !report.closed">
<div class="mx-4 mb-2 mt-2">
<MarkdownEditor
v-model="replyBody"
:placeholder="
formatMessage(
sortedMessages.length > 0
? messages.replyEditorPlaceholderReply
: messages.replyEditorPlaceholderSend,
)
"
:on-image-upload="onUploadImage"
/>
</div>
<div class="m-4 mt-3 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-2">
<ButtonStyled color="brand">
<button
v-if="sortedMessages.length > 0"
:disabled="!replyBody || isLoading"
@click="
isApproved(project)
? openReplyModal()
: runBlockingAction('reply', () => sendReply())
"
>
<SpinnerIcon
v-if="loadingAction === 'reply'"
class="animate-spin"
aria-hidden="true"
/>
<ReplyIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionReply) }}
<template v-if="currentMember && !isStaff(auth.user)">
<template v-if="isRejected(project)">
<ButtonStyled color="orange">
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
<ScaleIcon aria-hidden="true" />
Resubmit for review with reply
</button>
<button
v-else
:disabled="!replyBody || isLoading"
@click="
isApproved(project)
? openReplyModal()
: runBlockingAction('send', () => sendReply())
"
>
<SpinnerIcon
v-if="loadingAction === 'send'"
class="animate-spin"
aria-hidden="true"
/>
<SendIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionSend) }}
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
<ScaleIcon aria-hidden="true" />
Resubmit for review
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)">
<button
:disabled="!replyBody || isLoading"
@click="runBlockingAction('private-note', () => sendReply(null, true))"
>
<SpinnerIcon
v-if="loadingAction === 'private-note'"
class="animate-spin"
aria-hidden="true"
/>
<ScaleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionAddPrivateNote) }}
</button>
</ButtonStyled>
<template v-if="currentMember && !currentMember.staffOnly">
<template v-if="isRejected(project)">
<ButtonStyled color="orange">
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
<ScaleIcon aria-hidden="true" />
{{ formatMessage(messages.actionResubmitForReviewWithReply) }}
</template>
</template>
<div class="spacer"></div>
<div class="input-group extra-options">
<template v-if="report">
<template v-if="isStaff(auth.user)">
<ButtonStyled color="red">
<button
v-if="replyBody"
:disabled="isLoading"
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
>
<SpinnerIcon
v-if="loadingAction === 'close-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
Close with reply
</button>
<button
v-else
:disabled="isLoading"
@click="runBlockingAction('close', () => closeReport())"
>
<SpinnerIcon
v-if="loadingAction === 'close'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
Close thread
</button>
</ButtonStyled>
</template>
</template>
<template v-if="project">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="green">
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve-with-reply', () => sendReply(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
Approve with reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="green">
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve', () => setStatus(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
Approve
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled v-if="replyBody" color="red">
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject-with-reply', () => sendReply('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
Reject with reply
</button>
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
<ScaleIcon aria-hidden="true" />
{{ formatMessage(messages.actionResubmitForReview) }}
</ButtonStyled>
<ButtonStyled v-else color="red">
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject', () => setStatus('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
Reject
</button>
</ButtonStyled>
</template>
</template>
</div>
<div class="flex flex-wrap items-center gap-2">
<template v-if="report">
<template v-if="isStaff(auth.user)">
<ButtonStyled color="red">
<button
v-if="replyBody"
<OverflowMenu
class="btn-dropdown-animation"
:disabled="isLoading"
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
>
<SpinnerIcon
v-if="loadingAction === 'close-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionCloseWithReply) }}
</button>
<button
v-else
:disabled="isLoading"
@click="runBlockingAction('close', () => closeReport())"
>
<SpinnerIcon
v-if="loadingAction === 'close'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionCloseThread) }}
</button>
</ButtonStyled>
</template>
</template>
<template v-if="project">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="green">
<button
:disabled="isApproved(project) || isLoading"
@click="
runBlockingAction('approve-with-reply', () => sendReply(requestedStatus))
:options="
replyBody
? [
{
id: 'withhold-reply',
color: 'danger',
action: () =>
runBlockingAction('withhold-reply', () => sendReply('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft-reply',
action: () =>
runBlockingAction('set-to-draft-reply', () => sendReply('draft')),
hoverFilled: true,
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review-reply',
action: () =>
runBlockingAction('send-to-review-reply', () =>
sendReply('processing', true),
),
hoverFilled: true,
disabled: project.status === 'processing' || isLoading,
},
]
: [
{
id: 'withhold',
color: 'danger',
action: () =>
runBlockingAction('withhold', () => setStatus('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft',
action: () =>
runBlockingAction('set-to-draft', () => setStatus('draft')),
hoverFilled: true,
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review',
action: () =>
runBlockingAction('send-to-review', () => setStatus('processing')),
hoverFilled: true,
disabled: project.status === 'processing' || isLoading,
},
]
"
>
<SpinnerIcon
v-if="loadingAction === 'approve-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionApproveWithReply) }}
</button>
<SpinnerIcon v-if="isDropdownLoading" class="animate-spin" aria-hidden="true" />
<DropdownIcon v-else aria-hidden="true" />
<template #withhold-reply>
<EyeOffIcon aria-hidden="true" />
Withhold with reply
</template>
<template #withhold>
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #set-to-draft-reply>
<FileTextIcon aria-hidden="true" />
Set to draft with reply
</template>
<template #set-to-draft>
<FileTextIcon aria-hidden="true" />
Set to draft
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
Send to review with reply
</template>
<template #send-to-review>
<ScaleIcon aria-hidden="true" />
Send to review
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-else color="green">
<button
:disabled="isApproved(project) || isLoading"
@click="runBlockingAction('approve', () => setStatus(requestedStatus))"
>
<SpinnerIcon
v-if="loadingAction === 'approve'"
class="animate-spin"
aria-hidden="true"
/>
<CheckIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionApprove) }}
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled v-if="replyBody" color="red">
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject-with-reply', () => sendReply('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionRejectWithReply) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="red">
<button
:disabled="project.status === 'rejected' || isLoading"
@click="runBlockingAction('reject', () => setStatus('rejected'))"
>
<SpinnerIcon
v-if="loadingAction === 'reject'"
class="animate-spin"
aria-hidden="true"
/>
<XIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionReject) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<OverflowMenu
class="btn-dropdown-animation"
:disabled="isLoading"
:options="
replyBody
? [
{
id: 'withhold-reply',
color: 'danger',
action: () =>
runBlockingAction('withhold-reply', () => sendReply('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft-reply',
action: () =>
runBlockingAction('set-to-draft-reply', () => sendReply('draft')),
hoverFilled: true,
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review-reply',
action: () =>
runBlockingAction('send-to-review-reply', () =>
sendReply('processing', true),
),
hoverFilled: true,
disabled: project.status === 'processing' || isLoading,
},
]
: [
{
id: 'withhold',
color: 'danger',
action: () =>
runBlockingAction('withhold', () => setStatus('withheld')),
hoverFilled: true,
disabled: project.status === 'withheld' || isLoading,
},
{
id: 'set-to-draft',
action: () =>
runBlockingAction('set-to-draft', () => setStatus('draft')),
hoverFilled: true,
disabled: project.status === 'draft' || isLoading,
},
{
id: 'send-to-review',
action: () =>
runBlockingAction('send-to-review', () =>
setStatus('processing'),
),
hoverFilled: true,
disabled: project.status === 'processing' || isLoading,
},
]
"
>
<SpinnerIcon
v-if="isDropdownLoading"
class="animate-spin"
aria-hidden="true"
/>
<DropdownIcon v-else aria-hidden="true" />
<template #withhold-reply>
<EyeOffIcon aria-hidden="true" />
{{ formatMessage(messages.actionWithholdWithReply) }}
</template>
<template #withhold>
<EyeOffIcon aria-hidden="true" />
{{ formatMessage(messages.actionWithhold) }}
</template>
<template #set-to-draft-reply>
<FileTextIcon aria-hidden="true" />
{{ formatMessage(messages.actionSetToDraftWithReply) }}
</template>
<template #set-to-draft>
<FileTextIcon aria-hidden="true" />
{{ formatMessage(messages.actionSetToDraft) }}
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
{{ formatMessage(messages.actionSendToReviewWithReply) }}
</template>
<template #send-to-review>
<ScaleIcon aria-hidden="true" />
{{ formatMessage(messages.actionSendToReview) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
@@ -424,179 +374,19 @@ import {
import {
ButtonStyled,
Checkbox,
commonMessages,
CopyCode,
defineMessages,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
NewModal,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import Modal from '~/components/ui/Modal.vue'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isApproved, isRejected } from '~/helpers/projects.js'
import { isStaff } from '~/helpers/users.js'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
resubmitModalHeaderResubmitting: {
id: 'conversation-thread.resubmit-modal.header.resubmitting',
defaultMessage: 'Resubmitting for review',
},
resubmitModalHeaderSubmitting: {
id: 'conversation-thread.resubmit-modal.header.submitting',
defaultMessage: 'Submitting for review',
},
resubmitModalDescription: {
id: 'conversation-thread.resubmit-modal.description',
defaultMessage:
"You're submitting <project-title>{projectTitle}</project-title> to be reviewed again by the moderators.",
},
resubmitModalReminder: {
id: 'conversation-thread.resubmit-modal.reminder',
defaultMessage: 'Make sure you have addressed all the comments from the moderation team.',
},
resubmitModalWarning: {
id: 'conversation-thread.resubmit-modal.warning',
defaultMessage:
"Repeated submissions without addressing the moderators' comments may result in an account suspension.",
},
resubmitModalConfirmationDescription: {
id: 'conversation-thread.resubmit-modal.confirmation.description',
defaultMessage: 'Confirm I have addressed the messages from the moderators',
},
resubmitModalConfirmationLabel: {
id: 'conversation-thread.resubmit-modal.confirmation.label',
defaultMessage: "I confirm that I have properly addressed the moderators' comments.",
},
replyModalHeader: {
id: 'conversation-thread.reply-modal.header',
defaultMessage: 'Reply to thread',
},
replyModalDescription: {
id: 'conversation-thread.reply-modal.description',
defaultMessage:
'Your project is already approved. As such, the moderation team does not actively monitor this thread. However, they may still see your message if there is a problem with your project.',
},
replyModalHelpCenterNote: {
id: 'conversation-thread.reply-modal.help-center-note',
defaultMessage:
'If you need to get in contact with the moderation team, please use the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble in the bottom right corner to contact support.',
},
replyModalConfirmationDescription: {
id: 'conversation-thread.reply-modal.confirmation.description',
defaultMessage: 'Confirm moderators do not actively monitor this',
},
replyModalConfirmationLabel: {
id: 'conversation-thread.reply-modal.confirmation.label',
defaultMessage: 'I acknowledge that the moderators do not actively monitor the thread.',
},
closedThreadDescription: {
id: 'conversation-thread.closed-thread.description',
defaultMessage: 'This thread is closed and new messages cannot be sent to it.',
},
replyEditorPlaceholderReply: {
id: 'conversation-thread.reply-editor.placeholder.reply',
defaultMessage: 'Reply to thread...',
},
replyEditorPlaceholderSend: {
id: 'conversation-thread.reply-editor.placeholder.send',
defaultMessage: 'Send a message...',
},
actionResubmitForReview: {
id: 'conversation-thread.action.resubmit-for-review',
defaultMessage: 'Resubmit for review',
},
actionReplyToThread: {
id: 'conversation-thread.action.reply-to-thread',
defaultMessage: 'Reply to thread',
},
actionReopenThread: {
id: 'conversation-thread.action.reopen-thread',
defaultMessage: 'Reopen thread',
},
actionReply: {
id: 'conversation-thread.action.reply',
defaultMessage: 'Reply',
},
actionSend: {
id: 'conversation-thread.action.send',
defaultMessage: 'Send',
},
actionAddPrivateNote: {
id: 'conversation-thread.action.add-private-note',
defaultMessage: 'Add private note',
},
actionResubmitForReviewWithReply: {
id: 'conversation-thread.action.resubmit-for-review-with-reply',
defaultMessage: 'Resubmit for review with reply',
},
actionCloseWithReply: {
id: 'conversation-thread.action.close-with-reply',
defaultMessage: 'Close with reply',
},
actionCloseThread: {
id: 'conversation-thread.action.close-thread',
defaultMessage: 'Close thread',
},
actionApproveWithReply: {
id: 'conversation-thread.action.approve-with-reply',
defaultMessage: 'Approve with reply',
},
actionApprove: {
id: 'conversation-thread.action.approve',
defaultMessage: 'Approve',
},
actionRejectWithReply: {
id: 'conversation-thread.action.reject-with-reply',
defaultMessage: 'Reject with reply',
},
actionReject: {
id: 'conversation-thread.action.reject',
defaultMessage: 'Reject',
},
actionWithholdWithReply: {
id: 'conversation-thread.action.withhold-with-reply',
defaultMessage: 'Withhold with reply',
},
actionWithhold: {
id: 'conversation-thread.action.withhold',
defaultMessage: 'Withhold',
},
actionSetToDraftWithReply: {
id: 'conversation-thread.action.set-to-draft-with-reply',
defaultMessage: 'Set to draft with reply',
},
actionSetToDraft: {
id: 'conversation-thread.action.set-to-draft',
defaultMessage: 'Set to draft',
},
actionSendToReviewWithReply: {
id: 'conversation-thread.action.send-to-review-with-reply',
defaultMessage: 'Send to review with reply',
},
actionSendToReview: {
id: 'conversation-thread.action.send-to-review',
defaultMessage: 'Send to review',
},
errorSendingMessage: {
id: 'conversation-thread.error.sending-message',
defaultMessage: 'Error sending message',
},
errorClosingReport: {
id: 'conversation-thread.error.closing-report',
defaultMessage: 'Error closing report',
},
errorReopeningReport: {
id: 'conversation-thread.error.reopening-report',
defaultMessage: 'Error reopening report',
},
})
const props = defineProps({
thread: {
@@ -742,7 +532,7 @@ async function sendReply(status = null, privateMessage = false) {
}
} catch (err) {
addNotification({
title: formatMessage(messages.errorSendingMessage),
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -764,7 +554,7 @@ async function closeReport(reply) {
await updateThreadLocal()
} catch (err) {
addNotification({
title: formatMessage(messages.errorClosingReport),
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -782,7 +572,7 @@ async function reopenReport() {
await updateThreadLocal()
} catch (err) {
addNotification({
title: formatMessage(messages.errorReopeningReport),
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
@@ -814,8 +604,44 @@ async function resubmit() {
}
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>
.markdown-editor-spacing {
margin-bottom: var(--gap-md);
}
.messages {
display: flex;
flex-direction: column;
padding: var(--spacing-card-md);
}
.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>
@@ -1,11 +1,10 @@
<template>
<div
class="message px-4 py-3"
class="message"
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: isPrivateMessage,
'show-private-bg': flags.showModeratorPrivateMessageHighlight,
}"
>
<template v-if="members[message.author_id]">
@@ -23,6 +22,11 @@
/>
</AutoLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<LockIcon
v-if="isPrivateMessage"
v-tooltip="'Only visible to moderators'"
class="private-icon"
/>
<AutoLink :to="noLinks ? '' : `/user/${members[message.author_id].username}`">
{{ members[message.author_id].username }}
</AutoLink>
@@ -31,11 +35,6 @@
v-else-if="members[message.author_id].role === 'admin'"
v-tooltip="'Modrinth Team'"
/>
<EyeOffIcon
v-if="isPrivateMessage"
v-tooltip="'Only visible to moderators'"
class="ml-1 text-orange"
/>
<MicrophoneIcon
v-if="report && message.author_id === report.reporter_user?.id"
v-tooltip="'Reporter'"
@@ -80,12 +79,6 @@
<span v-if="message.body.new_status === 'processing'">
submitted the project for review.
</span>
<span v-else-if="message.body.old_status === 'processing'">
reviewed the project and set its status to <Badge :type="message.body.new_status" />.
</span>
<span v-else-if="message.body.new_status === 'draft'">
reverted this project back to a <Badge :type="message.body.new_status" />.
</span>
<span v-else>
changed the project's status from <Badge :type="message.body.old_status" /> to
<Badge :type="message.body.new_status" />.
@@ -133,7 +126,7 @@
<script setup>
import {
EyeOffIcon,
LockIcon,
MicrophoneIcon,
ModrinthIcon,
MoreHorizontalIcon,
@@ -185,7 +178,6 @@ const props = defineProps({
})
const emit = defineEmits(['update-thread'])
const flags = useFeatureFlags()
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
@@ -230,13 +222,15 @@ async function deleteMessage() {
<style lang="scss" scoped>
.message {
--gap-size: var(--spacing-card-xs);
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
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;
position: relative;
.avatar,
.backed-svg {
@@ -244,12 +238,14 @@ async function deleteMessage() {
}
&.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 {
@@ -264,22 +260,13 @@ async function deleteMessage() {
&:not(.no-actions):hover,
&:not(.no-actions):focus-within {
background-color: var(--surface-2-5);
background-color: var(--color-table-alternate-row);
.message__actions {
opacity: 1;
}
}
&.private.show-private-bg::before {
content: '';
inset: 0;
position: absolute;
background-color: var(--color-orange);
opacity: 0.05;
pointer-events: none;
}
&.no-actions {
padding: 0;
@@ -359,6 +346,10 @@ a:active + .message__author a,
color: var(--color-purple);
}
.private-icon {
color: var(--color-gray);
}
@media screen and (min-width: 600px) {
.message {
//grid-template:
@@ -372,7 +363,6 @@ a:active + .message__author a,
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr auto;
}
}
}
@@ -387,7 +377,7 @@ a:active + .message__author a,
'icon author date actions'
'icon body body actions';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr;
grid-template-rows: min-content 1fr auto;
}
}
}
@@ -1,11 +1,11 @@
<template>
<div>
<div v-if="flags.developerMode" class="m-4 font-bold text-heading">
<div v-if="flags.developerMode" class="mt-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="flex flex-col rounded-xl">
<div v-if="sortedMessages.length > 0" class="flex flex-col space-y-4 rounded-xl p-3 sm:p-4">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
@@ -28,7 +28,7 @@
</template>
<template v-else>
<div class="px-4 py-2">
<div>
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
@@ -37,7 +37,7 @@
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:gap-2"
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand">
@@ -49,11 +49,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
labrinthApiCanary: false,
dismissedExternalProjectsInfo: false,
modpackPermissionsPage: false,
showAllBanners: false,
alwaysIgnoreErrorBanner: false,
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
showModeratorPrivateMessageHighlight: true,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+8 -23
View File
@@ -34,39 +34,25 @@
'modrinth-parent__no-modal-blurs': !cosmetics.advancedRendering,
}"
>
<RussiaBanner v-if="flags.showAllBanners || isRussia" />
<TaxIdMismatchBanner v-if="flags.showAllBanners || showTinMismatchBanner" />
<TaxComplianceBanner v-if="flags.showAllBanners || showTaxComplianceBanner" />
<RussiaBanner v-if="isRussia" />
<TaxIdMismatchBanner v-if="showTinMismatchBanner" />
<TaxComplianceBanner v-if="showTaxComplianceBanner" />
<VerifyEmailBanner
v-if="
flags.showAllBanners ||
(auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email')
"
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
:has-email="!!auth?.user?.email"
/>
<SubscriptionPaymentFailedBanner
v-if="
flags.showAllBanners ||
(user.subscriptions.some((x) => x.status === 'payment-failed') &&
route.path !== '/settings/billing')
"
/>
<PreviewBanner
v-if="
flags.showAllBanners || (config.public.buildEnv === 'production' && config.public.preview)
"
/>
<StagingBanner
v-if="
flags.showAllBanners ||
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com')
user.subscriptions.some((x) => x.status === 'payment-failed') &&
route.path !== '/settings/billing'
"
/>
<PreviewBanner v-if="config.public.buildEnv === 'production' && config.public.preview" />
<StagingBanner v-if="config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com')" />
<GeneratedStateErrorsBanner
:errors="generatedStateErrors"
:api-url="config.public.apiBaseUrl"
/>
<ViewOnModrinthBanner />
<header
class="desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-6 py-4 lg:grid-cols-[auto_1fr_auto]"
>
@@ -781,7 +767,6 @@ import SubscriptionPaymentFailedBanner from '~/components/ui/banner/Subscription
import TaxComplianceBanner from '~/components/ui/banner/TaxComplianceBanner.vue'
import TaxIdMismatchBanner from '~/components/ui/banner/TaxIdMismatchBanner.vue'
import VerifyEmailBanner from '~/components/ui/banner/VerifyEmailBanner.vue'
import ViewOnModrinthBanner from '~/components/ui/banner/ViewOnModrinthBanner.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
+20 -218
View File
@@ -407,117 +407,6 @@
"collection.title": {
"message": "{name} - Collection"
},
"conversation-thread.action.add-private-note": {
"message": "Add private note"
},
"conversation-thread.action.approve": {
"message": "Approve"
},
"conversation-thread.action.approve-with-reply": {
"message": "Approve with reply"
},
"conversation-thread.action.close-thread": {
"message": "Close thread"
},
"conversation-thread.action.close-with-reply": {
"message": "Close with reply"
},
"conversation-thread.action.reject": {
"message": "Reject"
},
"conversation-thread.action.reject-with-reply": {
"message": "Reject with reply"
},
"conversation-thread.action.reopen-thread": {
"message": "Reopen thread"
},
"conversation-thread.action.reply": {
"message": "Reply"
},
"conversation-thread.action.reply-to-thread": {
"message": "Reply to thread"
},
"conversation-thread.action.resubmit-for-review": {
"message": "Resubmit for review"
},
"conversation-thread.action.resubmit-for-review-with-reply": {
"message": "Resubmit for review with reply"
},
"conversation-thread.action.send": {
"message": "Send"
},
"conversation-thread.action.send-to-review": {
"message": "Send to review"
},
"conversation-thread.action.send-to-review-with-reply": {
"message": "Send to review with reply"
},
"conversation-thread.action.set-to-draft": {
"message": "Set to draft"
},
"conversation-thread.action.set-to-draft-with-reply": {
"message": "Set to draft with reply"
},
"conversation-thread.action.withhold": {
"message": "Withhold"
},
"conversation-thread.action.withhold-with-reply": {
"message": "Withhold with reply"
},
"conversation-thread.closed-thread.description": {
"message": "This thread is closed and new messages cannot be sent to it."
},
"conversation-thread.error.closing-report": {
"message": "Error closing report"
},
"conversation-thread.error.reopening-report": {
"message": "Error reopening report"
},
"conversation-thread.error.sending-message": {
"message": "Error sending message"
},
"conversation-thread.reply-editor.placeholder.reply": {
"message": "Reply to thread..."
},
"conversation-thread.reply-editor.placeholder.send": {
"message": "Send a message..."
},
"conversation-thread.reply-modal.confirmation.description": {
"message": "Confirm moderators do not actively monitor this"
},
"conversation-thread.reply-modal.confirmation.label": {
"message": "I acknowledge that the moderators do not actively monitor the thread."
},
"conversation-thread.reply-modal.description": {
"message": "Your project is already approved. As such, the moderation team does not actively monitor this thread. However, they may still see your message if there is a problem with your project."
},
"conversation-thread.reply-modal.header": {
"message": "Reply to thread"
},
"conversation-thread.reply-modal.help-center-note": {
"message": "If you need to get in contact with the moderation team, please use the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble in the bottom right corner to contact support."
},
"conversation-thread.resubmit-modal.confirmation.description": {
"message": "Confirm I have addressed the messages from the moderators"
},
"conversation-thread.resubmit-modal.confirmation.label": {
"message": "I confirm that I have properly addressed the moderators' comments."
},
"conversation-thread.resubmit-modal.description": {
"message": "You're submitting <project-title>{projectTitle}</project-title> to be reviewed again by the moderators."
},
"conversation-thread.resubmit-modal.header.resubmitting": {
"message": "Resubmitting for review"
},
"conversation-thread.resubmit-modal.header.submitting": {
"message": "Submitting for review"
},
"conversation-thread.resubmit-modal.reminder": {
"message": "Make sure you have addressed all the comments from the moderation team."
},
"conversation-thread.resubmit-modal.warning": {
"message": "Repeated submissions without addressing the moderators' comments may result in an account suspension."
},
"create-project-version.create-modal.stage.add-files.admonition": {
"message": "Supplementary files are for supporting resources like source code, not for alternative versions or variants."
},
@@ -986,8 +875,23 @@
"dashboard.head-title": {
"message": "Dashboard"
},
"dashboard.notifications.button.mark-all-as-read": {
"message": "Mark all as read"
},
"dashboard.notifications.button.view-history": {
"message": "View history"
},
"dashboard.notifications.empty.no-unread": {
"message": "You have no unread notifications."
"message": "You don't have any unread notifications."
},
"dashboard.notifications.error.loading": {
"message": "Error loading notifications:"
},
"dashboard.notifications.history.label": {
"message": "History"
},
"dashboard.notifications.history.title": {
"message": "Notification history"
},
"dashboard.notifications.link.see-all": {
"message": "See all"
@@ -998,6 +902,9 @@
"dashboard.notifications.link.view-more": {
"message": "View {extraNotifs} more {extraNotifs, plural, one {notification} other {notifications}}"
},
"dashboard.notifications.loading": {
"message": "Loading notifications..."
},
"dashboard.organizations.button.create": {
"message": "Create organization"
},
@@ -1013,27 +920,6 @@
"dashboard.organizations.title": {
"message": "Organizations"
},
"dashboard.overview.notifications.button.mark-all-as-read": {
"message": "Mark all as read"
},
"dashboard.overview.notifications.button.view-history": {
"message": "View history"
},
"dashboard.overview.notifications.empty.no-unread": {
"message": "You don't have any unread notifications."
},
"dashboard.overview.notifications.error.loading": {
"message": "Error loading notifications:"
},
"dashboard.overview.notifications.history.label": {
"message": "History"
},
"dashboard.overview.notifications.history.title": {
"message": "Notification history"
},
"dashboard.overview.notifications.loading": {
"message": "Loading notifications..."
},
"dashboard.projects.bulk-edit-hint": {
"message": "You can edit multiple projects at once by selecting them below."
},
@@ -1868,20 +1754,14 @@
"layout.banner.add-email.description": {
"message": "For security reasons, Modrinth needs you to register an email address to your account."
},
"layout.banner.build-fail.always-ignore": {
"message": "Always ignore"
},
"layout.banner.build-fail.description": {
"message": "This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}"
},
"layout.banner.build-fail.ignore": {
"message": "Ignore"
},
"layout.banner.build-fail.title": {
"message": "Error generating state from API when building."
},
"layout.banner.preview.description": {
"message": "If you meant to access the official Modrinth website, visit {url}. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}."
"message": "If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}."
},
"layout.banner.preview.title": {
"message": "This is a preview deploy of the Modrinth website."
@@ -2711,84 +2591,6 @@
"project.license.title": {
"message": "License"
},
"project.moderation.admonition.approved.body.private": {
"message": "Your project is private, meaning it can only be accessed by you and people you invite."
},
"project.moderation.admonition.approved.body.public": {
"message": "Your project is published and discoverable on Modrinth."
},
"project.moderation.admonition.approved.body.unlisted": {
"message": "Your project is unlisted, meaning it can only be accessed with a direct link and is not discoverable on Modrinth."
},
"project.moderation.admonition.approved.body.visibility-message": {
"message": "You can change the visibility of your project in your project's <visibility-settings-link>visibility settings</visibility-settings-link>."
},
"project.moderation.admonition.approved.header": {
"message": "Project approved"
},
"project.moderation.admonition.draft.body": {
"message": "This is a draft project that cannot be seen by others until submitted for review and approved by Modrinth's moderation team."
},
"project.moderation.admonition.draft.header": {
"message": "Draft project"
},
"project.moderation.admonition.draft.submit-for-review": {
"message": "Once you have completed all required steps and ensured your project complies with Modrinth's <rules-link>Content Rules</rules-link> you can submit your project for review."
},
"project.moderation.admonition.rejected.address-all-concerns": {
"message": "Please address all moderation concerns, including any issues listed in messages below, before resubmitting this project."
},
"project.moderation.admonition.rejected.header": {
"message": "Changes requested"
},
"project.moderation.admonition.rejected.spam-notice": {
"message": "Repeatedly submitting your project without addressing all moderation concerns first may result in account suspension."
},
"project.moderation.admonition.under-review.body.1": {
"message": "Your project is in queue to be reviewed by Modrinth's moderation team."
},
"project.moderation.admonition.under-review.body.2": {
"message": "Your project will be scanned and then reviewed by human moderators to ensure it meets Modrinth's <rules-link>Content Rules</rules-link> and <terms-link>Terms of Use</terms-link>."
},
"project.moderation.admonition.under-review.body.3": {
"message": "You can still modify your project, it won't affect your position in the queue."
},
"project.moderation.admonition.under-review.body.4": {
"message": "We aim to review submissions in 24-48 hours, but some projects may face delays. This does not reflect an issue with your submission."
},
"project.moderation.admonition.under-review.body.5": {
"message": "<emphasis>We appreciate your patience while our moderators work hard to keep Modrinth safe, and look forward to helping you share your content! 💚</emphasis>"
},
"project.moderation.admonition.under-review.header": {
"message": "Project under review"
},
"project.moderation.admonition.withheld.body": {
"message": "Your project will not appear publicly and can only be accessed with a direct link.{requestedStatus, select, unlisted { Based on your selected <visibility-settings-link>visibility settings</visibility-settings-link>, most likely no action is necessary.} other { Please address all moderation concerns, including any issues listed in messages below before resubmitting this project.}}"
},
"project.moderation.admonition.withheld.header": {
"message": "Unlisted by staff"
},
"project.moderation.error.unauthorized": {
"message": "Unauthorized"
},
"project.moderation.thread.approved-warning": {
"message": "This thread is not actively monitored, but may be reviewed for information about your project if needed."
},
"project.moderation.thread.help-center-note.1": {
"message": "Content moderators cannot provide support for most issues and messages to this thread do not notify staff."
},
"project.moderation.thread.help-center-note.2": {
"message": "If you need assistance or have additional inquiries, please visit the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble to contact support."
},
"project.moderation.thread.moderator-see-user-ui-toggle": {
"message": "Show member UI"
},
"project.moderation.thread.private-description": {
"message": "This is a private conversation thread with the Modrinth moderators. They may message you with issues concerning this project."
},
"project.moderation.thread.title": {
"message": "Moderation messages"
},
"project.moderation.title": {
"message": "Moderation"
},
-1
View File
@@ -2302,7 +2302,6 @@ const currentMember = computed(() => {
payouts_split: 0,
avatar_url: auth.value.user.avatar_url,
name: auth.value.user.username,
staffOnly: true,
}
}
@@ -231,10 +231,6 @@ watch(
{ immediate: true },
)
function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
}
function createDownloadUrl(version) {
return createProjectDownloadUrl(getPrimaryFile(version).url, {
reason: cdnDownloadReason.value,
+163 -415
View File
@@ -1,434 +1,137 @@
<template>
<template v-if="canAccess">
<Admonition
v-if="userFacingUiVisible && moderationAdmonition"
:type="moderationAdmonition.type"
class="mb-4"
:header="formatMessage(moderationAdmonition.header)"
>
<template
v-for="(section, index) in moderationAdmonition.body"
:key="`moderation-admonition.${project.status}+${project.requested_status ?? 'none'}.body.${index}`"
>
<p
v-if="section.type === 'paragraph' && section.message"
class="preserve-lines mb-0 mt-2 leading-tight first:mt-0"
>
<IntlFormatted
:message-id="section.message"
:values="{
requestedStatus: project.requested_status ?? 'none',
}"
>
<template #rules-link="{ children }">
<nuxt-link to="/legal/rules" class="text-link" target="_blank">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
<template #terms-link="{ children }">
<nuxt-link to="/legal/terms" class="text-link" target="_blank">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
<template #visibility-settings-link="{ children }">
<router-link :to="`${getProjectLink(project)}/settings#visibility`" class="text-link">
<component :is="() => normalizeChildren(children)" />
</router-link>
</template>
<template #emphasis="{ children }">
<span class="font-semibold">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
<div v-if="canAccess">
<section class="universal-card">
<h2>Project status</h2>
<Badge :type="project.status" />
<p v-if="isApproved(project)">
Your project has been approved by the moderators and you may freely change project
visibility in
<router-link :to="`${getProjectLink(project)}/settings`" class="text-link"
>your project's settings</router-link
>.
</p>
<div v-else-if="isUnderReview(project)">
<p>
Modrinth's team of content moderators work hard to review all submitted projects.
Typically, you can expect a new project to be reviewed within 24 to 48 hours. Please keep
in mind that larger projects, especially modpacks, may require more time to review.
Certain holidays or events may also lead to delays depending on moderator availability.
Modrinth's moderators will leave a message below if they have any questions or concerns
for you.
</p>
<ul
v-else-if="section.type === 'bullets'"
class="mb-0 mt-2 flex list-disc flex-col gap-1 pl-4 leading-normal first:mt-0"
>
<li
v-for="(message, listIndex) in section.items"
:key="`list-item-${index}-${listIndex}`"
<p>
If your review has taken more than 48 hours, check our
<a
class="text-link"
href="https://support.modrinth.com/en/articles/8793355-modrinth-project-review-times"
target="_blank"
>
<IntlFormatted :message-id="message">
<template #rules-link="{ children }">
<nuxt-link to="/legal/rules" class="text-link" target="_blank">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
<template #terms-link="{ children }">
<nuxt-link to="/legal/terms" class="text-link" target="_blank">
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</li>
</ul>
</template>
</Admonition>
<div class="card-shadow mb-6 rounded-2xl border border-solid border-surface-4 bg-surface-3">
<div class="flex flex-col p-4">
<div class="flex items-center justify-between">
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
{{ formatMessage(messages.threadSectionTitle) }}
</h2>
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
<label for="moderator-see-user-ui-toggle">
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
</label>
</div>
</div>
<template v-if="userFacingUiVisible">
<p class="m-0 mt-2 leading-tight">
{{ formatMessage(messages.threadPrivateDescription) }}
</p>
<p class="mb-0 mt-3 leading-tight">
<IntlFormatted :message-id="messages.threadHelpCenterNote1">
<template #help-center-link="{ children }">
<a class="text-link" href="https://support.modrinth.com" target="_blank">
<component :is="() => normalizeChildren(children)" />
</a>
</template>
</IntlFormatted>
</p>
<p class="mb-0 mt-2 leading-tight">
<IntlFormatted :message-id="messages.threadHelpCenterNote2">
<template #help-center-link="{ children }">
<a class="text-link" href="https://support.modrinth.com" target="_blank">
<component :is="() => normalizeChildren(children)" />
</a>
</template>
</IntlFormatted>
</p>
<p
v-if="isApproved(project)"
class="mb-0 mt-3 flex items-center gap-2 font-semibold text-orange"
>
<IssuesIcon class="shrink-0" />
{{ formatMessage(messages.threadApprovedWarning) }}
</p>
</template>
support article on review times
</a>
for moderation delays.
</p>
</div>
<template v-else-if="isRejected(project)">
<p>
Your project does not currently meet Modrinth's
<nuxt-link to="/legal/rules" class="text-link" target="_blank">content rules</nuxt-link>
and the moderators have requested you make changes before it can be approved. Read the
messages from the moderators below and address their comments before resubmitting.
</p>
<p class="warning">
<IssuesIcon /> Repeated submissions without addressing the moderators' comments may result
in an account suspension.
</p>
</template>
<h3>Current visibility</h3>
<ul class="visibility-info">
<li v-if="isListed(project)">
<CheckIcon class="good" />
Listed in search results
</li>
<li v-else>
<XIcon class="bad" />
Not listed in search results
</li>
<li v-if="isListed(project)">
<CheckIcon class="good" />
Listed on the profiles of members
</li>
<li v-else>
<XIcon class="bad" />
Not listed on the profiles of members
</li>
<li v-if="isPrivate(project)">
<XIcon class="bad" />
Not accessible with a direct link
</li>
<li v-else>
<CheckIcon class="good" />
Accessible with a direct link
</li>
</ul>
</section>
<section id="messages" class="universal-card">
<h2>Messages</h2>
<p>
This is a private conversation thread with the Modrinth moderators. They may message you
with issues concerning this project. This thread is only checked when you submit your
project for review. For additional inquiries, please go to the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</p>
<p v-if="isApproved(project)" class="warning">
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
messages here if there is a problem with your project.
</p>
<ConversationThread
v-if="thread"
:thread="thread"
:project="project"
:set-status="setStatus"
:current-member="currentMember ?? undefined"
:current-member="currentMember"
:auth="auth"
class="overflow-clip rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2"
@update-thread="updateThread"
/>
<div
v-else
class="flex items-center justify-center gap-2 rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2 py-12"
>
<template v-if="pending">
<SpinnerIcon class="size-5 animate-spin" /> Loading messages
</template>
<template v-else>
<p class="m-0 text-red">Failed to load messages</p>
</template>
</div>
</div>
</template>
</section>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { IssuesIcon, SpinnerIcon } from '@modrinth/assets'
<script setup>
import { CheckIcon, IssuesIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
commonMessages,
defineMessage,
defineMessages,
Badge,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
IntlFormatted,
type MessageDescriptor,
normalizeChildren,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type Ref, watch } from 'vue'
import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
type ModerationAdmonitionSection =
| {
type: 'paragraph'
message: MessageDescriptor | null
}
| {
type: 'bullets'
items: MessageDescriptor[]
}
const messages = defineMessages({
admonitionRejectedSpamNotice: {
id: 'project.moderation.admonition.rejected.spam-notice',
defaultMessage:
'Repeatedly submitting your project without addressing all moderation concerns first may result in account suspension.',
},
threadSectionTitle: {
id: 'project.moderation.thread.title',
defaultMessage: 'Moderation messages',
},
moderatorSeeUserUiToggle: {
id: 'project.moderation.thread.moderator-see-user-ui-toggle',
defaultMessage: 'Show member UI',
},
threadPrivateDescription: {
id: 'project.moderation.thread.private-description',
defaultMessage:
'This is a private conversation thread with the Modrinth moderators. They may message you with issues concerning this project.',
},
threadHelpCenterNote1: {
id: 'project.moderation.thread.help-center-note.1',
defaultMessage:
'Content moderators cannot provide support for most issues and messages to this thread do not notify staff.',
},
threadHelpCenterNote2: {
id: 'project.moderation.thread.help-center-note.2',
defaultMessage:
'If you need assistance or have additional inquiries, please visit the <help-center-link>Modrinth Help Center</help-center-link> and click the blue bubble to contact support.',
},
threadApprovedWarning: {
id: 'project.moderation.thread.approved-warning',
defaultMessage:
'This thread is not actively monitored, but may be reviewed for information about your project if needed.',
},
approvedProjectVisibilityMessage: {
id: 'project.moderation.admonition.approved.body.visibility-message',
defaultMessage:
"You can change the visibility of your project in your project's <visibility-settings-link>visibility settings</visibility-settings-link>.",
},
})
import {
getProjectLink,
isApproved,
isListed,
isPrivate,
isRejected,
isUnderReview,
} from '~/helpers/projects.js'
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
currentMember: currentMemberRaw,
invalidate,
allMembers,
} = injectProjectPageContext()
const currentMember = currentMemberRaw as Ref<ProjectPageMember | null>
const { projectV2: project, currentMember, invalidate } = injectProjectPageContext()
const canAccess = computed(() => !!currentMember.value)
const userFacingUiVisible = computed(
() => !!currentMember.value && (!currentMember.value.staffOnly || moderatorSeeUserUi.value),
)
const approvedAdmonitionMessage = computed<MessageDescriptor | null>(() => {
switch (project.value?.status) {
case 'approved':
case 'archived':
return defineMessage({
id: 'project.moderation.admonition.approved.body.public',
defaultMessage: 'Your project is published and discoverable on Modrinth.',
})
case 'unlisted':
return defineMessage({
id: 'project.moderation.admonition.approved.body.unlisted',
defaultMessage:
'Your project is unlisted, meaning it can only be accessed with a direct link and is not discoverable on Modrinth.',
})
case 'private':
return defineMessage({
id: 'project.moderation.admonition.approved.body.private',
defaultMessage:
'Your project is private, meaning it can only be accessed by you and people you invite.',
})
default:
return null
}
})
const moderationAdmonition = computed<{
type: InstanceType<typeof Admonition>['type']
header: MessageDescriptor
body: ModerationAdmonitionSection[]
} | null>(() => {
const currentProject = project.value
if (currentProject.status === 'draft') {
return {
type: 'info',
header: defineMessage({
id: 'project.moderation.admonition.draft.header',
defaultMessage: 'Draft project',
}),
body: [
{
type: 'paragraph',
message: defineMessage({
id: 'project.moderation.admonition.draft.body',
defaultMessage:
"This is a draft project that cannot be seen by others until submitted for review and approved by Modrinth's moderation team.",
}),
},
{
type: 'paragraph',
message: defineMessage({
id: 'project.moderation.admonition.draft.submit-for-review',
defaultMessage:
"Once you have completed all required steps and ensured your project complies with Modrinth's <rules-link>Content Rules</rules-link> you can submit your project for review.",
}),
},
],
}
}
if (isApproved(currentProject) && approvedAdmonitionMessage.value) {
return {
type: 'success',
header: defineMessage({
id: 'project.moderation.admonition.approved.header',
defaultMessage: 'Project approved',
}),
body: [
{
type: 'paragraph',
message: approvedAdmonitionMessage.value,
},
{
type: 'paragraph',
message: messages.approvedProjectVisibilityMessage,
},
],
}
}
if (isUnderReview(currentProject)) {
return {
type: 'moderation',
header: defineMessage({
id: 'project.moderation.admonition.under-review.header',
defaultMessage: 'Project under review',
}),
body: [
{
type: 'paragraph',
message: defineMessage({
id: 'project.moderation.admonition.under-review.body.1',
defaultMessage:
"Your project is in queue to be reviewed by Modrinth's moderation team.",
}),
},
{
type: 'bullets',
items: [
defineMessage({
id: 'project.moderation.admonition.under-review.body.2',
defaultMessage:
"Your project will be scanned and then reviewed by human moderators to ensure it meets Modrinth's <rules-link>Content Rules</rules-link> and <terms-link>Terms of Use</terms-link>.",
}),
defineMessage({
id: 'project.moderation.admonition.under-review.body.3',
defaultMessage:
"You can still modify your project, it won't affect your position in the queue.",
}),
defineMessage({
id: 'project.moderation.admonition.under-review.body.4',
defaultMessage:
'We aim to review submissions in 24-48 hours, but some projects may face delays. This does not reflect an issue with your submission.',
}),
],
},
{
type: 'paragraph',
message: defineMessage({
id: 'project.moderation.admonition.under-review.body.5',
defaultMessage:
'<emphasis>We appreciate your patience while our moderators work hard to keep Modrinth safe, and look forward to helping you share your content! 💚</emphasis>',
}),
},
],
}
}
if (currentProject.status === 'withheld') {
return {
type: 'warning',
header: defineMessage({
id: 'project.moderation.admonition.withheld.header',
defaultMessage: 'Unlisted by staff',
}),
body: [
{
type: 'paragraph',
message: defineMessage({
id: 'project.moderation.admonition.withheld.body',
defaultMessage:
'Your project will not appear publicly and can only be accessed with a direct link.{requestedStatus, select, unlisted { Based on your selected <visibility-settings-link>visibility settings</visibility-settings-link>, most likely no action is necessary.} other { Please address all moderation concerns, including any issues listed in messages below before resubmitting this project.}}',
}),
},
{
type: 'paragraph',
message: messages.admonitionRejectedSpamNotice,
},
],
}
}
if (isRejected(currentProject)) {
return {
type: 'critical',
header: defineMessage({
id: 'project.moderation.admonition.rejected.header',
defaultMessage: 'Changes requested',
}),
body: [
{
type: 'paragraph',
message: defineMessage({
id: 'project.moderation.admonition.rejected.address-all-concerns',
defaultMessage:
'Please address all moderation concerns, including any issues listed in messages below, before resubmitting this project.',
}),
},
{
type: 'paragraph',
message: messages.admonitionRejectedSpamNotice,
},
],
}
}
return null
})
const moderatorSeeUserUi = computed<boolean>({
get() {
return flags.value.showModeratorProjectMemberUi
},
set(value: boolean) {
flags.value.showModeratorProjectMemberUi = value
saveFeatureFlags()
},
})
watch(
[currentMember, allMembers],
[currentMember, project],
() => {
if (allMembers.value.length > 0 && !canAccess.value) {
if (project.value && !canAccess.value) {
showError({
fatal: true,
statusCode: 401,
statusMessage: formatMessage(
defineMessage({
id: 'project.moderation.error.unauthorized',
defaultMessage: 'Unauthorized',
}),
),
statusMessage: 'Unauthorized',
})
}
},
@@ -439,23 +142,20 @@ const auth = await useAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { data: thread, isPending: pending } = useQuery({
const { data: thread } = useQuery({
queryKey: computed(() => ['thread', project.value?.thread_id]),
queryFn: () => client.labrinth.threads_v3.getThread(project.value.thread_id),
enabled: computed(() => !!project.value?.thread_id),
})
function updateThread(newThread: Labrinth.Threads.v3.Thread | null | undefined) {
function updateThread(newThread) {
const threadId = newThread?.id ?? project.value?.thread_id
if (!threadId) return
queryClient.setQueryData<Labrinth.Threads.v3.Thread | null | undefined>(
['thread', threadId],
newThread,
)
queryClient.setQueryData(['thread', threadId], newThread)
}
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
async function setStatus(status) {
startLoading()
try {
@@ -466,21 +166,69 @@ async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
await queryClient.invalidateQueries({ queryKey: ['thread', project.value?.thread_id] })
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: getErrorDescription(err),
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.stacked {
display: flex;
flex-direction: column;
}
function getErrorDescription(err: unknown): string {
if (typeof err === 'object' && err !== null && 'data' in err) {
const data = (err as { data?: { description?: string } }).data
if (data?.description) return data.description
.status-message {
:deep(.badge) {
display: contents;
svg {
vertical-align: top;
margin: 0;
}
}
return err instanceof Error ? err.message : String(err)
p:last-child {
margin-bottom: 0;
}
}
</script>
.unavailable-error {
.code {
margin-top: var(--spacing-card-sm);
}
svg {
vertical-align: top;
}
}
.visibility-info {
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
}
}
svg {
&.good {
color: var(--color-green);
}
&.bad {
color: var(--color-red);
}
}
.warning {
color: var(--color-orange);
font-weight: bold;
}
</style>
@@ -230,7 +230,7 @@
/>
</div>
</template>
<div id="visibility">
<div>
<label>
<span class="label__title">Visibility</span>
</label>
@@ -242,6 +242,36 @@
:disabled="!hasPermission"
:max-height="500"
/>
<div>If approved by the moderators:</div>
<ul class="visibility-info m-0">
<li>
<CheckIcon
v-if="visibility === 'approved' || visibility === 'archived'"
class="good"
/>
<XIcon v-else class="bad" />
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible in search
</li>
<li>
<XIcon v-if="visibility === 'unlisted' || visibility === 'private'" class="bad" />
<CheckIcon v-else class="good" />
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible on profile
</li>
<li>
<CheckIcon v-if="visibility !== 'private'" class="good" />
<IssuesIcon
v-else
v-tooltip="{
content:
visibility === 'private'
? 'Only members will be able to view the project.'
: '',
}"
class="warn"
/>
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible via URL
</li>
</ul>
</div>
</div>
</div>
@@ -330,7 +360,16 @@
</template>
<script setup>
import { ImageIcon, ScaleIcon, TrashIcon, TriangleAlertIcon, UploadIcon } from '@modrinth/assets'
import {
CheckIcon,
ImageIcon,
IssuesIcon,
ScaleIcon,
TrashIcon,
TriangleAlertIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
@@ -566,6 +605,14 @@ async function updateMonetizationStatus(status) {
}
}
const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
? project.value.status
: project.value.requested_status
return originalVisibility !== visibility.value
}
async function handleSave() {
saving.value = true
try {
@@ -94,31 +94,31 @@ const { formatMessage } = useVIntl()
const messages = defineMessages({
historyLabel: {
id: 'dashboard.overview.notifications.history.label',
id: 'dashboard.notifications.history.label',
defaultMessage: 'History',
},
notificationHistoryTitle: {
id: 'dashboard.overview.notifications.history.title',
id: 'dashboard.notifications.history.title',
defaultMessage: 'Notification history',
},
viewHistory: {
id: 'dashboard.overview.notifications.button.view-history',
id: 'dashboard.notifications.button.view-history',
defaultMessage: 'View history',
},
markAllAsRead: {
id: 'dashboard.overview.notifications.button.mark-all-as-read',
id: 'dashboard.notifications.button.mark-all-as-read',
defaultMessage: 'Mark all as read',
},
loadingNotifications: {
id: 'dashboard.overview.notifications.loading',
id: 'dashboard.notifications.loading',
defaultMessage: 'Loading notifications...',
},
errorLoadingNotifications: {
id: 'dashboard.overview.notifications.error.loading',
id: 'dashboard.notifications.error.loading',
defaultMessage: 'Error loading notifications:',
},
noUnreadNotifications: {
id: 'dashboard.overview.notifications.empty.no-unread',
id: 'dashboard.notifications.empty.no-unread',
defaultMessage: "You don't have any unread notifications.",
},
})
+4 -4
View File
@@ -38,13 +38,13 @@
</ul>
<p>Our designated copyright agent to receive DMCA Notices is:</p>
<p>
&emsp;DMCA Designated Agent<br />
&emsp;Copyright Manager<br />
&emsp;Rinth, Inc.<br />
&emsp;800 N King St<br />
&emsp;Suite 304 #3133<br />
&emsp;Suite 304 -3133<br />
&emsp;Wilmington, DE 19801<br />
&emsp;Phone: +1 (201) 431-5015<br />
&emsp;<a href="mailto:dmca@modrinth.com">dmca@modrinth.com</a><br />
&emsp;Phone: +1 (302) 281-2193<br />
&emsp;<a href="mailto:support@modrinth.com">support@modrinth.com</a><br />
</p>
<p>
If you fail to comply with all of the requirements of Section 512(c)(3) of the DMCA, your DMCA
+1 -1
View File
@@ -462,7 +462,7 @@ const emptyStateDescription = computed(() => {
return 'Check that your search query is correct!'
}
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
return `There are no ${currentFilterType.value.toLowerCase()} in the queue.`
return `There are no ${currentFilterType.value.toLowerCase()} in the queue`
}
return 'you will probably never see this but if you do, congrats!!! :D'
})
+1 -1
View File
@@ -50,7 +50,7 @@ useSeoMeta({
wrapper-class="w-full rounded-xl bg-bg-raised"
/>
</div>
<div class="mb-6 flex flex-col gap-2">
<div class="flex flex-col gap-2">
<div
v-for="flag in filteredFlags"
:key="`flag-${flag}`"
+1
View File
@@ -45,6 +45,7 @@ deadpool-redis.workspace = true
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
dotenvy = { workspace = true }
either = { workspace = true }
elasticsearch = { workspace = true, features = ["experimental-apis"] }
eyre = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
+90 -98
View File
@@ -120,50 +120,42 @@ impl FromStr for StringCsv {
}
vars! {
SENTRY_ENVIRONMENT: String = "development";
SENTRY_TRACES_SAMPLE_RATE: f32 = 0.1f32;
SITE_URL: String = "http://localhost:3000";
CDN_URL: String = "file:///tmp/modrinth";
LABRINTH_ADMIN_KEY: String = "";
LABRINTH_MEDAL_KEY: String = "";
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String = "";
RATE_LIMIT_IGNORE_KEY: String = "";
DATABASE_URL: String = "postgresql://labrinth:labrinth@localhost/labrinth";
REDIS_URL: String = "redis://localhost";
BIND_ADDR: String = "";
SELF_ADDR: String = "";
SENTRY_ENVIRONMENT: String;
SENTRY_TRACES_SAMPLE_RATE: f32;
SITE_URL: String;
CDN_URL: String;
LABRINTH_ADMIN_KEY: String;
LABRINTH_MEDAL_KEY: String;
LABRINTH_EXTERNAL_NOTIFICATION_KEY: String;
RATE_LIMIT_IGNORE_KEY: String;
DATABASE_URL: String;
REDIS_URL: String;
BIND_ADDR: String;
SELF_ADDR: String;
LOCAL_INDEX_INTERVAL: u64 = 3600u64;
VERSION_INDEX_INTERVAL: u64 = 1800u64;
LOCAL_INDEX_INTERVAL: u64;
VERSION_INDEX_INTERVAL: u64;
WHITELISTED_MODPACK_DOMAINS: Json<Vec<String>> = Json(vec![
"cdn.modrinth.com".into(),
"github.com".into(),
"raw.githubusercontent.com".into(),
]);
ALLOWED_CALLBACK_URLS: Json<Vec<String>> = Json(vec![
"localhost".into(),
".modrinth.com".into(),
"127.0.0.1".into(),
"[::1]".into(),
]);
ANALYTICS_ALLOWED_ORIGINS: Json<Vec<String>> = Json(vec![
"http://127.0.0.1:3000".into(),
"http://localhost:3000".into(),
"https://modrinth.com".into(),
"https://www.modrinth.com".into(),
"*".into(),
]);
WHITELISTED_MODPACK_DOMAINS: Json<Vec<String>>;
ALLOWED_CALLBACK_URLS: Json<Vec<String>>;
ANALYTICS_ALLOWED_ORIGINS: Json<Vec<String>>;
// search
SEARCH_BACKEND: crate::search::SearchBackendKind = crate::search::SearchBackendKind::Typesense;
MEILISEARCH_READ_ADDR: String;
MEILISEARCH_WRITE_ADDRS: StringCsv;
MEILISEARCH_KEY: String;
ELASTICSEARCH_URL: String;
ELASTICSEARCH_INDEX_PREFIX: String;
ELASTICSEARCH_USERNAME: String = "";
ELASTICSEARCH_PASSWORD: String = "";
SEARCH_INDEX_CHUNK_SIZE: i64 = 5000i64;
TYPESENSE_URL: String = "http://localhost:8108";
TYPESENSE_API_KEY: String = "modrinth";
TYPESENSE_INDEX_PREFIX: String = "labrinth";
// storage
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
STORAGE_BACKEND: crate::file_hosting::FileHostKind;
// s3
S3_PUBLIC_BUCKET_NAME: String = "";
@@ -181,98 +173,98 @@ vars! {
S3_PRIVATE_SECRET: String = "";
// local
MOCK_FILE_PATH: String = "/tmp/modrinth";
MOCK_FILE_PATH: String = "";
GITHUB_CLIENT_ID: String = "none";
GITHUB_CLIENT_SECRET: String = "none";
GITLAB_CLIENT_ID: String = "none";
GITLAB_CLIENT_SECRET: String = "none";
DISCORD_CLIENT_ID: String = "none";
DISCORD_CLIENT_SECRET: String = "none";
MICROSOFT_CLIENT_ID: String = "none";
MICROSOFT_CLIENT_SECRET: String = "none";
GOOGLE_CLIENT_ID: String = "none";
GOOGLE_CLIENT_SECRET: String = "none";
STEAM_API_KEY: String = "none";
GITHUB_CLIENT_ID: String;
GITHUB_CLIENT_SECRET: String;
GITLAB_CLIENT_ID: String;
GITLAB_CLIENT_SECRET: String;
DISCORD_CLIENT_ID: String;
DISCORD_CLIENT_SECRET: String;
MICROSOFT_CLIENT_ID: String;
MICROSOFT_CLIENT_SECRET: String;
GOOGLE_CLIENT_ID: String;
GOOGLE_CLIENT_SECRET: String;
STEAM_API_KEY: String;
TREMENDOUS_API_URL: String = "https://testflight.tremendous.com/api/v2/";
TREMENDOUS_API_KEY: String = "none";
TREMENDOUS_PRIVATE_KEY: String = "none";
TREMENDOUS_API_URL: String;
TREMENDOUS_API_KEY: String;
TREMENDOUS_PRIVATE_KEY: String;
PAYPAL_API_URL: String = "https://api-m.sandbox.paypal.com/v1/";
PAYPAL_WEBHOOK_ID: String = "none";
PAYPAL_CLIENT_ID: String = "none";
PAYPAL_CLIENT_SECRET: String = "none";
PAYPAL_NVP_USERNAME: String = "none";
PAYPAL_NVP_PASSWORD: String = "none";
PAYPAL_NVP_SIGNATURE: String = "none";
PAYPAL_API_URL: String;
PAYPAL_WEBHOOK_ID: String;
PAYPAL_CLIENT_ID: String;
PAYPAL_CLIENT_SECRET: String;
PAYPAL_NVP_USERNAME: String;
PAYPAL_NVP_PASSWORD: String;
PAYPAL_NVP_SIGNATURE: String;
PAYPAL_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
BREX_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
TREMENDOUS_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
MURAL_BALANCE_ALERT_THRESHOLD: u64 = 0u64;
HCAPTCHA_SECRET: String = "none";
HCAPTCHA_SECRET: String;
SMTP_USERNAME: String = "";
SMTP_PASSWORD: String = "";
SMTP_HOST: String = "localhost";
SMTP_PORT: u16 = 1025u16;
SMTP_TLS: String = "none";
SMTP_FROM_NAME: String = "Modrinth";
SMTP_FROM_ADDRESS: String = "no-reply@mail.modrinth.com";
SMTP_USERNAME: String;
SMTP_PASSWORD: String;
SMTP_HOST: String;
SMTP_PORT: u16;
SMTP_TLS: String;
SMTP_FROM_NAME: String;
SMTP_FROM_ADDRESS: String;
SITE_VERIFY_EMAIL_PATH: String = "auth/verify-email";
SITE_RESET_PASSWORD_PATH: String = "auth/reset-password";
SITE_BILLING_PATH: String = "none";
SITE_VERIFY_EMAIL_PATH: String;
SITE_RESET_PASSWORD_PATH: String;
SITE_BILLING_PATH: String;
SENDY_URL: String = "none";
SENDY_LIST_ID: String = "none";
SENDY_API_KEY: String = "none";
SENDY_URL: String;
SENDY_LIST_ID: String;
SENDY_API_KEY: String;
CLICKHOUSE_REPLICATED: bool = false;
CLICKHOUSE_URL: String = "http://localhost:8123";
CLICKHOUSE_USER: String = "default";
CLICKHOUSE_PASSWORD: String = "default";
CLICKHOUSE_DATABASE: String = "staging_ariadne";
CLICKHOUSE_REPLICATED: bool;
CLICKHOUSE_URL: String;
CLICKHOUSE_USER: String;
CLICKHOUSE_PASSWORD: String;
CLICKHOUSE_DATABASE: String;
FLAME_ANVIL_URL: String = "none";
FLAME_ANVIL_URL: String;
GOTENBERG_URL: String = "http://localhost:13000";
GOTENBERG_CALLBACK_BASE: String = "http://host.docker.internal:8000/_internal/gotenberg";
GOTENBERG_TIMEOUT: u64 = 30000u64;
GOTENBERG_URL: String;
GOTENBERG_CALLBACK_BASE: String;
GOTENBERG_TIMEOUT: u64;
STRIPE_API_KEY: String = "none";
STRIPE_WEBHOOK_SECRET: String = "none";
STRIPE_API_KEY: String;
STRIPE_WEBHOOK_SECRET: String;
ADITUDE_API_KEY: String = "none";
ADITUDE_API_KEY: String;
PYRO_API_KEY: String = "none";
PYRO_API_KEY: String;
BREX_API_URL: String = "https://platform.brexapis.com/v2/";
BREX_API_KEY: String = "none";
BREX_API_URL: String;
BREX_API_KEY: String;
DELPHI_URL: String = "";
DELPHI_URL: String;
AVALARA_1099_API_URL: String = "https://www.track1099.com/api";
AVALARA_1099_API_KEY: String = "none";
AVALARA_1099_API_TEAM_ID: String = "none";
AVALARA_1099_COMPANY_ID: String = "207337084";
AVALARA_1099_API_URL: String;
AVALARA_1099_API_KEY: String;
AVALARA_1099_API_TEAM_ID: String;
AVALARA_1099_COMPANY_ID: String;
ANROK_API_URL: String = "";
ANROK_API_KEY: String = "";
ANROK_API_URL: String;
ANROK_API_KEY: String;
PAYOUT_ALERT_SLACK_WEBHOOK: String = "none";
PAYOUT_ALERT_SLACK_WEBHOOK: String;
CLOUDFLARE_INTEGRATION: bool = false;
ARCHON_URL: String = "";
ARCHON_URL: String;
MURALPAY_API_URL: String = "https://api-staging.muralpay.com";
MURALPAY_API_KEY: String = "none";
MURALPAY_TRANSFER_API_KEY: String = "none";
MURALPAY_API_URL: String;
MURALPAY_API_KEY: String;
MURALPAY_TRANSFER_API_KEY: String;
MURALPAY_SOURCE_ACCOUNT_ID: muralpay::AccountId = muralpay::AccountId(uuid::Uuid::nil());
DEFAULT_AFFILIATE_REVENUE_SPLIT: Decimal = Decimal::new(1, 1);
DEFAULT_AFFILIATE_REVENUE_SPLIT: Decimal;
DATABASE_ACQUIRE_TIMEOUT_MS: u64 = 30000u64;
DATABASE_MIN_CONNECTIONS: u32 = 0u32;
@@ -294,7 +286,7 @@ vars! {
MODERATION_SLACK_WEBHOOK: String = "";
DELPHI_SLACK_WEBHOOK: String = "";
TREMENDOUS_CAMPAIGN_ID: String = "none";
TREMENDOUS_CAMPAIGN_ID: String = "";
// server pinging
SERVER_PING_MAX_CONCURRENT: usize = 16usize;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,701 @@
use std::sync::LazyLock;
use std::time::Duration;
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::search::backend::meilisearch::MeilisearchConfig;
use crate::search::indexing::index_local;
use crate::search::{SearchField, UploadSearchProject};
use crate::util::error::Context;
use ariadne::ids::base62_impl::to_base62;
use eyre::{Result, eyre};
use futures::StreamExt;
use futures::stream::FuturesOrdered;
use meilisearch_sdk::client::{Client, SwapIndexes};
use meilisearch_sdk::indexes::Index;
use meilisearch_sdk::settings::{PaginationSetting, Settings};
use meilisearch_sdk::task_info::TaskInfo;
use tracing::{Instrument, error, info, info_span, instrument};
// // The chunk size for adding projects to the indexing database. If the request size
// // is too large (>10MiB) then the request fails with an error. This chunk size
// // assumes a max average size of 4KiB per project to avoid this cap.
//
// Set this to 50k for better observability
const MEILISEARCH_CHUNK_SIZE: usize = 50000; // 10_000_000
fn search_operation_timeout() -> std::time::Duration {
std::time::Duration::from_millis(ENV.SEARCH_OPERATION_TIMEOUT)
}
pub async fn remove_documents(
ids: &[crate::models::ids::VersionId],
config: &MeilisearchConfig,
) -> Result<()> {
let mut indexes = get_indexes_for_indexing(config, false, false)
.await
.wrap_err("failed to get current indexes")?;
let indexes_next = get_indexes_for_indexing(config, true, false)
.await
.wrap_err("failed to get next indexes")?;
for list in &mut indexes {
for alt_list in &indexes_next {
list.extend(alt_list.iter().cloned());
}
}
let client = config
.make_batch_client()
.wrap_err("failed to create batch client")?;
let client = &client;
let ids_base62 = ids.iter().map(|x| to_base62(x.0)).collect::<Vec<_>>();
let mut deletion_tasks = FuturesOrdered::new();
client.across_all(indexes, |index_list, client| {
for index in index_list {
let owned_client = client.clone();
let ids_base62_ref = &ids_base62;
deletion_tasks.push_back(async move {
index
.delete_documents(ids_base62_ref)
.await
.wrap_err_with(|| {
eyre!("failed to request to delete documents {ids_base62_ref:?}")
})?
.wait_for_completion(
&owned_client,
None,
Some(Duration::from_secs(15)),
)
.await
.wrap_err_with(|| {
eyre!("failed to delete documents {ids_base62_ref:?}")
})
});
}
});
while let Some(result) = deletion_tasks.next().await {
result?;
}
Ok(())
}
pub async fn index_projects(
ro_pool: PgPool,
redis: RedisPool,
config: &MeilisearchConfig,
) -> Result<()> {
info!("Indexing projects.");
info!("Ensuring current indexes exists");
// First, ensure current index exists (so no error happens- current index should be worst-case empty, not missing)
get_indexes_for_indexing(config, false, false)
.await
.wrap_err("failed to get indexes for indexing")?;
info!("Deleting surplus indexes");
// Then, delete the next index if it still exists
let indices = get_indexes_for_indexing(config, true, false)
.await
.wrap_err("failed to get next indexes to delete")?;
for client_indices in indices {
for index in client_indices {
index.delete().await.wrap_err("failed to delete an index")?;
}
}
info!("Recreating next index");
// Recreate the next index for indexing
let indices = get_indexes_for_indexing(config, true, true)
.await
.wrap_internal_err("failed to recreate next index")?;
let all_loader_fields =
crate::database::models::loader_fields::LoaderField::get_fields_all(
&ro_pool, &redis,
)
.await
.wrap_internal_err("failed to get all loader fields")?
.into_iter()
.map(|x| x.field)
.collect::<Vec<_>>();
info!("Gathering local projects");
let mut cursor = 0;
let mut idx = 0;
let mut total = 0;
loop {
info!("Gathering index data chunk {idx}");
idx += 1;
let (uploads, next_cursor) =
index_local(&ro_pool, &redis, cursor, 10000).await?;
total += uploads.len();
if uploads.is_empty() {
info!(
"No more projects to index, indexed {total} projects after {idx} chunks"
);
break;
}
cursor = next_cursor;
add_projects_batch_client(
&indices,
uploads,
all_loader_fields.clone(),
config,
)
.await?;
}
info!("Swapping indexes");
// Swap the index
swap_index(config, "projects").await?;
swap_index(config, "projects_filtered").await?;
info!("Deleting old indexes");
// Delete the now-old index
for index_list in indices {
for index in index_list {
index.delete().await?;
}
}
info!("Done adding projects.");
Ok(())
}
pub async fn swap_index(
config: &MeilisearchConfig,
index_name: &str,
) -> Result<()> {
let client = config.make_batch_client()?;
let index_name_next = config.get_index_name(index_name, true);
let index_name = config.get_index_name(index_name, false);
let swap_indices = SwapIndexes {
indexes: (index_name_next, index_name),
rename: None,
};
let swap_indices_ref = &swap_indices;
// is it "indexes" or "indices"? who knows! roll a die!
client
.with_all_clients("swap_indexes", |client| async move {
let task = client
.swap_indexes([swap_indices_ref])
.await
.wrap_err("failed to swap indices")?;
monitor_task(
client,
task,
Duration::from_secs(60 * 10), // 10 minutes
Some(Duration::from_secs(1)),
)
.await?;
Ok(())
})
.await?;
Ok(())
}
#[instrument(skip(config))]
pub async fn get_indexes_for_indexing(
config: &MeilisearchConfig,
next: bool, // Get the 'next' one
update_settings: bool,
) -> Result<Vec<Vec<Index>>> {
let client = config.make_batch_client()?;
let project_name = config.get_index_name("projects", next);
let project_filtered_name =
config.get_index_name("projects_filtered", next);
let project_name_ref = &project_name;
let project_filtered_name_ref = &project_filtered_name;
let results = client
.with_all_clients("get_indexes_for_indexing", |client| async move {
let projects_index = create_or_update_index(
client,
project_name_ref,
Some(&[
"words",
"typo",
"proximity",
"attribute",
"exactness",
"sort",
]),
update_settings,
)
.await?;
let projects_filtered_index = create_or_update_index(
client,
project_filtered_name_ref,
Some(&[
"sort",
"words",
"typo",
"proximity",
"attribute",
"exactness",
]),
update_settings,
)
.await?;
Ok(vec![projects_index, projects_filtered_index])
})
.await?;
Ok(results)
}
#[instrument(skip_all, fields(name))]
async fn create_or_update_index(
client: &Client,
name: &str,
custom_rules: Option<&'static [&'static str]>,
update_settings: bool,
) -> Result<Index, meilisearch_sdk::errors::Error> {
info!("Updating/creating index");
match client.get_index(name).await {
Ok(index) => {
info!("Updating index settings.");
let mut settings = default_settings();
if let Some(custom_rules) = custom_rules {
settings = settings.with_ranking_rules(custom_rules);
}
if update_settings {
info!("Updating index settings");
index
.set_settings(&settings)
.await
.inspect_err(|e| {
error!("Error setting index settings: {e:?}")
})?
.wait_for_completion(
client,
None,
Some(search_operation_timeout()),
)
.await
.inspect_err(|e| {
error!(
"Error setting index settings while waiting: {e:?}"
)
})?;
}
info!("Done performing index settings set.");
Ok(index)
}
_ => {
info!("Creating index.");
// Only create index and set settings if the index doesn't already exist
let task = client.create_index(name, Some("version_id")).await?;
let task = task
.wait_for_completion(
client,
None,
Some(search_operation_timeout()),
)
.await
.inspect_err(|e| {
error!("Error creating index while waiting: {e:?}")
})?;
let index = task
.try_make_index(client)
.map_err(|x| x.unwrap_failure())?;
let mut settings = default_settings();
if let Some(custom_rules) = custom_rules {
settings = settings.with_ranking_rules(custom_rules);
}
if update_settings {
index
.set_settings(&settings)
.await
.inspect_err(|e| {
error!("Error setting index settings: {e:?}")
})?
.wait_for_completion(
client,
None,
Some(search_operation_timeout()),
)
.await
.inspect_err(|e| {
error!(
"Error setting index settings while waiting: {e:?}"
)
})?;
}
Ok(index)
}
}
}
#[instrument(skip_all, fields(%index.uid, mods.len = mods.len()))]
async fn add_to_index(
client: &Client,
index: &Index,
mods: &[UploadSearchProject],
) -> Result<()> {
for chunk in mods.chunks(MEILISEARCH_CHUNK_SIZE) {
info!(
"Adding chunk of {} versions starting with version id {}",
chunk.len(),
chunk[0].version_id
);
let now = std::time::Instant::now();
let task = index
.add_or_replace(chunk, Some("version_id"))
.await
.inspect_err(|e| error!("Error adding chunk to index: {e:?}"))?;
monitor_task(
client,
task,
Duration::from_secs(60 * 5), // Timeout after 10 minutes
Some(Duration::from_secs(1)), // Poll once every second
)
.await?;
info!(
"Added chunk of {} projects to index in {:.2} seconds",
chunk.len(),
now.elapsed().as_secs_f64()
);
}
Ok(())
}
async fn monitor_task(
client: &Client,
task: TaskInfo,
timeout: Duration,
poll: Option<Duration>,
) -> Result<()> {
let now = std::time::Instant::now();
let id = task.get_task_uid();
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.reset();
let wait = task.wait_for_completion(client, poll, Some(timeout));
tokio::select! {
biased;
result = wait => {
info!("Task {id} completed in {:.2} seconds: {result:?}", now.elapsed().as_secs_f64());
result?;
}
_ = interval.tick() => {
struct Id(u32);
impl AsRef<u32> for Id {
fn as_ref(&self) -> &u32 {
&self.0
}
}
// it takes an AsRef<u32> but u32 itself doesn't impl it lol
if let Ok(task) = client.get_task(Id(id)).await {
if task.is_pending() {
info!("Task {id} is still pending after {:.2} seconds", now.elapsed().as_secs_f64());
}
} else {
error!("Error getting task {id}");
}
}
};
Ok(())
}
#[instrument(skip_all, fields(index.uid = %index.uid))]
async fn update_and_add_to_index(
client: &Client,
index: &Index,
projects: &[UploadSearchProject],
_additional_fields: &[String],
) -> Result<()> {
// TODO: Uncomment this- hardcoding loader_fields is a band-aid fix, and will be fixed soon
// let mut new_filterable_attributes: Vec<String> = index.get_filterable_attributes().await?;
// let mut new_displayed_attributes = index.get_displayed_attributes().await?;
// // Check if any 'additional_fields' are not already in the index
// // Only add if they are not already in the index
// let new_fields = additional_fields
// .iter()
// .filter(|x| !new_filterable_attributes.contains(x))
// .collect::<Vec<_>>();
// if !new_fields.is_empty() {
// info!("Adding new fields to index: {:?}", new_fields);
// new_filterable_attributes.extend(new_fields.iter().map(|s: &&String| s.to_string()));
// new_displayed_attributes.extend(new_fields.iter().map(|s| s.to_string()));
// // Adds new fields to the index
// let filterable_task = index
// .set_filterable_attributes(new_filterable_attributes)
// .await?;
// let displayable_task = index
// .set_displayed_attributes(new_displayed_attributes)
// .await?;
// // Allow a long timeout for adding new attributes- it only needs to happen the once
// filterable_task
// .wait_for_completion(client, None, Some(search_operation_timeout() * 100))
// .await?;
// displayable_task
// .wait_for_completion(client, None, Some(search_operation_timeout() * 100))
// .await?;
// }
info!("Adding to index.");
add_to_index(client, index, projects).await?;
Ok(())
}
pub async fn add_projects_batch_client(
indices: &[Vec<Index>],
projects: Vec<UploadSearchProject>,
additional_fields: Vec<String>,
config: &MeilisearchConfig,
) -> Result<()> {
let client = config.make_batch_client()?;
let index_references = indices
.iter()
.map(|x| x.iter().collect())
.collect::<Vec<Vec<&Index>>>();
let mut tasks = FuturesOrdered::new();
let mut id = 0;
client.across_all(index_references, |index_list, client| {
let span = info_span!("add_projects_batch", client.idx = id);
id += 1;
for index in index_list {
let owned_client = client.clone();
let projects_ref = &projects;
let additional_fields_ref = &additional_fields;
tasks.push_back(
async move {
update_and_add_to_index(
&owned_client,
index,
projects_ref,
additional_fields_ref,
)
.await
}
.instrument(span.clone()),
);
}
});
while let Some(result) = tasks.next().await {
result?;
}
Ok(())
}
fn default_settings() -> Settings {
Settings::new()
.with_distinct_attribute(Some("project_id"))
.with_displayed_attributes(DEFAULT_DISPLAYED_ATTRIBUTES)
.with_searchable_attributes(DEFAULT_SEARCHABLE_ATTRIBUTES)
.with_sortable_attributes(DEFAULT_SORTABLE_ATTRIBUTES)
.with_filterable_attributes(&*MEILI_FILTERABLE_ATTRIBUTES)
.with_pagination(PaginationSetting {
max_total_hits: 2147483647,
})
}
pub struct MeilisearchFieldSpec {
pub path: &'static str,
pub filterable: bool,
}
impl SearchField {
pub const fn meilisearch_spec(self) -> MeilisearchFieldSpec {
match self {
SearchField::Categories => MeilisearchFieldSpec {
path: "categories",
filterable: true,
},
SearchField::Name => MeilisearchFieldSpec {
path: "name",
filterable: true,
},
SearchField::Author => MeilisearchFieldSpec {
path: "author",
filterable: true,
},
SearchField::License => MeilisearchFieldSpec {
path: "license",
filterable: true,
},
SearchField::ProjectTypes => MeilisearchFieldSpec {
path: "project_types",
filterable: true,
},
SearchField::ProjectId => MeilisearchFieldSpec {
path: "project_id",
filterable: true,
},
SearchField::OpenSource => MeilisearchFieldSpec {
path: "open_source",
filterable: true,
},
SearchField::Environment => MeilisearchFieldSpec {
path: "environment",
filterable: true,
},
SearchField::GameVersions => MeilisearchFieldSpec {
path: "game_versions",
filterable: true,
},
SearchField::ClientSide => MeilisearchFieldSpec {
path: "client_side",
filterable: true,
},
SearchField::ServerSide => MeilisearchFieldSpec {
path: "server_side",
filterable: true,
},
SearchField::MinecraftServerRegion => MeilisearchFieldSpec {
path: "minecraft_server.region",
filterable: true,
},
SearchField::MinecraftServerLanguages => MeilisearchFieldSpec {
path: "minecraft_server.languages",
filterable: true,
},
SearchField::MinecraftJavaServerContentKind => {
MeilisearchFieldSpec {
path: "minecraft_java_server.content.kind",
filterable: true,
}
}
SearchField::MinecraftJavaServerContentSupportedGameVersions => {
MeilisearchFieldSpec {
path: "minecraft_java_server.content.supported_game_versions",
filterable: true,
}
}
SearchField::MinecraftJavaServerPingData => MeilisearchFieldSpec {
path: "minecraft_java_server.ping.data",
filterable: true,
},
}
}
}
static MEILI_FILTERABLE_ATTRIBUTES: LazyLock<Vec<&'static str>> =
LazyLock::new(|| {
use strum::IntoEnumIterator;
SearchField::iter()
.filter_map(|field| {
let spec = field.meilisearch_spec();
spec.filterable.then_some(spec.path)
})
.collect()
});
const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[
"project_id",
"version_id",
"project_types",
"slug",
"author",
"name",
"summary",
"categories",
"display_categories",
"downloads",
"follows",
"icon_url",
"date_created",
"date_modified",
"latest_version",
"license",
"gallery",
"featured_gallery",
"color",
// Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist).
// TODO: remove these- as they should be automatically populated. This is a band-aid fix.
"environment",
"game_versions",
"mrpack_loaders",
// V2 legacy fields for logical consistency
"client_side",
"server_side",
// Non-searchable fields for filling out the Project model.
"license_url",
"monetization_status",
"team_id",
"thread_id",
"versions",
"date_published",
"date_queued",
"status",
"requested_status",
"games",
"organization_id",
"links",
"gallery_items",
"loaders", // search uses loaders as categories- this is purely for the Project model.
"project_loader_fields",
"minecraft_mod",
"minecraft_server",
"minecraft_java_server",
"minecraft_bedrock_server",
];
const DEFAULT_SEARCHABLE_ATTRIBUTES: &[&str] =
&["name", "summary", "author", "slug"];
const DEFAULT_SORTABLE_ATTRIBUTES: &[&str] = &[
"downloads",
"follows",
"date_created",
"date_modified",
"version_published_timestamp",
"minecraft_java_server.verified_plays_2w",
"minecraft_java_server.ping.data.players_online",
];
@@ -0,0 +1,489 @@
use crate::database::PgPool;
use crate::database::redis::RedisPool;
use crate::env::ENV;
use crate::models::ids::VersionId;
use crate::routes::ApiError;
use crate::search::backend::{
SearchIndex, SearchIndexName, combined_search_filters, parse_search_index,
parse_search_request,
};
use crate::search::{
ResultSearchProject, SearchBackend, SearchRequest, SearchResults,
TasksCancelFilter,
};
use crate::util::error::Context;
use async_trait::async_trait;
use eyre::Result;
use futures::TryStreamExt;
use futures::stream::FuturesOrdered;
use itertools::Itertools;
use meilisearch_sdk::client::Client;
use meilisearch_sdk::tasks::{Task, TasksCancelQuery};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::fmt::Write;
use std::time::Duration;
use tracing::{Instrument, info_span};
pub mod indexing;
#[derive(Debug, Clone)]
pub struct MeilisearchReadClient {
pub client: Client,
}
impl std::ops::Deref for MeilisearchReadClient {
type Target = Client;
fn deref(&self) -> &Self::Target {
&self.client
}
}
pub struct BatchClient {
pub clients: Vec<Client>,
}
impl BatchClient {
pub fn new(clients: Vec<Client>) -> Self {
Self { clients }
}
pub async fn with_all_clients<'a, T, G, Fut>(
&'a self,
task_name: &str,
generator: G,
) -> Result<Vec<T>>
where
G: Fn(&'a Client) -> Fut,
Fut: Future<Output = Result<T>> + 'a,
{
let mut tasks = FuturesOrdered::new();
for (idx, client) in self.clients.iter().enumerate() {
tasks.push_back(generator(client).instrument(info_span!(
"client_task",
task.name = task_name,
client.idx = idx,
)));
}
let results = tasks.try_collect::<Vec<T>>().await?;
Ok(results)
}
pub fn across_all<T, F, R>(&self, data: Vec<T>, mut predicate: F) -> Vec<R>
where
F: FnMut(T, &Client) -> R,
{
assert_eq!(
data.len(),
self.clients.len(),
"mismatch between data len and meilisearch client count"
);
self.clients
.iter()
.zip(data)
.map(|(client, item)| predicate(item, client))
.collect()
}
}
#[derive(Debug, Clone)]
pub struct MeilisearchConfig {
pub addresses: Vec<String>,
pub read_lb_address: String,
pub key: String,
pub meta_namespace: String,
}
impl MeilisearchConfig {
pub fn new(meta_namespace: Option<String>) -> Self {
Self {
addresses: ENV.MEILISEARCH_WRITE_ADDRS.0.clone(),
key: ENV.MEILISEARCH_KEY.clone(),
meta_namespace: meta_namespace.unwrap_or_default(),
read_lb_address: ENV.MEILISEARCH_READ_ADDR.clone(),
}
}
pub fn make_loadbalanced_read_client(
&self,
) -> Result<MeilisearchReadClient, meilisearch_sdk::errors::Error> {
Ok(MeilisearchReadClient {
client: Client::new(&self.read_lb_address, Some(&self.key))?,
})
}
pub fn make_batch_client(
&self,
) -> Result<BatchClient, meilisearch_sdk::errors::Error> {
Ok(BatchClient::new(
self.addresses
.iter()
.map(|address| {
Client::new(address.as_str(), Some(self.key.as_str()))
})
.collect::<Result<Vec<_>, _>>()?,
))
}
pub fn get_index_name(&self, index: &str, next: bool) -> String {
let alt = if next { "_alt" } else { "" };
format!("{}_{}_{}", self.meta_namespace, index, alt)
}
}
pub struct Meilisearch {
pub config: MeilisearchConfig,
}
impl Meilisearch {
pub fn new(config: MeilisearchConfig) -> Self {
Self { config }
}
fn get_sort_index(
&self,
index: &str,
new_filters: Option<&str>,
) -> Result<(String, &'static [&'static str]), ApiError> {
let sort = parse_search_index(index, new_filters)?;
let index_name = match sort.index_name {
SearchIndexName::Projects => {
self.config.get_index_name("projects", false)
}
SearchIndexName::ProjectsFiltered => {
self.config.get_index_name("projects_filtered", false)
}
};
Ok(match sort.index {
SearchIndex::Relevance => (
index_name,
&["downloads:desc", "version_published_timestamp:desc"],
),
SearchIndex::Downloads => (
index_name,
&["downloads:desc", "version_published_timestamp:desc"],
),
SearchIndex::Follows => (
index_name,
&["follows:desc", "version_published_timestamp:desc"],
),
SearchIndex::Updated => (
index_name,
&["date_modified:desc", "version_published_timestamp:desc"],
),
SearchIndex::Newest => (
index_name,
&["date_created:desc", "version_published_timestamp:desc"],
),
SearchIndex::MinecraftJavaServerVerifiedPlays2w => (
index_name,
&[
"minecraft_java_server.verified_plays_2w:desc",
"minecraft_java_server.ping.data.players_online:desc",
"version_published_timestamp:desc",
],
),
SearchIndex::MinecraftJavaServerPlayersOnline => (
index_name,
&[
"minecraft_java_server.ping.data.players_online:desc",
"version_published_timestamp:desc",
],
),
})
}
}
#[async_trait]
impl SearchBackend for Meilisearch {
async fn search_for_project_raw(
&self,
info: &SearchRequest,
) -> Result<SearchResults, ApiError> {
let parsed = parse_search_request(info)?;
let (index_name, sort_name) =
self.get_sort_index(parsed.index, info.new_filters.as_deref())?;
let client = self
.config
.make_loadbalanced_read_client()
.wrap_internal_err("failed to make load-balanced read client")?;
let meilisearch_index = client
.get_index(index_name)
.await
.wrap_internal_err("failed to get index")?;
let mut filter_string = String::new();
let results = {
let mut query = meilisearch_index.search();
query
.with_page(parsed.page)
.with_hits_per_page(parsed.hits_per_page)
.with_query(parsed.query)
.with_sort(sort_name);
if let Some(new_filters) = info.new_filters.as_deref() {
query.with_filter(new_filters);
} else {
let facets = if let Some(facets) = &info.facets {
let facets =
serde_json::from_str::<Vec<Vec<Value>>>(facets)
.wrap_request_err("failed to parse facets")?;
Some(facets)
} else {
None
};
let filters =
combined_search_filters(info).unwrap_or_else(|| "".into());
if let Some(facets) = facets {
let facets: Vec<Vec<Vec<String>>> =
facets
.into_iter()
.map(|facets| {
facets
.into_iter()
.map(|facet| {
if facet.is_array() {
serde_json::from_value::<Vec<String>>(facet)
.unwrap_or_default()
} else {
vec![
serde_json::from_value::<String>(facet)
.unwrap_or_default(),
]
}
})
.collect_vec()
})
.collect_vec();
filter_string.push('(');
for (index, facet_outer_list) in facets.iter().enumerate() {
filter_string.push('(');
for (facet_outer_index, facet_inner_list) in
facet_outer_list.iter().enumerate()
{
filter_string.push('(');
for (facet_inner_index, facet) in
facet_inner_list.iter().enumerate()
{
filter_string
.push_str(&facet.replace(':', " = "));
if facet_inner_index
!= (facet_inner_list.len() - 1)
{
filter_string.push_str(" AND ")
}
}
filter_string.push(')');
if facet_outer_index != (facet_outer_list.len() - 1)
{
filter_string.push_str(" OR ")
}
}
filter_string.push(')');
if index != (facets.len() - 1) {
filter_string.push_str(" AND ")
}
}
filter_string.push(')');
if !filters.is_empty() {
write!(filter_string, " AND ({filters})")
.expect("write should not fail");
}
} else {
filter_string.push_str(&filters);
}
if !filter_string.is_empty() {
query.with_filter(&filter_string);
}
}
if info.show_metadata {
query.with_show_ranking_score(true);
query.with_show_ranking_score_details(true);
query.execute().await?
} else {
query.execute::<ResultSearchProject>().await?
}
};
if info.show_metadata {
let hits = results
.hits
.into_iter()
.map(|hit| {
let metadata = serde_json::to_value(&hit)
.ok()
.and_then(|value| value.as_object().cloned())
.map(|mut value| {
value.remove("_formatted");
value.remove("_matchesPosition");
value.remove("_federation");
let result = value.remove("result");
let metadata = Value::Object(value);
(result, metadata)
});
let (result, metadata) =
metadata.unwrap_or((None, Value::Null));
let mut result = result
.and_then(|value| {
serde_json::from_value::<ResultSearchProject>(value)
.ok()
})
.unwrap_or(hit.result);
if !metadata.is_null() {
result.search_metadata = Some(metadata);
}
result
})
.collect();
Ok(SearchResults {
hits,
page: results.page.unwrap_or_default(),
hits_per_page: results.hits_per_page.unwrap_or_default(),
total_hits: results.total_hits.unwrap_or_default(),
})
} else {
Ok(SearchResults {
hits: results.hits.into_iter().map(|r| r.result).collect(),
page: results.page.unwrap_or_default(),
hits_per_page: results.hits_per_page.unwrap_or_default(),
total_hits: results.total_hits.unwrap_or_default(),
})
}
}
async fn index_projects(
&self,
ro_pool: PgPool,
redis: RedisPool,
) -> eyre::Result<()> {
indexing::index_projects(ro_pool, redis, &self.config).await?;
Ok(())
}
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()> {
indexing::remove_documents(ids, &self.config).await?;
Ok(())
}
async fn tasks(&self) -> eyre::Result<Value> {
let client = self
.config
.make_batch_client()
.wrap_internal_err("failed to make batch client")?;
let tasks = client
.with_all_clients("get_tasks", async |client| {
let tasks = client.get_tasks().await?;
Ok(tasks.results)
})
.await
.wrap_internal_err("failed to get tasks")?;
#[derive(Serialize)]
struct MeiliTask<Time> {
uid: u32,
status: &'static str,
duration: Option<Duration>,
enqueued_at: Option<Time>,
}
#[derive(Serialize)]
struct TaskList<Time> {
by_instance: HashMap<String, Vec<MeiliTask<Time>>>,
}
let response = tasks
.into_iter()
.enumerate()
.map(|(idx, instance_tasks)| {
let tasks = instance_tasks
.into_iter()
.filter_map(|task| {
Some(match task {
Task::Enqueued { content } => MeiliTask {
uid: content.uid,
status: "enqueued",
duration: None,
enqueued_at: Some(content.enqueued_at),
},
Task::Processing { content } => MeiliTask {
uid: content.uid,
status: "processing",
duration: None,
enqueued_at: Some(content.enqueued_at),
},
Task::Failed { content } => MeiliTask {
uid: content.task.uid,
status: "failed",
duration: Some(content.task.duration),
enqueued_at: Some(content.task.enqueued_at),
},
Task::Succeeded { .. } => return None,
})
})
.collect();
(idx.to_string(), tasks)
})
.collect::<HashMap<String, Vec<MeiliTask<_>>>>();
let response = serde_json::to_value(TaskList {
by_instance: response,
})
.wrap_internal_err("failed to serialize tasks response")?;
Ok(response)
}
async fn tasks_cancel(
&self,
filter: &TasksCancelFilter,
) -> eyre::Result<()> {
let client = self
.config
.make_batch_client()
.wrap_internal_err("failed to make batch client")?;
let all_results = client
.with_all_clients("cancel_tasks", async |client| {
let mut q = TasksCancelQuery::new(client);
match filter {
TasksCancelFilter::All => {}
TasksCancelFilter::Indexes { indexes } => {
q.with_index_uids(indexes.iter().map(|s| s.as_str()));
}
TasksCancelFilter::AllEnqueued => {
q.with_statuses(["enqueued"]);
}
};
let result = client.cancel_tasks_with(&q).await;
Ok(result)
})
.await
.wrap_internal_err("failed to cancel tasks")?;
for r in all_results {
r.wrap_internal_err("failed to cancel tasks")?;
}
Ok(())
}
}
+4
View File
@@ -1,8 +1,12 @@
mod common;
pub mod elasticsearch;
pub mod meilisearch;
pub mod typesense;
pub use common::{
ParsedSearchRequest, SearchIndex, SearchIndexName, SearchSort,
combined_search_filters, parse_search_index, parse_search_request,
};
pub use elasticsearch::Elasticsearch;
pub use meilisearch::{Meilisearch, MeilisearchConfig};
pub use typesense::{Typesense, TypesenseConfig};
+15
View File
@@ -52,6 +52,8 @@ pub struct SearchRequest {
#[serde(default)]
pub show_metadata: bool,
#[serde(default)]
pub elasticsearch_config: backend::elasticsearch::RequestConfig,
#[serde(default)]
pub typesense_config: backend::typesense::RequestConfig,
pub new_filters: Option<String>,
@@ -69,6 +71,8 @@ impl From<SearchQuery> for SearchRequest {
index: query.index,
limit: query.limit,
show_metadata: false,
elasticsearch_config:
backend::elasticsearch::RequestConfig::default(),
typesense_config: backend::typesense::RequestConfig::default(),
new_filters: query.new_filters,
facets: query.facets,
@@ -174,6 +178,8 @@ pub enum TasksCancelFilter {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SearchBackendKind {
Meilisearch,
Elasticsearch,
Typesense,
}
@@ -206,6 +212,8 @@ impl FromStr for SearchBackendKind {
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"meilisearch" => SearchBackendKind::Meilisearch,
"elasticsearch" => SearchBackendKind::Elasticsearch,
"typesense" => SearchBackendKind::Typesense,
_ => return Err(InvalidSearchBackendKind),
})
@@ -343,6 +351,13 @@ impl From<UploadSearchProject> for ResultSearchProject {
pub fn backend(meta_namespace: Option<String>) -> Box<dyn SearchBackend> {
match ENV.SEARCH_BACKEND {
SearchBackendKind::Meilisearch => {
let config = backend::MeilisearchConfig::new(meta_namespace);
Box::new(backend::Meilisearch::new(config))
}
SearchBackendKind::Elasticsearch => {
Box::new(backend::Elasticsearch::new(meta_namespace).unwrap())
}
SearchBackendKind::Typesense => {
let config = backend::TypesenseConfig::new(meta_namespace);
Box::new(backend::Typesense::new(config))
+1 -14
View File
@@ -10,23 +10,10 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-05-13T05:24:14+00:00`,
product: 'web',
body: `## Changed
- Overhauled the 'Moderation' tab on project pages to make the moderation status of your project clearer.
- Updated the report page to a more modern style.
- Adjusted the colors of certain status banner buttons to make them more readable.
- Updated the DMCA registered agent listed on the coypright policy page.
## Fixed
- Fixed status banners at the top of the page having really long buttons.
- Fixed status banners at the top of the page having an unusual amount of padding on the bottom when they didn't have an action.`,
},
{
date: `2026-05-12T20:06:07+00:00`,
product: 'app',
version: '0.13.17',
version: '0.13.16',
body: `## Fixed
- Fixed the app automatically re-opening after installing a pending update when the user closes the app.
- Fixed "Open in browser" not working.
@@ -1,3 +1,2 @@
In accordance with the above notice, this project will be temporarily %STATUS%.
In accordance with the above notice, this project will be temporarily %STATUS%.</br>
We ask that you resubmit this project once all moderation concerns have been addressed.
@@ -24,7 +24,7 @@
{{ relativeTimeLabel }}
</span>
</div>
<div class="font-normal text-contrast/85 leading-tight">
<div class="font-normal text-contrast/85">
<slot>{{ body }}</slot>
</div>
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
@@ -80,7 +80,7 @@ import ButtonStyled from './ButtonStyled.vue'
const props = withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation'
type?: 'info' | 'warning' | 'critical' | 'success'
header?: string
body?: string
showActionsUnderneath?: boolean
@@ -141,7 +141,6 @@ const typeClasses = {
warning: 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
moderation: 'border-brand-orange bg-bg-orange',
}
const iconClasses = {
@@ -149,7 +148,6 @@ const iconClasses = {
warning: 'text-brand-orange',
critical: 'text-brand-red',
success: 'text-brand-green',
moderation: 'text-brand-orange',
}
const buttonColors = {
@@ -157,7 +155,6 @@ const buttonColors = {
warning: 'orange',
critical: 'red',
success: 'green',
moderation: 'orange',
} as const
const progressTrackClasses = {
@@ -165,7 +162,6 @@ const progressTrackClasses = {
warning: 'bg-brand-orange/20',
critical: 'bg-brand-red/20',
success: 'bg-brand-green/20',
moderation: 'bg-brand-orange/20',
}
const progressFillClasses = {
+3 -3
View File
@@ -21,10 +21,10 @@
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
</template>
<template v-else-if="type === 'unlisted'">
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
</template>
<template v-else-if="type === 'withheld'">
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
</template>
<template v-else-if="type === 'private'">
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
@@ -89,9 +89,9 @@ import {
BugIcon,
CalendarIcon,
CheckIcon,
EyeOffIcon,
FileTextIcon,
GlobeIcon,
LinkIcon,
LockIcon,
ModrinthIcon,
ScaleIcon,
+2 -2
View File
@@ -1,6 +1,6 @@
<template>
<button
class="group bg-transparent border-none p-0 m-0 flex items-center text-left gap-3 checkbox-outer outline-offset-4 text-contrast"
class="group bg-transparent border-none p-0 m-0 flex items-center gap-3 checkbox-outer outline-offset-4 text-contrast"
:disabled="disabled"
:class="
disabled
@@ -13,7 +13,7 @@
@click="toggle"
>
<span
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid shrink-0"
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid"
:class="{
'bg-brand border-button-border text-brand-inverted': modelValue,
'bg-surface-2 border-surface-5 text-primary': !modelValue,
@@ -1,41 +1,39 @@
<template>
<NewModal ref="linkModal" :header="formatMessage(messages.linkModalHeader)" class="!w-[40rem]">
<NewModal ref="linkModal" header="Insert link">
<div class="modal-insert">
<label class="label" for="insert-link-label">
<span class="label__title">{{ formatMessage(messages.linkModalLabelFieldTitle) }}</span>
<span class="label__title">Label</span>
</label>
<StyledInput
id="insert-link-label"
v-model="linkText"
:icon="AlignLeftIcon"
type="text"
:placeholder="formatMessage(messages.linkModalLabelFieldPlaceholder)"
placeholder="Enter label..."
clearable
wrapper-class="w-full"
/>
<label class="label" for="insert-link-url">
<span class="label__title">
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
</span>
<span class="label__title">URL<span class="required">*</span></span>
</label>
<StyledInput
id="insert-link-url"
v-model="linkUrl"
:icon="LinkIcon"
type="text"
:placeholder="formatMessage(messages.linkModalUrlFieldPlaceholder)"
placeholder="Enter the link's URL..."
clearable
wrapper-class="w-full"
@input="validateURL"
/>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
@@ -47,9 +45,7 @@
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => linkModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
<button @click="() => linkModal?.hide()"><XIcon /> Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
@@ -61,21 +57,18 @@
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
<PlusIcon /> Insert
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="imageModal" :header="formatMessage(messages.imageModalHeader)" class="!w-[40rem]">
<NewModal ref="imageModal" header="Insert image">
<div class="modal-insert">
<label class="label" for="insert-image-alt">
<span class="label__title">
{{ formatMessage(messages.imageModalDescriptionFieldTitle) }}
<span class="required">*</span>
</span>
<span class="label__title">Description (alt text)<span class="required">*</span></span>
<span class="label__description">
{{ formatMessage(messages.imageModalDescriptionFieldDescription) }}
Describe the image completely as you would to someone who could not see the image.
</span>
</label>
<StyledInput
@@ -83,22 +76,15 @@
v-model="linkText"
:icon="AlignLeftIcon"
type="text"
:placeholder="formatMessage(messages.imageModalDescriptionFieldPlaceholder)"
placeholder="Describe the image..."
clearable
wrapper-class="w-full"
/>
<label class="label" for="insert-link-url">
<span class="label__title">
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
</span>
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div v-if="props.onImageUpload" class="image-strategy-chips">
<Chips
v-model="imageUploadOption"
:items="['upload', 'link']"
:format-label="formatImageUploadOption"
:aria-label="formatMessage(messages.imageModalUploadModeLabel)"
/>
<Chips v-model="imageUploadOption" :items="['upload', 'link']" />
</div>
<div
v-if="props.onImageUpload && imageUploadOption === 'upload'"
@@ -106,7 +92,7 @@
>
<FileInput
accept="image/png,image/jpeg,image/gif,image/webp"
:prompt="formatMessage(messages.imageModalUploadPrompt)"
prompt="Drag and drop to upload or click to select file"
long-style
should-always-reset
class="file-input"
@@ -121,19 +107,19 @@
v-model="linkUrl"
:icon="ImageIcon"
type="text"
:placeholder="formatMessage(messages.imageModalUrlFieldPlaceholder)"
placeholder="Enter the image URL..."
clearable
wrapper-class="w-full"
@input="validateURL"
/>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
<div class="markdown-body-wrapper">
@@ -145,9 +131,7 @@
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => imageModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
<button @click="() => imageModal?.hide()"><XIcon /> Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
@@ -159,40 +143,36 @@
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
<PlusIcon /> Insert
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="videoModal" :header="formatMessage(messages.videoModalHeader)" class="!w-[40rem]">
<NewModal ref="videoModal" header="Insert YouTube video">
<div class="modal-insert">
<label class="label" for="insert-video-url">
<span class="label__title">
{{ formatMessage(messages.videoModalUrlFieldTitle) }}<span class="required">*</span>
</span>
<span class="label__description">
{{ formatMessage(messages.videoModalUrlFieldDescription) }}
</span>
<span class="label__title">YouTube video URL<span class="required">*</span></span>
<span class="label__description"> Enter a valid link to a YouTube video. </span>
</label>
<StyledInput
id="insert-video-url"
v-model="linkUrl"
:icon="YouTubeIcon"
type="text"
:placeholder="formatMessage(messages.videoModalUrlFieldPlaceholder)"
placeholder="Enter YouTube video URL"
clearable
wrapper-class="w-full"
@input="validateURL"
/>
<template v-if="linkValidationErrorMessage">
<span class="label">
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
<span class="label__title">Error</span>
<span class="label__description">{{ linkValidationErrorMessage }}</span>
</span>
</template>
<span class="label">
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
<span class="label__title">Preview</span>
<span class="label__description"></span>
</span>
@@ -205,9 +185,7 @@
</div>
<div class="flex gap-2 justify-end mt-4">
<ButtonStyled type="outlined">
<button @click="() => videoModal?.hide()">
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
</button>
<button @click="() => videoModal?.hide()"><XIcon /> Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
@@ -219,41 +197,37 @@
}
"
>
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
<PlusIcon /> Insert
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="block grow w-full">
<div class="editor-action-row w-full">
<div class="w-full flex justify-between items-center flex-wrap gap-2">
<div class="editor-actions">
<template
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
:key="_i"
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label.id">
<ButtonStyled circular>
<button
v-tooltip="formatMessage(button.label)"
:aria-label="formatMessage(button.label)"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:disabled="previewMode || disabled"
@click="() => button.action(editor)"
>
<component :is="button.icon" />
</button>
</ButtonStyled>
</template>
<div class="editor-action-row">
<div class="editor-actions">
<template
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
:key="_i"
>
<div class="divider"></div>
<template v-for="button in buttonGroup.buttons" :key="button.label">
<ButtonStyled circular>
<button
v-tooltip="button.label"
:aria-label="button.label"
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
:disabled="previewMode || disabled"
@click="() => button.action(editor)"
>
<component :is="button.icon" />
</button>
</ButtonStyled>
</template>
</div>
<div class="flex items-center gap-2">
<Toggle id="preview" v-model="previewMode" small />
<label class="label" for="preview">
{{ formatMessage(messages.editorPreviewToggleLabel) }}
</label>
</template>
<div class="preview">
<Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label>
</div>
</div>
</div>
@@ -261,29 +235,20 @@
<div v-if="!previewMode" class="info-blurb mt-2">
<div class="info-blurb">
<InfoIcon />
<IntlFormatted :message-id="messages.editorMarkdownFormattingSupport">
<template #markdown-link="{ children }">
<a
class="markdown-resource-link"
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
target="_blank"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
<span
>This editor supports
<a
class="markdown-resource-link"
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
target="_blank"
>Markdown formatting</a
>.</span
>
</div>
<div :class="{ hide: !props.maxLength }" class="max-length-label">
<span>{{ formatMessage(messages.editorMaxLengthLabel) }} </span>
<span>Max length: </span>
<span>
{{
props.maxLength
? formatMessage(messages.editorMaxLengthValue, {
currentLength: currentValue?.length || 0,
maxLength: props.maxLength,
})
: formatMessage(messages.editorMaxLengthUnlimited)
}}
{{ props.maxLength ? `${currentValue?.length || 0}/${props.maxLength}` : 'Unlimited' }}
</span>
</div>
</div>
@@ -333,214 +298,13 @@ import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages.ts'
import NewModal from '../modal/NewModal.vue'
import ButtonStyled from './ButtonStyled.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'
import IntlFormatted from './IntlFormatted.vue'
import StyledInput from './StyledInput.vue'
import Toggle from './Toggle.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
insertButton: {
id: 'markdown-editor.insert-button',
defaultMessage: 'Insert',
},
urlLabel: {
id: 'markdown-editor.url-label',
defaultMessage: 'URL',
},
errorLabel: {
id: 'markdown-editor.error-label',
defaultMessage: 'Error',
},
previewLabel: {
id: 'markdown-editor.preview-label',
defaultMessage: 'Preview',
},
linkModalHeader: {
id: 'markdown-editor.link-modal.header',
defaultMessage: 'Insert link',
},
linkModalLabelFieldTitle: {
id: 'markdown-editor.link-modal.label-field.title',
defaultMessage: 'Label',
},
linkModalLabelFieldPlaceholder: {
id: 'markdown-editor.link-modal.label-field.placeholder',
defaultMessage: 'Enter label...',
},
linkModalUrlFieldPlaceholder: {
id: 'markdown-editor.link-modal.url-field.placeholder',
defaultMessage: "Enter the link's URL...",
},
imageModalHeader: {
id: 'markdown-editor.image-modal.header',
defaultMessage: 'Insert image',
},
imageModalDescriptionFieldTitle: {
id: 'markdown-editor.image-modal.description-field.title',
defaultMessage: 'Description (alt text)',
},
imageModalDescriptionFieldDescription: {
id: 'markdown-editor.image-modal.description-field.description',
defaultMessage:
'Describe the image completely as you would to someone who could not see the image.',
},
imageModalDescriptionFieldPlaceholder: {
id: 'markdown-editor.image-modal.description-field.placeholder',
defaultMessage: 'Describe the image...',
},
imageModalUploadModeLabel: {
id: 'markdown-editor.image-modal.upload-mode.label',
defaultMessage: 'Image source',
},
imageModalUploadModeUpload: {
id: 'markdown-editor.image-modal.upload-mode.upload',
defaultMessage: 'Upload',
},
imageModalUploadModeLink: {
id: 'markdown-editor.image-modal.upload-mode.link',
defaultMessage: 'Link',
},
imageModalUploadPrompt: {
id: 'markdown-editor.image-modal.upload.prompt',
defaultMessage: 'Drag and drop to upload or click to select file',
},
imageModalUrlFieldPlaceholder: {
id: 'markdown-editor.image-modal.url-field.placeholder',
defaultMessage: 'Enter the image URL...',
},
videoModalHeader: {
id: 'markdown-editor.video-modal.header',
defaultMessage: 'Insert YouTube video',
},
videoModalUrlFieldTitle: {
id: 'markdown-editor.video-modal.url-field.title',
defaultMessage: 'YouTube video URL',
},
videoModalUrlFieldDescription: {
id: 'markdown-editor.video-modal.url-field.description',
defaultMessage: 'Enter a valid link to a YouTube video.',
},
videoModalUrlFieldPlaceholder: {
id: 'markdown-editor.video-modal.url-field.placeholder',
defaultMessage: 'Enter YouTube video URL',
},
editorPreviewToggleLabel: {
id: 'markdown-editor.preview-toggle.label',
defaultMessage: 'Preview',
},
editorMarkdownFormattingSupport: {
id: 'markdown-editor.markdown-formatting-support',
defaultMessage: 'This editor supports <markdown-link>Markdown formatting</markdown-link>.',
},
editorMaxLengthLabel: {
id: 'markdown-editor.max-length.label',
defaultMessage: 'Max length:',
},
editorMaxLengthValue: {
id: 'markdown-editor.max-length.value',
defaultMessage: '{currentLength}/{maxLength}',
},
editorMaxLengthUnlimited: {
id: 'markdown-editor.max-length.unlimited',
defaultMessage: 'Unlimited',
},
editorPlaceholder: {
id: 'markdown-editor.placeholder',
defaultMessage: 'Write something...',
},
toolbarHeading1: {
id: 'markdown-editor.toolbar.heading-1',
defaultMessage: 'Heading 1',
},
toolbarHeading2: {
id: 'markdown-editor.toolbar.heading-2',
defaultMessage: 'Heading 2',
},
toolbarHeading3: {
id: 'markdown-editor.toolbar.heading-3',
defaultMessage: 'Heading 3',
},
toolbarBold: {
id: 'markdown-editor.toolbar.bold',
defaultMessage: 'Bold',
},
toolbarItalic: {
id: 'markdown-editor.toolbar.italic',
defaultMessage: 'Italic',
},
toolbarStrikethrough: {
id: 'markdown-editor.toolbar.strikethrough',
defaultMessage: 'Strikethrough',
},
toolbarCode: {
id: 'markdown-editor.toolbar.code',
defaultMessage: 'Code',
},
toolbarSpoiler: {
id: 'markdown-editor.toolbar.spoiler',
defaultMessage: 'Spoiler',
},
toolbarBulletedList: {
id: 'markdown-editor.toolbar.bulleted-list',
defaultMessage: 'Bulleted list',
},
toolbarOrderedList: {
id: 'markdown-editor.toolbar.ordered-list',
defaultMessage: 'Ordered list',
},
toolbarQuote: {
id: 'markdown-editor.toolbar.quote',
defaultMessage: 'Quote',
},
toolbarLink: {
id: 'markdown-editor.toolbar.link',
defaultMessage: 'Link',
},
toolbarImage: {
id: 'markdown-editor.toolbar.image',
defaultMessage: 'Image',
},
toolbarVideo: {
id: 'markdown-editor.toolbar.video',
defaultMessage: 'Video',
},
videoEmbedTitle: {
id: 'markdown-editor.video-embed.title',
defaultMessage: 'YouTube video player',
},
urlValidationErrorMalformed: {
id: 'markdown-editor.url-validation-error.malformed',
defaultMessage: 'Invalid URL. Make sure the URL is well-formed.',
},
urlValidationErrorUnsupportedProtocol: {
id: 'markdown-editor.url-validation-error.unsupported-protocol',
defaultMessage: 'Unsupported protocol. Use http or https.',
},
urlValidationErrorBlockedDomain: {
id: 'markdown-editor.url-validation-error.blocked-domain',
defaultMessage: 'Invalid URL. This domain is not allowed.',
},
uploadErrorNoHandler: {
id: 'markdown-editor.upload-error.no-handler',
defaultMessage: 'No image upload handler provided',
},
uploadErrorNoFile: {
id: 'markdown-editor.upload-error.no-file',
defaultMessage: 'No file provided',
},
defaultImageAltText: {
id: 'markdown-editor.default-image-alt-text',
defaultMessage: 'Replace this with a description',
},
})
const props = withDefaults(
defineProps<{
modelValue: string
@@ -561,7 +325,7 @@ const props = withDefaults(
disabled: false,
headingButtons: true,
onImageUpload: undefined,
placeholder: undefined,
placeholder: 'Write something...',
maxLength: undefined,
maxHeight: undefined,
minHeight: undefined,
@@ -574,9 +338,6 @@ let isDisabledCompartment: Compartment | null = null
let editorThemeCompartment: Compartment | null = null
const emit = defineEmits(['update:modelValue'])
const resolvedPlaceholder = computed(
() => props.placeholder ?? formatMessage(messages.editorPlaceholder),
)
onMounted(() => {
const updateListener = EditorView.updateListener.of((update) => {
@@ -632,7 +393,7 @@ onMounted(() => {
uploadImagesFromList(clipboardData.files)
.then(function (url) {
const selection = markdownCommands.yankSelection(view)
const altText = selection || formatMessage(messages.defaultImageAltText)
const altText = selection || 'Replace this with a description'
const linkMarkdown = `![${altText}](${url})`
return markdownCommands.replaceSelection(view, linkMarkdown)
})
@@ -705,7 +466,7 @@ onMounted(() => {
addKeymap: false,
}),
keymap.of(historyKeymap),
cm_placeholder(resolvedPlaceholder.value),
cm_placeholder(props.placeholder || ''),
inputFilter,
isDisabledCompartment.of(disabledCompartment),
editorThemeCompartment.of(theme),
@@ -733,7 +494,7 @@ onBeforeUnmount(() => {
})
type ButtonAction = {
label: MessageDescriptor
label: string
icon: Component
action: (editor: EditorView | null) => void
}
@@ -754,12 +515,12 @@ function runEditorCommand(command: (view: EditorView) => boolean, editor: Editor
}
const composeCommandButton = (
label: MessageDescriptor,
name: string,
icon: Component,
command: (view: EditorView) => boolean,
) => {
return {
label,
label: name,
icon,
action: (e: EditorView | null) => runEditorCommand(command, e),
}
@@ -770,41 +531,33 @@ const BUTTONS: ButtonGroupMap = {
display: props.headingButtons,
hideOnMobile: false,
buttons: [
composeCommandButton(messages.toolbarHeading1, Heading1Icon, markdownCommands.toggleHeader),
composeCommandButton(messages.toolbarHeading2, Heading2Icon, markdownCommands.toggleHeader2),
composeCommandButton(messages.toolbarHeading3, Heading3Icon, markdownCommands.toggleHeader3),
composeCommandButton('Heading 1', Heading1Icon, markdownCommands.toggleHeader),
composeCommandButton('Heading 2', Heading2Icon, markdownCommands.toggleHeader2),
composeCommandButton('Heading 3', Heading3Icon, markdownCommands.toggleHeader3),
],
},
stylizing: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton(messages.toolbarBold, BoldIcon, markdownCommands.toggleBold),
composeCommandButton(messages.toolbarItalic, ItalicIcon, markdownCommands.toggleItalic),
composeCommandButton('Bold', BoldIcon, markdownCommands.toggleBold),
composeCommandButton('Italic', ItalicIcon, markdownCommands.toggleItalic),
composeCommandButton(
messages.toolbarStrikethrough,
'Strikethrough',
StrikethroughIcon,
markdownCommands.toggleStrikethrough,
),
composeCommandButton(messages.toolbarCode, CodeIcon, markdownCommands.toggleCodeBlock),
composeCommandButton(messages.toolbarSpoiler, ScanEyeIcon, markdownCommands.toggleSpoiler),
composeCommandButton('Code', CodeIcon, markdownCommands.toggleCodeBlock),
composeCommandButton('Spoiler', ScanEyeIcon, markdownCommands.toggleSpoiler),
],
},
lists: {
display: true,
hideOnMobile: false,
buttons: [
composeCommandButton(
messages.toolbarBulletedList,
ListBulletedIcon,
markdownCommands.toggleBulletList,
),
composeCommandButton(
messages.toolbarOrderedList,
ListOrderedIcon,
markdownCommands.toggleOrderedList,
),
composeCommandButton(messages.toolbarQuote, TextQuoteIcon, markdownCommands.toggleQuote),
composeCommandButton('Bulleted list', ListBulletedIcon, markdownCommands.toggleBulletList),
composeCommandButton('Ordered list', ListOrderedIcon, markdownCommands.toggleOrderedList),
composeCommandButton('Quote', TextQuoteIcon, markdownCommands.toggleQuote),
],
},
components: {
@@ -812,17 +565,17 @@ const BUTTONS: ButtonGroupMap = {
hideOnMobile: false,
buttons: [
{
label: messages.toolbarLink,
label: 'Link',
icon: LinkIcon,
action: () => openLinkModal(),
},
{
label: messages.toolbarImage,
label: 'Image',
icon: ImageIcon,
action: () => openImageModal(),
},
{
label: messages.toolbarVideo,
label: 'Video',
icon: YouTubeIcon,
action: () => openVideoModal(),
},
@@ -940,12 +693,12 @@ function cleanUrl(input: string): string {
try {
url = new URL(input)
} catch {
throw new Error(formatMessage(messages.urlValidationErrorMalformed))
throw new Error('Invalid URL. Make sure the URL is well-formed.')
}
// Check for unsupported protocols
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(formatMessage(messages.urlValidationErrorUnsupportedProtocol))
throw new Error('Unsupported protocol. Use http or https.')
}
// If the scheme is "http", automatically upgrade it to "https"
@@ -956,7 +709,7 @@ function cleanUrl(input: string): string {
// Block certain domains for compliance
const blockedDomains = ['forgecdn', 'cdn.discordapp', 'media.discordapp']
if (blockedDomains.some((domain) => url.hostname.includes(domain))) {
throw new Error(formatMessage(messages.urlValidationErrorBlockedDomain))
throw new Error('Invalid URL. This domain is not allowed.')
}
return url.toString()
@@ -980,7 +733,7 @@ const linkMarkdown = computed(() => {
const uploadImagesFromList = async (files: FileList): Promise<string> => {
const file = files[0]
if (!props.onImageUpload) {
throw new Error(formatMessage(messages.uploadErrorNoHandler))
throw new Error('No image upload handler provided')
}
if (file) {
try {
@@ -993,7 +746,7 @@ const uploadImagesFromList = async (files: FileList): Promise<string> => {
}
}
}
throw new Error(formatMessage(messages.uploadErrorNoFile))
throw new Error('No file provided')
}
const handleImageUpload = async (files: FileList) => {
@@ -1012,15 +765,6 @@ const handleImageUpload = async (files: FileList) => {
}
const imageUploadOption = ref<string>('upload')
function formatImageUploadOption(option: string) {
if (option === 'upload') {
return formatMessage(messages.imageModalUploadModeUpload)
}
if (option === 'link') {
return formatMessage(messages.imageModalUploadModeLink)
}
return option
}
const imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
const canInsertImage = computed(() => {
@@ -1037,7 +781,7 @@ const youtubeRegex =
const videoMarkdown = computed(() => {
const match = youtubeRegex.exec(linkUrl.value)
if (match) {
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="${formatMessage(messages.videoEmbedTitle)}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
return `<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/${match[1]}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>`
}
return ''
})
@@ -1180,13 +924,11 @@ function openVideoModal() {
}
.modal-insert {
padding: var(--gap-lg);
.label {
margin-block: var(--gap-lg) var(--gap-sm);
display: block;
&:first-child {
margin-top: 0;
}
}
.label__title {
+2 -2
View File
@@ -2,8 +2,8 @@
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold drop-shadow-xl"
:class="{ 'shadow-sm': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink
@@ -25,7 +25,6 @@
aria-modal="true"
:aria-labelledby="headerId"
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
v-bind="$attrs"
@keydown="handleKeyDown"
>
<div
@@ -346,10 +345,6 @@ function handleKeyDown(event: KeyboardEvent) {
}
}
}
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>
@@ -1,10 +1,6 @@
<template>
<div
:class="[
'banner-grid relative border-b-2 border-solid border-0 z-10',
containerClasses[variant],
{ 'no-actions': !$slots.actions, slim: slim },
]"
:class="['banner-grid relative border-b-2 border-solid border-0', containerClasses[variant]]"
>
<div
:class="[
@@ -20,20 +16,12 @@
<slot name="description" />
</div>
<div v-if="$slots.actions" class="grid-area-[actions] flex items-center gap-2">
<div v-if="$slots.actions" class="grid-area-[actions]">
<slot name="actions" />
</div>
<div
v-if="$slots.actions_right || $slots.actions_top_right"
class="grid-area-[actions_right] flex flex-col gap-2 items-end"
>
<div v-if="$slots.actions_top_right" class="flex items-center gap-2 justify-end">
<slot name="actions_top_right" />
</div>
<div v-if="$slots.actions_right" class="flex items-center gap-2 justify-end my-auto">
<slot name="actions_right" />
</div>
<div v-if="$slots.actions_right" class="grid-area-[actions_right]">
<slot name="actions_right" />
</div>
</div>
</template>
@@ -41,15 +29,9 @@
<script lang="ts" setup>
import { getSeverityIcon } from '../../utils'
withDefaults(
defineProps<{
variant: 'error' | 'warning' | 'info'
slim?: boolean
}>(),
{
slim: false,
},
)
defineProps<{
variant: 'error' | 'warning' | 'info'
}>()
const containerClasses = {
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
@@ -76,16 +58,6 @@ const iconClasses = {
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
}
.banner-grid.no-actions {
grid-template-areas:
'title actions_right'
'description actions_right';
}
.banner-grid.slim {
@apply flex py-4 gap-2 items-center;
}
.grid-area-\[title\] {
grid-area: title;
}
-144
View File
@@ -2036,150 +2036,6 @@
"locale.zh-TW": {
"defaultMessage": "Chinese (Traditional)"
},
"markdown-editor.default-image-alt-text": {
"defaultMessage": "Replace this with a description"
},
"markdown-editor.error-label": {
"defaultMessage": "Error"
},
"markdown-editor.image-modal.description-field.description": {
"defaultMessage": "Describe the image completely as you would to someone who could not see the image."
},
"markdown-editor.image-modal.description-field.placeholder": {
"defaultMessage": "Describe the image..."
},
"markdown-editor.image-modal.description-field.title": {
"defaultMessage": "Description (alt text)"
},
"markdown-editor.image-modal.header": {
"defaultMessage": "Insert image"
},
"markdown-editor.image-modal.upload-mode.label": {
"defaultMessage": "Image source"
},
"markdown-editor.image-modal.upload-mode.link": {
"defaultMessage": "Link"
},
"markdown-editor.image-modal.upload-mode.upload": {
"defaultMessage": "Upload"
},
"markdown-editor.image-modal.upload.prompt": {
"defaultMessage": "Drag and drop to upload or click to select file"
},
"markdown-editor.image-modal.url-field.placeholder": {
"defaultMessage": "Enter the image URL..."
},
"markdown-editor.insert-button": {
"defaultMessage": "Insert"
},
"markdown-editor.link-modal.header": {
"defaultMessage": "Insert link"
},
"markdown-editor.link-modal.label-field.placeholder": {
"defaultMessage": "Enter label..."
},
"markdown-editor.link-modal.label-field.title": {
"defaultMessage": "Label"
},
"markdown-editor.link-modal.url-field.placeholder": {
"defaultMessage": "Enter the link's URL..."
},
"markdown-editor.markdown-formatting-support": {
"defaultMessage": "This editor supports <markdown-link>Markdown formatting</markdown-link>."
},
"markdown-editor.max-length.label": {
"defaultMessage": "Max length:"
},
"markdown-editor.max-length.unlimited": {
"defaultMessage": "Unlimited"
},
"markdown-editor.max-length.value": {
"defaultMessage": "{currentLength}/{maxLength}"
},
"markdown-editor.placeholder": {
"defaultMessage": "Write something..."
},
"markdown-editor.preview-label": {
"defaultMessage": "Preview"
},
"markdown-editor.preview-toggle.label": {
"defaultMessage": "Preview"
},
"markdown-editor.toolbar.bold": {
"defaultMessage": "Bold"
},
"markdown-editor.toolbar.bulleted-list": {
"defaultMessage": "Bulleted list"
},
"markdown-editor.toolbar.code": {
"defaultMessage": "Code"
},
"markdown-editor.toolbar.heading-1": {
"defaultMessage": "Heading 1"
},
"markdown-editor.toolbar.heading-2": {
"defaultMessage": "Heading 2"
},
"markdown-editor.toolbar.heading-3": {
"defaultMessage": "Heading 3"
},
"markdown-editor.toolbar.image": {
"defaultMessage": "Image"
},
"markdown-editor.toolbar.italic": {
"defaultMessage": "Italic"
},
"markdown-editor.toolbar.link": {
"defaultMessage": "Link"
},
"markdown-editor.toolbar.ordered-list": {
"defaultMessage": "Ordered list"
},
"markdown-editor.toolbar.quote": {
"defaultMessage": "Quote"
},
"markdown-editor.toolbar.spoiler": {
"defaultMessage": "Spoiler"
},
"markdown-editor.toolbar.strikethrough": {
"defaultMessage": "Strikethrough"
},
"markdown-editor.toolbar.video": {
"defaultMessage": "Video"
},
"markdown-editor.upload-error.no-file": {
"defaultMessage": "No file provided"
},
"markdown-editor.upload-error.no-handler": {
"defaultMessage": "No image upload handler provided"
},
"markdown-editor.url-label": {
"defaultMessage": "URL"
},
"markdown-editor.url-validation-error.blocked-domain": {
"defaultMessage": "Invalid URL. This domain is not allowed."
},
"markdown-editor.url-validation-error.malformed": {
"defaultMessage": "Invalid URL. Make sure the URL is well-formed."
},
"markdown-editor.url-validation-error.unsupported-protocol": {
"defaultMessage": "Unsupported protocol. Use http or https."
},
"markdown-editor.video-embed.title": {
"defaultMessage": "YouTube video player"
},
"markdown-editor.video-modal.header": {
"defaultMessage": "Insert YouTube video"
},
"markdown-editor.video-modal.url-field.description": {
"defaultMessage": "Enter a valid link to a YouTube video."
},
"markdown-editor.video-modal.url-field.placeholder": {
"defaultMessage": "Enter YouTube video URL"
},
"markdown-editor.video-modal.url-field.title": {
"defaultMessage": "YouTube video URL"
},
"modal.add-payment-method.action": {
"defaultMessage": "Add payment method"
},
-4
View File
@@ -25,8 +25,6 @@ import {
PayPalIcon,
PlugIcon,
PolygonIcon,
ScaleIcon,
ServerIcon,
UnknownIcon,
UpdatedIcon,
USDCColorIcon,
@@ -51,7 +49,6 @@ export const PROJECT_TYPE_ICONS: Record<ProjectType, Component> = {
plugin: PlugIcon,
datapack: BracesIcon,
project: BoxIcon,
minecraft_java_server: ServerIcon,
}
export const PAYMENT_METHOD_ICONS: Record<string, Component> = {
@@ -71,7 +68,6 @@ export const SEVERITY_ICONS: Record<string, Component> = {
error: XCircleIcon,
critical: XCircleIcon,
success: CheckCircleIcon,
moderation: ScaleIcon,
}
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
+1 -1
View File
@@ -119,7 +119,7 @@ export const formatProjectType = (name, short = false) => {
return 'PLG'
} else if (name === 'datapack') {
return 'DPK'
} else if (name === 'minecraft_java_server') {
} else if (name === 'server') {
return 'SRV'
}
}