You've already forked AstralRinth
forked from didirus/AstralRinth
feat: moderation improvements (#3881)
* feat: rough draft of tool * fix: example doc * feat: multiselect chips * feat: conditional actions+messaages + utils for handling conditions * feat: migrate checklist v1 to new format. * fix: lint issues * fix: severity util * feat: README.md * feat: start implementing new moderation checklist * feat: message assembly + fix imports * fix: lint issues * feat: add input suggestions * feat: utility cleanup * fix: icon * chore: remove debug logging * chore: remove debug button * feat: modpack permissions flow into it's own component * feat: icons + use id in stage selection button * Support md/plain text in stages. * fix: checklist not persisting/showing on subpages * feat: message gen + appr/with/deny buttons * feat: better notification placement + queue navigation * fix: default props for futureProjects * fix: modpack perms message * fix: issue with future projects props * fix: tab index + z index fixes * feat: keybinds * fix: file approval types * fix: generate message for non-modpack projects * feat: add generate message to stages dropdown * fix: variables not expanding * feat: requests * fix: empty message approval * fix: issues from sync * chore: add comment for old moderation checklist impl * fix: git artifacts * fix: update visibility logic for stages and actions * fix: cleanup logic for should show * fix: markdown editor accidental edit
This commit is contained in:
245
packages/moderation/types/actions.ts
Normal file
245
packages/moderation/types/actions.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { Project } from '@modrinth/utils'
|
||||
import type { WeightedMessage } from './messages'
|
||||
|
||||
export type ActionType =
|
||||
| 'button'
|
||||
| 'dropdown'
|
||||
| 'multi-select-chips'
|
||||
| 'toggle'
|
||||
| 'conditional-button'
|
||||
|
||||
export type Action =
|
||||
| ButtonAction
|
||||
| DropdownAction
|
||||
| MultiSelectChipsAction
|
||||
| ToggleAction
|
||||
| ConditionalButtonAction
|
||||
|
||||
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
|
||||
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export interface BaseAction {
|
||||
/**
|
||||
* The type of action, which determines how the action is presented to the moderator and what it does.
|
||||
*/
|
||||
type: ActionType
|
||||
|
||||
/**
|
||||
* Any additional text data that is required to complete the action.
|
||||
*/
|
||||
relevantExtraInput?: AdditionalTextInput[]
|
||||
|
||||
/**
|
||||
* Suggested moderation status when this action is selected.
|
||||
*/
|
||||
suggestedStatus?: ModerationStatus
|
||||
|
||||
/**
|
||||
* Suggested severity level for this moderation action.
|
||||
*/
|
||||
severity?: ModerationSeverity
|
||||
|
||||
/**
|
||||
* Actions that become available when this action is selected.
|
||||
*/
|
||||
enablesActions?: Action[]
|
||||
|
||||
/**
|
||||
* Actions that become unavailable when this action is selected.
|
||||
*/
|
||||
disablesActions?: string[] // Array of action IDs
|
||||
|
||||
/**
|
||||
* Unique identifier for this action, used for conditional logic.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this action should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the action is always shown.
|
||||
*/
|
||||
shouldShow?: (project: Project) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a conditional message that changes based on other selected actions.
|
||||
*/
|
||||
export interface ConditionalMessage extends WeightedMessage {
|
||||
/**
|
||||
* Conditions that must be met for this message to be used.
|
||||
*/
|
||||
conditions: {
|
||||
/**
|
||||
* Action IDs that must be selected for this message to apply.
|
||||
*/
|
||||
requiredActions?: string[]
|
||||
|
||||
/**
|
||||
* Action IDs that must NOT be selected for this message to apply.
|
||||
*/
|
||||
excludedActions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback message if conditions are not met.
|
||||
*/
|
||||
fallbackMessage?: () => Promise<typeof import('*.md?raw') | string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message.
|
||||
*/
|
||||
export interface ButtonAction extends BaseAction, WeightedMessage {
|
||||
type: 'button'
|
||||
|
||||
/**
|
||||
* The label of the button, which is displayed to the moderator. The text on the button.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* Alternative messages based on other selected actions.
|
||||
*/
|
||||
conditionalMessages?: ConditionalMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a simple toggle/checkbox action with separate layout handling.
|
||||
*/
|
||||
export interface ToggleAction extends BaseAction, WeightedMessage {
|
||||
type: 'toggle'
|
||||
|
||||
/**
|
||||
* The label of the toggle, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* Description text that appears below the toggle.
|
||||
*/
|
||||
description?: string
|
||||
|
||||
/**
|
||||
* Whether the toggle is checked by default.
|
||||
*/
|
||||
defaultChecked?: boolean
|
||||
|
||||
/**
|
||||
* Alternative messages based on other selected actions.
|
||||
*/
|
||||
conditionalMessages?: ConditionalMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a button that has different behavior based on other selected actions.
|
||||
*/
|
||||
export interface ConditionalButtonAction extends BaseAction {
|
||||
type: 'conditional-button'
|
||||
|
||||
/**
|
||||
* The label of the button, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* Different message configurations based on conditions.
|
||||
*/
|
||||
messageVariants: ConditionalMessage[]
|
||||
}
|
||||
|
||||
export interface DropdownActionOption extends WeightedMessage {
|
||||
/**
|
||||
* The label of the option, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface DropdownAction extends BaseAction {
|
||||
type: 'dropdown'
|
||||
|
||||
/**
|
||||
* The label associated with the dropdown.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* The options available in the dropdown.
|
||||
*/
|
||||
options: DropdownActionOption[]
|
||||
|
||||
/**
|
||||
* The default option selected in the dropdown, by index.
|
||||
*/
|
||||
defaultOption?: number
|
||||
}
|
||||
|
||||
export interface MultiSelectChipsOption extends WeightedMessage {
|
||||
/**
|
||||
* The label of the chip, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface MultiSelectChipsAction extends BaseAction {
|
||||
type: 'multi-select-chips'
|
||||
|
||||
/**
|
||||
* The label associated with the multi-select chips.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* The options available in the multi-select chips.
|
||||
*/
|
||||
options: MultiSelectChipsOption[]
|
||||
}
|
||||
|
||||
export interface AdditionalTextInput {
|
||||
/**
|
||||
* The label of the text input, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* The placeholder text for the text input.
|
||||
*/
|
||||
placeholder?: string
|
||||
|
||||
/**
|
||||
* Whether the text input is required to be filled out before the action can be completed.
|
||||
*/
|
||||
required?: boolean
|
||||
|
||||
/**
|
||||
* Whether the text input should use the full markdown editor rather than a simple text input.
|
||||
*/
|
||||
large?: boolean
|
||||
|
||||
/**
|
||||
* The variable name that will be replaced in the message with the input value.
|
||||
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
|
||||
* will be replaced with the input value.
|
||||
*/
|
||||
variable?: string
|
||||
|
||||
/**
|
||||
* Conditions that determine when this input is shown.
|
||||
*/
|
||||
showWhen?: {
|
||||
/**
|
||||
* Action IDs that must be selected for this input to be shown.
|
||||
*/
|
||||
requiredActions?: string[]
|
||||
|
||||
/**
|
||||
* Action IDs that must NOT be selected for this input to be shown.
|
||||
*/
|
||||
excludedActions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional suggestions for the input. Useful for repeating phrases or common responses.
|
||||
*/
|
||||
suggestions?: string[]
|
||||
}
|
||||
130
packages/moderation/types/keybinds.ts
Normal file
130
packages/moderation/types/keybinds.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Project } from '@modrinth/utils'
|
||||
|
||||
export interface ModerationActions {
|
||||
tryGoNext: () => void
|
||||
tryGoBack: () => void
|
||||
tryGenerateMessage: () => void
|
||||
trySkipProject: () => void
|
||||
|
||||
tryToggleCollapse: () => void
|
||||
tryResetProgress: () => void
|
||||
tryExitModeration: () => void
|
||||
|
||||
tryApprove: () => void
|
||||
tryReject: () => void
|
||||
tryWithhold: () => void
|
||||
tryEditMessage: () => void
|
||||
|
||||
tryToggleAction: (actionIndex: number) => void
|
||||
trySelectDropdownOption: (actionIndex: number, optionIndex: number) => void
|
||||
tryToggleChip: (actionIndex: number, chipIndex: number) => void
|
||||
|
||||
tryFocusNextAction: () => void
|
||||
tryFocusPreviousAction: () => void
|
||||
tryActivateFocusedAction: () => void
|
||||
}
|
||||
|
||||
export interface ModerationState {
|
||||
currentStage: number
|
||||
totalStages: number
|
||||
currentStageId: string | undefined
|
||||
currentStageTitle: string
|
||||
|
||||
isCollapsed: boolean
|
||||
isDone: boolean
|
||||
hasGeneratedMessage: boolean
|
||||
isLoadingMessage: boolean
|
||||
isModpackPermissionsStage: boolean
|
||||
|
||||
futureProjectCount: number
|
||||
visibleActionsCount: number
|
||||
|
||||
focusedActionIndex: number | null
|
||||
focusedActionType: 'button' | 'toggle' | 'dropdown' | 'multi-select' | null
|
||||
}
|
||||
|
||||
export interface ModerationContext {
|
||||
project: Project
|
||||
state: ModerationState
|
||||
actions: ModerationActions
|
||||
}
|
||||
|
||||
export interface KeybindDefinition {
|
||||
key: string
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
meta?: boolean
|
||||
preventDefault?: boolean
|
||||
}
|
||||
|
||||
export interface KeybindListener {
|
||||
id: string
|
||||
keybind: KeybindDefinition | KeybindDefinition[] | string | string[]
|
||||
description: string
|
||||
enabled?: (ctx: ModerationContext) => boolean
|
||||
action: (ctx: ModerationContext) => void
|
||||
}
|
||||
|
||||
export function parseKeybind(keybindString: string): KeybindDefinition {
|
||||
const parts = keybindString.split('+').map((p) => p.trim().toLowerCase())
|
||||
|
||||
return {
|
||||
key: parts.find((p) => !['ctrl', 'shift', 'alt', 'meta', 'cmd'].includes(p)) || '',
|
||||
ctrl: parts.includes('ctrl') || parts.includes('cmd'),
|
||||
shift: parts.includes('shift'),
|
||||
alt: parts.includes('alt'),
|
||||
meta: parts.includes('meta') || parts.includes('cmd'),
|
||||
preventDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeKeybind(keybind: KeybindDefinition | string): KeybindDefinition {
|
||||
return typeof keybind === 'string' ? parseKeybind(keybind) : keybind
|
||||
}
|
||||
|
||||
export function matchesKeybind(event: KeyboardEvent, keybind: KeybindDefinition | string): boolean {
|
||||
const def = normalizeKeybind(keybind)
|
||||
return (
|
||||
event.key.toLowerCase() === def.key.toLowerCase() &&
|
||||
event.ctrlKey === (def.ctrl ?? false) &&
|
||||
event.shiftKey === (def.shift ?? false) &&
|
||||
event.altKey === (def.alt ?? false) &&
|
||||
event.metaKey === (def.meta ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
export function handleKeybind(
|
||||
event: KeyboardEvent,
|
||||
ctx: ModerationContext,
|
||||
keybinds: KeybindListener[],
|
||||
): boolean {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const keybind of keybinds) {
|
||||
if (keybind.enabled && !keybind.enabled(ctx)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const keybindDefs = Array.isArray(keybind.keybind)
|
||||
? keybind.keybind.map(normalizeKeybind)
|
||||
: [normalizeKeybind(keybind.keybind)]
|
||||
|
||||
const matches = keybindDefs.some((def) => matchesKeybind(event, def))
|
||||
|
||||
if (matches) {
|
||||
keybind.action(ctx)
|
||||
|
||||
const shouldPrevent = keybindDefs.some((def) => def.preventDefault !== false)
|
||||
if (shouldPrevent) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
13
packages/moderation/types/messages.ts
Normal file
13
packages/moderation/types/messages.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface WeightedMessage {
|
||||
/**
|
||||
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
|
||||
*/
|
||||
weight: number
|
||||
|
||||
/**
|
||||
* The message which is appended to the final moderation message if the button is active.
|
||||
* @returns A function that lazily loads the message which is appended if the button is active.
|
||||
* @example async () => (await import('../messages/example.md?raw')).default,
|
||||
*/
|
||||
message: () => Promise<string>
|
||||
}
|
||||
52
packages/moderation/types/stage.ts
Normal file
52
packages/moderation/types/stage.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Project } from '@modrinth/utils'
|
||||
import type { Action } from './actions'
|
||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||
|
||||
/**
|
||||
* Represents a moderation stage with associated actions and optional navigation logic.
|
||||
*/
|
||||
export interface Stage {
|
||||
/**
|
||||
* The title of the stage, displayed to the moderator.
|
||||
*/
|
||||
title: string
|
||||
|
||||
/**
|
||||
* An optional description or additional text for the stage.
|
||||
*/
|
||||
text?: () => Promise<string>
|
||||
|
||||
/**
|
||||
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* Optional icon for the stage, displayed in the stage list and next to the title.
|
||||
*/
|
||||
icon?: FunctionalComponent<SVGAttributes>
|
||||
|
||||
/**
|
||||
* URL to the guidance document for this stage.
|
||||
*/
|
||||
guidance_url: string
|
||||
|
||||
/**
|
||||
* An array of actions that can be taken in this stage.
|
||||
*/
|
||||
actions: Action[]
|
||||
|
||||
/**
|
||||
* Optional navigation path to redirect the moderator when this stage is shown.
|
||||
*
|
||||
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
|
||||
*/
|
||||
navigate?: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this stage should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the stage is always shown.
|
||||
*/
|
||||
shouldShow?: (project: Project) => boolean
|
||||
}
|
||||
Reference in New Issue
Block a user