feat: new proj moderation page (#6044)
* feat: new proj moderation page * make requested changes * add boolean for showing delay message * fix server icon + shortened code * fix server icon * refactor admonitions * msg correction. * correction + change spam-notice * Separate status info from instruction details * Tweak timing delay msg, thread activity warning, and refer to moderation with consistent terms. * Whoops, actually updated msgs correctly now. * prepr + margin * split out strings, simplify code again * fix: a few more moderation fixes (#6048) * fix: move tooltip to button * fix: lock status buttons after pressing * fix: unlisted/withheld icon on legacy badge * prepprrr * fix banners, add some extra dev mode stuff * fix thread id copy padding * tweak: adjust some of the status change messages (#6041) * update messages & bunch of other stuff * rename toggle * change hover to 2.5, fix error size * private msg overlay --------- Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BlueskyIcon, DiscordIcon, GithubIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
|
import {
|
||||||
|
BlueskyIcon,
|
||||||
|
DiscordIcon,
|
||||||
|
GithubIcon,
|
||||||
|
MastodonIcon,
|
||||||
|
ToggleRightIcon,
|
||||||
|
TwitterIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
AutoLink,
|
AutoLink,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
@@ -10,6 +17,7 @@ import {
|
|||||||
type MessageDescriptor,
|
type MessageDescriptor,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { commonSettingsMessages } from '@modrinth/ui/src/utils/common-messages.js'
|
||||||
|
|
||||||
import TextLogo from '~/components/brand/TextLogo.vue'
|
import TextLogo from '~/components/brand/TextLogo.vue'
|
||||||
|
|
||||||
@@ -233,11 +241,21 @@ function developerModeIncrement() {
|
|||||||
role="region"
|
role="region"
|
||||||
:aria-label="formatMessage(messages.modrinthInformation)"
|
:aria-label="formatMessage(messages.modrinthInformation)"
|
||||||
>
|
>
|
||||||
<TextLogo
|
<div class="flex items-center gap-2">
|
||||||
aria-hidden="true"
|
<TextLogo
|
||||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
aria-hidden="true"
|
||||||
@click="developerModeIncrement()"
|
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>
|
||||||
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
v-for="(social, index) in socialLinks"
|
v-for="(social, index) in socialLinks"
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
import { XCircleIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
const flags = useFeatureFlags()
|
||||||
|
|
||||||
|
const tempIgnored = ref(false)
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
@@ -13,16 +17,34 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
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}",
|
"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<{
|
defineProps<{
|
||||||
errors: any[] | undefined
|
errors: any[] | undefined
|
||||||
apiUrl: string
|
apiUrl: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function alwaysIgnoreBanner() {
|
||||||
|
flags.value.alwaysIgnoreErrorBanner = true
|
||||||
|
saveFeatureFlags()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagewideBanner v-if="errors?.length" variant="error">
|
<PagewideBanner
|
||||||
|
v-if="
|
||||||
|
flags.showAllBanners || (errors?.length && !tempIgnored && !flags.alwaysIgnoreErrorBanner)
|
||||||
|
"
|
||||||
|
variant="error"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ formatMessage(messages.title) }}</span>
|
<span>{{ formatMessage(messages.title) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,5 +56,19 @@ defineProps<{
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</template>
|
</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>
|
</PagewideBanner>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const flags = useFeatureFlags()
|
const flags = useFeatureFlags()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
@@ -21,7 +22,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
id: 'layout.banner.preview.description',
|
id: 'layout.banner.preview.description',
|
||||||
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}.`,
|
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}.`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,10 +30,12 @@ function hidePreviewBanner() {
|
|||||||
flags.value.hidePreviewBanner = true
|
flags.value.hidePreviewBanner = true
|
||||||
saveFeatureFlags()
|
saveFeatureFlags()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = computed(() => `https://modrinth.com${route.fullPath}`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagewideBanner v-if="!flags.hidePreviewBanner" variant="info">
|
<PagewideBanner v-if="!flags.hidePreviewBanner || flags.showAllBanners" variant="info">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ formatMessage(messages.title) }}</span>
|
<span>{{ formatMessage(messages.title) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -45,9 +48,9 @@ function hidePreviewBanner() {
|
|||||||
branch: config.public.branch,
|
branch: config.public.branch,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #link="{ children }">
|
<template #url>
|
||||||
<a href="https://modrinth.com" target="_blank" rel="noopener" class="text-link">
|
<a :href="url" target="_blank" rel="noopener" class="text-link">
|
||||||
<component :is="() => normalizeChildren(children)" />
|
{{ url }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
<template #branch-link="{ children }">
|
<template #branch-link="{ children }">
|
||||||
@@ -75,7 +78,7 @@ function hidePreviewBanner() {
|
|||||||
</IntlFormatted>
|
</IntlFormatted>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #actions_right>
|
<template #actions_top_right>
|
||||||
<ButtonStyled type="transparent" circular>
|
<ButtonStyled type="transparent" circular>
|
||||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
|
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
|
||||||
<XIcon aria-hidden="true" />
|
<XIcon aria-hidden="true" />
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ function hideRussiaCensorshipBanner() {
|
|||||||
<span class="text-xs font-medium">(Перевод на русский)</span>
|
<span class="text-xs font-medium">(Перевод на русский)</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled>
|
<ButtonStyled type="transparent" hover-color-fill="background">
|
||||||
<nuxt-link to="/news/article/standing-by-our-values">
|
<nuxt-link to="/news/article/standing-by-our-values">
|
||||||
<BookTextIcon /> Read our full statement
|
<BookTextIcon /> Read our full statement
|
||||||
<span class="text-xs font-medium">(English)</span>
|
<span class="text-xs font-medium">(English)</span>
|
||||||
@@ -55,7 +55,7 @@ function hideRussiaCensorshipBanner() {
|
|||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions_right>
|
<template #actions_top_right>
|
||||||
<ButtonStyled circular type="transparent">
|
<ButtonStyled circular type="transparent">
|
||||||
<button
|
<button
|
||||||
v-tooltip="formatMessage(commonMessages.closeButton)"
|
v-tooltip="formatMessage(commonMessages.closeButton)"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const cosmetics = useCosmetics()
|
const cosmetics = useCosmetics()
|
||||||
|
const flags = useFeatureFlags()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
@@ -29,14 +30,14 @@ function hideStagingBanner() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PagewideBanner v-if="!cosmetics.hideStagingBanner" variant="warning">
|
<PagewideBanner v-if="flags.showAllBanners || !cosmetics.hideStagingBanner" variant="warning">
|
||||||
<template #title>
|
<template #title>
|
||||||
<span>{{ formatMessage(messages.title) }}</span>
|
<span>{{ formatMessage(messages.title) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #description>
|
<template #description>
|
||||||
{{ formatMessage(messages.description) }}
|
{{ formatMessage(messages.description) }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions_right>
|
<template #actions_top_right>
|
||||||
<ButtonStyled type="transparent" circular>
|
<ButtonStyled type="transparent" circular>
|
||||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hideStagingBanner">
|
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hideStagingBanner">
|
||||||
<XIcon aria-hidden="true" />
|
<XIcon aria-hidden="true" />
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const messages = defineMessages({
|
|||||||
<template #description>
|
<template #description>
|
||||||
<span>{{ formatMessage(messages.description) }}</span>
|
<span>{{ formatMessage(messages.description) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions_right>
|
||||||
<ButtonStyled>
|
<ButtonStyled color="red">
|
||||||
<nuxt-link to="/settings/billing">
|
<nuxt-link to="/settings/billing">
|
||||||
<SettingsIcon aria-hidden="true" />
|
<SettingsIcon aria-hidden="true" />
|
||||||
{{ formatMessage(messages.action) }}
|
{{ formatMessage(messages.action) }}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function openTaxForm(e: MouseEvent) {
|
|||||||
formatMessage(messages.description, { threshold: formatMoney(taxThreshold) })
|
formatMessage(messages.description, { threshold: formatMoney(taxThreshold) })
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions_right>
|
||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
<button @click="openTaxForm"><FileTextIcon /> {{ formatMessage(messages.action) }}</button>
|
<button @click="openTaxForm"><FileTextIcon /> {{ formatMessage(messages.action) }}</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const messages = defineMessages({
|
|||||||
<template #description>
|
<template #description>
|
||||||
<span>{{ formatMessage(messages.description) }}</span>
|
<span>{{ formatMessage(messages.description) }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions_right>
|
||||||
<div class="flex w-fit flex-row">
|
<div class="flex w-fit flex-row">
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
|
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
|
||||||
|
|||||||
@@ -96,14 +96,12 @@ async function handleResendEmailVerification() {
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions_right>
|
||||||
<ButtonStyled v-if="hasEmail">
|
<ButtonStyled color="orange">
|
||||||
<button @click="handleResendEmailVerification">
|
<button v-if="hasEmail" @click="handleResendEmailVerification">
|
||||||
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
<nuxt-link v-else to="/settings/account">
|
||||||
<ButtonStyled v-else>
|
|
||||||
<nuxt-link to="/settings/account">
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
<SettingsIcon aria-hidden="true" />
|
||||||
{{ formatMessage(addEmailBannerMessages.action) }}
|
{{ formatMessage(addEmailBannerMessages.action) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<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>
|
<template>
|
||||||
<div class="shadow-card rounded-2xl border border-solid border-surface-5 bg-surface-3 p-4">
|
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -107,8 +107,8 @@
|
|||||||
<LinkIcon />
|
<LinkIcon />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled v-tooltip="'Begin review'" circular color="orange">
|
<ButtonStyled circular color="orange">
|
||||||
<button @click="openProjectForReview">
|
<button v-tooltip="'Begin review'" @click="openProjectForReview">
|
||||||
<ScaleIcon />
|
<ScaleIcon />
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@
|
|||||||
:expand-text="expandText"
|
:expand-text="expandText"
|
||||||
collapse-text="Collapse thread"
|
collapse-text="Collapse thread"
|
||||||
>
|
>
|
||||||
<div class="bg-surface-2 p-4 pt-2">
|
<div class="bg-surface-2 pt-2">
|
||||||
<ThreadView
|
<ThreadView
|
||||||
v-if="threadWithReportBody"
|
v-if="threadWithReportBody"
|
||||||
ref="reportThread"
|
ref="reportThread"
|
||||||
|
|||||||
@@ -1075,6 +1075,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
mode="local"
|
mode="local"
|
||||||
:links="navTabsLinks"
|
:links="navTabsLinks"
|
||||||
:active-index="activeTabIndex"
|
:active-index="activeTabIndex"
|
||||||
|
class="bg-surface-3! shadow-none!"
|
||||||
@tab-click="handleTabClick"
|
@tab-click="handleTabClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1087,7 +1088,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
collapse-text="Collapse thread"
|
collapse-text="Collapse thread"
|
||||||
class="border-x border-b border-solid border-surface-3"
|
class="border-x border-b border-solid border-surface-3"
|
||||||
>
|
>
|
||||||
<div class="bg-surface-2 p-4 pt-0">
|
<div class="bg-surface-2 pt-0">
|
||||||
<!-- DEV-531 -->
|
<!-- DEV-531 -->
|
||||||
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
|
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
|
||||||
<ThreadView
|
<ThreadView
|
||||||
|
|||||||
@@ -358,26 +358,44 @@
|
|||||||
|
|
||||||
<div v-else-if="generatedMessage" class="flex items-center gap-2">
|
<div v-else-if="generatedMessage" class="flex items-center gap-2">
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button @click="goBackToStages">
|
<button :disabled="loadingModerationDecision" @click="goBackToStages">
|
||||||
<LeftArrowIcon aria-hidden="true" />
|
<LeftArrowIcon aria-hidden="true" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
<button @click="sendMessage('rejected')">
|
<button :disabled="loadingModerationDecision" @click="sendMessage('rejected')">
|
||||||
<XIcon aria-hidden="true" />
|
<SpinnerIcon
|
||||||
|
v-if="moderationDecision === 'rejected'"
|
||||||
|
class="animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<XIcon v-else aria-hidden="true" />
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
<button @click="sendMessage('withheld')">
|
<button :disabled="loadingModerationDecision" @click="sendMessage('withheld')">
|
||||||
<EyeOffIcon aria-hidden="true" />
|
<SpinnerIcon
|
||||||
|
v-if="moderationDecision === 'withheld'"
|
||||||
|
class="animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<LinkIcon v-else aria-hidden="true" />
|
||||||
Withhold
|
Withhold
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="green">
|
<ButtonStyled color="green">
|
||||||
<button @click="sendMessage(projectV2.requested_status ?? 'approved')">
|
<button
|
||||||
<CheckIcon aria-hidden="true" />
|
:disabled="loadingModerationDecision"
|
||||||
|
@click="sendMessage(approveSendStatus)"
|
||||||
|
>
|
||||||
|
<SpinnerIcon
|
||||||
|
v-if="moderationDecision === approveSendStatus"
|
||||||
|
class="animate-spin"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<CheckIcon v-else aria-hidden="true" />
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -428,14 +446,15 @@ import {
|
|||||||
BrushCleaningIcon,
|
BrushCleaningIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
EyeOffIcon,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
KeyboardIcon,
|
KeyboardIcon,
|
||||||
LeftArrowIcon,
|
LeftArrowIcon,
|
||||||
|
LinkIcon,
|
||||||
ListBulletedIcon,
|
ListBulletedIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
|
SpinnerIcon,
|
||||||
ToggleLeftIcon,
|
ToggleLeftIcon,
|
||||||
ToggleRightIcon,
|
ToggleRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
@@ -760,6 +779,12 @@ const message = ref(
|
|||||||
)
|
)
|
||||||
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
|
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
|
||||||
const loadingMessage = ref(false)
|
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)
|
const done = ref(false)
|
||||||
|
|
||||||
function persistGeneratedMessageState() {
|
function persistGeneratedMessageState() {
|
||||||
@@ -1074,6 +1099,7 @@ function resetProgress() {
|
|||||||
done.value = false
|
done.value = false
|
||||||
clearGeneratedMessageState()
|
clearGeneratedMessageState()
|
||||||
loadingMessage.value = false
|
loadingMessage.value = false
|
||||||
|
moderationDecision.value = null
|
||||||
|
|
||||||
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
|
||||||
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
|
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
|
||||||
@@ -1190,7 +1216,7 @@ function handleKeybinds(event: KeyboardEvent) {
|
|||||||
tryResetProgress: resetProgress,
|
tryResetProgress: resetProgress,
|
||||||
tryExitModeration: handleExit,
|
tryExitModeration: handleExit,
|
||||||
|
|
||||||
tryApprove: () => sendMessage(projectV2.value.requested_status ?? 'approved'),
|
tryApprove: () => sendMessage(approveSendStatus.value),
|
||||||
tryReject: () => sendMessage('rejected'),
|
tryReject: () => sendMessage('rejected'),
|
||||||
tryWithhold: () => sendMessage('withheld'),
|
tryWithhold: () => sendMessage('withheld'),
|
||||||
tryEditMessage: goBackToStages,
|
tryEditMessage: goBackToStages,
|
||||||
@@ -1977,6 +2003,7 @@ async function sendMessage(status: ProjectStatus) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
moderationDecision.value = status
|
||||||
try {
|
try {
|
||||||
await useBaseFetch(`project/${projectId}`, {
|
await useBaseFetch(`project/${projectId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -2026,6 +2053,8 @@ async function sendMessage(status: ProjectStatus) {
|
|||||||
text: 'Failed to submit moderation decision. Please try again.',
|
text: 'Failed to submit moderation decision. Please try again.',
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
moderationDecision.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<section class="universal-card">
|
<section>
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
v-if="breadcrumbsStack"
|
v-if="breadcrumbsStack"
|
||||||
:current-title="`Report ${reportId}`"
|
:current-title="`Report ${reportId}`"
|
||||||
:link-stack="breadcrumbsStack"
|
:link-stack="breadcrumbsStack"
|
||||||
/>
|
/>
|
||||||
<h2>Report details</h2>
|
<h2>Report details</h2>
|
||||||
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
|
<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"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="report && thread" class="universal-card">
|
<section
|
||||||
<h2>Messages</h2>
|
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>
|
||||||
<ConversationThread
|
<ConversationThread
|
||||||
|
class="overflow-clip rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2"
|
||||||
:thread="thread"
|
:thread="thread"
|
||||||
:report="report"
|
:report="report"
|
||||||
:auth="auth"
|
:auth="auth"
|
||||||
|
|||||||
@@ -1,28 +1,45 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Modal
|
<NewModal
|
||||||
ref="modalSubmit"
|
ref="modalSubmit"
|
||||||
:header="isRejected(project) ? 'Resubmit for review' : 'Submit for review'"
|
:header="
|
||||||
|
formatMessage(
|
||||||
|
isRejected(project)
|
||||||
|
? messages.resubmitModalHeaderResubmitting
|
||||||
|
: messages.resubmitModalHeaderSubmitting,
|
||||||
|
)
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<div class="modal-submit universal-body">
|
<div class="flex max-w-[35rem] flex-col gap-3">
|
||||||
<span>
|
<p class="m-0">
|
||||||
You're submitting <span class="project-title">{{ project.title }}</span> to be reviewed
|
<IntlFormatted
|
||||||
again by the moderators.
|
:message-id="messages.resubmitModalDescription"
|
||||||
</span>
|
:message-values="{ projectTitle: project.title }"
|
||||||
<span>
|
>
|
||||||
Make sure you have addressed the comments from the moderation team.
|
<template #project-title="{ children }">
|
||||||
<span class="known-errors">
|
<span class="font-semibold text-contrast">
|
||||||
Repeated submissions without addressing the moderators' comments may result in an
|
<component :is="() => children" />
|
||||||
account suspension.
|
</span>
|
||||||
</span>
|
</template>
|
||||||
</span>
|
</IntlFormatted>
|
||||||
|
</p>
|
||||||
|
<p class="m-0">{{ formatMessage(messages.resubmitModalReminder) }}</p>
|
||||||
|
<p class="m-0 font-semibold text-red">
|
||||||
|
{{ formatMessage(messages.resubmitModalWarning) }}
|
||||||
|
</p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="submissionConfirmation"
|
v-model="submissionConfirmation"
|
||||||
description="Confirm I have addressed the messages from the moderators"
|
:description="formatMessage(messages.resubmitModalConfirmationDescription)"
|
||||||
>
|
>
|
||||||
I confirm that I have properly addressed the moderators' comments.
|
{{ formatMessage(messages.resubmitModalConfirmationLabel) }}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<div class="input-group push-right">
|
<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>
|
||||||
<ButtonStyled color="orange">
|
<ButtonStyled color="orange">
|
||||||
<button
|
<button
|
||||||
:disabled="!submissionConfirmation || isLoading"
|
:disabled="!submissionConfirmation || isLoading"
|
||||||
@@ -34,33 +51,37 @@
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<ScaleIcon v-else aria-hidden="true" />
|
<ScaleIcon v-else aria-hidden="true" />
|
||||||
Resubmit for review
|
{{ formatMessage(messages.actionResubmitForReview) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</NewModal>
|
||||||
<Modal ref="modalReply" header="Reply to thread">
|
<NewModal ref="modalReply" :header="formatMessage(messages.replyModalHeader)">
|
||||||
<div class="modal-submit universal-body">
|
<div class="flex max-w-[45rem] flex-col gap-3">
|
||||||
<span>
|
<p class="m-0">{{ formatMessage(messages.replyModalDescription) }}</p>
|
||||||
Your project is already approved. As such, the moderation team does not actively monitor
|
<p class="m-0">
|
||||||
this thread. However, they may still see your message if there is a problem with your
|
<IntlFormatted :message-id="messages.replyModalHelpCenterNote">
|
||||||
project.
|
<template #help-center-link="{ children }">
|
||||||
</span>
|
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||||
<span>
|
<component :is="() => children" />
|
||||||
If you need to get in contact with the moderation team, please use the
|
</a>
|
||||||
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
</template>
|
||||||
Modrinth Help Center
|
</IntlFormatted>
|
||||||
</a>
|
</p>
|
||||||
and click the green bubble to contact support.
|
|
||||||
</span>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="replyConfirmation"
|
v-model="replyConfirmation"
|
||||||
description="Confirm moderators do not actively monitor this"
|
:description="formatMessage(messages.replyModalConfirmationDescription)"
|
||||||
>
|
>
|
||||||
I acknowledge that the moderators do not actively monitor the thread.
|
{{ formatMessage(messages.replyModalConfirmationLabel) }}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<div class="input-group push-right">
|
<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>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
:disabled="!replyConfirmation || isLoading"
|
:disabled="!replyConfirmation || isLoading"
|
||||||
@@ -72,289 +93,318 @@
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<ReplyIcon v-else aria-hidden="true" />
|
<ReplyIcon v-else aria-hidden="true" />
|
||||||
Reply to thread
|
{{ formatMessage(messages.actionReplyToThread) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</NewModal>
|
||||||
<div v-if="flags.developerMode" class="thread-id">
|
<div v-if="flags.developerMode" class="mx-4 mb-3 font-semibold">
|
||||||
Thread ID:
|
Thread ID:
|
||||||
<CopyCode :text="thread.id" />
|
<CopyCode :text="thread.id" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sortedMessages.length > 0" class="messages universal-card recessed">
|
<div v-bind="$attrs" class="flex flex-col">
|
||||||
<ThreadMessage
|
<div v-if="sortedMessages.length > 0" class="flex flex-col pt-2">
|
||||||
v-for="message in sortedMessages"
|
<ThreadMessage
|
||||||
:key="'message-' + message.id"
|
v-for="message in sortedMessages"
|
||||||
:thread="thread"
|
:key="'message-' + message.id"
|
||||||
:message="message"
|
:thread="thread"
|
||||||
:members="members"
|
:message="message"
|
||||||
:report="report"
|
:members="members"
|
||||||
:auth="auth"
|
:report="report"
|
||||||
raised
|
:auth="auth"
|
||||||
@update-thread="() => updateThreadLocal()"
|
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>
|
</div>
|
||||||
<div class="input-group">
|
<template v-if="report && report.closed">
|
||||||
<ButtonStyled color="brand">
|
<p>{{ formatMessage(messages.closedThreadDescription) }}</p>
|
||||||
<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)">
|
<ButtonStyled v-if="isStaff(auth.user)">
|
||||||
<button
|
<button :disabled="isLoading" @click="runBlockingAction('reopen', () => reopenReport())">
|
||||||
:disabled="!replyBody || isLoading"
|
|
||||||
@click="runBlockingAction('private-note', () => sendReply(null, true))"
|
|
||||||
>
|
|
||||||
<SpinnerIcon
|
<SpinnerIcon
|
||||||
v-if="loadingAction === 'private-note'"
|
v-if="loadingAction === 'reopen'"
|
||||||
class="animate-spin"
|
class="animate-spin"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<ScaleIcon v-else aria-hidden="true" />
|
<CheckCircleIcon v-else aria-hidden="true" />
|
||||||
Add private note
|
{{ formatMessage(messages.actionReopenThread) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<template v-if="currentMember && !isStaff(auth.user)">
|
</template>
|
||||||
<template v-if="isRejected(project)">
|
<template v-else-if="!report || !report.closed">
|
||||||
<ButtonStyled color="orange">
|
<div class="mx-4 mb-2 mt-2">
|
||||||
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
|
<MarkdownEditor
|
||||||
<ScaleIcon aria-hidden="true" />
|
v-model="replyBody"
|
||||||
Resubmit for review with reply
|
: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) }}
|
||||||
</button>
|
</button>
|
||||||
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
|
<button
|
||||||
<ScaleIcon aria-hidden="true" />
|
v-else
|
||||||
Resubmit for review
|
: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>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
<ButtonStyled v-if="isStaff(auth.user)">
|
||||||
</template>
|
<button
|
||||||
<div class="spacer"></div>
|
:disabled="!replyBody || isLoading"
|
||||||
<div class="input-group extra-options">
|
@click="runBlockingAction('private-note', () => sendReply(null, true))"
|
||||||
<template v-if="report">
|
>
|
||||||
<template v-if="isStaff(auth.user)">
|
<SpinnerIcon
|
||||||
<ButtonStyled color="red">
|
v-if="loadingAction === 'private-note'"
|
||||||
<button
|
class="animate-spin"
|
||||||
v-if="replyBody"
|
aria-hidden="true"
|
||||||
:disabled="isLoading"
|
/>
|
||||||
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
|
<ScaleIcon v-else aria-hidden="true" />
|
||||||
>
|
{{ formatMessage(messages.actionAddPrivateNote) }}
|
||||||
<SpinnerIcon
|
</button>
|
||||||
v-if="loadingAction === 'close-with-reply'"
|
</ButtonStyled>
|
||||||
class="animate-spin"
|
<template v-if="currentMember && !currentMember.staffOnly">
|
||||||
aria-hidden="true"
|
<template v-if="isRejected(project)">
|
||||||
/>
|
<ButtonStyled color="orange">
|
||||||
<CheckCircleIcon v-else aria-hidden="true" />
|
<button v-if="replyBody" :disabled="isLoading" @click="openResubmitModal(true)">
|
||||||
Close with reply
|
<ScaleIcon aria-hidden="true" />
|
||||||
</button>
|
{{ formatMessage(messages.actionResubmitForReviewWithReply) }}
|
||||||
<button
|
</button>
|
||||||
v-else
|
<button v-else :disabled="isLoading" @click="openResubmitModal(false)">
|
||||||
:disabled="isLoading"
|
<ScaleIcon aria-hidden="true" />
|
||||||
@click="runBlockingAction('close', () => closeReport())"
|
{{ formatMessage(messages.actionResubmitForReview) }}
|
||||||
>
|
</button>
|
||||||
<SpinnerIcon
|
</ButtonStyled>
|
||||||
v-if="loadingAction === 'close'"
|
</template>
|
||||||
class="animate-spin"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<CheckCircleIcon v-else aria-hidden="true" />
|
|
||||||
Close thread
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
<template v-if="project">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<template v-if="isStaff(auth.user)">
|
<template v-if="report">
|
||||||
<ButtonStyled v-if="replyBody" color="green">
|
<template v-if="isStaff(auth.user)">
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
<ButtonStyled color="red">
|
<ButtonStyled color="red">
|
||||||
<OverflowMenu
|
<button
|
||||||
class="btn-dropdown-animation"
|
v-if="replyBody"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
:options="
|
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
|
||||||
replyBody
|
>
|
||||||
? [
|
<SpinnerIcon
|
||||||
{
|
v-if="loadingAction === 'close-with-reply'"
|
||||||
id: 'withhold-reply',
|
class="animate-spin"
|
||||||
color: 'danger',
|
aria-hidden="true"
|
||||||
action: () =>
|
/>
|
||||||
runBlockingAction('withhold-reply', () => sendReply('withheld')),
|
<CheckCircleIcon v-else aria-hidden="true" />
|
||||||
hoverFilled: true,
|
{{ formatMessage(messages.actionCloseWithReply) }}
|
||||||
disabled: project.status === 'withheld' || isLoading,
|
</button>
|
||||||
},
|
<button
|
||||||
{
|
v-else
|
||||||
id: 'set-to-draft-reply',
|
:disabled="isLoading"
|
||||||
action: () =>
|
@click="runBlockingAction('close', () => closeReport())"
|
||||||
runBlockingAction('set-to-draft-reply', () => sendReply('draft')),
|
>
|
||||||
hoverFilled: true,
|
<SpinnerIcon
|
||||||
disabled: project.status === 'draft' || isLoading,
|
v-if="loadingAction === 'close'"
|
||||||
},
|
class="animate-spin"
|
||||||
{
|
aria-hidden="true"
|
||||||
id: 'send-to-review-reply',
|
/>
|
||||||
action: () =>
|
<CheckCircleIcon v-else aria-hidden="true" />
|
||||||
runBlockingAction('send-to-review-reply', () =>
|
{{ formatMessage(messages.actionCloseThread) }}
|
||||||
sendReply('processing', true),
|
</button>
|
||||||
),
|
</ButtonStyled>
|
||||||
hoverFilled: true,
|
</template>
|
||||||
disabled: project.status === 'processing' || isLoading,
|
</template>
|
||||||
},
|
<template v-if="project">
|
||||||
]
|
<template v-if="isStaff(auth.user)">
|
||||||
: [
|
<ButtonStyled v-if="replyBody" color="green">
|
||||||
{
|
<button
|
||||||
id: 'withhold',
|
:disabled="isApproved(project) || isLoading"
|
||||||
color: 'danger',
|
@click="
|
||||||
action: () =>
|
runBlockingAction('approve-with-reply', () => sendReply(requestedStatus))
|
||||||
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" />
|
<SpinnerIcon
|
||||||
<DropdownIcon v-else aria-hidden="true" />
|
v-if="loadingAction === 'approve-with-reply'"
|
||||||
<template #withhold-reply>
|
class="animate-spin"
|
||||||
<EyeOffIcon aria-hidden="true" />
|
aria-hidden="true"
|
||||||
Withhold with reply
|
/>
|
||||||
</template>
|
<CheckIcon v-else aria-hidden="true" />
|
||||||
<template #withhold>
|
{{ formatMessage(messages.actionApproveWithReply) }}
|
||||||
<EyeOffIcon aria-hidden="true" />
|
</button>
|
||||||
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>
|
||||||
</div>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -374,19 +424,179 @@ import {
|
|||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
commonMessages,
|
||||||
CopyCode,
|
CopyCode,
|
||||||
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
|
IntlFormatted,
|
||||||
MarkdownEditor,
|
MarkdownEditor,
|
||||||
|
NewModal,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
|
||||||
import Modal from '~/components/ui/Modal.vue'
|
|
||||||
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
|
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
|
||||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||||
import { isApproved, isRejected } from '~/helpers/projects.js'
|
import { isApproved, isRejected } from '~/helpers/projects.js'
|
||||||
import { isStaff } from '~/helpers/users.js'
|
import { isStaff } from '~/helpers/users.js'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
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({
|
const props = defineProps({
|
||||||
thread: {
|
thread: {
|
||||||
@@ -532,7 +742,7 @@ async function sendReply(status = null, privateMessage = false) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Error sending message',
|
title: formatMessage(messages.errorSendingMessage),
|
||||||
text: err.data ? err.data.description : err,
|
text: err.data ? err.data.description : err,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
@@ -554,7 +764,7 @@ async function closeReport(reply) {
|
|||||||
await updateThreadLocal()
|
await updateThreadLocal()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Error closing report',
|
title: formatMessage(messages.errorClosingReport),
|
||||||
text: err.data ? err.data.description : err,
|
text: err.data ? err.data.description : err,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
@@ -572,7 +782,7 @@ async function reopenReport() {
|
|||||||
await updateThreadLocal()
|
await updateThreadLocal()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Error reopening report',
|
title: formatMessage(messages.errorReopeningReport),
|
||||||
text: err.data ? err.data.description : err,
|
text: err.data ? err.data.description : err,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
@@ -604,44 +814,8 @@ async function resubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
|
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
</script>
|
</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,10 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="message"
|
class="message px-4 py-3"
|
||||||
:class="{
|
:class="{
|
||||||
'has-body': message.body.type === 'text' && !forceCompact,
|
'has-body': message.body.type === 'text' && !forceCompact,
|
||||||
'no-actions': noLinks,
|
'no-actions': noLinks,
|
||||||
private: isPrivateMessage,
|
private: isPrivateMessage,
|
||||||
|
'show-private-bg': flags.showModeratorPrivateMessageHighlight,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="members[message.author_id]">
|
<template v-if="members[message.author_id]">
|
||||||
@@ -22,11 +23,6 @@
|
|||||||
/>
|
/>
|
||||||
</AutoLink>
|
</AutoLink>
|
||||||
<span :class="`message__author role-${members[message.author_id].role}`">
|
<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}`">
|
<AutoLink :to="noLinks ? '' : `/user/${members[message.author_id].username}`">
|
||||||
{{ members[message.author_id].username }}
|
{{ members[message.author_id].username }}
|
||||||
</AutoLink>
|
</AutoLink>
|
||||||
@@ -35,6 +31,11 @@
|
|||||||
v-else-if="members[message.author_id].role === 'admin'"
|
v-else-if="members[message.author_id].role === 'admin'"
|
||||||
v-tooltip="'Modrinth Team'"
|
v-tooltip="'Modrinth Team'"
|
||||||
/>
|
/>
|
||||||
|
<EyeOffIcon
|
||||||
|
v-if="isPrivateMessage"
|
||||||
|
v-tooltip="'Only visible to moderators'"
|
||||||
|
class="ml-1 text-orange"
|
||||||
|
/>
|
||||||
<MicrophoneIcon
|
<MicrophoneIcon
|
||||||
v-if="report && message.author_id === report.reporter_user?.id"
|
v-if="report && message.author_id === report.reporter_user?.id"
|
||||||
v-tooltip="'Reporter'"
|
v-tooltip="'Reporter'"
|
||||||
@@ -79,6 +80,12 @@
|
|||||||
<span v-if="message.body.new_status === 'processing'">
|
<span v-if="message.body.new_status === 'processing'">
|
||||||
submitted the project for review.
|
submitted the project for review.
|
||||||
</span>
|
</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>
|
<span v-else>
|
||||||
changed the project's status from <Badge :type="message.body.old_status" /> to
|
changed the project's status from <Badge :type="message.body.old_status" /> to
|
||||||
<Badge :type="message.body.new_status" />.
|
<Badge :type="message.body.new_status" />.
|
||||||
@@ -126,7 +133,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
LockIcon,
|
EyeOffIcon,
|
||||||
MicrophoneIcon,
|
MicrophoneIcon,
|
||||||
ModrinthIcon,
|
ModrinthIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
@@ -178,6 +185,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update-thread'])
|
const emit = defineEmits(['update-thread'])
|
||||||
|
const flags = useFeatureFlags()
|
||||||
|
|
||||||
const formattedMessage = computed(() => {
|
const formattedMessage = computed(() => {
|
||||||
const body = renderString(props.message.body.body)
|
const body = renderString(props.message.body.body)
|
||||||
@@ -222,15 +230,13 @@ async function deleteMessage() {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.message {
|
.message {
|
||||||
--gap-size: var(--spacing-card-xs);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: var(--gap-size);
|
gap: var(--spacing-card-sm);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-radius: var(--size-rounded-card);
|
|
||||||
padding: var(--spacing-card-md);
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
.avatar,
|
.avatar,
|
||||||
.backed-svg {
|
.backed-svg {
|
||||||
@@ -238,14 +244,12 @@ async function deleteMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.has-body {
|
&.has-body {
|
||||||
--gap-size: var(--spacing-card-sm);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template:
|
grid-template:
|
||||||
'icon author actions'
|
'icon author actions'
|
||||||
'icon body actions'
|
'icon body actions'
|
||||||
'date date date';
|
'date date date';
|
||||||
grid-template-columns: min-content auto 1fr;
|
grid-template-columns: min-content auto 1fr;
|
||||||
column-gap: var(--gap-size);
|
|
||||||
row-gap: var(--spacing-card-xs);
|
row-gap: var(--spacing-card-xs);
|
||||||
|
|
||||||
.message__icon {
|
.message__icon {
|
||||||
@@ -260,13 +264,22 @@ async function deleteMessage() {
|
|||||||
|
|
||||||
&:not(.no-actions):hover,
|
&:not(.no-actions):hover,
|
||||||
&:not(.no-actions):focus-within {
|
&:not(.no-actions):focus-within {
|
||||||
background-color: var(--color-table-alternate-row);
|
background-color: var(--surface-2-5);
|
||||||
|
|
||||||
.message__actions {
|
.message__actions {
|
||||||
opacity: 1;
|
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 {
|
&.no-actions {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
@@ -346,10 +359,6 @@ a:active + .message__author a,
|
|||||||
color: var(--color-purple);
|
color: var(--color-purple);
|
||||||
}
|
}
|
||||||
|
|
||||||
.private-icon {
|
|
||||||
color: var(--color-gray);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 600px) {
|
@media screen and (min-width: 600px) {
|
||||||
.message {
|
.message {
|
||||||
//grid-template:
|
//grid-template:
|
||||||
@@ -363,6 +372,7 @@ a:active + .message__author a,
|
|||||||
'icon body actions'
|
'icon body actions'
|
||||||
'date date date';
|
'date date date';
|
||||||
grid-template-columns: min-content auto 1fr;
|
grid-template-columns: min-content auto 1fr;
|
||||||
|
grid-template-rows: min-content 1fr auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +387,7 @@ a:active + .message__author a,
|
|||||||
'icon author date actions'
|
'icon author date actions'
|
||||||
'icon body body actions';
|
'icon body body actions';
|
||||||
grid-template-columns: min-content auto 1fr;
|
grid-template-columns: min-content auto 1fr;
|
||||||
grid-template-rows: min-content 1fr auto;
|
grid-template-rows: min-content 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="flags.developerMode" class="mt-4 font-bold text-heading">
|
<div v-if="flags.developerMode" class="m-4 font-bold text-heading">
|
||||||
Thread ID:
|
Thread ID:
|
||||||
<CopyCode :text="thread.id" />
|
<CopyCode :text="thread.id" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sortedMessages.length > 0" class="flex flex-col space-y-4 rounded-xl p-3 sm:p-4">
|
<div v-if="sortedMessages.length > 0" class="flex flex-col rounded-xl">
|
||||||
<ThreadMessage
|
<ThreadMessage
|
||||||
v-for="message in sortedMessages"
|
v-for="message in sortedMessages"
|
||||||
:key="'message-' + message.id"
|
:key="'message-' + message.id"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div>
|
<div class="px-4 py-2">
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
v-model="replyBody"
|
v-model="replyBody"
|
||||||
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
|
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"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||||
<ButtonStyled v-if="sortedMessages.length > 0" color="brand">
|
<ButtonStyled v-if="sortedMessages.length > 0" color="brand">
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
labrinthApiCanary: false,
|
labrinthApiCanary: false,
|
||||||
dismissedExternalProjectsInfo: false,
|
dismissedExternalProjectsInfo: false,
|
||||||
modpackPermissionsPage: false,
|
modpackPermissionsPage: false,
|
||||||
|
showAllBanners: false,
|
||||||
|
alwaysIgnoreErrorBanner: false,
|
||||||
|
showViewProdRouteBanner: false,
|
||||||
|
showModeratorProjectMemberUi: false,
|
||||||
|
showModeratorPrivateMessageHighlight: true,
|
||||||
} as const)
|
} as const)
|
||||||
|
|
||||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||||
|
|||||||
@@ -34,25 +34,39 @@
|
|||||||
'modrinth-parent__no-modal-blurs': !cosmetics.advancedRendering,
|
'modrinth-parent__no-modal-blurs': !cosmetics.advancedRendering,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<RussiaBanner v-if="isRussia" />
|
<RussiaBanner v-if="flags.showAllBanners || isRussia" />
|
||||||
<TaxIdMismatchBanner v-if="showTinMismatchBanner" />
|
<TaxIdMismatchBanner v-if="flags.showAllBanners || showTinMismatchBanner" />
|
||||||
<TaxComplianceBanner v-if="showTaxComplianceBanner" />
|
<TaxComplianceBanner v-if="flags.showAllBanners || showTaxComplianceBanner" />
|
||||||
<VerifyEmailBanner
|
<VerifyEmailBanner
|
||||||
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
v-if="
|
||||||
|
flags.showAllBanners ||
|
||||||
|
(auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email')
|
||||||
|
"
|
||||||
:has-email="!!auth?.user?.email"
|
:has-email="!!auth?.user?.email"
|
||||||
/>
|
/>
|
||||||
<SubscriptionPaymentFailedBanner
|
<SubscriptionPaymentFailedBanner
|
||||||
v-if="
|
v-if="
|
||||||
user.subscriptions.some((x) => x.status === 'payment-failed') &&
|
flags.showAllBanners ||
|
||||||
route.path !== '/settings/billing'
|
(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')
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<PreviewBanner v-if="config.public.buildEnv === 'production' && config.public.preview" />
|
|
||||||
<StagingBanner v-if="config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com')" />
|
|
||||||
<GeneratedStateErrorsBanner
|
<GeneratedStateErrorsBanner
|
||||||
:errors="generatedStateErrors"
|
:errors="generatedStateErrors"
|
||||||
:api-url="config.public.apiBaseUrl"
|
:api-url="config.public.apiBaseUrl"
|
||||||
/>
|
/>
|
||||||
|
<ViewOnModrinthBanner />
|
||||||
<header
|
<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]"
|
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]"
|
||||||
>
|
>
|
||||||
@@ -767,6 +781,7 @@ import SubscriptionPaymentFailedBanner from '~/components/ui/banner/Subscription
|
|||||||
import TaxComplianceBanner from '~/components/ui/banner/TaxComplianceBanner.vue'
|
import TaxComplianceBanner from '~/components/ui/banner/TaxComplianceBanner.vue'
|
||||||
import TaxIdMismatchBanner from '~/components/ui/banner/TaxIdMismatchBanner.vue'
|
import TaxIdMismatchBanner from '~/components/ui/banner/TaxIdMismatchBanner.vue'
|
||||||
import VerifyEmailBanner from '~/components/ui/banner/VerifyEmailBanner.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 CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
|
||||||
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
|
||||||
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
|
||||||
|
|||||||
@@ -407,6 +407,117 @@
|
|||||||
"collection.title": {
|
"collection.title": {
|
||||||
"message": "{name} - Collection"
|
"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": {
|
"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."
|
"message": "Supplementary files are for supporting resources like source code, not for alternative versions or variants."
|
||||||
},
|
},
|
||||||
@@ -875,23 +986,8 @@
|
|||||||
"dashboard.head-title": {
|
"dashboard.head-title": {
|
||||||
"message": "Dashboard"
|
"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": {
|
"dashboard.notifications.empty.no-unread": {
|
||||||
"message": "You don't have any unread notifications."
|
"message": "You have no 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": {
|
"dashboard.notifications.link.see-all": {
|
||||||
"message": "See all"
|
"message": "See all"
|
||||||
@@ -902,9 +998,6 @@
|
|||||||
"dashboard.notifications.link.view-more": {
|
"dashboard.notifications.link.view-more": {
|
||||||
"message": "View {extraNotifs} more {extraNotifs, plural, one {notification} other {notifications}}"
|
"message": "View {extraNotifs} more {extraNotifs, plural, one {notification} other {notifications}}"
|
||||||
},
|
},
|
||||||
"dashboard.notifications.loading": {
|
|
||||||
"message": "Loading notifications..."
|
|
||||||
},
|
|
||||||
"dashboard.organizations.button.create": {
|
"dashboard.organizations.button.create": {
|
||||||
"message": "Create organization"
|
"message": "Create organization"
|
||||||
},
|
},
|
||||||
@@ -920,6 +1013,27 @@
|
|||||||
"dashboard.organizations.title": {
|
"dashboard.organizations.title": {
|
||||||
"message": "Organizations"
|
"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": {
|
"dashboard.projects.bulk-edit-hint": {
|
||||||
"message": "You can edit multiple projects at once by selecting them below."
|
"message": "You can edit multiple projects at once by selecting them below."
|
||||||
},
|
},
|
||||||
@@ -1754,14 +1868,20 @@
|
|||||||
"layout.banner.add-email.description": {
|
"layout.banner.add-email.description": {
|
||||||
"message": "For security reasons, Modrinth needs you to register an email address to your account."
|
"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": {
|
"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}"
|
"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": {
|
"layout.banner.build-fail.title": {
|
||||||
"message": "Error generating state from API when building."
|
"message": "Error generating state from API when building."
|
||||||
},
|
},
|
||||||
"layout.banner.preview.description": {
|
"layout.banner.preview.description": {
|
||||||
"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}."
|
"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}."
|
||||||
},
|
},
|
||||||
"layout.banner.preview.title": {
|
"layout.banner.preview.title": {
|
||||||
"message": "This is a preview deploy of the Modrinth website."
|
"message": "This is a preview deploy of the Modrinth website."
|
||||||
@@ -2591,6 +2711,84 @@
|
|||||||
"project.license.title": {
|
"project.license.title": {
|
||||||
"message": "License"
|
"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": {
|
"project.moderation.title": {
|
||||||
"message": "Moderation"
|
"message": "Moderation"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2302,6 +2302,7 @@ const currentMember = computed(() => {
|
|||||||
payouts_split: 0,
|
payouts_split: 0,
|
||||||
avatar_url: auth.value.user.avatar_url,
|
avatar_url: auth.value.user.avatar_url,
|
||||||
name: auth.value.user.username,
|
name: auth.value.user.username,
|
||||||
|
staffOnly: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function getPrimaryFile(version) {
|
||||||
|
return version.files.find((x) => x.primary) || version.files[0]
|
||||||
|
}
|
||||||
|
|
||||||
function createDownloadUrl(version) {
|
function createDownloadUrl(version) {
|
||||||
return createProjectDownloadUrl(getPrimaryFile(version).url, {
|
return createProjectDownloadUrl(getPrimaryFile(version).url, {
|
||||||
reason: cdnDownloadReason.value,
|
reason: cdnDownloadReason.value,
|
||||||
|
|||||||
@@ -1,137 +1,434 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="canAccess">
|
<template v-if="canAccess">
|
||||||
<section class="universal-card">
|
<Admonition
|
||||||
<h2>Project status</h2>
|
v-if="userFacingUiVisible && moderationAdmonition"
|
||||||
<Badge :type="project.status" />
|
:type="moderationAdmonition.type"
|
||||||
<p v-if="isApproved(project)">
|
class="mb-4"
|
||||||
Your project has been approved by the moderators and you may freely change project
|
:header="formatMessage(moderationAdmonition.header)"
|
||||||
visibility in
|
>
|
||||||
<router-link :to="`${getProjectLink(project)}/settings`" class="text-link"
|
<template
|
||||||
>your project's settings</router-link
|
v-for="(section, index) in moderationAdmonition.body"
|
||||||
>.
|
:key="`moderation-admonition.${project.status}+${project.requested_status ?? 'none'}.body.${index}`"
|
||||||
</p>
|
>
|
||||||
<div v-else-if="isUnderReview(project)">
|
<p
|
||||||
<p>
|
v-if="section.type === 'paragraph' && section.message"
|
||||||
Modrinth's team of content moderators work hard to review all submitted projects.
|
class="preserve-lines mb-0 mt-2 leading-tight first:mt-0"
|
||||||
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.
|
<IntlFormatted
|
||||||
Certain holidays or events may also lead to delays depending on moderator availability.
|
:message-id="section.message"
|
||||||
Modrinth's moderators will leave a message below if they have any questions or concerns
|
:values="{
|
||||||
for you.
|
requestedStatus: project.requested_status ?? 'none',
|
||||||
</p>
|
}"
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
support article on review times
|
<template #rules-link="{ children }">
|
||||||
</a>
|
<nuxt-link to="/legal/rules" class="text-link" target="_blank">
|
||||||
for moderation delays.
|
<component :is="() => normalizeChildren(children)" />
|
||||||
</p>
|
</nuxt-link>
|
||||||
</div>
|
</template>
|
||||||
<template v-else-if="isRejected(project)">
|
<template #terms-link="{ children }">
|
||||||
<p>
|
<nuxt-link to="/legal/terms" class="text-link" target="_blank">
|
||||||
Your project does not currently meet Modrinth's
|
<component :is="() => normalizeChildren(children)" />
|
||||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">content rules</nuxt-link>
|
</nuxt-link>
|
||||||
and the moderators have requested you make changes before it can be approved. Read the
|
</template>
|
||||||
messages from the moderators below and address their comments before resubmitting.
|
<template #visibility-settings-link="{ children }">
|
||||||
</p>
|
<router-link :to="`${getProjectLink(project)}/settings#visibility`" class="text-link">
|
||||||
<p class="warning">
|
<component :is="() => normalizeChildren(children)" />
|
||||||
<IssuesIcon /> Repeated submissions without addressing the moderators' comments may result
|
</router-link>
|
||||||
in an account suspension.
|
</template>
|
||||||
|
<template #emphasis="{ children }">
|
||||||
|
<span class="font-semibold">
|
||||||
|
<component :is="() => normalizeChildren(children)" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</IntlFormatted>
|
||||||
</p>
|
</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}`"
|
||||||
|
>
|
||||||
|
<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>
|
</template>
|
||||||
<h3>Current visibility</h3>
|
</Admonition>
|
||||||
<ul class="visibility-info">
|
<div class="card-shadow mb-6 rounded-2xl border border-solid border-surface-4 bg-surface-3">
|
||||||
<li v-if="isListed(project)">
|
<div class="flex flex-col p-4">
|
||||||
<CheckIcon class="good" />
|
<div class="flex items-center justify-between">
|
||||||
Listed in search results
|
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
|
||||||
</li>
|
{{ formatMessage(messages.threadSectionTitle) }}
|
||||||
<li v-else>
|
</h2>
|
||||||
<XIcon class="bad" />
|
<div v-if="currentMember?.staffOnly" class="flex items-center gap-2">
|
||||||
Not listed in search results
|
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||||
</li>
|
<label for="moderator-see-user-ui-toggle">
|
||||||
<li v-if="isListed(project)">
|
{{ formatMessage(messages.moderatorSeeUserUiToggle) }}
|
||||||
<CheckIcon class="good" />
|
</label>
|
||||||
Listed on the profiles of members
|
</div>
|
||||||
</li>
|
</div>
|
||||||
<li v-else>
|
<template v-if="userFacingUiVisible">
|
||||||
<XIcon class="bad" />
|
<p class="m-0 mt-2 leading-tight">
|
||||||
Not listed on the profiles of members
|
{{ formatMessage(messages.threadPrivateDescription) }}
|
||||||
</li>
|
</p>
|
||||||
<li v-if="isPrivate(project)">
|
<p class="mb-0 mt-3 leading-tight">
|
||||||
<XIcon class="bad" />
|
<IntlFormatted :message-id="messages.threadHelpCenterNote1">
|
||||||
Not accessible with a direct link
|
<template #help-center-link="{ children }">
|
||||||
</li>
|
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||||
<li v-else>
|
<component :is="() => normalizeChildren(children)" />
|
||||||
<CheckIcon class="good" />
|
</a>
|
||||||
Accessible with a direct link
|
</template>
|
||||||
</li>
|
</IntlFormatted>
|
||||||
</ul>
|
</p>
|
||||||
</section>
|
<p class="mb-0 mt-2 leading-tight">
|
||||||
<section id="messages" class="universal-card">
|
<IntlFormatted :message-id="messages.threadHelpCenterNote2">
|
||||||
<h2>Messages</h2>
|
<template #help-center-link="{ children }">
|
||||||
<p>
|
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
||||||
This is a private conversation thread with the Modrinth moderators. They may message you
|
<component :is="() => normalizeChildren(children)" />
|
||||||
with issues concerning this project. This thread is only checked when you submit your
|
</a>
|
||||||
project for review. For additional inquiries, please go to the
|
</template>
|
||||||
<a class="text-link" href="https://support.modrinth.com" target="_blank">
|
</IntlFormatted>
|
||||||
Modrinth Help Center
|
</p>
|
||||||
</a>
|
<p
|
||||||
and click the green bubble to contact support.
|
v-if="isApproved(project)"
|
||||||
</p>
|
class="mb-0 mt-3 flex items-center gap-2 font-semibold text-orange"
|
||||||
<p v-if="isApproved(project)" class="warning">
|
>
|
||||||
<IssuesIcon /> The moderators do not actively monitor this chat. However, they may still see
|
<IssuesIcon class="shrink-0" />
|
||||||
messages here if there is a problem with your project.
|
{{ formatMessage(messages.threadApprovedWarning) }}
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<ConversationThread
|
<ConversationThread
|
||||||
v-if="thread"
|
v-if="thread"
|
||||||
:thread="thread"
|
:thread="thread"
|
||||||
:project="project"
|
:project="project"
|
||||||
:set-status="setStatus"
|
:set-status="setStatus"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember ?? undefined"
|
||||||
:auth="auth"
|
:auth="auth"
|
||||||
|
class="overflow-clip rounded-b-2xl border-0 border-t border-solid border-surface-4 bg-surface-2"
|
||||||
@update-thread="updateThread"
|
@update-thread="updateThread"
|
||||||
/>
|
/>
|
||||||
</section>
|
<div
|
||||||
</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>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { CheckIcon, IssuesIcon, XIcon } from '@modrinth/assets'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
|
import { IssuesIcon, SpinnerIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Admonition,
|
||||||
|
commonMessages,
|
||||||
|
defineMessage,
|
||||||
|
defineMessages,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
injectProjectPageContext,
|
injectProjectPageContext,
|
||||||
|
IntlFormatted,
|
||||||
|
type MessageDescriptor,
|
||||||
|
normalizeChildren,
|
||||||
|
Toggle,
|
||||||
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, type Ref, watch } from 'vue'
|
||||||
|
|
||||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||||
import {
|
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
|
||||||
getProjectLink,
|
|
||||||
isApproved,
|
const { formatMessage } = useVIntl()
|
||||||
isListed,
|
const flags = useFeatureFlags()
|
||||||
isPrivate,
|
|
||||||
isRejected,
|
type ProjectPageMember = Labrinth.Projects.v3.TeamMember & { staffOnly?: boolean }
|
||||||
isUnderReview,
|
type ModerationAdmonitionSection =
|
||||||
} from '~/helpers/projects.js'
|
| {
|
||||||
|
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>.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const { projectV2: project, currentMember, invalidate } = injectProjectPageContext()
|
const {
|
||||||
|
projectV2: project,
|
||||||
|
currentMember: currentMemberRaw,
|
||||||
|
invalidate,
|
||||||
|
allMembers,
|
||||||
|
} = injectProjectPageContext()
|
||||||
|
const currentMember = currentMemberRaw as Ref<ProjectPageMember | null>
|
||||||
|
|
||||||
const canAccess = computed(() => !!currentMember.value)
|
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(
|
watch(
|
||||||
[currentMember, project],
|
[currentMember, allMembers],
|
||||||
() => {
|
() => {
|
||||||
if (project.value && !canAccess.value) {
|
if (allMembers.value.length > 0 && !canAccess.value) {
|
||||||
showError({
|
showError({
|
||||||
fatal: true,
|
fatal: true,
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Unauthorized',
|
statusMessage: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: 'project.moderation.error.unauthorized',
|
||||||
|
defaultMessage: 'Unauthorized',
|
||||||
|
}),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -142,20 +439,23 @@ const auth = await useAuth()
|
|||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: thread } = useQuery({
|
const { data: thread, isPending: pending } = useQuery({
|
||||||
queryKey: computed(() => ['thread', project.value?.thread_id]),
|
queryKey: computed(() => ['thread', project.value?.thread_id]),
|
||||||
queryFn: () => client.labrinth.threads_v3.getThread(project.value.thread_id),
|
queryFn: () => client.labrinth.threads_v3.getThread(project.value.thread_id),
|
||||||
enabled: computed(() => !!project.value?.thread_id),
|
enabled: computed(() => !!project.value?.thread_id),
|
||||||
})
|
})
|
||||||
|
|
||||||
function updateThread(newThread) {
|
function updateThread(newThread: Labrinth.Threads.v3.Thread | null | undefined) {
|
||||||
const threadId = newThread?.id ?? project.value?.thread_id
|
const threadId = newThread?.id ?? project.value?.thread_id
|
||||||
if (!threadId) return
|
if (!threadId) return
|
||||||
|
|
||||||
queryClient.setQueryData(['thread', threadId], newThread)
|
queryClient.setQueryData<Labrinth.Threads.v3.Thread | null | undefined>(
|
||||||
|
['thread', threadId],
|
||||||
|
newThread,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setStatus(status) {
|
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -166,69 +466,21 @@ async function setStatus(status) {
|
|||||||
await queryClient.invalidateQueries({ queryKey: ['thread', project.value?.thread_id] })
|
await queryClient.invalidateQueries({ queryKey: ['thread', project.value?.thread_id] })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'An error occurred',
|
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||||
text: err.data ? err.data.description : err,
|
text: getErrorDescription(err),
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
stopLoading()
|
stopLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
return err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
|
||||||
.stacked {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-message {
|
|
||||||
:deep(.badge) {
|
|
||||||
display: contents;
|
|
||||||
|
|
||||||
svg {
|
|
||||||
vertical-align: top;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div id="visibility">
|
||||||
<label>
|
<label>
|
||||||
<span class="label__title">Visibility</span>
|
<span class="label__title">Visibility</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -242,36 +242,6 @@
|
|||||||
:disabled="!hasPermission"
|
:disabled="!hasPermission"
|
||||||
:max-height="500"
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,16 +330,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { ImageIcon, ScaleIcon, TrashIcon, TriangleAlertIcon, UploadIcon } from '@modrinth/assets'
|
||||||
CheckIcon,
|
|
||||||
ImageIcon,
|
|
||||||
IssuesIcon,
|
|
||||||
ScaleIcon,
|
|
||||||
TrashIcon,
|
|
||||||
TriangleAlertIcon,
|
|
||||||
UploadIcon,
|
|
||||||
XIcon,
|
|
||||||
} from '@modrinth/assets'
|
|
||||||
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
|
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -605,14 +566,6 @@ 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() {
|
async function handleSave() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -94,31 +94,31 @@ const { formatMessage } = useVIntl()
|
|||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
historyLabel: {
|
historyLabel: {
|
||||||
id: 'dashboard.notifications.history.label',
|
id: 'dashboard.overview.notifications.history.label',
|
||||||
defaultMessage: 'History',
|
defaultMessage: 'History',
|
||||||
},
|
},
|
||||||
notificationHistoryTitle: {
|
notificationHistoryTitle: {
|
||||||
id: 'dashboard.notifications.history.title',
|
id: 'dashboard.overview.notifications.history.title',
|
||||||
defaultMessage: 'Notification history',
|
defaultMessage: 'Notification history',
|
||||||
},
|
},
|
||||||
viewHistory: {
|
viewHistory: {
|
||||||
id: 'dashboard.notifications.button.view-history',
|
id: 'dashboard.overview.notifications.button.view-history',
|
||||||
defaultMessage: 'View history',
|
defaultMessage: 'View history',
|
||||||
},
|
},
|
||||||
markAllAsRead: {
|
markAllAsRead: {
|
||||||
id: 'dashboard.notifications.button.mark-all-as-read',
|
id: 'dashboard.overview.notifications.button.mark-all-as-read',
|
||||||
defaultMessage: 'Mark all as read',
|
defaultMessage: 'Mark all as read',
|
||||||
},
|
},
|
||||||
loadingNotifications: {
|
loadingNotifications: {
|
||||||
id: 'dashboard.notifications.loading',
|
id: 'dashboard.overview.notifications.loading',
|
||||||
defaultMessage: 'Loading notifications...',
|
defaultMessage: 'Loading notifications...',
|
||||||
},
|
},
|
||||||
errorLoadingNotifications: {
|
errorLoadingNotifications: {
|
||||||
id: 'dashboard.notifications.error.loading',
|
id: 'dashboard.overview.notifications.error.loading',
|
||||||
defaultMessage: 'Error loading notifications:',
|
defaultMessage: 'Error loading notifications:',
|
||||||
},
|
},
|
||||||
noUnreadNotifications: {
|
noUnreadNotifications: {
|
||||||
id: 'dashboard.notifications.empty.no-unread',
|
id: 'dashboard.overview.notifications.empty.no-unread',
|
||||||
defaultMessage: "You don't have any unread notifications.",
|
defaultMessage: "You don't have any unread notifications.",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ const emptyStateDescription = computed(() => {
|
|||||||
return 'Check that your search query is correct!'
|
return 'Check that your search query is correct!'
|
||||||
}
|
}
|
||||||
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
|
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'
|
return 'you will probably never see this but if you do, congrats!!! :D'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ useSeoMeta({
|
|||||||
wrapper-class="w-full rounded-xl bg-bg-raised"
|
wrapper-class="w-full rounded-xl bg-bg-raised"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="mb-6 flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="flag in filteredFlags"
|
v-for="flag in filteredFlags"
|
||||||
:key="`flag-${flag}`"
|
:key="`flag-${flag}`"
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
In accordance with the above notice, this project will be temporarily %STATUS%.</br>
|
In accordance with the above notice, this project will be temporarily %STATUS%.
|
||||||
|
|
||||||
We ask that you resubmit this project once all moderation concerns have been addressed.
|
We ask that you resubmit this project once all moderation concerns have been addressed.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
{{ relativeTimeLabel }}
|
{{ relativeTimeLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-normal text-contrast/85">
|
<div class="font-normal text-contrast/85 leading-tight">
|
||||||
<slot>{{ body }}</slot>
|
<slot>{{ body }}</slot>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||||
@@ -80,7 +80,7 @@ import ButtonStyled from './ButtonStyled.vue'
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
type?: 'info' | 'warning' | 'critical' | 'success'
|
type?: 'info' | 'warning' | 'critical' | 'success' | 'moderation'
|
||||||
header?: string
|
header?: string
|
||||||
body?: string
|
body?: string
|
||||||
showActionsUnderneath?: boolean
|
showActionsUnderneath?: boolean
|
||||||
@@ -141,6 +141,7 @@ const typeClasses = {
|
|||||||
warning: 'border-brand-orange bg-bg-orange',
|
warning: 'border-brand-orange bg-bg-orange',
|
||||||
critical: 'border-brand-red bg-bg-red',
|
critical: 'border-brand-red bg-bg-red',
|
||||||
success: 'border-brand-green bg-bg-green',
|
success: 'border-brand-green bg-bg-green',
|
||||||
|
moderation: 'border-brand-orange bg-bg-orange',
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconClasses = {
|
const iconClasses = {
|
||||||
@@ -148,6 +149,7 @@ const iconClasses = {
|
|||||||
warning: 'text-brand-orange',
|
warning: 'text-brand-orange',
|
||||||
critical: 'text-brand-red',
|
critical: 'text-brand-red',
|
||||||
success: 'text-brand-green',
|
success: 'text-brand-green',
|
||||||
|
moderation: 'text-brand-orange',
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonColors = {
|
const buttonColors = {
|
||||||
@@ -155,6 +157,7 @@ const buttonColors = {
|
|||||||
warning: 'orange',
|
warning: 'orange',
|
||||||
critical: 'red',
|
critical: 'red',
|
||||||
success: 'green',
|
success: 'green',
|
||||||
|
moderation: 'orange',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const progressTrackClasses = {
|
const progressTrackClasses = {
|
||||||
@@ -162,6 +165,7 @@ const progressTrackClasses = {
|
|||||||
warning: 'bg-brand-orange/20',
|
warning: 'bg-brand-orange/20',
|
||||||
critical: 'bg-brand-red/20',
|
critical: 'bg-brand-red/20',
|
||||||
success: 'bg-brand-green/20',
|
success: 'bg-brand-green/20',
|
||||||
|
moderation: 'bg-brand-orange/20',
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressFillClasses = {
|
const progressFillClasses = {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@
|
|||||||
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
<CheckIcon aria-hidden="true" /> {{ formatMessage(messages.approvedLabel) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="type === 'unlisted'">
|
<template v-else-if="type === 'unlisted'">
|
||||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.unlistedLabel) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="type === 'withheld'">
|
<template v-else-if="type === 'withheld'">
|
||||||
<EyeOffIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.withheldLabel) }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="type === 'private'">
|
<template v-else-if="type === 'private'">
|
||||||
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
|
<LockIcon aria-hidden="true" /> {{ formatMessage(messages.privateLabel) }}
|
||||||
@@ -89,9 +89,9 @@ import {
|
|||||||
BugIcon,
|
BugIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
EyeOffIcon,
|
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
LinkIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
ModrinthIcon,
|
ModrinthIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="group bg-transparent border-none p-0 m-0 flex items-center gap-3 checkbox-outer outline-offset-4 text-contrast"
|
class="group bg-transparent border-none p-0 m-0 flex items-center text-left gap-3 checkbox-outer outline-offset-4 text-contrast"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="
|
:class="
|
||||||
disabled
|
disabled
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid"
|
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid shrink-0"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-brand border-button-border text-brand-inverted': modelValue,
|
'bg-brand border-button-border text-brand-inverted': modelValue,
|
||||||
'bg-surface-2 border-surface-5 text-primary': !modelValue,
|
'bg-surface-2 border-surface-5 text-primary': !modelValue,
|
||||||
|
|||||||
@@ -1,39 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<NewModal ref="linkModal" header="Insert link">
|
<NewModal ref="linkModal" :header="formatMessage(messages.linkModalHeader)" class="!w-[40rem]">
|
||||||
<div class="modal-insert">
|
<div class="modal-insert">
|
||||||
<label class="label" for="insert-link-label">
|
<label class="label" for="insert-link-label">
|
||||||
<span class="label__title">Label</span>
|
<span class="label__title">{{ formatMessage(messages.linkModalLabelFieldTitle) }}</span>
|
||||||
</label>
|
</label>
|
||||||
<StyledInput
|
<StyledInput
|
||||||
id="insert-link-label"
|
id="insert-link-label"
|
||||||
v-model="linkText"
|
v-model="linkText"
|
||||||
:icon="AlignLeftIcon"
|
:icon="AlignLeftIcon"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter label..."
|
:placeholder="formatMessage(messages.linkModalLabelFieldPlaceholder)"
|
||||||
clearable
|
clearable
|
||||||
wrapper-class="w-full"
|
wrapper-class="w-full"
|
||||||
/>
|
/>
|
||||||
<label class="label" for="insert-link-url">
|
<label class="label" for="insert-link-url">
|
||||||
<span class="label__title">URL<span class="required">*</span></span>
|
<span class="label__title">
|
||||||
|
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<StyledInput
|
<StyledInput
|
||||||
id="insert-link-url"
|
id="insert-link-url"
|
||||||
v-model="linkUrl"
|
v-model="linkUrl"
|
||||||
:icon="LinkIcon"
|
:icon="LinkIcon"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter the link's URL..."
|
:placeholder="formatMessage(messages.linkModalUrlFieldPlaceholder)"
|
||||||
clearable
|
clearable
|
||||||
wrapper-class="w-full"
|
wrapper-class="w-full"
|
||||||
@input="validateURL"
|
@input="validateURL"
|
||||||
/>
|
/>
|
||||||
<template v-if="linkValidationErrorMessage">
|
<template v-if="linkValidationErrorMessage">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label__title">Error</span>
|
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
|
||||||
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label__title">Preview</span>
|
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
|
||||||
<span class="label__description"></span>
|
<span class="label__description"></span>
|
||||||
</span>
|
</span>
|
||||||
<div class="markdown-body-wrapper">
|
<div class="markdown-body-wrapper">
|
||||||
@@ -45,7 +47,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
<ButtonStyled type="outlined">
|
<ButtonStyled type="outlined">
|
||||||
<button @click="() => linkModal?.hide()"><XIcon /> Cancel</button>
|
<button @click="() => linkModal?.hide()">
|
||||||
|
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
@@ -57,18 +61,21 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon /> Insert
|
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
<NewModal ref="imageModal" header="Insert image">
|
<NewModal ref="imageModal" :header="formatMessage(messages.imageModalHeader)" class="!w-[40rem]">
|
||||||
<div class="modal-insert">
|
<div class="modal-insert">
|
||||||
<label class="label" for="insert-image-alt">
|
<label class="label" for="insert-image-alt">
|
||||||
<span class="label__title">Description (alt text)<span class="required">*</span></span>
|
<span class="label__title">
|
||||||
|
{{ formatMessage(messages.imageModalDescriptionFieldTitle) }}
|
||||||
|
<span class="required">*</span>
|
||||||
|
</span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
Describe the image completely as you would to someone who could not see the image.
|
{{ formatMessage(messages.imageModalDescriptionFieldDescription) }}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<StyledInput
|
<StyledInput
|
||||||
@@ -76,15 +83,22 @@
|
|||||||
v-model="linkText"
|
v-model="linkText"
|
||||||
:icon="AlignLeftIcon"
|
:icon="AlignLeftIcon"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Describe the image..."
|
:placeholder="formatMessage(messages.imageModalDescriptionFieldPlaceholder)"
|
||||||
clearable
|
clearable
|
||||||
wrapper-class="w-full"
|
wrapper-class="w-full"
|
||||||
/>
|
/>
|
||||||
<label class="label" for="insert-link-url">
|
<label class="label" for="insert-link-url">
|
||||||
<span class="label__title">URL<span class="required">*</span></span>
|
<span class="label__title">
|
||||||
|
{{ formatMessage(messages.urlLabel) }}<span class="required">*</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div v-if="props.onImageUpload" class="image-strategy-chips">
|
<div v-if="props.onImageUpload" class="image-strategy-chips">
|
||||||
<Chips v-model="imageUploadOption" :items="['upload', 'link']" />
|
<Chips
|
||||||
|
v-model="imageUploadOption"
|
||||||
|
:items="['upload', 'link']"
|
||||||
|
:format-label="formatImageUploadOption"
|
||||||
|
:aria-label="formatMessage(messages.imageModalUploadModeLabel)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="props.onImageUpload && imageUploadOption === 'upload'"
|
v-if="props.onImageUpload && imageUploadOption === 'upload'"
|
||||||
@@ -92,7 +106,7 @@
|
|||||||
>
|
>
|
||||||
<FileInput
|
<FileInput
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
prompt="Drag and drop to upload or click to select file"
|
:prompt="formatMessage(messages.imageModalUploadPrompt)"
|
||||||
long-style
|
long-style
|
||||||
should-always-reset
|
should-always-reset
|
||||||
class="file-input"
|
class="file-input"
|
||||||
@@ -107,19 +121,19 @@
|
|||||||
v-model="linkUrl"
|
v-model="linkUrl"
|
||||||
:icon="ImageIcon"
|
:icon="ImageIcon"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter the image URL..."
|
:placeholder="formatMessage(messages.imageModalUrlFieldPlaceholder)"
|
||||||
clearable
|
clearable
|
||||||
wrapper-class="w-full"
|
wrapper-class="w-full"
|
||||||
@input="validateURL"
|
@input="validateURL"
|
||||||
/>
|
/>
|
||||||
<template v-if="linkValidationErrorMessage">
|
<template v-if="linkValidationErrorMessage">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label__title">Error</span>
|
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
|
||||||
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label__title">Preview</span>
|
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
|
||||||
<span class="label__description"></span>
|
<span class="label__description"></span>
|
||||||
</span>
|
</span>
|
||||||
<div class="markdown-body-wrapper">
|
<div class="markdown-body-wrapper">
|
||||||
@@ -131,7 +145,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
<ButtonStyled type="outlined">
|
<ButtonStyled type="outlined">
|
||||||
<button @click="() => imageModal?.hide()"><XIcon /> Cancel</button>
|
<button @click="() => imageModal?.hide()">
|
||||||
|
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
@@ -143,36 +159,40 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon /> Insert
|
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
<NewModal ref="videoModal" header="Insert YouTube video">
|
<NewModal ref="videoModal" :header="formatMessage(messages.videoModalHeader)" class="!w-[40rem]">
|
||||||
<div class="modal-insert">
|
<div class="modal-insert">
|
||||||
<label class="label" for="insert-video-url">
|
<label class="label" for="insert-video-url">
|
||||||
<span class="label__title">YouTube video URL<span class="required">*</span></span>
|
<span class="label__title">
|
||||||
<span class="label__description"> Enter a valid link to a YouTube video. </span>
|
{{ formatMessage(messages.videoModalUrlFieldTitle) }}<span class="required">*</span>
|
||||||
|
</span>
|
||||||
|
<span class="label__description">
|
||||||
|
{{ formatMessage(messages.videoModalUrlFieldDescription) }}
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<StyledInput
|
<StyledInput
|
||||||
id="insert-video-url"
|
id="insert-video-url"
|
||||||
v-model="linkUrl"
|
v-model="linkUrl"
|
||||||
:icon="YouTubeIcon"
|
:icon="YouTubeIcon"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter YouTube video URL"
|
:placeholder="formatMessage(messages.videoModalUrlFieldPlaceholder)"
|
||||||
clearable
|
clearable
|
||||||
wrapper-class="w-full"
|
wrapper-class="w-full"
|
||||||
@input="validateURL"
|
@input="validateURL"
|
||||||
/>
|
/>
|
||||||
<template v-if="linkValidationErrorMessage">
|
<template v-if="linkValidationErrorMessage">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label__title">Error</span>
|
<span class="label__title">{{ formatMessage(messages.errorLabel) }}</span>
|
||||||
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
<span class="label__description">{{ linkValidationErrorMessage }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="label__title">Preview</span>
|
<span class="label__title">{{ formatMessage(messages.previewLabel) }}</span>
|
||||||
<span class="label__description"></span>
|
<span class="label__description"></span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -185,7 +205,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
<ButtonStyled type="outlined">
|
<ButtonStyled type="outlined">
|
||||||
<button @click="() => videoModal?.hide()"><XIcon /> Cancel</button>
|
<button @click="() => videoModal?.hide()">
|
||||||
|
<XIcon /> {{ formatMessage(commonMessages.cancelButton) }}
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button
|
<button
|
||||||
@@ -197,37 +219,41 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<PlusIcon /> Insert
|
<PlusIcon /> {{ formatMessage(messages.insertButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
<div class="block grow w-full">
|
<div class="block grow w-full">
|
||||||
<div class="editor-action-row">
|
<div class="editor-action-row w-full">
|
||||||
<div class="editor-actions">
|
<div class="w-full flex justify-between items-center flex-wrap gap-2">
|
||||||
<template
|
<div class="editor-actions">
|
||||||
v-for="(buttonGroup, _i) in Object.values(BUTTONS).filter((bg) => bg.display)"
|
<template
|
||||||
:key="_i"
|
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">
|
<div class="divider"></div>
|
||||||
<ButtonStyled circular>
|
<template v-for="button in buttonGroup.buttons" :key="button.label.id">
|
||||||
<button
|
<ButtonStyled circular>
|
||||||
v-tooltip="button.label"
|
<button
|
||||||
:aria-label="button.label"
|
v-tooltip="formatMessage(button.label)"
|
||||||
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
|
:aria-label="formatMessage(button.label)"
|
||||||
:disabled="previewMode || disabled"
|
:class="{ 'mobile-hidden-group': !!buttonGroup.hideOnMobile }"
|
||||||
@click="() => button.action(editor)"
|
:disabled="previewMode || disabled"
|
||||||
>
|
@click="() => button.action(editor)"
|
||||||
<component :is="button.icon" />
|
>
|
||||||
</button>
|
<component :is="button.icon" />
|
||||||
</ButtonStyled>
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</div>
|
||||||
<div class="preview">
|
<div class="flex items-center gap-2">
|
||||||
<Toggle id="preview" v-model="previewMode" />
|
<Toggle id="preview" v-model="previewMode" small />
|
||||||
<label class="label" for="preview"> Preview </label>
|
<label class="label" for="preview">
|
||||||
|
{{ formatMessage(messages.editorPreviewToggleLabel) }}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,20 +261,29 @@
|
|||||||
<div v-if="!previewMode" class="info-blurb mt-2">
|
<div v-if="!previewMode" class="info-blurb mt-2">
|
||||||
<div class="info-blurb">
|
<div class="info-blurb">
|
||||||
<InfoIcon />
|
<InfoIcon />
|
||||||
<span
|
<IntlFormatted :message-id="messages.editorMarkdownFormattingSupport">
|
||||||
>This editor supports
|
<template #markdown-link="{ children }">
|
||||||
<a
|
<a
|
||||||
class="markdown-resource-link"
|
class="markdown-resource-link"
|
||||||
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
|
href="https://support.modrinth.com/en/articles/8801962-advanced-markdown-formatting"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>Markdown formatting</a
|
>
|
||||||
>.</span
|
<component :is="() => children" />
|
||||||
>
|
</a>
|
||||||
|
</template>
|
||||||
|
</IntlFormatted>
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ hide: !props.maxLength }" class="max-length-label">
|
<div :class="{ hide: !props.maxLength }" class="max-length-label">
|
||||||
<span>Max length: </span>
|
<span>{{ formatMessage(messages.editorMaxLengthLabel) }} </span>
|
||||||
<span>
|
<span>
|
||||||
{{ props.maxLength ? `${currentValue?.length || 0}/${props.maxLength}` : 'Unlimited' }}
|
{{
|
||||||
|
props.maxLength
|
||||||
|
? formatMessage(messages.editorMaxLengthValue, {
|
||||||
|
currentLength: currentValue?.length || 0,
|
||||||
|
maxLength: props.maxLength,
|
||||||
|
})
|
||||||
|
: formatMessage(messages.editorMaxLengthUnlimited)
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,13 +333,214 @@ import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/
|
|||||||
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
|
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
|
||||||
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
|
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 NewModal from '../modal/NewModal.vue'
|
||||||
import ButtonStyled from './ButtonStyled.vue'
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
import Chips from './Chips.vue'
|
import Chips from './Chips.vue'
|
||||||
import FileInput from './FileInput.vue'
|
import FileInput from './FileInput.vue'
|
||||||
|
import IntlFormatted from './IntlFormatted.vue'
|
||||||
import StyledInput from './StyledInput.vue'
|
import StyledInput from './StyledInput.vue'
|
||||||
import Toggle from './Toggle.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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: string
|
modelValue: string
|
||||||
@@ -325,7 +561,7 @@ const props = withDefaults(
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
headingButtons: true,
|
headingButtons: true,
|
||||||
onImageUpload: undefined,
|
onImageUpload: undefined,
|
||||||
placeholder: 'Write something...',
|
placeholder: undefined,
|
||||||
maxLength: undefined,
|
maxLength: undefined,
|
||||||
maxHeight: undefined,
|
maxHeight: undefined,
|
||||||
minHeight: undefined,
|
minHeight: undefined,
|
||||||
@@ -338,6 +574,9 @@ let isDisabledCompartment: Compartment | null = null
|
|||||||
let editorThemeCompartment: Compartment | null = null
|
let editorThemeCompartment: Compartment | null = null
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const resolvedPlaceholder = computed(
|
||||||
|
() => props.placeholder ?? formatMessage(messages.editorPlaceholder),
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const updateListener = EditorView.updateListener.of((update) => {
|
const updateListener = EditorView.updateListener.of((update) => {
|
||||||
@@ -393,7 +632,7 @@ onMounted(() => {
|
|||||||
uploadImagesFromList(clipboardData.files)
|
uploadImagesFromList(clipboardData.files)
|
||||||
.then(function (url) {
|
.then(function (url) {
|
||||||
const selection = markdownCommands.yankSelection(view)
|
const selection = markdownCommands.yankSelection(view)
|
||||||
const altText = selection || 'Replace this with a description'
|
const altText = selection || formatMessage(messages.defaultImageAltText)
|
||||||
const linkMarkdown = ``
|
const linkMarkdown = ``
|
||||||
return markdownCommands.replaceSelection(view, linkMarkdown)
|
return markdownCommands.replaceSelection(view, linkMarkdown)
|
||||||
})
|
})
|
||||||
@@ -466,7 +705,7 @@ onMounted(() => {
|
|||||||
addKeymap: false,
|
addKeymap: false,
|
||||||
}),
|
}),
|
||||||
keymap.of(historyKeymap),
|
keymap.of(historyKeymap),
|
||||||
cm_placeholder(props.placeholder || ''),
|
cm_placeholder(resolvedPlaceholder.value),
|
||||||
inputFilter,
|
inputFilter,
|
||||||
isDisabledCompartment.of(disabledCompartment),
|
isDisabledCompartment.of(disabledCompartment),
|
||||||
editorThemeCompartment.of(theme),
|
editorThemeCompartment.of(theme),
|
||||||
@@ -494,7 +733,7 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
type ButtonAction = {
|
type ButtonAction = {
|
||||||
label: string
|
label: MessageDescriptor
|
||||||
icon: Component
|
icon: Component
|
||||||
action: (editor: EditorView | null) => void
|
action: (editor: EditorView | null) => void
|
||||||
}
|
}
|
||||||
@@ -515,12 +754,12 @@ function runEditorCommand(command: (view: EditorView) => boolean, editor: Editor
|
|||||||
}
|
}
|
||||||
|
|
||||||
const composeCommandButton = (
|
const composeCommandButton = (
|
||||||
name: string,
|
label: MessageDescriptor,
|
||||||
icon: Component,
|
icon: Component,
|
||||||
command: (view: EditorView) => boolean,
|
command: (view: EditorView) => boolean,
|
||||||
) => {
|
) => {
|
||||||
return {
|
return {
|
||||||
label: name,
|
label,
|
||||||
icon,
|
icon,
|
||||||
action: (e: EditorView | null) => runEditorCommand(command, e),
|
action: (e: EditorView | null) => runEditorCommand(command, e),
|
||||||
}
|
}
|
||||||
@@ -531,33 +770,41 @@ const BUTTONS: ButtonGroupMap = {
|
|||||||
display: props.headingButtons,
|
display: props.headingButtons,
|
||||||
hideOnMobile: false,
|
hideOnMobile: false,
|
||||||
buttons: [
|
buttons: [
|
||||||
composeCommandButton('Heading 1', Heading1Icon, markdownCommands.toggleHeader),
|
composeCommandButton(messages.toolbarHeading1, Heading1Icon, markdownCommands.toggleHeader),
|
||||||
composeCommandButton('Heading 2', Heading2Icon, markdownCommands.toggleHeader2),
|
composeCommandButton(messages.toolbarHeading2, Heading2Icon, markdownCommands.toggleHeader2),
|
||||||
composeCommandButton('Heading 3', Heading3Icon, markdownCommands.toggleHeader3),
|
composeCommandButton(messages.toolbarHeading3, Heading3Icon, markdownCommands.toggleHeader3),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
stylizing: {
|
stylizing: {
|
||||||
display: true,
|
display: true,
|
||||||
hideOnMobile: false,
|
hideOnMobile: false,
|
||||||
buttons: [
|
buttons: [
|
||||||
composeCommandButton('Bold', BoldIcon, markdownCommands.toggleBold),
|
composeCommandButton(messages.toolbarBold, BoldIcon, markdownCommands.toggleBold),
|
||||||
composeCommandButton('Italic', ItalicIcon, markdownCommands.toggleItalic),
|
composeCommandButton(messages.toolbarItalic, ItalicIcon, markdownCommands.toggleItalic),
|
||||||
composeCommandButton(
|
composeCommandButton(
|
||||||
'Strikethrough',
|
messages.toolbarStrikethrough,
|
||||||
StrikethroughIcon,
|
StrikethroughIcon,
|
||||||
markdownCommands.toggleStrikethrough,
|
markdownCommands.toggleStrikethrough,
|
||||||
),
|
),
|
||||||
composeCommandButton('Code', CodeIcon, markdownCommands.toggleCodeBlock),
|
composeCommandButton(messages.toolbarCode, CodeIcon, markdownCommands.toggleCodeBlock),
|
||||||
composeCommandButton('Spoiler', ScanEyeIcon, markdownCommands.toggleSpoiler),
|
composeCommandButton(messages.toolbarSpoiler, ScanEyeIcon, markdownCommands.toggleSpoiler),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
lists: {
|
lists: {
|
||||||
display: true,
|
display: true,
|
||||||
hideOnMobile: false,
|
hideOnMobile: false,
|
||||||
buttons: [
|
buttons: [
|
||||||
composeCommandButton('Bulleted list', ListBulletedIcon, markdownCommands.toggleBulletList),
|
composeCommandButton(
|
||||||
composeCommandButton('Ordered list', ListOrderedIcon, markdownCommands.toggleOrderedList),
|
messages.toolbarBulletedList,
|
||||||
composeCommandButton('Quote', TextQuoteIcon, markdownCommands.toggleQuote),
|
ListBulletedIcon,
|
||||||
|
markdownCommands.toggleBulletList,
|
||||||
|
),
|
||||||
|
composeCommandButton(
|
||||||
|
messages.toolbarOrderedList,
|
||||||
|
ListOrderedIcon,
|
||||||
|
markdownCommands.toggleOrderedList,
|
||||||
|
),
|
||||||
|
composeCommandButton(messages.toolbarQuote, TextQuoteIcon, markdownCommands.toggleQuote),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
@@ -565,17 +812,17 @@ const BUTTONS: ButtonGroupMap = {
|
|||||||
hideOnMobile: false,
|
hideOnMobile: false,
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
label: 'Link',
|
label: messages.toolbarLink,
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
action: () => openLinkModal(),
|
action: () => openLinkModal(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Image',
|
label: messages.toolbarImage,
|
||||||
icon: ImageIcon,
|
icon: ImageIcon,
|
||||||
action: () => openImageModal(),
|
action: () => openImageModal(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Video',
|
label: messages.toolbarVideo,
|
||||||
icon: YouTubeIcon,
|
icon: YouTubeIcon,
|
||||||
action: () => openVideoModal(),
|
action: () => openVideoModal(),
|
||||||
},
|
},
|
||||||
@@ -693,12 +940,12 @@ function cleanUrl(input: string): string {
|
|||||||
try {
|
try {
|
||||||
url = new URL(input)
|
url = new URL(input)
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid URL. Make sure the URL is well-formed.')
|
throw new Error(formatMessage(messages.urlValidationErrorMalformed))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported protocols
|
// Check for unsupported protocols
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
throw new Error('Unsupported protocol. Use http or https.')
|
throw new Error(formatMessage(messages.urlValidationErrorUnsupportedProtocol))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the scheme is "http", automatically upgrade it to "https"
|
// If the scheme is "http", automatically upgrade it to "https"
|
||||||
@@ -709,7 +956,7 @@ function cleanUrl(input: string): string {
|
|||||||
// Block certain domains for compliance
|
// Block certain domains for compliance
|
||||||
const blockedDomains = ['forgecdn', 'cdn.discordapp', 'media.discordapp']
|
const blockedDomains = ['forgecdn', 'cdn.discordapp', 'media.discordapp']
|
||||||
if (blockedDomains.some((domain) => url.hostname.includes(domain))) {
|
if (blockedDomains.some((domain) => url.hostname.includes(domain))) {
|
||||||
throw new Error('Invalid URL. This domain is not allowed.')
|
throw new Error(formatMessage(messages.urlValidationErrorBlockedDomain))
|
||||||
}
|
}
|
||||||
|
|
||||||
return url.toString()
|
return url.toString()
|
||||||
@@ -733,7 +980,7 @@ const linkMarkdown = computed(() => {
|
|||||||
const uploadImagesFromList = async (files: FileList): Promise<string> => {
|
const uploadImagesFromList = async (files: FileList): Promise<string> => {
|
||||||
const file = files[0]
|
const file = files[0]
|
||||||
if (!props.onImageUpload) {
|
if (!props.onImageUpload) {
|
||||||
throw new Error('No image upload handler provided')
|
throw new Error(formatMessage(messages.uploadErrorNoHandler))
|
||||||
}
|
}
|
||||||
if (file) {
|
if (file) {
|
||||||
try {
|
try {
|
||||||
@@ -746,7 +993,7 @@ const uploadImagesFromList = async (files: FileList): Promise<string> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error('No file provided')
|
throw new Error(formatMessage(messages.uploadErrorNoFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImageUpload = async (files: FileList) => {
|
const handleImageUpload = async (files: FileList) => {
|
||||||
@@ -765,6 +1012,15 @@ const handleImageUpload = async (files: FileList) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageUploadOption = ref<string>('upload')
|
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 imageMarkdown = computed(() => (linkMarkdown.value.length ? `!${linkMarkdown.value}` : ''))
|
||||||
|
|
||||||
const canInsertImage = computed(() => {
|
const canInsertImage = computed(() => {
|
||||||
@@ -781,7 +1037,7 @@ const youtubeRegex =
|
|||||||
const videoMarkdown = computed(() => {
|
const videoMarkdown = computed(() => {
|
||||||
const match = youtubeRegex.exec(linkUrl.value)
|
const match = youtubeRegex.exec(linkUrl.value)
|
||||||
if (match) {
|
if (match) {
|
||||||
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 `<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 ''
|
return ''
|
||||||
})
|
})
|
||||||
@@ -924,11 +1180,13 @@ function openVideoModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-insert {
|
.modal-insert {
|
||||||
padding: var(--gap-lg);
|
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
margin-block: var(--gap-lg) var(--gap-sm);
|
margin-block: var(--gap-lg) var(--gap-sm);
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.label__title {
|
.label__title {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<nav
|
<nav
|
||||||
v-if="filteredLinks.length > 1"
|
v-if="filteredLinks.length > 1"
|
||||||
ref="scrollContainer"
|
ref="scrollContainer"
|
||||||
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold drop-shadow-xl"
|
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||||
:class="{ 'shadow-sm': mode === 'navigation' }"
|
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
|
||||||
>
|
>
|
||||||
<template v-if="mode === 'navigation'">
|
<template v-if="mode === 'navigation'">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
:aria-labelledby="headerId"
|
:aria-labelledby="headerId"
|
||||||
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
|
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
|
||||||
|
v-bind="$attrs"
|
||||||
@keydown="handleKeyDown"
|
@keydown="handleKeyDown"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -345,6 +346,10 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="['banner-grid relative border-b-2 border-solid border-0', containerClasses[variant]]"
|
:class="[
|
||||||
|
'banner-grid relative border-b-2 border-solid border-0 z-10',
|
||||||
|
containerClasses[variant],
|
||||||
|
{ 'no-actions': !$slots.actions, slim: slim },
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
@@ -16,12 +20,20 @@
|
|||||||
<slot name="description" />
|
<slot name="description" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="grid-area-[actions]">
|
<div v-if="$slots.actions" class="grid-area-[actions] flex items-center gap-2">
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="$slots.actions_right" class="grid-area-[actions_right]">
|
<div
|
||||||
<slot name="actions_right" />
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -29,9 +41,15 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { getSeverityIcon } from '../../utils'
|
import { getSeverityIcon } from '../../utils'
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
variant: 'error' | 'warning' | 'info'
|
defineProps<{
|
||||||
}>()
|
variant: 'error' | 'warning' | 'info'
|
||||||
|
slim?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
slim: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const containerClasses = {
|
const containerClasses = {
|
||||||
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
|
error: 'bg-banners-error-bg text-banners-error-text border-banners-error-border',
|
||||||
@@ -58,6 +76,16 @@ const iconClasses = {
|
|||||||
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
|
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\] {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2036,6 +2036,150 @@
|
|||||||
"locale.zh-TW": {
|
"locale.zh-TW": {
|
||||||
"defaultMessage": "Chinese (Traditional)"
|
"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": {
|
"modal.add-payment-method.action": {
|
||||||
"defaultMessage": "Add payment method"
|
"defaultMessage": "Add payment method"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
PayPalIcon,
|
PayPalIcon,
|
||||||
PlugIcon,
|
PlugIcon,
|
||||||
PolygonIcon,
|
PolygonIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
ServerIcon,
|
||||||
UnknownIcon,
|
UnknownIcon,
|
||||||
UpdatedIcon,
|
UpdatedIcon,
|
||||||
USDCColorIcon,
|
USDCColorIcon,
|
||||||
@@ -49,6 +51,7 @@ export const PROJECT_TYPE_ICONS: Record<ProjectType, Component> = {
|
|||||||
plugin: PlugIcon,
|
plugin: PlugIcon,
|
||||||
datapack: BracesIcon,
|
datapack: BracesIcon,
|
||||||
project: BoxIcon,
|
project: BoxIcon,
|
||||||
|
minecraft_java_server: ServerIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PAYMENT_METHOD_ICONS: Record<string, Component> = {
|
export const PAYMENT_METHOD_ICONS: Record<string, Component> = {
|
||||||
@@ -68,6 +71,7 @@ export const SEVERITY_ICONS: Record<string, Component> = {
|
|||||||
error: XCircleIcon,
|
error: XCircleIcon,
|
||||||
critical: XCircleIcon,
|
critical: XCircleIcon,
|
||||||
success: CheckCircleIcon,
|
success: CheckCircleIcon,
|
||||||
|
moderation: ScaleIcon,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
|
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export const formatProjectType = (name, short = false) => {
|
|||||||
return 'PLG'
|
return 'PLG'
|
||||||
} else if (name === 'datapack') {
|
} else if (name === 'datapack') {
|
||||||
return 'DPK'
|
return 'DPK'
|
||||||
} else if (name === 'server') {
|
} else if (name === 'minecraft_java_server') {
|
||||||
return 'SRV'
|
return 'SRV'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user