diff --git a/Cargo.lock b/Cargo.lock index b38f70ef16..2aedb0cf90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1595,6 +1595,18 @@ dependencies = [ "piper", ] +[[package]] +name = "blocklist" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e82d7baf42162b8cbb0040770d863ba1be61408a58d6273ed5238e49e8debb5" +dependencies = [ + "fst", + "once_cell", + "reqwest 0.13.2", + "tokio", +] + [[package]] name = "bollard" version = "0.19.4" @@ -5683,6 +5695,7 @@ dependencies = [ "aws-sdk-s3", "base64 0.22.1", "bitflags 2.9.4", + "blocklist", "bytes", "cel", "censor", @@ -9092,6 +9105,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.3", "lru-slab", @@ -9627,6 +9641,7 @@ dependencies = [ "log", "percent-encoding", "pin-project-lite", + "quinn", "rustls 0.23.32", "rustls-pki-types", "rustls-platform-verifier", @@ -12767,13 +12782,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.9.4", "bytes", + "futures-core", "futures-util", "http 1.3.1", "http-body 1.0.1", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index d4571ca68e..65cc11a5cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = [ ] } base64 = "0.22.1" bitflags = "2.9.4" +blocklist = { version = "1.0.0", default-features = false } bon = "3.9.3" bytemuck = "1.24.0" bytes = "1.10.1" diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts index c44b2bf657..a94069cb6d 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.stories.ts @@ -3,6 +3,7 @@ import { type Nag, nagDefinitions, toProjectNag } from '@modrinth/moderation' import type { Meta, StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' +import { DEFAULT_FEATURE_FLAGS } from '../../../composables/featureFlags' import ModerationProjectNags from './ModerationProjectNags.vue' const categories = [ @@ -92,7 +93,6 @@ const tags = { const previewValues = { count: 3, - domain: 'example.com', fullUrl: 'https://example.com/prohibited-link', languageCount: 12, length: 12, @@ -103,7 +103,6 @@ const previewValues = { tagCount: 9, tags: '16x|32x', totalAvailableTags: 20, - type: 'mod', url: 'https://example.com/prohibited-link', value: 'example', } @@ -120,47 +119,130 @@ const suggestionKinds = new Set([ const warningKinds = new Set([ 'missing-alt-text', - 'verify-external-links', 'too-many-languages', 'too-many-tags', 'multiple-resolution-tags', 'moderator-feedback', ]) -const previewNags = Object.keys(nagDefinitions).map((kind) => { +function createValidationNag( + kind: Labrinth.Projects.v3.NormalizedProjectNagKind, + details: Labrinth.Projects.v3.ProjectNag['details'] = {}, +): Labrinth.Projects.v3.ProjectNag { + return { + kind: kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind, + severity: suggestionKinds.has(kind) + ? 'suggestion' + : warningKinds.has(kind) + ? 'warning' + : 'required', + details: { ...previewValues, ...details }, + } +} + +interface NagPreviewVariant { + details?: Labrinth.Projects.v3.ProjectNag['details'] + projectType?: string +} + +const linkFields = [ + 'issues', + 'source', + 'wiki', + 'discord', + 'site', + 'store', + 'license', + 'description', + 'patreon', + 'bmac', + 'paypal', + 'github', + 'ko-fi', + 'other', +] + +const fieldLinkReasons = [ + 'global_blocklist_match', + 'external_blocklist_match', + 'wrong_field', + 'ip_address', + 'malformed', + 'not_in_allowlist', + 'duplicate', + 'unverifiable', +] + +const nagVariants: Partial< + Record +> = { + 'link-validation': [ + {}, + ...fieldLinkReasons.flatMap((reason) => + linkFields.map((field, index) => ({ + details: { + reason, + field, + other_field: linkFields[(index + 1) % linkFields.length], + }, + })), + ), + { details: { reason: 'download', field: 'description' } }, + { details: { reason: 'discord_invite', field: 'discord' } }, + { details: { reason: 'source_repository', field: 'source' } }, + ...['issues', 'wiki', 'source'].map((field) => ({ + details: { reason: 'repository_feature', field }, + })), + ], + 'invalid-license-url': [ + {}, + { details: { domain: 'example.com' } }, + { details: { reason: 'malformed' } }, + ], + 'upload-gallery-image': [{}, { projectType: 'resourcepack' }, { projectType: 'shader' }], + 'long-headers': [{}, { details: { count: 1 } }], + 'all-tags-selected': [{}, { details: { totalAvailableTags: 1 } }], + 'multiple-resolution-tags': [{}, { details: { count: 1, tags: ['16x'] } }], + 'too-many-tags': [{}, { details: { tagCount: 1 } }], + 'too-many-tags-server': [{}, { details: { tagCount: 1 } }], + 'too-many-languages': [{}, { details: { languageCount: 1 } }], +} + +const everyNag: Nag[] = Object.keys(nagDefinitions).flatMap((kind) => { const normalizedKind = kind as Labrinth.Projects.v3.NormalizedProjectNagKind - const projectNagKind = kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind - const severity: Labrinth.Projects.v3.ProjectNagSeverity = suggestionKinds.has(normalizedKind) - ? 'suggestion' - : warningKinds.has(normalizedKind) - ? 'warning' - : 'required' - return toProjectNag( - { kind: projectNagKind, severity, details: previewValues }, - previewValues.projectType, - ) + return (nagVariants[normalizedKind] ?? [{}]).map((variant, index) => { + const nag = toProjectNag( + createValidationNag(normalizedKind, variant.details), + variant.projectType ?? previewValues.projectType, + ) + return { ...nag, id: `${nag.id}:preview:${index}` } + }) }) -const everyNag: Nag[] = [ - ...previewNags, - { - id: 'resubmit-for-review-preview', - title: 'Resubmit for review', - description: () => - "Your project has been rejected by Modrinth's staff. Address the moderation team's feedback before resubmitting.", - status: 'special-submit-action', - shouldShow: () => true, - link: { - path: 'moderation', - title: 'Visit moderation page', - shouldShow: () => true, - }, - }, -] +const draftNags = [ + 'add-icon', + 'add-description', + 'upload-version', + 'select-environment', + 'add-links', + 'too-many-tags', + 'check-disclosures', +] satisfies Labrinth.Projects.v3.NormalizedProjectNagKind[] const meta = { title: 'Website/Moderation/PublishingChecklist', component: ModerationProjectNags, + beforeEach: () => { + const previousFlags = Object.getOwnPropertyDescriptor(globalThis, 'useFeatureFlags') + Object.defineProperty(globalThis, 'useFeatureFlags', { + configurable: true, + value: () => ref({ ...DEFAULT_FEATURE_FLAGS }), + }) + return () => { + if (previousFlags) Object.defineProperty(globalThis, 'useFeatureFlags', previousFlags) + else Reflect.deleteProperty(globalThis, 'useFeatureFlags') + } + }, decorators: [ (story) => ({ components: { story }, @@ -199,6 +281,9 @@ export default meta type Story = StoryObj export const EntirePublishingChecklist: Story = { + args: { + validationNags: draftNags.map((kind) => createValidationNag(kind)), + }, parameters: { docs: { description: { @@ -211,13 +296,30 @@ export const EntirePublishingChecklist: Story = { export const EveryNag: Story = { args: { nags: everyNag, + validationNags: draftNags.map((kind) => createValidationNag(kind)), }, parameters: { docs: { description: { story: - 'Every publishing-checklist validation nag plus the submit and resubmit actions, including combinations that cannot normally appear together.', + 'Every registered nag and its message variants, including link reasons and fields, license errors, gallery project types, and singular/plural copy.', }, }, }, } + +export const RejectedProject: Story = { + args: { + project: createProject('rejected'), + projectV3: createProjectV3('rejected'), + validationNags: [createValidationNag('moderator-feedback')], + }, +} + +export const WithheldProject: Story = { + args: { + project: createProject('withheld'), + projectV3: createProjectV3('withheld'), + validationNags: [createValidationNag('moderator-feedback')], + }, +} diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue index b73a754029..1e36ea8198 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue @@ -74,7 +74,7 @@ ]" :aria-label="getStatusTooltip(nag.status)" /> - {{ getFormattedMessage(nag.title) }} + {{ getFormattedMessage(nag.title, nag.values) }} (() => { if (props.nags) return props.nags - const nagsByKind = new Map< - Labrinth.Projects.v3.NormalizedProjectNagKind, - Labrinth.Projects.v3.ProjectNag - >() + const nagsById = new Map() for (const nag of props.validationNags) { const kind = normalizeProjectNagKind(nag.kind) - if (kind && !nagsByKind.has(kind)) nagsByKind.set(kind, nag) + if (!kind) continue + const mapped = toProjectNag(nag, props.project.project_type) + if (!nagsById.has(mapped.id)) nagsById.set(mapped.id, mapped) } - return [...nagsByKind.values()].map((nag) => toProjectNag(nag, props.project.project_type)) + return [...nagsById.values()] }) function isNagComplete(nag: Nag): boolean { @@ -491,7 +490,7 @@ watch( const actionableNagKeys = new Set( validationNags .filter((nag) => nag.severity === 'required' || nag.severity === 'warning') - .map((nag) => `${nag.severity}:${nag.kind}`), + .map((nag) => `${nag.severity}:${nag.kind}:${JSON.stringify(nag.details)}`), ) const previousNagKeys = previousActionableNagKeys const hasNewActionableNag = @@ -552,11 +551,14 @@ function getNagDescriptionSegments(nag: Nag): { text: string; isUrl: boolean }[] .map((text) => ({ text, isUrl: /^https?:\/\//i.test(text) })) } -function getFormattedMessage(message: string | MessageDescriptor): string { +function getFormattedMessage( + message: string | MessageDescriptor, + values?: Nag['values'], +): string { if (typeof message === 'string') { return message } - return formatMessage(message) + return formatMessage(message, values) } diff --git a/apps/frontend/src/composables/project-nag-validation.ts b/apps/frontend/src/composables/project-nag-validation.ts index 2bf81e6edf..171f834683 100644 --- a/apps/frontend/src/composables/project-nag-validation.ts +++ b/apps/frontend/src/composables/project-nag-validation.ts @@ -14,8 +14,7 @@ export type ProjectSettingsField = | 'custom-license' | 'license-url' | 'external-links' - | 'source-issues-discord-links' - | 'non-discord-link-fields' + | 'link-field' | 'source-availability' | 'permissions' | 'server-region' @@ -50,14 +49,15 @@ export const projectNagFields = { ], icon: ['add-icon'], description: [ + 'link-validation', 'project-description-slur', 'project-description-profanity', 'project-description-non-standard-text', 'project-description-non-english', + 'project-description-matches-summary', 'add-description', 'description-too-short', 'project-description-spam', - 'project-description-banned-link', 'long-headers', 'description-ends-with-header', 'adjacent-headers', @@ -67,10 +67,9 @@ export const projectNagFields = { 'gallery-images': ['upload-gallery-image', 'feature-gallery-image'], license: ['select-license'], 'custom-license': ['add-custom-license-details'], - 'license-url': ['invalid-license-url'], - 'external-links': ['add-links', 'add-links-server', 'identical-links', 'banned-link-usage'], - 'source-issues-discord-links': ['verify-external-links'], - 'non-discord-link-fields': ['misused-discord-link'], + 'license-url': ['invalid-license-url', 'link-validation'], + 'link-field': ['link-validation'], + 'external-links': ['add-links', 'add-links-server'], 'source-availability': ['gpl-license-source-required'], permissions: ['review-permissions'], 'server-region': ['select-country'], diff --git a/apps/frontend/src/composables/project-save-validation.ts b/apps/frontend/src/composables/project-save-validation.ts new file mode 100644 index 0000000000..66ecca9716 --- /dev/null +++ b/apps/frontend/src/composables/project-save-validation.ts @@ -0,0 +1,92 @@ +import type { Labrinth } from '@modrinth/api-client' +import { normalizeProjectNagKind, toProjectFieldMessage } from '@modrinth/moderation' +import { injectProjectPageContext } from '@modrinth/ui' +import { computed, ref } from 'vue' + +import { projectNagFields, type ProjectSettingsField } from './project-nag-validation' + +function matchesField(nag: Labrinth.Projects.v3.ProjectNag, field: string, detailField = field) { + const kinds: readonly string[] | undefined = Object.hasOwn(projectNagFields, field) + ? projectNagFields[field as ProjectSettingsField] + : undefined + const kind = normalizeProjectNagKind(nag.kind) + if (kinds && (!kind || !kinds.includes(kind))) return false + if (typeof nag.details.field === 'string') return nag.details.field === detailField + if (Array.isArray(nag.details.fields)) return nag.details.fields.includes(detailField) + return kinds !== undefined +} + +/** Keeps rejected-save messages attached to the exact values that were submitted. */ +export function useProjectSaveValidation(state: () => unknown) { + const { projectV2 } = injectProjectPageContext() + const rejected = ref([]) + const rejectedState = ref('') + const showMessages = computed( + () => + projectV2.value.status === 'processing' && rejectedState.value === JSON.stringify(state()), + ) + const messages = computed(() => + showMessages.value ? rejected.value.map((nag) => toProjectFieldMessage(nag)) : [], + ) + + const hasErrors = computed(() => messages.value.some((message) => message.severity === 'error')) + + function snapshot() { + return JSON.stringify(state()) ?? '' + } + + function capture(error: unknown, submittedState: string): boolean { + if (projectV2.value.status !== 'processing') return false + let value = error + for (let depth = 0; depth < 5; depth++) { + if (typeof value !== 'object' || value === null) return false + const data = value as Record + const details = data.details + if (typeof details === 'object' && details !== null && 'nags' in details) { + const nags = details.nags + if (!Array.isArray(nags)) return false + const recognized = nags.filter( + (nag): nag is Labrinth.Projects.v3.ProjectNag => + typeof nag === 'object' && + nag !== null && + typeof nag.kind === 'string' && + normalizeProjectNagKind(nag.kind) !== null && + ['required', 'warning', 'suggestion'].includes(nag.severity) && + typeof nag.details === 'object' && + nag.details !== null, + ) + rejected.value = recognized.filter((nag) => nag.severity !== 'suggestion') + rejectedState.value = submittedState + return recognized.length > 0 + } + value = data.responseData ?? data.data ?? data.originalError ?? data.cause + } + return false + } + + function forField(field: string, detailField = field) { + if (!showMessages.value) return [] + return rejected.value + .filter((nag) => matchesField(nag, field, detailField)) + .map((nag) => toProjectFieldMessage(nag)) + } + + function withoutFields(fields: (string | [field: string, detailField: string])[]) { + if (!showMessages.value) return [] + return rejected.value + .filter( + (nag) => + !fields.some((field) => + Array.isArray(field) ? matchesField(nag, ...field) : matchesField(nag, field), + ), + ) + .map((nag) => toProjectFieldMessage(nag)) + } + + function clear() { + rejected.value = [] + rejectedState.value = '' + } + + return { capture, clear, messages, hasErrors, forField, withoutFields, snapshot } +} diff --git a/apps/frontend/src/helpers/donation-links.ts b/apps/frontend/src/helpers/donation-links.ts new file mode 100644 index 0000000000..15788a5cb7 --- /dev/null +++ b/apps/frontend/src/helpers/donation-links.ts @@ -0,0 +1,55 @@ +export const donationUsernamePrefixes: Record = { + patreon: 'https://www.patreon.com/', + bmac: 'https://buymeacoffee.com/', + paypal: 'https://www.paypal.me/', + github: 'https://github.com/sponsors/', + 'ko-fi': 'https://ko-fi.com/', +} + +export interface DonationInput { + id?: string + url: string + input: string + mode: 'username' | 'url' +} + +export function donationUsernameFromUrl( + platform: string | undefined, + raw: string, +): string | undefined { + const prefix = platform ? donationUsernamePrefixes[platform] : undefined + if (!prefix || !raw.startsWith(prefix)) return undefined + const username = raw.slice(prefix.length) + if (!username || /[/?#\s]/.test(username)) return undefined + try { + return decodeURIComponent(username) + } catch { + return undefined + } +} + +export function donationInput(id?: string, url = ''): DonationInput { + const username = donationUsernameFromUrl(id, url) + return { + id, + url, + input: username ?? url, + mode: + username !== undefined || (!url && id && donationUsernamePrefixes[id]) ? 'username' : 'url', + } +} + +export function setDonationInput(row: DonationInput, value: string | number, detectUrl = true) { + row.input = String(value) + if (detectUrl && /^https?:\/\//i.test(row.input.trim())) row.mode = 'url' + const prefix = row.id ? donationUsernamePrefixes[row.id] : undefined + row.url = + row.mode === 'username' && prefix && row.input + ? prefix + encodeURIComponent(row.input) + : row.input +} + +export function toggleDonationInput(row: DonationInput) { + row.mode = row.mode === 'username' ? 'url' : 'username' + setDonationInput(row, row.input, false) +} diff --git a/apps/frontend/src/helpers/project-url.ts b/apps/frontend/src/helpers/project-url.ts new file mode 100644 index 0000000000..c4d87e6a8c --- /dev/null +++ b/apps/frontend/src/helpers/project-url.ts @@ -0,0 +1,5 @@ +export function normalizeProjectUrl(value: string): string { + const url = value.trim() + if (!url || /^[a-z][a-z\d+.-]*:/i.test(url)) return url + return `https://${url}` +} diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 1c13110e4d..a440d189fd 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -3887,6 +3887,21 @@ "project.settings.delete-project.title": { "message": "Delete project" }, + "project.settings.description.failed": { + "message": "Failed to update description" + }, + "project.settings.description.intro": { + "message": "You can type an extended description of your project here. The description must clearly and honestly describe the purpose and function of the project. See section 2.1 of the Content Rules for the full requirements." + }, + "project.settings.description.title": { + "message": "Description" + }, + "project.settings.description.updated": { + "message": "Description updated" + }, + "project.settings.description.updated-text": { + "message": "Your description has been updated." + }, "project.settings.disclosures.advertising.description.1": { "message": "You must enable this if your project contains advertisements, sponsorships, or promotions of other works." }, @@ -4097,11 +4112,179 @@ "project.settings.general.url.title": { "message": "URL" }, - "project.settings.links.donation.duplicate-type": { - "message": "You already have another {platform} link." + "project.settings.license.all-rights": { + "message": "All Rights Reserved/No License" + }, + "project.settings.license.allow-later": { + "message": "Allow later editions" + }, + "project.settings.license.custom": { + "message": "Custom" + }, + "project.settings.license.custom-url-description": { + "message": "The web location of the full license text. You have to provide a link since this is a custom license." + }, + "project.settings.license.failed": { + "message": "Failed to update license" + }, + "project.settings.license.has-spdx": { + "message": "Use SPDX identifier" + }, + "project.settings.license.intro": { + "message": "It is important to choose a proper license for your {type}. You may choose one from our list or provide a custom license. You may also provide a custom URL to your chosen license; otherwise, the license text will be displayed. See our licensing guide for more information." + }, + "project.settings.license.later": { + "message": "Later editions" + }, + "project.settings.license.later-description": { + "message": "The license you selected has an \"or later\" clause. If you check this box, users may use your project under later editions of the license." + }, + "project.settings.license.missing-name": { + "message": "Enter a name or SPDX identifier for your custom license." + }, + "project.settings.license.missing-url": { + "message": "Enter a URL to the full text of your custom license." + }, + "project.settings.license.name": { + "message": "License name" + }, + "project.settings.license.name-description": { + "message": "The full name of the license. If the license has a SPDX identifier, please check the checkbox and use the identifier instead." + }, + "project.settings.license.name-placeholder": { + "message": "License name" + }, + "project.settings.license.optional-url": { + "message": "License URL (optional)" + }, + "project.settings.license.select": { + "message": "Select a license" + }, + "project.settings.license.select-description": { + "message": "How users are and aren't allowed to use your project." + }, + "project.settings.license.select-placeholder": { + "message": "Select license..." + }, + "project.settings.license.spdx": { + "message": "SPDX identifier" + }, + "project.settings.license.spdx-description": { + "message": "If your license does not have an official SPDX license identifier, uncheck the box and enter the name of the license instead." + }, + "project.settings.license.spdx-placeholder": { + "message": "SPDX identifier" + }, + "project.settings.license.title": { + "message": "License" + }, + "project.settings.license.updated": { + "message": "License updated" + }, + "project.settings.license.updated-text": { + "message": "Your license has been updated." + }, + "project.settings.license.url": { + "message": "License URL" + }, + "project.settings.license.url-description": { + "message": "The web location of the full license text. If you don't provide a link, the license text will be displayed instead." + }, + "project.settings.links.add-link": { + "message": "Add link" + }, + "project.settings.links.discord": { + "message": "Discord invite" + }, + "project.settings.links.discord-description": { + "message": "An invitation link to your Discord server." + }, + "project.settings.links.donation-link": { + "message": "Link" + }, + "project.settings.links.donation-platform": { + "message": "Platform" + }, + "project.settings.links.donation-url": { + "message": "URL" + }, + "project.settings.links.donation-username": { + "message": "Username" + }, + "project.settings.links.donation-username-placeholder": { + "message": "Enter your {platform} username" + }, + "project.settings.links.donations": { + "message": "Donation links" + }, + "project.settings.links.donations-description": { + "message": "Add donation links for users to support you directly." + }, + "project.settings.links.failed": { + "message": "Failed to update links" + }, + "project.settings.links.issues": { + "message": "Issue tracker" + }, + "project.settings.links.issues-description": { + "message": "A place for users to report bugs, issues, and concerns about your project." + }, + "project.settings.links.link-type": { + "message": "Link type" + }, + "project.settings.links.no-donation-links": { + "message": "No donation links added" + }, + "project.settings.links.remove-donation-link": { + "message": "Remove donation link" + }, + "project.settings.links.server-discord": { + "message": "Discord" + }, + "project.settings.links.server-updated": { + "message": "Your server links have been updated." + }, + "project.settings.links.server-wiki-description": { + "message": "A page containing information, documentation, and help for the server." + }, + "project.settings.links.site": { + "message": "Website" + }, + "project.settings.links.site-description": { + "message": "Your server's official website." + }, + "project.settings.links.source": { + "message": "Source code" + }, + "project.settings.links.source-description": { + "message": "A page/repository containing the source code for your project" + }, + "project.settings.links.store": { + "message": "Store" + }, + "project.settings.links.store-description": { + "message": "A link to your server's store or shop." + }, + "project.settings.links.title": { + "message": "Links" + }, + "project.settings.links.updated": { + "message": "Your links have been updated." + }, + "project.settings.links.updated-title": { + "message": "Links updated" + }, + "project.settings.links.url-placeholder": { + "message": "Enter a valid URL" + }, + "project.settings.links.visit-link": { + "message": "Visit {url}" + }, + "project.settings.links.wiki": { + "message": "Wiki page" }, - "project.settings.links.donation.no-type": { - "message": "Please select a platform for this Donation link." + "project.settings.links.wiki-description": { + "message": "A page containing information, documentation, and help for the project." }, "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." diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue index 948e98b6f1..b3571cbd78 100644 --- a/apps/frontend/src/pages/[type]/[project].vue +++ b/apps/frontend/src/pages/[type]/[project].vue @@ -1394,15 +1394,17 @@ function addProjectMutationErrorNotification(error) { error?.responseData?.description ?? error?.data?.description ?? error?.message - const isProjectReviewValidationError = description === PROJECT_REVIEW_VALIDATION_ERROR + const response = error?.responseData ?? error?.data ?? error?.v1Error + const isProjectValidationError = + Array.isArray(response?.details?.nags) || description === PROJECT_REVIEW_VALIDATION_ERROR addNotification({ title: formatMessage( - isProjectReviewValidationError + isProjectValidationError && project.value.status === 'processing' ? messages.projectReviewSaveFailed : commonMessages.errorNotificationTitle, ), - text: isProjectReviewValidationError + text: isProjectValidationError ? formatMessage(messages.projectReviewSaveFailedDescription) : description, type: 'error', @@ -1417,7 +1419,8 @@ const patchProjectMutation = useMutation({ return data }, - onMutate: async ({ projectId, data }) => { + onMutate: async ({ projectId, data, optimistic = true }) => { + if (!optimistic) return await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] }) @@ -1497,7 +1500,8 @@ const patchProjectV3Mutation = useMutation({ return data }, - onMutate: async ({ projectId, data }) => { + onMutate: async ({ projectId, data, optimistic = true }) => { + if (!optimistic) return await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] }) await queryClient.cancelQueries({ queryKey: ['project', 'v2', projectId] }) @@ -1524,8 +1528,8 @@ const patchProjectV3Mutation = useMutation({ addProjectMutationErrorNotification(err) }, - onSettled: () => { - void invalidateProject() + onSettled: async () => { + await invalidateProject() }, }) @@ -2176,12 +2180,12 @@ async function setProcessing() { ) } -async function patchProject(resData, quiet = false) { +async function patchProject(resData, quiet = false, throwOnError = false) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { patchProjectMutation.mutate( - { projectId: project.value.id, data: resData }, + { projectId: project.value.id, data: resData, optimistic: !throwOnError }, { onSuccess: async () => { if (!quiet) { @@ -2193,19 +2197,19 @@ async function patchProject(resData, quiet = false) { } resolve(true) }, - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) }) } -async function patchProjectV3(resData, quiet = false) { +async function patchProjectV3(resData, quiet = false, throwOnError = false) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { patchProjectV3Mutation.mutate( - { projectId: project.value.id, data: resData }, + { projectId: project.value.id, data: resData, optimistic: !throwOnError }, { onSuccess: async () => { if (!quiet) { @@ -2217,7 +2221,7 @@ async function patchProjectV3(resData, quiet = false) { } resolve(true) }, - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) @@ -2239,30 +2243,58 @@ async function patchIcon(icon) { }) } -async function createGalleryItem(file, title, description, featured, ordering) { +async function createGalleryItem( + file, + title, + description, + featured, + ordering, + throwOnError = false, +) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { createGalleryItemMutation.mutate( - { projectId: project.value.id, file, title, description, featured, ordering }, + { + projectId: project.value.id, + file, + title, + description, + featured, + ordering, + }, { onSuccess: () => resolve(true), - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) }) } -async function editGalleryItem(imageUrl, title, description, featured, ordering) { +async function editGalleryItem( + imageUrl, + title, + description, + featured, + ordering, + throwOnError = false, +) { startLoading() - return new Promise((resolve) => { + return new Promise((resolve, reject) => { editGalleryItemMutation.mutate( - { projectId: project.value.id, imageUrl, title, description, featured, ordering }, + { + projectId: project.value.id, + imageUrl, + title, + description, + featured, + ordering, + }, { onSuccess: () => resolve(true), - onError: () => resolve(false), + onError: (error) => (throwOnError ? reject(error) : resolve(false)), onSettled: () => stopLoading(), }, ) diff --git a/apps/frontend/src/pages/[type]/[project]/settings/description.vue b/apps/frontend/src/pages/[type]/[project]/settings/description.vue index 147d7c6ded..df0ecc9489 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/description.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/description.vue @@ -4,20 +4,20 @@
-

Description

+

{{ formatMessage(messages.title) }}

- You can type an extended description of your project here. - - The description must clearly and honestly describe the purpose and function of the - project. See section 2.1 of the - Content Rules - for the full requirements. - + + +
+
Content Rules for the full requirements.', + }, + updated: { id: 'project.settings.description.updated', defaultMessage: 'Description updated' }, + updatedText: { + id: 'project.settings.description.updated-text', + defaultMessage: 'Your description has been updated.', + }, + failed: { + id: 'project.settings.description.failed', + defaultMessage: 'Failed to update description', + }, +}) const aiImageWarningModal = useTemplateRef('aiImageWarningModal') useProjectSettingsHeadTitle(commonProjectSettingsMessages.description) @@ -72,7 +99,8 @@ const { } = useSavable( () => ({ description: project.value.body }), async ({ description }) => { - await patchProject({ body: description }) + await labrinth.projects_v3.edit(project.value.id, { description }) + await invalidate() }, ) @@ -86,12 +114,33 @@ const hasPermission = computed( (currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) === TeamMemberPermission.EDIT_BODY), ) -const descriptionValidation = useProjectNagMessages('description') -const canSave = computed(() => hasPermission.value) +const descriptionValidation = useProjectNagMessages('description', 'description') +const saveValidation = useProjectSaveValidation(() => current.value) +const canSave = computed( + () => + hasPermission.value && + !saveValidation.messages.value.some((message) => message.severity === 'error'), +) async function save() { - if (!canSave.value) return - await saveForm() + if (!canSave.value || saving.value) return + const submittedState = saveValidation.snapshot() + try { + await saveForm() + saveValidation.clear() + addNotification({ + title: formatMessage(messages.updated), + text: formatMessage(messages.updatedText), + type: 'success', + }) + } catch (error) { + saveValidation.capture(error, submittedState) + addNotification({ + title: formatMessage(messages.failed), + text: error instanceof Error ? error.message : String(error), + type: 'error', + }) + } } async function onUploadHandler(file: File) { diff --git a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue index 5494cbd306..291e31cae6 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/disclosures.vue @@ -41,6 +41,7 @@ import { import ValidationMessage from '~/components/ValidationMessage.vue' import { useAuth } from '~/composables/auth' import { useProjectNagMessages } from '~/composables/project-nag-validation' +import { useProjectSaveValidation } from '~/composables/project-save-validation' const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5 @@ -190,7 +191,7 @@ const { saved, current, saving, - reset, + reset: resetForm, save: saveForm, } = useSavable( () => disclosuresToForm(disclosuresResponse.value?.disclosures ?? []), @@ -216,10 +217,23 @@ const hasChanges = computed( () => JSON.stringify(savedSnapshot.value) !== JSON.stringify(currentSnapshot.value), ) +const saveValidation = useProjectSaveValidation(() => currentSnapshot.value) + async function save() { - if (!hasChanges.value) return - await saveForm() - await refreshProjectValidation() + if (!hasChanges.value || !canSave.value || saving.value) return + const submittedState = saveValidation.snapshot() + try { + await saveForm() + saveValidation.clear() + await refreshProjectValidation() + } catch (error) { + if (!saveValidation.capture(error, submittedState)) throw error + } +} + +function reset() { + resetForm() + saveValidation.clear() } function disclosureUpdateProps(type: DisclosureType) { @@ -263,7 +277,10 @@ const disclosureTextValidation = useProjectNagMessages('disclosure-text') const disclosureValidation = useProjectNagMessages('disclosures') const canSave = computed( - () => hasPermission.value && (isAdminUser.value || issues.value.length === 0), + () => + !saveValidation.hasErrors.value && + hasPermission.value && + (isAdminUser.value || issues.value.length === 0), ) const saveDisabledReason = computed(() => { @@ -372,6 +389,7 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges) " /> + -