+
+
https://modrinth.com/project/
-
+
+
@@ -147,6 +160,16 @@ import {
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
+import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
+import ValidationMessage from '~/components/ValidationMessage.vue'
+import {
+ useProjectSummaryValidation,
+ useProjectTitleValidation,
+} from '~/composables/project-field-validation'
+import {
+ useProjectSlugSuggestions,
+ useSlugSuggestionVisibility,
+} from '~/composables/project-slug-suggestions'
import { generateUrlSlug } from '~/utils/slugs'
import CreateLimitAlert from './CreateLimitAlert.vue'
@@ -272,6 +295,16 @@ const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
+const {
+ onFocusIn: onSlugSuggestionFocusIn,
+ onFocusOut: onSlugSuggestionFocusOut,
+ visible: showSlugSuggestions,
+} = useSlugSuggestionVisibility()
+const { checking: checkingSlugSuggestions, suggestions: slugSuggestions } =
+ useProjectSlugSuggestions({
+ title: name,
+ username: () => auth.value.user?.username,
+ })
const projectType = ref
('project')
const projectTypeOptions = computed[]>(() => [
{
@@ -302,9 +335,20 @@ const visibilities = ref([
])
const visibility = ref(visibilities.value[0])
+const nameForValidation = ref(name.value)
+const nameValidation = useProjectTitleValidation(nameForValidation)
+const summaryValidation = useProjectSummaryValidation(description, name)
+
const disableCreate = computed(() => {
if (hasHitLimit.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
if (description.value.trim().length < 3) return true
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
return true
@@ -391,6 +435,7 @@ async function fetchOrganizations() {
}
async function createProject() {
+ if (disableCreate.value) return
startLoading()
const formData = new FormData()
@@ -471,9 +516,11 @@ async function createProject() {
async function show(event?: MouseEvent, options?: ShowOptions) {
name.value = ''
+ nameForValidation.value = name.value
slug.value = ''
description.value = ''
manualSlug.value = false
+ showSlugSuggestions.value = false
owner.value = 'self'
projectType.value = options?.type ?? 'project'
await fetchOrganizations()
@@ -485,4 +532,13 @@ function updatedName() {
slug.value = generateUrlSlug(name.value)
}
}
+
+function selectSlugSuggestion(suggestion: string) {
+ slug.value = suggestion
+ manualSlug.value = true
+}
+
+watch([slugSuggestions, checkingSlugSuggestions], ([suggestions, checking]) => {
+ if (!manualSlug.value && !checking) slug.value = suggestions[0] ?? ''
+})
diff --git a/apps/frontend/src/components/ui/moderation/ModerateByIdsModal.vue b/apps/frontend/src/components/ui/moderation/ModerateByIdsModal.vue
new file mode 100644
index 0000000000..9a592383ca
--- /dev/null
+++ b/apps/frontend/src/components/ui/moderation/ModerateByIdsModal.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
index fcdd9591c8..73d77c9e4e 100644
--- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
+++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue
@@ -15,69 +15,100 @@
{{ getFormattedMessage(messages.warning) }}
- |
-
-
- {{ getFormattedMessage(messages.suggestion) }}
-
+
+ |
+
+
+ {{ getFormattedMessage(messages.suggestion) }}
+
+
-
+
-
-
-
-
- {{ getFormattedMessage(nag.title) }}
-
- {{ getNagDescription(nag) }}
-
+
+
+
- {{ getFormattedMessage(nag.link.title) }}
-
-
-
+
+
+
+ {{ getFormattedMessage(nag.title) }}
+
+ {{ getNagDescription(nag) }}
+
+ {{ getFormattedMessage(nag.link.title) }}
+
+
+
+
+
+
-
+
@@ -93,14 +124,17 @@ import {
TriangleAlertIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
-import { nags } from '@modrinth/moderation'
-import { Button, IconButton } from '@modrinth/ui'
+import { getNags, nagDestinations, validateProject } from '@modrinth/moderation'
+import { Accordion, 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 {
+ categories?: Labrinth.Tags.v2.Category[]
rejectedStatuses: string[]
+ gameVersions: { version: string }[]
+ loaders: { name: string }[]
}
interface Props {
@@ -176,21 +210,135 @@ const emit = defineEmits<{
setProcessing: [processing: boolean]
}>()
+const isProcessing = computed(() => props.project.status === 'processing')
+
+const nagScroller = ref(null)
+const canScrollNags = ref(false)
+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) {
+ canScrollNags.value = false
+ showLeftNagShadow.value = false
+ showRightNagShadow.value = false
+ return
+ }
+
+ canScrollNags.value = el.scrollWidth > el.clientWidth + 1
+ showLeftNagShadow.value = canScrollNags.value && el.scrollLeft > 0
+ showRightNagShadow.value =
+ canScrollNags.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 ||
+ el.scrollWidth <= el.clientWidth + 1 ||
+ 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 nagContext = computed(() => ({
project: props.project,
projectV3: props.projectV3,
versions: props.versions,
- currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
+ currentMember: props.currentMember?.user,
currentRoute: props.routeName,
tags: props.tags,
- submitProject: submitForReview,
}))
const canSubmitForReview = computed(() => {
- return (
- applicableNags.value.filter((nag) => nag.status === 'required' && !isNagComplete(nag))
- .length === 0
- )
+ return validateProject(nagContext.value).valid
})
async function submitForReview() {
@@ -200,7 +348,7 @@ async function submitForReview() {
}
const applicableNags = computed(() => {
- return nags.filter((nag) => {
+ return getNags(nagContext.value).filter((nag) => {
return nag.shouldShow(nagContext.value)
})
})
@@ -211,7 +359,11 @@ function isNagComplete(nag: Nag): boolean {
}
const visibleNags = computed(() => {
- const finalNags = applicableNags.value.filter((nag) => !isNagComplete(nag))
+ const finalNags = applicableNags.value.filter(
+ (nag) =>
+ !isNagComplete(nag) &&
+ (!isProcessing.value || nag.status === 'required' || nag.status === 'warning'),
+ )
if (props.project.status === 'draft') {
finalNags.push({
@@ -232,9 +384,8 @@ const visibleNags = computed(() => {
status: 'special-submit-action',
shouldShow: (ctx) => ctx.tags.rejectedStatuses.includes(ctx.project.status),
link: {
- path: 'moderation',
+ ...nagDestinations.moderation,
title: messages.visitModerationPage,
- shouldShow: () => props.routeName !== 'type-project-moderation',
},
})
}
@@ -247,8 +398,10 @@ const visibleNags = computed(() => {
return finalNags
})
+watch(visibleNags, () => nextTick(updateNagScrollShadows))
+
function shouldShowLink(nag: Nag): boolean {
- return nag.link?.shouldShow ? nag.link.shouldShow(nagContext.value) : false
+ return nag.link?.shouldShow(nagContext.value) ?? false
}
function getDefaultIcon(status: NagStatus): Component {
@@ -295,7 +448,18 @@ function getFormattedMessage(message: string | MessageDescriptor): string {
diff --git a/apps/frontend/src/composables/project-field-validation.ts b/apps/frontend/src/composables/project-field-validation.ts
new file mode 100644
index 0000000000..b99eaedcd9
--- /dev/null
+++ b/apps/frontend/src/composables/project-field-validation.ts
@@ -0,0 +1,148 @@
+import {
+ extractProjectLinks,
+ type FieldValidationMessage,
+ findBlockedProjectContentLink,
+ type LinkCheckContext,
+ type LinkCheckResult,
+ validateLink,
+ validateProjectDescription,
+ validateProjectNameField,
+ validateProjectSummary,
+} from '@modrinth/moderation'
+import { defineMessages } from '@modrinth/ui'
+import { computed, type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
+
+export const projectTextValidationMessages = defineMessages({
+ resolveIssuesToSave: {
+ id: 'project.text-validation.resolve-issues-to-save',
+ defaultMessage: 'Resolve the issues from your edits to save.',
+ },
+})
+
+export function useProjectTitleValidation(text: MaybeRefOrGetter) {
+ return computed(() => validateProjectNameField(toValue(text) ?? ''))
+}
+
+export function useProjectSummaryValidation(
+ summary: MaybeRefOrGetter,
+ title: MaybeRefOrGetter,
+) {
+ return computed(() =>
+ validateProjectSummary({
+ summary: toValue(summary),
+ name: toValue(title),
+ }),
+ )
+}
+
+export function useLinkValidation(context: MaybeRefOrGetter) {
+ const result = ref(null)
+ const pending = ref(false)
+ let debounceTimer: ReturnType | undefined
+ let requestId = 0
+
+ watch(
+ () => toValue(context),
+ (value) => {
+ clearTimeout(debounceTimer)
+ const currentRequestId = ++requestId
+ result.value = null
+
+ if (import.meta.server || !value.url) {
+ pending.value = false
+ return
+ }
+
+ pending.value = true
+ debounceTimer = setTimeout(async () => {
+ try {
+ const validation = await validateLink(value)
+ if (currentRequestId === requestId) result.value = validation ?? null
+ } catch {
+ if (currentRequestId === requestId) result.value = null
+ } finally {
+ if (currentRequestId === requestId) pending.value = false
+ }
+ }, 500)
+ },
+ { deep: true, immediate: true },
+ )
+
+ onScopeDispose(() => {
+ clearTimeout(debounceTimer)
+ requestId++
+ })
+
+ return { pending, result }
+}
+
+export function useProjectDescriptionValidation(
+ description: MaybeRefOrGetter,
+) {
+ const linkValidation = ref(null)
+ const pending = ref(false)
+ let debounceTimer: ReturnType | undefined
+ let requestId = 0
+
+ watch(
+ () => toValue(description),
+ (text) => {
+ clearTimeout(debounceTimer)
+ const currentRequestId = ++requestId
+ linkValidation.value = null
+
+ if (import.meta.server) return
+ if (findBlockedProjectContentLink(text ?? '')) {
+ pending.value = false
+ return
+ }
+
+ const links = extractProjectLinks(text ?? '')
+ if (links.length === 0) {
+ pending.value = false
+ return
+ }
+
+ pending.value = true
+ debounceTimer = setTimeout(async () => {
+ const contexts: LinkCheckContext[] = links.map((url) => ({
+ field: 'description',
+ generalContent: true,
+ url,
+ }))
+
+ try {
+ const checks = (
+ await Promise.all(contexts.map((context) => validateLink(context)))
+ ).filter((check): check is LinkCheckResult => check !== undefined)
+ if (currentRequestId !== requestId) return
+
+ linkValidation.value =
+ checks.find((check) => check.severity === 'error') ??
+ checks.find((check) => check.severity === 'warn') ??
+ null
+ } catch {
+ if (currentRequestId === requestId) linkValidation.value = null
+ } finally {
+ if (currentRequestId === requestId) pending.value = false
+ }
+ }, 500)
+ },
+ { immediate: true },
+ )
+
+ onScopeDispose(() => {
+ clearTimeout(debounceTimer)
+ requestId++
+ })
+
+ const validation = computed>(() => [
+ ...validateProjectDescription(toValue(description)),
+ ...(linkValidation.value ? [linkValidation.value] : []),
+ ])
+
+ return {
+ pending,
+ validation,
+ }
+}
diff --git a/apps/frontend/src/composables/project-slug-suggestions.ts b/apps/frontend/src/composables/project-slug-suggestions.ts
new file mode 100644
index 0000000000..73fb0bd5c5
--- /dev/null
+++ b/apps/frontend/src/composables/project-slug-suggestions.ts
@@ -0,0 +1,97 @@
+import { ModrinthApiError } from '@modrinth/api-client'
+import { injectModrinthClient } from '@modrinth/ui'
+import { useQueryClient } from '@tanstack/vue-query'
+import { type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
+
+import { generateProjectSlugSuggestions } from '~/utils/slugs'
+
+const STALE_TIME = 1000 * 60 * 5
+const CHECK_DEBOUNCE = 300
+
+interface ProjectSlugSuggestionOptions {
+ title: MaybeRefOrGetter
+ username?: MaybeRefOrGetter
+ currentProjectId?: MaybeRefOrGetter
+}
+
+export function useSlugSuggestionVisibility() {
+ const visible = ref(false)
+
+ function onFocusIn() {
+ visible.value = true
+ }
+
+ function onFocusOut(event: FocusEvent) {
+ const container = event.currentTarget as HTMLElement
+ if (!container.contains(event.relatedTarget as Node | null)) visible.value = false
+ }
+
+ return {
+ onFocusIn,
+ onFocusOut,
+ visible,
+ }
+}
+
+export function useProjectSlugSuggestions({
+ title,
+ username,
+ currentProjectId,
+}: ProjectSlugSuggestionOptions) {
+ const client = injectModrinthClient()
+ const queryClient = useQueryClient()
+ const suggestions = ref([])
+ const checking = ref(false)
+ let debounceTimer: ReturnType | undefined
+ let requestId = 0
+
+ async function isAvailable(slug: string, projectId?: string | null) {
+ try {
+ const result = await queryClient.fetchQuery({
+ queryKey: ['project', 'check', slug],
+ queryFn: () => client.labrinth.projects_v2.check(slug),
+ staleTime: STALE_TIME,
+ retry: false,
+ })
+ return result.id === projectId
+ } catch (error) {
+ return error instanceof ModrinthApiError && error.statusCode === 404
+ }
+ }
+
+ watch(
+ () => [toValue(title), toValue(username), toValue(currentProjectId)] as const,
+ ([newTitle, newUsername, projectId]) => {
+ if (import.meta.server) return
+
+ clearTimeout(debounceTimer)
+ const currentRequestId = ++requestId
+ const candidates = generateProjectSlugSuggestions(newTitle, newUsername)
+ suggestions.value = []
+
+ if (candidates.length === 0) {
+ checking.value = false
+ return
+ }
+
+ checking.value = true
+ debounceTimer = setTimeout(async () => {
+ const availability = await Promise.all(
+ candidates.map((candidate) => isAvailable(candidate, projectId)),
+ )
+ if (currentRequestId !== requestId) return
+
+ suggestions.value = candidates.filter((_, index) => availability[index])
+ checking.value = false
+ }, CHECK_DEBOUNCE)
+ },
+ { immediate: true },
+ )
+
+ onScopeDispose(() => clearTimeout(debounceTimer))
+
+ return {
+ checking,
+ suggestions,
+ }
+}
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 38ef534d84..bb8c554005 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -4037,6 +4037,12 @@
"project.settings.general.url.title": {
"message": "URL"
},
+ "project.settings.links.donation.duplicate-type": {
+ "message": "You already have another {platform} link."
+ },
+ "project.settings.links.donation.no-type": {
+ "message": "Please select a platform for this Donation link."
+ },
"project.settings.monetization.description": {
"message": "Projects on Modrinth are automatically enrolled in the Rewards Program. If you don't want to (or can't for legal reasons) earn revenue from this project, you can turn it off here."
},
@@ -4241,6 +4247,12 @@
"project.settings.tags.upload-version-first.heading": {
"message": "Upload versions before adding tags"
},
+ "project.slug-suggestions.label": {
+ "message": "Suggestions:"
+ },
+ "project.text-validation.resolve-issues-to-save": {
+ "message": "Resolve the issues from your edits to save."
+ },
"project.versions.copy-id-option": {
"message": "Copy ID"
},
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 d6226dba1f..34f6d0e1b6 100644
--- a/apps/frontend/src/pages/[type]/[project]/gallery.vue
+++ b/apps/frontend/src/pages/[type]/[project]/gallery.vue
@@ -44,6 +44,7 @@
:maxlength="64"
placeholder="Enter title..."
/>
+
@@ -53,6 +54,7 @@
:maxlength="255"
placeholder="Enter description..."
/>
+
@@ -90,7 +92,7 @@
v-if="editIndex === -1"
type="colored"
color="brand"
- :disabled="shouldPreventActions"
+ :disabled="shouldPreventActions || !canSaveGalleryFields"
@click="createGalleryItem"
>
@@ -100,7 +102,7 @@
v-else
type="colored"
color="brand"
- :disabled="shouldPreventActions"
+ :disabled="shouldPreventActions || !canSaveGalleryFields"
@click="editGalleryItem"
>
@@ -296,6 +298,7 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
+import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
import {
Button,
ButtonLink,
@@ -309,9 +312,11 @@ import {
Textarea,
useFormatDateTime,
} from '@modrinth/ui'
+import { isAdmin } from '@modrinth/utils'
import { useEventListener } from '@vueuse/core'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
+import ValidationMessage from '~/components/ValidationMessage.vue'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
@@ -379,6 +384,17 @@ const previewImage = ref(null)
// UI state
const shouldPreventActions = ref(false)
+const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
+const galleryDescriptionValidation = computed(() =>
+ validateProjectGalleryDescription(editDescription.value),
+)
+const galleryFieldsInvalid = computed(
+ () =>
+ galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
+ galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
+)
+const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
+const canSaveGalleryFields = computed(() => isAdminUser.value || !galleryFieldsInvalid.value)
// Constant for accepted file types
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
@@ -479,6 +495,7 @@ function showPreviewImage() {
// CRUD operations
async function createGalleryItem() {
+ if (!canSaveGalleryFields.value) return
shouldPreventActions.value = true
startLoading()
@@ -499,6 +516,7 @@ async function createGalleryItem() {
}
async function editGalleryItem() {
+ if (!canSaveGalleryFields.value) return
shouldPreventActions.value = true
startLoading()
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 }}
-
-
+
@@ -41,22 +39,25 @@