diff --git a/apps/frontend/src/components/ValidationMessage.vue b/apps/frontend/src/components/ValidationMessage.vue index e1d6bb6c5a..a18cae7712 100644 --- a/apps/frontend/src/components/ValidationMessage.vue +++ b/apps/frontend/src/components/ValidationMessage.vue @@ -1,11 +1,17 @@ @@ -20,16 +26,15 @@ interface ValidationCheck { values?: Record } -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', + ), +) diff --git a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue index 3f47802433..99b1dc9975 100644 --- a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue +++ b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue @@ -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 diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue index fcdd9591c8..24f224fb58 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue @@ -32,51 +32,72 @@ -
+
+
- - - {{ getFormattedMessage(nag.title) }} - - {{ getNagDescription(nag) }} - - {{ getFormattedMessage(nag.link.title) }} - - + + + {{ getFormattedMessage(nag.title) }} + + {{ getNagDescription(nag) }} + + {{ getFormattedMessage(nag.link.title) }} + + +
+
@@ -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(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 | 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(() => ({ + 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(() => ({ 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(() => { 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); +} diff --git a/apps/frontend/src/composables/project-field-validation.ts b/apps/frontend/src/composables/project-field-validation.ts index 8ae6ef11ae..862ee1d819 100644 --- a/apps/frontend/src/composables/project-field-validation.ts +++ b/apps/frontend/src/composables/project-field-validation.ts @@ -98,9 +98,10 @@ export function useProjectDescriptionValidation( requestId++ }) - const validation = computed( - () => validateProjectDescription(toValue(description)) ?? linkValidation.value, - ) + const validation = computed>(() => [ + ...validateProjectDescription(toValue(description)), + ...(linkValidation.value ? [linkValidation.value] : []), + ]) return { pending, diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue index f601a5964a..cd6814c717 100644 --- a/apps/frontend/src/pages/[type]/[project].vue +++ b/apps/frontend/src/pages/[type]/[project].vue @@ -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" diff --git a/apps/frontend/src/pages/[type]/[project]/gallery.vue b/apps/frontend/src/pages/[type]/[project]/gallery.vue index 57d301193a..1381c150cb 100644 --- a/apps/frontend/src/pages/[type]/[project]/gallery.vue +++ b/apps/frontend/src/pages/[type]/[project]/gallery.vue @@ -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 diff --git a/apps/frontend/src/pages/[type]/[project]/settings.vue b/apps/frontend/src/pages/[type]/[project]/settings.vue index 19f469effd..aa4008b3e8 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings.vue @@ -166,7 +166,7 @@ const moderatorSeeUserUi = computed({ -
- - {{ descriptionWarning }} - -