mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: update nags with field validation, splitting between required/warning nags
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="check && check.severity !== 'valid'"
|
||||
class="flex w-full items-center gap-1.5"
|
||||
:class="check.severity === 'error' ? 'text-red' : 'text-orange'"
|
||||
>
|
||||
<component :is="icon" class="my-auto" />
|
||||
{{ message }}
|
||||
<div v-if="validations.length > 0" class="flex w-full flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(validation, index) in validations"
|
||||
:key="validation.message?.id ?? index"
|
||||
class="flex w-full items-center gap-1.5"
|
||||
:class="validation.severity === 'error' ? 'text-red' : 'text-orange'"
|
||||
>
|
||||
<component
|
||||
:is="validation.severity === 'error' ? XCircleIcon : TriangleAlertIcon"
|
||||
class="my-auto"
|
||||
/>
|
||||
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,16 +26,15 @@ interface ValidationCheck {
|
||||
values?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{ check?: ValidationCheck | null }>(), {
|
||||
const props = withDefaults(defineProps<{ check?: ValidationCheck | ValidationCheck[] | null }>(), {
|
||||
check: null,
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const icon = computed(() => (props.check?.severity === 'error' ? XCircleIcon : TriangleAlertIcon))
|
||||
|
||||
const message = computed(() => {
|
||||
if (!props.check?.message) return undefined
|
||||
return formatMessage(props.check.message, props.check.values)
|
||||
})
|
||||
const validations = computed(() =>
|
||||
(Array.isArray(props.check) ? props.check : props.check ? [props.check] : []).filter(
|
||||
(validation) => validation.severity !== 'valid',
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -339,7 +339,11 @@ const summaryValidation = useProjectSummaryValidation(description, name)
|
||||
|
||||
const disableCreate = computed(() => {
|
||||
if (hasHitLimit.value) return true
|
||||
if (nameValidation.value || summaryValidation.value) return true
|
||||
if (
|
||||
nameValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
summaryValidation.value.some((validation) => validation.severity === 'error')
|
||||
)
|
||||
return true
|
||||
if (!name.value.trim() || !slug.value.trim()) return true
|
||||
if (!manualSlug.value && checkingSlugSuggestions.value) return true
|
||||
if (!manualSlug.value && !slugSuggestions.value.includes(slug.value)) return true
|
||||
|
||||
@@ -32,51 +32,72 @@
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!collapsed" class="mt-4 grid grid-cols-[repeat(auto-fit,minmax(15rem,1fr))] gap-2">
|
||||
<div v-if="!collapsed" class="relative mt-4">
|
||||
<div
|
||||
v-for="nag in visibleNags"
|
||||
:key="nag.id"
|
||||
class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
|
||||
class="nag-scroll-shadow-left pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-8 bg-surface-3 transition-opacity duration-200"
|
||||
:class="showLeftNagShadow ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<div
|
||||
ref="nagScroller"
|
||||
class="flex w-full cursor-grab select-none gap-2 overflow-x-auto overflow-y-hidden pb-2"
|
||||
:class="{ 'is-dragging': draggingNags }"
|
||||
@pointerdown="onNagPointerDown"
|
||||
@pointermove="onNagPointerMove"
|
||||
@pointerup="finishNagDrag"
|
||||
@pointercancel="finishNagDrag"
|
||||
@click.capture="onNagClick"
|
||||
@wheel="onNagWheel"
|
||||
@scroll="updateNagScrollShadows"
|
||||
>
|
||||
<span class="flex items-center gap-2 font-medium text-contrast">
|
||||
<component
|
||||
:is="nag.icon || getDefaultIcon(nag.status)"
|
||||
v-tooltip="getStatusTooltip(nag.status)"
|
||||
:class="[
|
||||
'size-4',
|
||||
nag.status === 'required' && 'text-red',
|
||||
nag.status === 'warning' && 'text-orange',
|
||||
nag.status === 'suggestion' && 'text-purple',
|
||||
]"
|
||||
:aria-label="getStatusTooltip(nag.status)"
|
||||
/>
|
||||
{{ getFormattedMessage(nag.title) }}
|
||||
</span>
|
||||
{{ getNagDescription(nag) }}
|
||||
<NuxtLink
|
||||
v-if="nag.link && shouldShowLink(nag)"
|
||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
|
||||
nag.link.path
|
||||
}`"
|
||||
class="goto-link mt-auto"
|
||||
<div
|
||||
v-for="nag in visibleNags"
|
||||
:key="nag.id"
|
||||
class="flex w-72 shrink-0 flex-col gap-3 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
|
||||
>
|
||||
{{ getFormattedMessage(nag.link.title) }}
|
||||
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
|
||||
</NuxtLink>
|
||||
<Button
|
||||
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
|
||||
v-tooltip="
|
||||
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
|
||||
"
|
||||
type="colored"
|
||||
color="orange"
|
||||
:disabled="!canSubmitForReview"
|
||||
@click="submitForReview"
|
||||
>
|
||||
<SendIcon />
|
||||
{{ getFormattedMessage(messages.submitForReviewButton) }}
|
||||
</Button>
|
||||
<span class="flex items-center gap-2 font-medium text-contrast">
|
||||
<component
|
||||
:is="nag.icon || getDefaultIcon(nag.status)"
|
||||
v-tooltip="getStatusTooltip(nag.status)"
|
||||
:class="[
|
||||
'size-4',
|
||||
nag.status === 'required' && 'text-red',
|
||||
nag.status === 'warning' && 'text-orange',
|
||||
nag.status === 'suggestion' && 'text-purple',
|
||||
]"
|
||||
:aria-label="getStatusTooltip(nag.status)"
|
||||
/>
|
||||
{{ getFormattedMessage(nag.title) }}
|
||||
</span>
|
||||
{{ getNagDescription(nag) }}
|
||||
<NuxtLink
|
||||
v-if="nag.link && shouldShowLink(nag)"
|
||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
|
||||
nag.link.path
|
||||
}`"
|
||||
class="goto-link mt-auto"
|
||||
>
|
||||
{{ getFormattedMessage(nag.link.title) }}
|
||||
<ChevronRightIcon aria-hidden="true" class="featured-header-chevron" />
|
||||
</NuxtLink>
|
||||
<Button
|
||||
v-if="nag.status === 'special-submit-action' && nag.id === 'submit-for-review'"
|
||||
v-tooltip="
|
||||
!canSubmitForReview ? getFormattedMessage(messages.submitChecklistTooltip) : undefined
|
||||
"
|
||||
type="colored"
|
||||
color="orange"
|
||||
:disabled="!canSubmitForReview"
|
||||
@click="submitForReview"
|
||||
>
|
||||
<SendIcon />
|
||||
{{ getFormattedMessage(messages.submitForReviewButton) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nag-scroll-shadow-right pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-8 bg-surface-3 transition-opacity duration-200"
|
||||
:class="showRightNagShadow ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -92,15 +113,17 @@ import {
|
||||
SendIcon,
|
||||
TriangleAlertIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
|
||||
import { nags } from '@modrinth/moderation'
|
||||
import type { Nag, NagContext, NagStatus, ProjectTitleMetadata } from '@modrinth/moderation'
|
||||
import { nags, validateProjectFields } from '@modrinth/moderation'
|
||||
import { Button, IconButton } from '@modrinth/ui'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
interface Tags {
|
||||
rejectedStatuses: string[]
|
||||
gameVersions: { version: string }[]
|
||||
loaders: { name: string }[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -176,9 +199,125 @@ const emit = defineEmits<{
|
||||
setProcessing: [processing: boolean]
|
||||
}>()
|
||||
|
||||
const nagScroller = ref<HTMLElement | null>(null)
|
||||
const showLeftNagShadow = ref(false)
|
||||
const showRightNagShadow = ref(false)
|
||||
const draggingNags = ref(false)
|
||||
|
||||
let nagScrollerResizeObserver: ResizeObserver | null = null
|
||||
let nagDragPointerId: number | null = null
|
||||
let nagDragCaptureTarget: Element | null = null
|
||||
let nagDragStartX = 0
|
||||
let nagDragStartScrollLeft = 0
|
||||
let suppressNagClick = false
|
||||
let suppressNagClickTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function updateNagScrollShadows() {
|
||||
const el = nagScroller.value
|
||||
if (!el) {
|
||||
showLeftNagShadow.value = false
|
||||
showRightNagShadow.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showLeftNagShadow.value = el.scrollLeft > 0
|
||||
showRightNagShadow.value = el.scrollLeft < el.scrollWidth - el.clientWidth - 1
|
||||
}
|
||||
|
||||
function onNagWheel(event: WheelEvent) {
|
||||
const el = nagScroller.value
|
||||
if (!el || el.scrollWidth <= el.clientWidth) return
|
||||
|
||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
|
||||
event.preventDefault()
|
||||
el.scrollLeft += delta
|
||||
}
|
||||
|
||||
function onNagPointerDown(event: PointerEvent) {
|
||||
const el = nagScroller.value
|
||||
if (!el || event.pointerType === 'touch' || event.button !== 0) return
|
||||
|
||||
nagDragPointerId = event.pointerId
|
||||
nagDragStartX = event.clientX
|
||||
nagDragStartScrollLeft = el.scrollLeft
|
||||
suppressNagClick = false
|
||||
nagDragCaptureTarget =
|
||||
event.target instanceof Element ? (event.target.closest('a, button') ?? el) : el
|
||||
nagDragCaptureTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function onNagPointerMove(event: PointerEvent) {
|
||||
const el = nagScroller.value
|
||||
if (!el || event.pointerId !== nagDragPointerId) return
|
||||
|
||||
const distance = event.clientX - nagDragStartX
|
||||
if (!draggingNags.value && Math.abs(distance) < 4) return
|
||||
|
||||
draggingNags.value = true
|
||||
suppressNagClick = true
|
||||
event.preventDefault()
|
||||
el.scrollLeft = nagDragStartScrollLeft - distance
|
||||
}
|
||||
|
||||
function finishNagDrag(event: PointerEvent) {
|
||||
if (event.pointerId !== nagDragPointerId) return
|
||||
|
||||
if (nagDragCaptureTarget?.hasPointerCapture(event.pointerId)) {
|
||||
nagDragCaptureTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
nagDragPointerId = null
|
||||
nagDragCaptureTarget = null
|
||||
draggingNags.value = false
|
||||
|
||||
if (suppressNagClick) {
|
||||
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
|
||||
suppressNagClickTimeout = setTimeout(() => {
|
||||
suppressNagClick = false
|
||||
suppressNagClickTimeout = null
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function onNagClick(event: MouseEvent) {
|
||||
if (!suppressNagClick) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
suppressNagClick = false
|
||||
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
|
||||
suppressNagClickTimeout = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nagScrollerResizeObserver = new ResizeObserver(updateNagScrollShadows)
|
||||
if (nagScroller.value) nagScrollerResizeObserver.observe(nagScroller.value)
|
||||
nextTick(updateNagScrollShadows)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
nagScrollerResizeObserver?.disconnect()
|
||||
if (suppressNagClickTimeout) clearTimeout(suppressNagClickTimeout)
|
||||
})
|
||||
|
||||
watch(nagScroller, (el, previousEl) => {
|
||||
if (previousEl) nagScrollerResizeObserver?.unobserve(previousEl)
|
||||
if (el) nagScrollerResizeObserver?.observe(el)
|
||||
nextTick(updateNagScrollShadows)
|
||||
})
|
||||
|
||||
const titleMetadata = computed<ProjectTitleMetadata>(() => ({
|
||||
gameVersions: props.tags.gameVersions.map(({ version }) => version),
|
||||
loaders: props.tags.loaders.map(({ name }) => name),
|
||||
}))
|
||||
|
||||
const projectValidation = computed(() =>
|
||||
validateProjectFields(props.projectV3, titleMetadata.value),
|
||||
)
|
||||
|
||||
const nagContext = computed<NagContext>(() => ({
|
||||
project: props.project,
|
||||
projectV3: props.projectV3,
|
||||
projectValidation: projectValidation.value,
|
||||
versions: props.versions,
|
||||
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
|
||||
currentRoute: props.routeName,
|
||||
@@ -247,6 +386,8 @@ const visibleNags = computed<Nag[]>(() => {
|
||||
return finalNags
|
||||
})
|
||||
|
||||
watch(visibleNags, () => nextTick(updateNagScrollShadows))
|
||||
|
||||
function shouldShowLink(nag: Nag): boolean {
|
||||
return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
|
||||
}
|
||||
@@ -298,4 +439,19 @@ function getFormattedMessage(message: string | MessageDescriptor): string {
|
||||
.duration-250 {
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
.is-dragging,
|
||||
.is-dragging * {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.nag-scroll-shadow-left {
|
||||
-webkit-mask-image: linear-gradient(to right, black, transparent);
|
||||
mask-image: linear-gradient(to right, black, transparent);
|
||||
}
|
||||
|
||||
.nag-scroll-shadow-right {
|
||||
-webkit-mask-image: linear-gradient(to left, black, transparent);
|
||||
mask-image: linear-gradient(to left, black, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,9 +98,10 @@ export function useProjectDescriptionValidation(
|
||||
requestId++
|
||||
})
|
||||
|
||||
const validation = computed<ProjectTextValidationResult | LinkCheckResult | null>(
|
||||
() => validateProjectDescription(toValue(description)) ?? linkValidation.value,
|
||||
)
|
||||
const validation = computed<Array<ProjectTextValidationResult | LinkCheckResult>>(() => [
|
||||
...validateProjectDescription(toValue(description)),
|
||||
...(linkValidation.value ? [linkValidation.value] : []),
|
||||
])
|
||||
|
||||
return {
|
||||
pending,
|
||||
|
||||
@@ -139,7 +139,9 @@
|
||||
v-if="
|
||||
projectV3 &&
|
||||
currentMember &&
|
||||
(projectV3.status === 'draft' || tags.rejectedStatuses.includes(projectV3.status))
|
||||
(projectV3.status === 'draft' ||
|
||||
projectV3.status === 'processing' ||
|
||||
tags.rejectedStatuses.includes(projectV3.status))
|
||||
"
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
|
||||
@@ -386,7 +386,9 @@ const shouldPreventActions = ref(false)
|
||||
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
|
||||
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
|
||||
const galleryFieldsInvalid = computed(
|
||||
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
|
||||
() =>
|
||||
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
|
||||
// Constant for accepted file types
|
||||
|
||||
@@ -166,7 +166,7 @@ const moderatorSeeUserUi = computed<boolean>({
|
||||
<ModerationProjectNags
|
||||
v-if="
|
||||
projectV3 &&
|
||||
((currentMember && project.status === 'draft') ||
|
||||
((currentMember && (project.status === 'draft' || project.status === 'processing')) ||
|
||||
tags.rejectedStatuses.includes(project.status))
|
||||
"
|
||||
:project="project"
|
||||
|
||||
@@ -21,11 +21,6 @@
|
||||
:on-image-upload="onUploadHandler"
|
||||
/>
|
||||
<ValidationMessage :check="descriptionValidation" class="mt-2" />
|
||||
<div v-if="descriptionWarning" class="mt-2">
|
||||
<SettingsInlineWarning>
|
||||
{{ descriptionWarning }}
|
||||
</SettingsInlineWarning>
|
||||
</div>
|
||||
</div>
|
||||
<UnsavedChangesPopup
|
||||
:original="saved"
|
||||
@@ -44,13 +39,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { countText, MIN_DESCRIPTION_CHARS } from '@modrinth/moderation'
|
||||
import {
|
||||
commonProjectSettingsMessages,
|
||||
ConfirmLeaveModal,
|
||||
injectProjectPageContext,
|
||||
MarkdownEditor,
|
||||
SettingsInlineWarning,
|
||||
UnsavedChangesPopup,
|
||||
usePageLeaveSafety,
|
||||
useSavable,
|
||||
@@ -96,7 +89,9 @@ const hasPermission = computed(
|
||||
)
|
||||
const { pending: descriptionLinksPending, validation: descriptionValidation } =
|
||||
useProjectDescriptionValidation(() => current.value.description)
|
||||
const hasValidationIssues = computed(() => descriptionValidation.value?.severity === 'error')
|
||||
const hasValidationIssues = computed(() =>
|
||||
descriptionValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
const canSave = computed(
|
||||
() => hasPermission.value && !hasValidationIssues.value && !descriptionLinksPending.value,
|
||||
)
|
||||
@@ -106,17 +101,6 @@ async function save() {
|
||||
await saveForm()
|
||||
}
|
||||
|
||||
const descriptionWarning = computed(() => {
|
||||
const text = current.value.description?.trim() || ''
|
||||
const charCount = countText(text)
|
||||
|
||||
if (charCount < MIN_DESCRIPTION_CHARS) {
|
||||
return `It's recommended to have a description with at least ${MIN_DESCRIPTION_CHARS} characters. (${charCount}/${MIN_DESCRIPTION_CHARS})`
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
async function onUploadHandler(file: File) {
|
||||
if (await fileDeclaresAi(file)) {
|
||||
aiImageWarningModal.value?.show()
|
||||
|
||||
@@ -346,7 +346,9 @@ const shouldPreventActions = ref(false)
|
||||
const galleryTitleValidation = computed(() => validateProjectText(editTitle.value))
|
||||
const galleryDescriptionValidation = computed(() => validateProjectText(editDescription.value))
|
||||
const galleryFieldsInvalid = computed(
|
||||
() => !!galleryTitleValidation.value || !!galleryDescriptionValidation.value,
|
||||
() =>
|
||||
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
|
||||
@@ -64,11 +64,6 @@
|
||||
resize="vertical"
|
||||
/>
|
||||
<ValidationMessage :check="summaryValidation" class="mt-2" />
|
||||
<div v-if="summaryWarning" class="my-2">
|
||||
<SettingsInlineWarning>
|
||||
{{ summaryWarning }}
|
||||
</SettingsInlineWarning>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -295,7 +290,7 @@
|
||||
:saving="saving"
|
||||
:can-save="canSave"
|
||||
:save-disabled-reason="
|
||||
hasPermission && hasValidationIssues
|
||||
hasPermission && hasBlockingValidationIssues
|
||||
? projectTextValidationMessages.resolveIssuesToSave
|
||||
: undefined
|
||||
"
|
||||
@@ -308,7 +303,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ImageIcon, ScaleIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -435,8 +429,13 @@ const hasPermission = computed(() => {
|
||||
|
||||
const nameValidation = useProjectTitleValidation(name)
|
||||
const summaryValidation = useProjectSummaryValidation(summary, name)
|
||||
const hasValidationIssues = computed(() => !!nameValidation.value || !!summaryValidation.value)
|
||||
const canSave = computed(() => hasPermission.value && !hasValidationIssues.value)
|
||||
const hasValidationIssues = computed(
|
||||
() =>
|
||||
nameValidation.value.some((validation) => validation.severity === 'error') ||
|
||||
summaryValidation.value.some((validation) => validation.severity === 'error'),
|
||||
)
|
||||
const hasBlockingValidationIssues = computed(() => hasValidationIssues.value && !isStaff.value)
|
||||
const canSave = computed(() => hasPermission.value && !hasBlockingValidationIssues.value)
|
||||
|
||||
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
|
||||
|
||||
@@ -445,17 +444,6 @@ const hasDeletePermission = computed(() => {
|
||||
return ((currentMember.value?.permissions ?? 0) & DELETE_PROJECT) === DELETE_PROJECT
|
||||
})
|
||||
|
||||
const summaryWarning = computed(() => {
|
||||
const text = summary.value?.trim() || ''
|
||||
const charCount = text.length
|
||||
|
||||
if (charCount < MIN_SUMMARY_CHARS) {
|
||||
return `It's recommended to have a summary with at least ${MIN_SUMMARY_CHARS} characters. (${charCount}/${MIN_SUMMARY_CHARS})`
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const visibilityOptions = computed(() =>
|
||||
tags.value.approvedStatuses
|
||||
.filter((status) => status !== 'archived')
|
||||
|
||||
Reference in New Issue
Block a user