From b98c1fe7b8ebc672db1a675b0e078b70e111d0c1 Mon Sep 17 00:00:00 2001 From: Calum Date: Fri, 11 Jul 2025 16:18:24 +0100 Subject: [PATCH] feat: set up typed nag (validators) system --- packages/moderation/data/nags.ts | 4 + packages/moderation/data/nags/core.ts | 128 ++++++++++++++++++++++++++ packages/moderation/index.ts | 2 + packages/moderation/types/nags.ts | 90 ++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 packages/moderation/data/nags.ts create mode 100644 packages/moderation/data/nags/core.ts create mode 100644 packages/moderation/types/nags.ts diff --git a/packages/moderation/data/nags.ts b/packages/moderation/data/nags.ts new file mode 100644 index 000000000..6d009ecc5 --- /dev/null +++ b/packages/moderation/data/nags.ts @@ -0,0 +1,4 @@ +import type { Nag } from '../types/nags' +import { coreNags } from './nags/core' + +export default [...coreNags] as Nag[] diff --git a/packages/moderation/data/nags/core.ts b/packages/moderation/data/nags/core.ts new file mode 100644 index 000000000..ad6d467ff --- /dev/null +++ b/packages/moderation/data/nags/core.ts @@ -0,0 +1,128 @@ +import type { Nag, NagContext } from '../../types/nags' +import { formatProjectType } from '@modrinth/utils' + +export const coreNags: Nag[] = [ + { + id: 'upload-version', + title: 'Upload a version', + description: () => 'At least one version is required for a project to be submitted for review.', + status: 'required', + shouldShow: (context: NagContext) => context.versions.length < 1, + link: { + path: 'versions', + title: 'Visit versions page', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-versions', + }, + }, + { + id: 'add-description', + title: 'Add a description', + description: () => + "A description that clearly describes the project's purpose and function is required.", + status: 'required', + shouldShow: (context: NagContext) => + context.project.body === '' || context.project.body.startsWith('# Placeholder description'), + link: { + path: 'settings/description', + title: 'Visit description settings', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-description', + }, + }, + { + id: 'add-icon', + title: 'Add an icon', + description: () => + 'Your project should have a nice-looking icon to uniquely identify your project at a glance.', + status: 'suggestion', + shouldShow: (context: NagContext) => !context.project.icon_url, + link: { + path: 'settings', + title: 'Visit general settings', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', + }, + }, + { + id: 'feature-gallery-image', + title: 'Feature a gallery image', + description: () => 'Featured gallery images may be the first impression of many users.', + status: 'suggestion', + shouldShow: (context: NagContext) => { + const featuredGalleryImage = context.project.gallery?.find((img) => img.featured) + return context.project?.gallery?.length === 0 || !featuredGalleryImage + }, + link: { + path: 'gallery', + title: 'Visit gallery page', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-gallery', + }, + }, + { + id: 'select-tags', + title: 'Select tags', + description: () => 'Select all tags that apply to your project.', + status: 'suggestion', + shouldShow: (context: NagContext) => + context.project.versions.length > 0 && context.project.categories.length < 1, + link: { + path: 'settings/tags', + title: 'Visit tag settings', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-tags', + }, + }, + { + id: 'add-links', + title: 'Add external links', + description: () => + 'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.', + status: 'suggestion', + shouldShow: (context: NagContext) => + !( + context.project.issues_url || + context.project.source_url || + context.project.wiki_url || + context.project.discord_url || + context.project.donation_urls.length > 0 + ), + link: { + path: 'settings/links', + title: 'Visit links settings', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links', + }, + }, + { + id: 'select-environments', + title: 'Select supported environments', + description: (context: NagContext) => + `Select if the ${formatProjectType(context.project.project_type).toLowerCase()} functions on the client-side and/or server-side.`, + status: 'required', + shouldShow: (context: NagContext) => { + const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack'] + return ( + context.project.versions.length > 0 && + !excludedTypes.includes(context.project.project_type) && + (context.project.client_side === 'unknown' || + context.project.server_side === 'unknown' || + (context.project.client_side === 'unsupported' && + context.project.server_side === 'unsupported')) + ) + }, + link: { + path: 'settings', + title: 'Visit general settings', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings', + }, + }, + { + id: 'select-license', + title: 'Select license', + description: (context: NagContext) => + `Select the license your ${formatProjectType(context.project.project_type).toLowerCase()} is distributed under.`, + status: 'required', + shouldShow: (context: NagContext) => context.project.license.id === 'LicenseRef-Unknown', + link: { + path: 'settings/license', + title: 'Visit license settings', + shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-license', + }, + }, +] diff --git a/packages/moderation/index.ts b/packages/moderation/index.ts index b0a6afea9..b8f8f1d8c 100644 --- a/packages/moderation/index.ts +++ b/packages/moderation/index.ts @@ -2,7 +2,9 @@ export * from './types/actions' export * from './types/messages' export * from './types/stage' export * from './types/keybinds' +export * from './types/nags' export * from './utils' export { default as checklist } from './data/checklist' export { default as keybinds } from './data/keybinds' +export { default as nags } from './data/nags' diff --git a/packages/moderation/types/nags.ts b/packages/moderation/types/nags.ts new file mode 100644 index 000000000..0a48f8951 --- /dev/null +++ b/packages/moderation/types/nags.ts @@ -0,0 +1,90 @@ +import type { Project, User, Version } from '@modrinth/utils' +import type { FunctionalComponent, SVGAttributes } from 'vue' + +/** + * Type which represents the status type of a nag. + * + * - `required` indicates that the nag must be addressed. + * - `warning` indicates that the nag is important but not critical, and can be ignored. It is often used for issues that should be resolved but do not block project submission. + * - `suggestion` indicates that the nag is a recommendation and can be ignored. + */ +export type NagStatus = 'required' | 'warning' | 'suggestion' + +/** + * Interface representing the context in which a nag is displayed. + * It includes the project, versions, current member, all members, and the current route. + * This context is used to determine whether a nag or it's link should be shown and how it should be presented. + */ +export interface NagContext { + /** + * The project associated with the nag. + */ + project: Project + /** + * The versions associated with the project. + */ + versions: Version[] + /** + * The current project member viewing the nag. + */ + currentMember: User + /** + * The current route in the application. + */ + currentRoute: string +} + +/** + * Interface representing a nag's link. + */ +export interface NagLink { + /** + * A relative path to the nag's link, e.g. '/settings'. + */ + path: string + /** + * The text to display for the nag's link. + */ + title: string + /** + * The status of the nag, which can be 'required', 'warning', or 'suggestion'. + */ + shouldShow?: (context: NagContext) => boolean +} + +/** + * Interface representing a nag. + */ +export interface Nag { + /** + * A unique identifier for the nag. + */ + id: string + /** + * The title of the nag. + */ + title: string + /** + * A function that returns the description of the nag. + * It can accept a context to provide dynamic descriptions. + */ + description: (context: NagContext) => string + /** + * The status of the nag, which can be 'required', 'warning', or 'suggestion'. + */ + status: NagStatus + /** + * An optional icon for the nag, usually from `@modrinth/assets`. + * If not specified it will use the default icon associated with the nag status. + */ + icon?: FunctionalComponent + /** + * A function that determines whether the nag should be shown based on the context. + */ + shouldShow?: (context: NagContext) => boolean + /** + * An optional link associated with the nag. + * If provided, it should be displayed alongside the nag. + */ + link?: NagLink +}