mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 13:05:50 +00:00
Merge remote-tracking branch 'origin/main' into prospector/disclosures-frontend
This commit is contained in:
@@ -186,6 +186,7 @@ import _LogInIcon from './icons/log-in.svg?component'
|
||||
import _LogOutIcon from './icons/log-out.svg?component'
|
||||
import _MailIcon from './icons/mail.svg?component'
|
||||
import _ManageIcon from './icons/manage.svg?component'
|
||||
import _MapPinIcon from './icons/map-pin.svg?component'
|
||||
import _MaximizeIcon from './icons/maximize.svg?component'
|
||||
import _MegaphoneIcon from './icons/megaphone.svg?component'
|
||||
import _MemoryStickIcon from './icons/memory-stick.svg?component'
|
||||
@@ -624,6 +625,7 @@ export const LogInIcon = _LogInIcon
|
||||
export const LogOutIcon = _LogOutIcon
|
||||
export const MailIcon = _MailIcon
|
||||
export const ManageIcon = _ManageIcon
|
||||
export const MapPinIcon = _MapPinIcon
|
||||
export const MaximizeIcon = _MaximizeIcon
|
||||
export const MegaphoneIcon = _MegaphoneIcon
|
||||
export const MemoryStickIcon = _MemoryStickIcon
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 380 B |
@@ -1 +1,2 @@
|
||||
src/locales/**
|
||||
src/types/node/pipe.ts
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*"
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import type { NodeState, StageFn, StageNodeBuilder } from '../types/node'
|
||||
import { group, STAGES_KEY } from '../types/node'
|
||||
import type { NodeState, StageNode } from '../types/node'
|
||||
import useCategoriesStage from './stages/categories'
|
||||
import useDescriptionStage from './stages/description'
|
||||
import useGalleryStage from './stages/gallery'
|
||||
@@ -13,7 +11,7 @@ import usePermissionsStage from './stages/permissions'
|
||||
import usePostApprovalStage from './stages/post-approval'
|
||||
import useReReviewStage from './stages/re-review'
|
||||
import useReuploadsStage from './stages/reupload'
|
||||
import useOtherRulesStage from './stages/other-rules'
|
||||
import useRulesStage from './stages/rules'
|
||||
import useStatusAlertsStage from './stages/status-alerts'
|
||||
import useSummaryStage from './stages/summary'
|
||||
import useTitleSlugStage from './stages/title-slug'
|
||||
@@ -22,8 +20,8 @@ import useVersionsStage from './stages/versions'
|
||||
|
||||
export function useStages(
|
||||
globalState: Ref<Record<string, Record<string, NodeState>>>,
|
||||
): StageNodeBuilder[] {
|
||||
const mainStages: StageNodeBuilder[] = [
|
||||
): StageNode[] {
|
||||
const mainStages: StageNode[] = [
|
||||
usePostApprovalStage(),
|
||||
useUndefinedProjectStage(),
|
||||
useReReviewStage(),
|
||||
@@ -38,12 +36,7 @@ export function useStages(
|
||||
useVersionsStage(),
|
||||
useReuploadsStage(),
|
||||
usePermissionsStage(),
|
||||
useOtherRulesStage(),
|
||||
useRulesStage(),
|
||||
]
|
||||
provide(STAGES_KEY, ref(mainStages))
|
||||
return [...mainStages, useStatusAlertsStage(mainStages, globalState)]
|
||||
}
|
||||
|
||||
export const stages: ReadonlyArray<StageFn> = []
|
||||
|
||||
export default group()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KeybindListener } from '../types/keybinds'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { KeybindListener } from '../types/keybinds'
|
||||
|
||||
const copyProjectLink = async (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
permalink: boolean,
|
||||
@@ -32,7 +33,6 @@ const keybinds: { [id: string]: KeybindListener } = {
|
||||
keybind: 'ArrowRight',
|
||||
description: 'Go to next stage',
|
||||
scope: 'checklist',
|
||||
enabled: (ctx) => !ctx.state.isDone,
|
||||
action: (ctx) => ctx.actions.tryGoNext(),
|
||||
},
|
||||
'previous-stage': {
|
||||
|
||||
-2
@@ -2,5 +2,3 @@
|
||||
|
||||
Per section 2.1 of %RULES%, your %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your %PROJECT_TYPE%.</br>
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
%CUSTOM_ADVICE%
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
## Missing Dependencies
|
||||
|
||||
Per section 5.6 of %RULES%, it is important that relevant dependencies be listed in the dependencies section of your project.
|
||||
Please ensure that all relevant dependencies are included in the Dependencies section of each version of your project.
|
||||
+1
@@ -0,0 +1 @@
|
||||
This project should have the following game version(s) selected: %GAME_VERSIONS%.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
## Game Version Metadata
|
||||
|
||||
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which Minecraft versions are selected.
|
||||
|
||||
%CORRECT%
|
||||
@@ -0,0 +1 @@
|
||||
This project should have the following loader(s) selected: %LOADERS%.
|
||||
@@ -0,0 +1,5 @@
|
||||
## Loader Metadata
|
||||
|
||||
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which loaders are selected.
|
||||
|
||||
%CORRECT%
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { Nag, NagContext } from '../../types/nags'
|
||||
import { licenseRequiresSource, licensesRequiringSource, notSourceAsDistributed } from '../../utils'
|
||||
import license from '../stages/license'
|
||||
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
|
||||
|
||||
export const commonLinkDomains = {
|
||||
source: [
|
||||
|
||||
@@ -8,4 +8,5 @@ export const Priorities = new (class extends Priority {
|
||||
rejected = this.before()
|
||||
withheld = this.before()
|
||||
note = this.after()
|
||||
tempLast = this.after()
|
||||
})()
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function () {
|
||||
),
|
||||
|
||||
group().children(
|
||||
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').severity('low').message(),
|
||||
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('optimization-misused', 'Optimization')
|
||||
.shown(
|
||||
@@ -47,7 +47,6 @@ export default function () {
|
||||
),
|
||||
)
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(optimizationMsg)
|
||||
.fix(
|
||||
fix().project((patch) => {
|
||||
@@ -61,7 +60,6 @@ export default function () {
|
||||
toggle('resolutions-misused', 'Resolutions')
|
||||
.shown(computed(() => project.value.project_types.includes('resourcepack')))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(resolutionsMsg)
|
||||
.fix(
|
||||
fix().project((patch) => {
|
||||
|
||||
@@ -15,24 +15,23 @@ export default function () {
|
||||
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080508042e70089dd787e',
|
||||
)
|
||||
.icon(LibraryIcon)
|
||||
.navigate('/')
|
||||
.navigate()
|
||||
.children(
|
||||
group()
|
||||
.title('Description Issues?')
|
||||
.children(
|
||||
toggle('insufficient', 'Insufficient')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message('insufficient/header', (s) => ({ CUSTOM_ADVICE: s.custom?.explainer }))
|
||||
.message('insufficient/header')
|
||||
.children(
|
||||
group()
|
||||
.title('Why is this Description Insufficient?')
|
||||
.multiSelect('reason')
|
||||
.children(
|
||||
toggle('custom', 'Custom').children(
|
||||
markdown('explainer')
|
||||
.title('How can the author improve their description?')
|
||||
.required(),
|
||||
.required()
|
||||
.rawMessage((state) => `${state.value}\n\n`),
|
||||
),
|
||||
toggle('fork', 'Fork').message('piece/fork'),
|
||||
toggle('unfinished', 'Unfinished').message('piece/unfinished'),
|
||||
@@ -44,15 +43,13 @@ export default function () {
|
||||
`insufficient/default/${project.value?.minecraft_java_server ? 'servers' : project.value?.project_types?.includes('modpack') ? 'packs' : 'projects'}`,
|
||||
)
|
||||
.rawMessage(async (state) => {
|
||||
const reasons = state?.reason instanceof Set ? state.reason : new Set<string>()
|
||||
return SHOW_SPOILER_ADVICE.some((reason) => reasons.has(reason))
|
||||
return SHOW_SPOILER_ADVICE.some((reason) => state?.[reason] === true)
|
||||
? await md('checklist/messages/description/insufficient/piece/spoiler-guide')(state)
|
||||
: ''
|
||||
}),
|
||||
|
||||
toggle('non-english', 'Non-english')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(() => `non-english${project.value.minecraft_java_server ? '-server' : ''}`)
|
||||
.shown(
|
||||
computed(() => {
|
||||
@@ -63,25 +60,13 @@ export default function () {
|
||||
}),
|
||||
),
|
||||
|
||||
toggle('headers-as-body', 'Headers as body text')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
toggle('headers-as-body', 'Headers as body text').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('image-only', 'Image-only')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
toggle('image-only', 'Image-only').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('non-standard-text', 'Non-standard text')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
toggle('non-standard-text', 'Non-standard text').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('clarity', 'Unclear / Misleading')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('clarity', 'Unclear / Misleading').suggestedStatus('rejected').message(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,18 +16,14 @@ export default function () {
|
||||
.navigate('/gallery')
|
||||
.children(
|
||||
group().children(
|
||||
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').severity('low').message(),
|
||||
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('not-relevant', 'Not relevant')
|
||||
.shown(computed(() => project.value.gallery.length > 0))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('showcase-clarity', 'Showcase Clarity')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('showcase-clarity', 'Showcase Clarity').suggestedStatus('rejected').message(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ export default function () {
|
||||
toggle('invalid-link', 'Invalid Link')
|
||||
.shown(computed(() => !!project.value.license?.url))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.children(check('custom-license', 'Invalid Link: Custom License').message())
|
||||
.collect(),
|
||||
@@ -61,12 +60,11 @@ export default function () {
|
||||
toggle('no-source', 'No Source')
|
||||
.shown(needSource)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.rawMessage(async (state) => {
|
||||
if (state.fork) return noSourceForkMsg(state)
|
||||
return noSourceMsg(state)
|
||||
})
|
||||
.children(check('fork', 'No Source: Fork').severity('high')),
|
||||
.children(check('fork', 'No Source: Fork')),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, md, mdOptional, stage, toggle } from '../../types/node'
|
||||
import type { ChildEntry, GroupNodeBuilder } from '../../types/node'
|
||||
import { promptSourceRequired } from '../..'
|
||||
import type { ChildEntry, GroupNode } from '../../types/node'
|
||||
import { group, md, mdOptional, stage, toggle } from '../../types/node'
|
||||
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
const linkNames: Record<string, string> = {}
|
||||
|
||||
type LinkSectionBuilder = GroupNodeBuilder & {
|
||||
type LinkSectionBuilder = GroupNode & {
|
||||
children(...extras: ChildEntry[]): LinkSectionBuilder
|
||||
label(badge: Ref<boolean>): LinkSectionBuilder
|
||||
}
|
||||
@@ -75,7 +75,6 @@ export default function () {
|
||||
.navigate('/settings/links')
|
||||
.shown(computed(() => Object.keys(project.value.link_urls).length > 0))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(async (state) => {
|
||||
const sections = Object.entries(state).filter(
|
||||
([, s]) => s && typeof s === 'object' && !(s instanceof Set),
|
||||
|
||||
@@ -3,7 +3,16 @@ import { DatabaseIcon } from '@modrinth/assets'
|
||||
import { ENVIRONMENTS_COPY, injectProjectPageContext, injectTags } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { dropdown, fix, group, md, option, stage, toggle } from '../../types/node'
|
||||
import {
|
||||
appComponent as _appComponent,
|
||||
dropdown,
|
||||
fix,
|
||||
group,
|
||||
md,
|
||||
option,
|
||||
stage,
|
||||
toggle,
|
||||
} from '../../types/node'
|
||||
import { requiresEnvironmentInfo } from '../../utils'
|
||||
|
||||
const loaderLabels: Record<string, string> = {
|
||||
@@ -13,7 +22,7 @@ const loaderLabels: Record<string, string> = {
|
||||
resourcepack: 'Resource Pack',
|
||||
}
|
||||
|
||||
function formatLoaderLabel(id: string): string {
|
||||
function _formatLoaderLabel(id: string): string {
|
||||
return (
|
||||
loaderLabels[id] ??
|
||||
id
|
||||
@@ -25,7 +34,11 @@ function formatLoaderLabel(id: string): string {
|
||||
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
const { loaders } = injectTags()
|
||||
const { loaders: _loaders, gameVersions: _gameVersions } = injectTags()
|
||||
|
||||
const _currentGameVersions = computed(
|
||||
() => (project.value.game_versions as string[] | undefined) ?? [],
|
||||
)
|
||||
|
||||
return (
|
||||
stage('metadata', 'Metadata')
|
||||
@@ -64,7 +77,6 @@ export default function () {
|
||||
toggle('environment', 'Environment')
|
||||
.shown(computed(() => requiresEnvironmentInfo(project.value.project_types)))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(async (state) => {
|
||||
const correctEnvironment = state?.['correct-environment'] as string | undefined
|
||||
|
||||
@@ -94,7 +106,7 @@ export default function () {
|
||||
.title('Correct Environment')
|
||||
.children(
|
||||
dropdown('correct-environment')
|
||||
.children(
|
||||
.options(
|
||||
...(Object.keys(ENVIRONMENTS_COPY) as Labrinth.Projects.v3.Environment[])
|
||||
.filter((id) => id !== 'unknown')
|
||||
.map((id) => option(id, ENVIRONMENTS_COPY[id].title.defaultMessage ?? id)),
|
||||
@@ -103,67 +115,96 @@ export default function () {
|
||||
.none('Unknown'),
|
||||
),
|
||||
),
|
||||
// TODO: chyz, fix pls (make into single set of buttons where current loaders start selected and non current start non selected
|
||||
// toggle('loader', `Loader${project.value.loaders.length > 1 ? 's' : ''}`).children(
|
||||
// group()
|
||||
// .title('Loader Issues?')
|
||||
// .action(
|
||||
// action()
|
||||
// .suggestedStatus('flagged')
|
||||
// .severity('medium')
|
||||
// .message(async (state) => {
|
||||
// //TODO: chyz
|
||||
// //TODO: coolbot this one is a bit of a doozy
|
||||
// const header = await md('checklist/messages/metadata/loader/incorrect')(state)
|
||||
// const selected = state.loaders
|
||||
// if (selected instanceof Set && selected.size > 0) {
|
||||
// const list = [...selected]
|
||||
// .map((id) => `- ${formatLoaderLabel(id)}`)
|
||||
// .join('\n')
|
||||
// return `${header}\n${list}`
|
||||
// }
|
||||
// return header
|
||||
// }),
|
||||
// )
|
||||
// .children(
|
||||
// toggle('incorrect', 'Incorrect').children(
|
||||
// group()
|
||||
// .title('Incorrect Loaders')
|
||||
// .multiSelect('loaders')
|
||||
// .children(
|
||||
// ...project.value.loaders.map((id) => option(id, formatLoaderLabel(id))),
|
||||
// ),
|
||||
// ),
|
||||
// TODO: chyz, this should be the same interface as incorrect, as a corrections scheme, with selected loaders default on.
|
||||
// toggle('missing', 'Missing').children(
|
||||
// group()
|
||||
// .title('Missing Loaders')
|
||||
// .multiSelect('loaders')
|
||||
// .children(
|
||||
// ...(() => {
|
||||
// //TODO: chyz maybe this can be done better
|
||||
// // (plugin loaders and datapack are marked as valid for mods which makes this suck)
|
||||
// const existingTypes = new Set(
|
||||
// loaders.value
|
||||
// .filter((l) => project.value.loaders.includes(l.name))
|
||||
// .flatMap((l) => l.supported_project_types),
|
||||
// )
|
||||
// const referenceTypes =
|
||||
// existingTypes.size > 0
|
||||
// ? existingTypes
|
||||
// : new Set(project.value.project_types)
|
||||
// return loaders.value
|
||||
// .filter(
|
||||
// (loader) =>
|
||||
// loader.supported_project_types.every((t) => referenceTypes.has(t)) &&
|
||||
// !project.value.loaders.includes(loader.name),
|
||||
// )
|
||||
// .map((loader) => option(loader.name, formatLoaderLabel(loader.name)))
|
||||
// })(),
|
||||
// )/
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
toggle('dependencies', 'Dependencies').suggestedStatus('flagged').message(),
|
||||
|
||||
// toggle('loader', 'Loaders (WIP)')
|
||||
// .suggestedStatus('flagged')
|
||||
// .rawMessage(async (state) => {
|
||||
// const selected =
|
||||
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
|
||||
// const current = new Set(project.value.loaders)
|
||||
// const isCorrected =
|
||||
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
|
||||
//
|
||||
// let correct = ''
|
||||
// if (isCorrected) {
|
||||
// const list = [...selected].map((id) => formatLoaderLabel(id)).join(', ')
|
||||
// correct = await md('checklist/messages/metadata/loader/correction', () => ({
|
||||
// LOADERS: list || 'none',
|
||||
// }))(state)
|
||||
// }
|
||||
//
|
||||
// return md('checklist/messages/metadata/loader/inaccurate', () => ({
|
||||
// CORRECT: correct,
|
||||
// }))(state)
|
||||
// })
|
||||
// .fix(
|
||||
// fix().project((patch, state) => {
|
||||
// const selected =
|
||||
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
|
||||
// const next = [...selected]
|
||||
// const current = project.value.loaders
|
||||
// if (next.length === current.length && next.every((id) => current.includes(id)))
|
||||
// return
|
||||
// patch.loaders = next
|
||||
// }),
|
||||
// )
|
||||
// .children(
|
||||
// appComponent('loaders', 'loader-picker')
|
||||
// .valueKind('set')
|
||||
// .initial(() => new Set(project.value.loaders))
|
||||
// .props((ctx) => ({
|
||||
// loaders: loaders.value,
|
||||
// toggleLoader: ctx.toggleSetValue,
|
||||
// })),
|
||||
// ),
|
||||
//
|
||||
// toggle('game-version', 'Game Versions (WIP)')
|
||||
// .suggestedStatus('flagged')
|
||||
// .rawMessage(async (state) => {
|
||||
// const selected =
|
||||
// state['game-versions'] instanceof Set
|
||||
// ? state['game-versions']
|
||||
// : new Set(currentGameVersions.value)
|
||||
// const current = new Set(currentGameVersions.value)
|
||||
// const isCorrected =
|
||||
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
|
||||
//
|
||||
// let correct = ''
|
||||
// if (isCorrected) {
|
||||
// const list = [...selected].join(', ')
|
||||
// correct = await md('checklist/messages/metadata/game-version/correction', () => ({
|
||||
// GAME_VERSIONS: list || 'none',
|
||||
// }))(state)
|
||||
// }
|
||||
//
|
||||
// return md('checklist/messages/metadata/game-version/inaccurate', () => ({
|
||||
// CORRECT: correct,
|
||||
// }))(state)
|
||||
// })
|
||||
// .fix(
|
||||
// fix().project((patch, state) => {
|
||||
// const selected =
|
||||
// state['game-versions'] instanceof Set
|
||||
// ? state['game-versions']
|
||||
// : new Set(currentGameVersions.value)
|
||||
// const next = [...selected]
|
||||
// const current = currentGameVersions.value
|
||||
// if (next.length === current.length && next.every((id) => current.includes(id)))
|
||||
// return
|
||||
// patch.game_versions = next
|
||||
// }),
|
||||
// )
|
||||
// .children(
|
||||
// appComponent('game-versions', 'game-version-picker')
|
||||
// .valueKind('set')
|
||||
// .initial(() => new Set(currentGameVersions.value))
|
||||
// .props(() => ({
|
||||
// gameVersions: gameVersions.value,
|
||||
// noHeader: true,
|
||||
// })),
|
||||
// ),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SignatureIcon } from '@modrinth/assets'
|
||||
import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, stage, toggle } from '../../types/node'
|
||||
|
||||
function isResolved(attributionGroup: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
const attribution = attributionGroup.attribution
|
||||
if (!attribution) return false
|
||||
if (attribution.kind === 'globally_allowed') return true
|
||||
return attribution.moderation_status?.kind === 'approved'
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const { data: attributionData } = useQuery({
|
||||
queryKey: ['project-attribution', project.value.id],
|
||||
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
|
||||
})
|
||||
|
||||
const unresolvedCount = computed(
|
||||
() => (attributionData.value ?? []).filter((g) => !isResolved(g)).length,
|
||||
)
|
||||
|
||||
return stage('permissions', 'Modpack Permissions')
|
||||
.hint("Does this project's external content have any issues?")
|
||||
@@ -16,30 +35,24 @@ export default function () {
|
||||
computed(
|
||||
() =>
|
||||
(project.value.project_types?.includes('modpack') ?? false) &&
|
||||
!project.value.minecraft_server,
|
||||
!project.value.minecraft_server &&
|
||||
unresolvedCount.value > 0,
|
||||
),
|
||||
)
|
||||
.sticky()
|
||||
.children(
|
||||
group().children(
|
||||
toggle('invalid-permissions', 'Invalid permissions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('invalid-permissions', 'Invalid permissions').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('prohibited-external-content', 'Prohibited externals')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('missing-permissions', 'Missing permissions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('missing-permissions', 'Missing permissions').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('non-commercial-external-content', 'Non-commercial externals')
|
||||
.shown(computed(() => project.value.monetization_status === 'monetized'))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, stage, text, toggle } from '../../types/node'
|
||||
import { Priorities } from '../priorities.ts'
|
||||
|
||||
//TODO chyz
|
||||
//TODO coolbot needs discussion
|
||||
@@ -20,15 +21,15 @@ export default function () {
|
||||
group().children(
|
||||
toggle('issue-warning', 'Issue warning')
|
||||
.suggestedStatus('approved')
|
||||
.severity('low')
|
||||
.message(),
|
||||
.message()
|
||||
.priority(Priorities.tempLast),
|
||||
|
||||
toggle('missed-deadline', 'Missed due date')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
STATUS: state.status,
|
||||
}))
|
||||
.priority(Priorities.tempLast)
|
||||
.children(
|
||||
//TODO: chyz this shouldn't need to be provided by moderator
|
||||
text('status').title('What status is the project being set to?').required(),
|
||||
@@ -36,35 +37,31 @@ export default function () {
|
||||
|
||||
toggle('metadata-issue', 'Incorrect metadata')
|
||||
.suggestedStatus('approved')
|
||||
.severity('low')
|
||||
.message()
|
||||
.children(
|
||||
toggle('dependencies', 'Missing Dependencies')
|
||||
.severity('low')
|
||||
.message(undefined, (state) => ({
|
||||
DEPENDENCY_NAME: state['dependency-name'],
|
||||
DEPENDENCY_LINK: state['dependency-link'],
|
||||
.message((state) => ({
|
||||
DEPENDENCY_NAME: state['name'],
|
||||
DEPENDENCY_LINK: state['link'],
|
||||
}))
|
||||
.children(
|
||||
text('dependency-name').title('Dependency name').required(),
|
||||
text('dependency-link').title('Dependency link').required(),
|
||||
text('name').title('Dependency name').required(),
|
||||
text('link').title('Dependency link').required(),
|
||||
),
|
||||
|
||||
toggle('mc-versions', 'Game versions')
|
||||
.severity('low')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
SPECIFICS: state.specifics,
|
||||
}))
|
||||
.children(text('specifics').title('More details about the game versions issue?')),
|
||||
|
||||
toggle('loaders', 'Loaders')
|
||||
.severity('low')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
SPECIFICS: state.specifics,
|
||||
}))
|
||||
.children(text('specifics').title('More details about the loaders issue?')),
|
||||
|
||||
toggle('license', 'Inconsistent Licensing').severity('low').message(),
|
||||
toggle('license', 'Inconsistent Licensing').message(),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
|
||||
@@ -32,13 +32,9 @@ export default function () {
|
||||
group().children(
|
||||
toggle('ignored', 'Yes')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.children(
|
||||
toggle('warning', 'Multiple times in a row')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('warning', 'Multiple times in a row').suggestedStatus('rejected').message(),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
|
||||
@@ -20,14 +20,13 @@ export default function () {
|
||||
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080d1a0a2cda3ff2ce997',
|
||||
)
|
||||
.icon(CopyrightIcon)
|
||||
.navigate('/')
|
||||
.navigate()
|
||||
.children(
|
||||
group().children(
|
||||
toggle('reupload', 'Re-upload')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
ORIGINAL_PROJECT: state['original-project'],
|
||||
ORIGINAL_AUTHOR: state['original-author'],
|
||||
}))
|
||||
@@ -39,25 +38,19 @@ export default function () {
|
||||
toggle('unclear-fork', 'Unclear Fork')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('insufficient-fork', 'Insufficient Fork')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('request-proof', 'Proof of permissions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('request-proof', 'Proof of permissions').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('identity-verification', 'Verify Identity')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
PLATFORM: state.platform,
|
||||
}))
|
||||
.children(text('platform').title('Where else can the project be found?').required()),
|
||||
@@ -65,8 +58,7 @@ export default function () {
|
||||
toggle('identity-verification-server', 'Verify Identity')
|
||||
.shown(computed(() => !!project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
CONTACT: state.contact,
|
||||
}))
|
||||
.children(text('contact').title('Known public contact method').required()),
|
||||
@@ -74,17 +66,15 @@ export default function () {
|
||||
toggle('request-proof-server', 'Reuploaded pack')
|
||||
.shown(isServerModpack)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('custom-pack-verification', 'Override verification')
|
||||
.shown(isServerModpack)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message()
|
||||
.children(
|
||||
check('list', 'List overrides?')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
OVERRIDES: state.overrides,
|
||||
}))
|
||||
.children(markdown('overrides').title('Add list of overrides.')),
|
||||
@@ -94,8 +84,7 @@ export default function () {
|
||||
toggle('custom-pack-prohibited', 'Forbidden Overrides')
|
||||
.shown(isServerModpack)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
OVERRIDES: state.overrides,
|
||||
}))
|
||||
.children(markdown('overrides').title('Forbidden overrides list').required()),
|
||||
|
||||
+22
-38
@@ -2,9 +2,8 @@ import { ListBulletedIcon } from '@modrinth/assets'
|
||||
import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, markdown, option, stage, toggle } from '../../types/node'
|
||||
import { group, markdown, stage, toggle } from '../../types/node'
|
||||
|
||||
//TODO: coolbot have fun :3
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
|
||||
@@ -20,61 +19,49 @@ export default function () {
|
||||
toggle('paid-access-server', 'Paid access server')
|
||||
.shown(computed(() => !!project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message(),
|
||||
|
||||
// TODO: chyz, the lists built by these message have empty line gaps.
|
||||
toggle('prohibited-content', 'Prohibited Content')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message('prohibited-content-header')
|
||||
.collect()
|
||||
.children(
|
||||
group()
|
||||
.multiSelect('options')
|
||||
.title('Which Prohibited Content rules does this project violate?')
|
||||
.children(
|
||||
option('objectionable', 'Objectionable').message(),
|
||||
option('discriminatory', 'Discriminatory or Explicit').message(),
|
||||
option('ip-infringement', 'IP Infringement').message(),
|
||||
option('legal-rights', 'Rights Violation').message(),
|
||||
option('illegal-activity', 'Illegal Activity').message(),
|
||||
option('harmful', 'Harmful or Deceptive').message(),
|
||||
option('misleading', 'Misleading claims').message(),
|
||||
option('impersonation', 'Impersonation').message(),
|
||||
option('false-endorsement', 'False Endorsement').message(),
|
||||
option('profanity', 'Profanity').message(),
|
||||
option('undisclosed-upload', 'Undisclosed Data Upload').message(),
|
||||
option('mojang-bypass', 'Mojang Bypass').message(),
|
||||
toggle('objectionable', 'Objectionable').message(),
|
||||
toggle('discriminatory', 'Discriminatory or Explicit').message(),
|
||||
toggle('ip-infringement', 'IP Infringement').message(),
|
||||
toggle('legal-rights', 'Rights Violation').message(),
|
||||
toggle('illegal-activity', 'Illegal Activity').message(),
|
||||
toggle('harmful', 'Harmful or Deceptive').message(),
|
||||
toggle('misleading', 'Misleading claims').message(),
|
||||
toggle('impersonation', 'Impersonation').message(),
|
||||
toggle('false-endorsement', 'False Endorsement').message(),
|
||||
toggle('profanity', 'Profanity').message(),
|
||||
toggle('undisclosed-upload', 'Undisclosed Data Upload').message(),
|
||||
toggle('mojang-bypass', 'Mojang Bypass').message(),
|
||||
),
|
||||
),
|
||||
|
||||
toggle('cheat-or-hack-advertising', 'Hacks')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message(),
|
||||
toggle('cheat-or-hack-advertising', 'Hacks').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('server-side-opt-out', 'Opt-out')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('server-side-opt-out', 'Opt-out').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('server-side-opt-in', 'Opt-in')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('high')
|
||||
.message('server-side-opt-in-header')
|
||||
.collect()
|
||||
.children(
|
||||
group()
|
||||
.multiSelect('options')
|
||||
.title('Which features require a Server-side Opt-in?')
|
||||
.children(
|
||||
option('x-ray', 'X-ray').message(),
|
||||
option('aim-bot', 'Aim Assist').message(),
|
||||
option('movement', 'Movement').message(),
|
||||
option('pvp', 'PvP').message(),
|
||||
option('hiding-mods', 'Anti 3.x').message(),
|
||||
option('item-duplication', 'Dupe').message(),
|
||||
toggle('x-ray', 'X-ray').message(),
|
||||
toggle('aim-bot', 'Aim Assist').message(),
|
||||
toggle('movement', 'Movement').message(),
|
||||
toggle('pvp', 'PvP').message(),
|
||||
toggle('hiding-mods', 'Anti 3.x').message(),
|
||||
toggle('item-duplication', 'Dupe').message(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -88,14 +75,11 @@ export default function () {
|
||||
),
|
||||
)
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('rule-breaking-other', 'Other')
|
||||
// TODO: chyz, the required asterisk is on a separate line
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message(undefined, (state) => ({ MESSAGE: state.message }))
|
||||
.message((state) => ({ MESSAGE: state.message }))
|
||||
.children(
|
||||
markdown('message').title('Explain how it infringes on content rules.').required(),
|
||||
),
|
||||
@@ -3,9 +3,9 @@ import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NodeState, StageNodeBuilder } from '../../types/node'
|
||||
import type { AnyNode, ChildNode, NodeState, StageNode } from '../../types/node'
|
||||
import {
|
||||
NodeBuilder,
|
||||
externalGroup,
|
||||
getBooleanChildState,
|
||||
group,
|
||||
isNodeActive,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { Priorities } from '../priorities.ts'
|
||||
|
||||
export default function (
|
||||
mainStages: StageNodeBuilder[],
|
||||
mainStages: StageNode[],
|
||||
globalState: Ref<Record<string, Record<string, NodeState>>>,
|
||||
) {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
@@ -47,23 +47,26 @@ export default function (
|
||||
.priority(Priorities.alerts)
|
||||
.applyFixes()
|
||||
.children(
|
||||
computed<NodeBuilder | null>(() => {
|
||||
const fixNodes: NodeBuilder[] = []
|
||||
computed<AnyNode | null>(() => {
|
||||
const fixGroups: ChildNode[] = []
|
||||
walkNodes(
|
||||
[group().children(...mainStages)],
|
||||
(globalState.value ?? {}) as unknown as Record<string, NodeState>,
|
||||
(node, nodeState) => {
|
||||
if (!node._fixes.length) return
|
||||
(node, nodeState, _localState, path) => {
|
||||
if (!('_fixes' in node) || !(node as { _fixes: unknown[] })._fixes.length) return
|
||||
if (!isNodeActive(node, nodeState)) return
|
||||
const childState = getBooleanChildState(nodeState)
|
||||
fixNodes.push(
|
||||
...resolveChildren(node, childState).filter(
|
||||
(c): c is NodeBuilder => c instanceof NodeBuilder,
|
||||
),
|
||||
const children = resolveChildren(
|
||||
node as never,
|
||||
getBooleanChildState(nodeState),
|
||||
).filter(
|
||||
(c): c is Exclude<ChildNode, string | (() => unknown)> =>
|
||||
typeof c === 'object' && c !== null,
|
||||
)
|
||||
if (children.length === 0) return
|
||||
fixGroups.push(externalGroup(path).children(...children))
|
||||
},
|
||||
)
|
||||
return fixNodes.length > 0 ? group().children(...fixNodes) : null
|
||||
return fixGroups.length > 0 ? group().children(...fixGroups) : null
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
@@ -31,20 +31,17 @@ export default function () {
|
||||
toggle('insufficient', 'Insufficient')
|
||||
.enabled((state) => !state['repeat-title'])
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('repeat-title', 'Repeat of Title')
|
||||
.enabled((state) => !state.insufficient)
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('formatting', 'Formatting').suggestedStatus('flagged').severity('low').message(),
|
||||
toggle('formatting', 'Formatting').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('non-english', 'Non-english')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.shown(
|
||||
computed(() => {
|
||||
@@ -62,7 +59,6 @@ export default function () {
|
||||
toggle('repeat-ip', 'Repeat of IP')
|
||||
.shown(computed(() => !!project.value?.minecraft_server))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,18 +5,23 @@ import {
|
||||
TagCategoryRefreshCcwIcon,
|
||||
TagCategoryWandSparklesIcon,
|
||||
UserPlusIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Alert, injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
|
||||
import {
|
||||
Alert,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
ProjectStatusLink,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { md, type NodeState } from '../../types/node'
|
||||
import { button, fix, group, option, stage, text, toggle } from '../../types/node'
|
||||
import { check, fix, group, md, stage, text, toggle } from '../../types/node'
|
||||
|
||||
const STALE_TIME = 1000 * 60 * 5
|
||||
|
||||
type AutoSlugStatus = 'loading' | 'available' | 'unavailable'
|
||||
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | null
|
||||
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | 'empty' | 'invalid' | null
|
||||
|
||||
//TODO: make this not a copy of frontend/src/utils/slugs.generateUrlSlug
|
||||
// (as in move the other one so we can use it here)
|
||||
@@ -46,12 +51,6 @@ export default function () {
|
||||
const slugValidation = ref<SlugValidation>(null)
|
||||
let slugDebounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function currentSlug(state: Record<string, NodeState>) {
|
||||
return (
|
||||
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value ?? project.value.slug
|
||||
)
|
||||
}
|
||||
|
||||
async function checkSlugTaken(slug: string): Promise<Labrinth.Projects.v3.Project | null> {
|
||||
try {
|
||||
return await queryClient.fetchQuery({
|
||||
@@ -66,8 +65,13 @@ export default function () {
|
||||
}
|
||||
|
||||
const SlugStatus = () => {
|
||||
const v = slugValidation.value
|
||||
if (v === null) return null
|
||||
const v =
|
||||
slugValidation.value ??
|
||||
(autoSlugStatus.value === 'loading'
|
||||
? 'checking'
|
||||
: autoSlugStatus.value === 'unavailable'
|
||||
? 'taken'
|
||||
: 'available')
|
||||
if (v === 'checking')
|
||||
return (
|
||||
<Alert type="checking" class="w-full">
|
||||
@@ -86,19 +90,25 @@ export default function () {
|
||||
Slug is available
|
||||
</Alert>
|
||||
)
|
||||
if (v === 'empty')
|
||||
return (
|
||||
<Alert type="error" class="w-full">
|
||||
Slug cannot be empty
|
||||
</Alert>
|
||||
)
|
||||
if (v === 'invalid')
|
||||
return (
|
||||
<Alert type="error" class="w-full">
|
||||
Invalid Slug
|
||||
</Alert>
|
||||
)
|
||||
const by = correctSlugConflict.value
|
||||
return (
|
||||
<Alert type="error" class="w-full">
|
||||
Slug taken
|
||||
{by ? (
|
||||
<>
|
||||
{' by '}
|
||||
<a href={`/project/${by.slug}`} target="_blank" class="underline">
|
||||
{by.name}
|
||||
</a>
|
||||
{` (${by.status})`}
|
||||
</>
|
||||
) : null}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
Slug taken
|
||||
{by ? <ProjectStatusLink project={by} newTab /> : null}
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -157,7 +167,9 @@ export default function () {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// fall through to 'unavailable' below
|
||||
}
|
||||
|
||||
autoSlugStatus.value = 'unavailable'
|
||||
},
|
||||
@@ -190,12 +202,7 @@ export default function () {
|
||||
{autoSlugStatus.value === 'loading' ? (
|
||||
'...'
|
||||
) : by ? (
|
||||
<>
|
||||
<a href={`/project/${by.slug}`} target="_blank" class="underline">
|
||||
{by.name}
|
||||
</a>
|
||||
{` (${by.status})`}
|
||||
</>
|
||||
<ProjectStatusLink project={by} newTab />
|
||||
) : (
|
||||
'No'
|
||||
)}
|
||||
@@ -206,30 +213,22 @@ export default function () {
|
||||
group('title')
|
||||
.title('Title Issues?')
|
||||
.children(
|
||||
toggle('useless-info', 'Contains Useless Info')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
toggle('useless-info', 'Contains Useless Info').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('minecraft-branding', 'Minecraft Title')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
toggle('minecraft-branding', 'Minecraft Title').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('similarities', 'Title Similarities')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.children(
|
||||
group()
|
||||
.title('Similarities Additional Info')
|
||||
.multiSelect('options')
|
||||
.children(
|
||||
option('modpack', 'Modpack Named After Mod')
|
||||
check('modpack', 'Modpack Named After Mod')
|
||||
.shown(computed(() => project.value.project_types.includes('modpack')))
|
||||
.message(),
|
||||
|
||||
option('fork', 'Forked Project')
|
||||
check('fork', 'Forked Project')
|
||||
.shown(computed(() => !project.value?.minecraft_server))
|
||||
.message(),
|
||||
),
|
||||
@@ -241,100 +240,76 @@ export default function () {
|
||||
.title('Slug Issues')
|
||||
.shown(computed(() => hasCustomSlug(project.value)))
|
||||
.children(
|
||||
group()
|
||||
.multiSelect('issues')
|
||||
.children(
|
||||
toggle('misused', 'Misused')
|
||||
.children(
|
||||
group()
|
||||
.title('Correct Slug')
|
||||
.children(
|
||||
text('correct-slug')
|
||||
.initial(() => resolvedAutoSlug.value ?? project.value.slug)
|
||||
.onChange((value, { override }) => {
|
||||
if (!value) return override(project.value.slug ?? '')
|
||||
clearTimeout(slugDebounceTimer)
|
||||
if (value === project.value.slug) {
|
||||
slugValidation.value = 'unchanged'
|
||||
return
|
||||
group().children(
|
||||
toggle('misused', 'Misused')
|
||||
.children(
|
||||
group()
|
||||
.title('Correct Slug')
|
||||
.children(
|
||||
text('correct-slug')
|
||||
.initial(() => resolvedAutoSlug.value ?? project.value.slug ?? '')
|
||||
.onChange((value) => {
|
||||
clearTimeout(slugDebounceTimer)
|
||||
if (value === project.value.slug) {
|
||||
slugValidation.value = 'unchanged'
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
slugValidation.value = 'empty'
|
||||
correctSlugConflict.value = null
|
||||
return
|
||||
}
|
||||
if (generateUrlSlug(value) !== value) {
|
||||
slugValidation.value = 'invalid'
|
||||
correctSlugConflict.value = null
|
||||
return
|
||||
}
|
||||
slugValidation.value = 'checking'
|
||||
slugDebounceTimer = setTimeout(async () => {
|
||||
const conflict = await checkSlugTaken(value).catch(() => null)
|
||||
if (conflict !== null && conflict.id !== project.value.id) {
|
||||
correctSlugConflict.value = conflict
|
||||
slugValidation.value = 'taken'
|
||||
} else {
|
||||
correctSlugConflict.value = null
|
||||
slugValidation.value = 'available'
|
||||
}
|
||||
slugValidation.value = 'checking'
|
||||
slugDebounceTimer = setTimeout(async () => {
|
||||
const conflict = await checkSlugTaken(value).catch(() => null)
|
||||
if (conflict !== null && conflict.id !== project.value.id) {
|
||||
correctSlugConflict.value = conflict
|
||||
slugValidation.value = 'taken'
|
||||
} else {
|
||||
correctSlugConflict.value = null
|
||||
slugValidation.value = 'available'
|
||||
}
|
||||
}, 400)
|
||||
}),
|
||||
}, 400)
|
||||
})
|
||||
.tweak(TagCategoryWandSparklesIcon, () =>
|
||||
autoSlugStatus.value === 'available' ? resolvedAutoSlug.value : null,
|
||||
)
|
||||
.tweak(UserPlusIcon, (current) =>
|
||||
ownerUsername.value && !current?.includes(ownerUsername.value)
|
||||
? `${current}-${ownerUsername.value}`
|
||||
: null,
|
||||
)
|
||||
.tweak(WrenchIcon, (current) => generateUrlSlug(current ?? ''))
|
||||
.tweak(TagCategoryRefreshCcwIcon, () => project.value.slug),
|
||||
|
||||
button()
|
||||
.icon(TagCategoryWandSparklesIcon)
|
||||
.tooltip(computed(() => resolvedAutoSlug.value ?? ''))
|
||||
.enabled(
|
||||
(state) =>
|
||||
autoSlugStatus.value === 'available' &&
|
||||
resolvedAutoSlug.value !== null &&
|
||||
resolvedAutoSlug.value !== currentSlug(state),
|
||||
)
|
||||
.onClick((state) => {
|
||||
if (resolvedAutoSlug.value) state['correct-slug'] = resolvedAutoSlug.value
|
||||
}),
|
||||
|
||||
button()
|
||||
.icon(UserPlusIcon)
|
||||
.tooltip((state) => {
|
||||
const current = currentSlug(state)
|
||||
if (!ownerUsername.value || current?.includes(ownerUsername.value))
|
||||
return current ?? ''
|
||||
return `${current}-${ownerUsername.value}`
|
||||
})
|
||||
.enabled(
|
||||
(state) =>
|
||||
ownerUsername.value !== null &&
|
||||
!currentSlug(state)?.includes(ownerUsername.value),
|
||||
)
|
||||
.onClick((state) => {
|
||||
state['correct-slug'] = `${currentSlug(state)}-${ownerUsername.value}`
|
||||
}),
|
||||
|
||||
button()
|
||||
.icon(TagCategoryRefreshCcwIcon)
|
||||
.tooltip(computed(() => project.value.slug ?? ''))
|
||||
.enabled((state) => currentSlug(state) !== project.value.slug)
|
||||
.onClick((state) => {
|
||||
state['correct-slug'] = project.value.slug
|
||||
}),
|
||||
|
||||
SlugStatus,
|
||||
),
|
||||
)
|
||||
.rawMessage(async (state) => {
|
||||
let correct = ''
|
||||
if (slugValidation.value === 'available') {
|
||||
const slug = state['correct-slug'] as string | undefined
|
||||
if (slug)
|
||||
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
|
||||
SUGGESTED_SLUG: slug,
|
||||
}))(state)
|
||||
}
|
||||
return md('checklist/messages/title-slug/slug/misused', () => ({
|
||||
CORRECT: correct,
|
||||
}))(state)
|
||||
})
|
||||
.fix(
|
||||
//TODO chyz think of some way to have initial values actually be reflected in state without having to store them
|
||||
fix().project((patch, state) => {
|
||||
const slug =
|
||||
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value
|
||||
if (!slug || slug === project.value.slug) return
|
||||
patch.slug = slug
|
||||
}),
|
||||
),
|
||||
),
|
||||
SlugStatus,
|
||||
),
|
||||
)
|
||||
.rawMessage(async (state) => {
|
||||
let correct = ''
|
||||
if (slugValidation.value === 'available') {
|
||||
const slug = state['correct-slug'] as string | undefined
|
||||
if (slug)
|
||||
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
|
||||
SUGGESTED_SLUG: slug,
|
||||
}))(state)
|
||||
}
|
||||
return md('checklist/messages/title-slug/slug/misused', () => ({
|
||||
CORRECT: correct,
|
||||
}))(state)
|
||||
})
|
||||
.fix(
|
||||
fix().project((patch, state) => {
|
||||
if (slugValidation.value !== 'available') return
|
||||
patch.slug = state['correct-slug'] as string
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,38 +19,31 @@ export default function () {
|
||||
group().children(
|
||||
toggle('incorrect-additional-files', 'Incorrect additional files')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('incorrect-project-type', 'Incorrect Project Type')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.children(
|
||||
dropdown('type')
|
||||
.title('Correct Project Type')
|
||||
.required()
|
||||
.none('Unknown')
|
||||
.children(
|
||||
option('modpack', 'Modpack')
|
||||
.shown(computed(() => !project.value.project_types.includes('modpack')))
|
||||
.message(),
|
||||
option('resourcepack', 'Resource Pack')
|
||||
.shown(computed(() => !project.value.project_types.includes('resourcepack')))
|
||||
.message(),
|
||||
option('datapack', 'Data Pack')
|
||||
.shown(computed(() => !project.value.loaders.includes('datapack')))
|
||||
.message(),
|
||||
.options(
|
||||
option('modpack', 'Modpack').message(),
|
||||
option('resourcepack', 'Resource Pack').message(),
|
||||
option('datapack', 'Data Pack').message(),
|
||||
),
|
||||
)
|
||||
.collect(),
|
||||
|
||||
toggle('alternate-versions', 'Alternate Versions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.children(
|
||||
dropdown('distribution')
|
||||
.title('Distribution Type')
|
||||
.required()
|
||||
.none('Unknown')
|
||||
.children(
|
||||
.options(
|
||||
option('primary', 'Primary Files').message(),
|
||||
option('additional', 'Additional Files').message(),
|
||||
option('mono', 'Monofile')
|
||||
@@ -78,7 +71,6 @@ export default function () {
|
||||
toggle('vanilla-assets', 'Vanilla Assets')
|
||||
.shown(computed(() => project.value.project_types.includes('resourcepack')))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('redist-libs', 'Packed Libs')
|
||||
@@ -90,18 +82,15 @@ export default function () {
|
||||
),
|
||||
)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('duplicate-primary-files', 'Duplicate Primary Files')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('unsupported', 'Unsupported')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
INVALID_TYPE: state['invalid-type'],
|
||||
}))
|
||||
.children(text('invalid-type').title('Unsupported Type').required()),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import keybinds from '../data/keybinds.ts'
|
||||
import {
|
||||
type KeybindDefinition,
|
||||
type KeybindListener,
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
type ModerationContext,
|
||||
normalizeKeybind,
|
||||
} from '../types/keybinds.ts'
|
||||
import keybinds from '../data/keybinds.ts'
|
||||
|
||||
function normalizeKeybinds(
|
||||
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
|
||||
@@ -58,6 +58,9 @@ export class Keybinds {
|
||||
continue
|
||||
}
|
||||
|
||||
// The scope check above guarantees ctx matches keybind's expected context shape,
|
||||
// but TS can't correlate that narrowing across these two independently-typed variables.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (keybind.enabled && !keybind.enabled(ctx as any)) {
|
||||
continue
|
||||
}
|
||||
@@ -66,6 +69,7 @@ export class Keybinds {
|
||||
const matches = definitions.some((def) => matchesKeybind(event, def))
|
||||
|
||||
if (matches) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
keybind.action(ctx as any)
|
||||
|
||||
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
|
||||
import { moderationSettings } from '../index.ts'
|
||||
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
|
||||
|
||||
export class Settings {
|
||||
private readonly settings: { [id: string]: any }
|
||||
private readonly settings: { [id: string]: unknown }
|
||||
private readonly onChange: () => void
|
||||
|
||||
constructor(
|
||||
settings: { [id: string]: any } | undefined = undefined,
|
||||
settings: { [id: string]: unknown } | undefined = undefined,
|
||||
onChange: () => void = () => {},
|
||||
) {
|
||||
this.settings = settings || {}
|
||||
@@ -16,11 +16,15 @@ export class Settings {
|
||||
get<T>(definition: SettingDefinitionBase<T>): T {
|
||||
const value = this.settings[definition.id]
|
||||
|
||||
return (isValidFor(definition, value) ? value : undefined) ?? definition.default
|
||||
return (
|
||||
(isValidFor(definition as SettingDefinitionBase<unknown>, value)
|
||||
? (value as T)
|
||||
: undefined) ?? definition.default
|
||||
)
|
||||
}
|
||||
|
||||
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
|
||||
const previous = this.settings[definition.id] ?? definition.default
|
||||
const previous = (this.settings[definition.id] as T | undefined) ?? definition.default
|
||||
this.settings[definition.id] = value
|
||||
definition.onChange?.(previous, value ?? definition.default)
|
||||
this.onChange()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as checklist, stages, useStages } from './data/checklist'
|
||||
export { useStages } from './data/checklist'
|
||||
export { default as keybinds } from './data/keybinds'
|
||||
export { default as moderationSettings } from './data/settings'
|
||||
export { default as nags } from './data/nags'
|
||||
export * from './data/nags/index'
|
||||
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
|
||||
@@ -9,17 +8,14 @@ export {
|
||||
type TechReviewContext,
|
||||
default as techReviewQuickReplies,
|
||||
} from './data/quick-replies/tech-review-quick-replies'
|
||||
export { default as moderationSettings } from './data/settings'
|
||||
export * from './handles/keybinds'
|
||||
export * from './handles/settings'
|
||||
export * from './locales'
|
||||
export * from './types/actions'
|
||||
export * from './types/keybinds'
|
||||
export * from './types/settings'
|
||||
export * from './types/messages'
|
||||
export * from './types/nags'
|
||||
export * from './types/node'
|
||||
export * from './types/priority'
|
||||
export * from './types/quick-reply'
|
||||
export * from './types/reports'
|
||||
export * from './types/stage'
|
||||
export * from './types/settings'
|
||||
export * from './utils'
|
||||
export * from './handles/keybinds'
|
||||
export * from './handles/settings'
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
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 ChecklistActionContext {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
projectV3: Labrinth.Projects.v3.Project
|
||||
versions?: Labrinth.Versions.v2.Version[] | null
|
||||
}
|
||||
|
||||
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: Labrinth.Projects.v2.Project,
|
||||
projectV3: Labrinth.Projects.v3.Project,
|
||||
context?: ChecklistActionContext,
|
||||
) => 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[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]
|
||||
|
||||
/**
|
||||
* Global fallback message if no variants match their conditions.
|
||||
*/
|
||||
fallbackMessage?: () => Promise<string>
|
||||
|
||||
/**
|
||||
* The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message.
|
||||
*/
|
||||
fallbackWeight?: number
|
||||
}
|
||||
|
||||
export interface DropdownActionOption extends WeightedMessage {
|
||||
/**
|
||||
* The label of the option, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this option should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the option is always shown.
|
||||
*/
|
||||
shouldShow?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3: Labrinth.Projects.v3.Project,
|
||||
context?: ChecklistActionContext,
|
||||
) => boolean
|
||||
}
|
||||
|
||||
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 {
|
||||
/**
|
||||
* Stable identifier for the option. If omitted, the label is used.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* The label of the chip, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this option should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the option is always shown.
|
||||
*/
|
||||
shouldShow?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3: Labrinth.Projects.v3.Project,
|
||||
context?: ChecklistActionContext,
|
||||
) => boolean
|
||||
}
|
||||
|
||||
export type MultiSelectChipsOptionsResolver = (
|
||||
context: ChecklistActionContext,
|
||||
) => MultiSelectChipsOption[]
|
||||
|
||||
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[] | MultiSelectChipsOptionsResolver
|
||||
|
||||
/**
|
||||
* If set, all selected option messages are joined with this string and emitted as a single
|
||||
* message part rather than individual parts. Useful for building bullet lists under a shared header.
|
||||
*/
|
||||
joinWith?: string
|
||||
}
|
||||
|
||||
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[]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,880 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue'
|
||||
import { markRaw, toValue } from 'vue'
|
||||
|
||||
import {
|
||||
expandVariables,
|
||||
flattenProjectV3Variables,
|
||||
flattenProjectVariables,
|
||||
flattenStaticVariables,
|
||||
} from '../utils'
|
||||
import type { ModerationSeverity, ModerationStatus } from './actions'
|
||||
import type { Priority } from './priority.ts'
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeState =
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| Set<string>
|
||||
| NodeStateWithChildren
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export interface NodeStateWithChildren {
|
||||
value?: NodeState
|
||||
[childId: string]: NodeState
|
||||
}
|
||||
|
||||
// ─── Function types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
|
||||
concat(...others: MessageFn[]): MessageFn
|
||||
}
|
||||
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
|
||||
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
|
||||
|
||||
export type Reactive<T> = T | Ref<T>
|
||||
|
||||
export function resolve<T>(value: Reactive<T>): T {
|
||||
return toValue(value as T | Ref<T>)
|
||||
}
|
||||
|
||||
export type ChildNode = NodeBuilder | (() => unknown) | string
|
||||
|
||||
export type ChildEntry =
|
||||
| NodeBuilder
|
||||
| string
|
||||
| (() => unknown)
|
||||
| null
|
||||
| Ref<NodeBuilder | null>
|
||||
| ((state?: Record<string, NodeState>) => NodeBuilder | NodeBuilder[] | null)
|
||||
|
||||
export type NodeType =
|
||||
| 'toggle'
|
||||
| 'check'
|
||||
| 'button'
|
||||
| 'text'
|
||||
| 'markdown'
|
||||
| 'group'
|
||||
| 'dropdown'
|
||||
| 'option'
|
||||
| 'stage'
|
||||
|
||||
// ─── Message helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const messageFiles = import.meta.glob('../data/messages/**/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
export function mdOptional(
|
||||
path: string,
|
||||
getVars?: (state: Record<string, any>) => Record<string, any>,
|
||||
): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const loader = messageFiles[`../data/messages/${path}.md`]
|
||||
if (!loader) return ''
|
||||
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
|
||||
export function mdEscape(text: string): string {
|
||||
return text.replace(/[\\*_`[~]/g, '\\$&')
|
||||
}
|
||||
|
||||
const USER_CONTENT_KEYS = [
|
||||
'PROJECT_TITLE',
|
||||
'PROJECT_SLUG',
|
||||
'PROJECT_SUMMARY',
|
||||
'PROJECT_TYPE',
|
||||
'PROJECT_STATUS',
|
||||
]
|
||||
|
||||
export async function loadMd(
|
||||
path: string,
|
||||
state: Record<string, NodeState>,
|
||||
project: Labrinth.Projects.v3.Project,
|
||||
projectV2: Labrinth.Projects.v2.Project,
|
||||
getVars?: (state: Record<string, any>) => Record<string, any>,
|
||||
): Promise<string> {
|
||||
// Call getVars before any await so Vue's watchEffect tracks reactive reads inside it
|
||||
const extraVars = getVars ? getVars(state) : null
|
||||
const loader = messageFiles[`../data/messages/${path}.md`]
|
||||
if (!loader) {
|
||||
_onMissingMd?.(path)
|
||||
return ''
|
||||
}
|
||||
const raw = (await loader()) as string
|
||||
const vars: Record<string, string> = {
|
||||
...flattenStaticVariables(),
|
||||
...flattenProjectVariables(projectV2),
|
||||
...flattenProjectV3Variables(project),
|
||||
}
|
||||
for (const key of USER_CONTENT_KEYS) {
|
||||
if (key in vars) vars[key] = mdEscape(vars[key])
|
||||
}
|
||||
if (extraVars) {
|
||||
for (const [k, v] of Object.entries(extraVars)) {
|
||||
vars[k] = String(v ?? '')
|
||||
}
|
||||
}
|
||||
const expanded = expandVariables(raw, projectV2, project, vars)
|
||||
// Code spans render literally — markdown escapes inside them show as-is, so strip them
|
||||
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
|
||||
}
|
||||
|
||||
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
|
||||
const rich = fn as MessageFn
|
||||
rich.concat = (...others) =>
|
||||
makeMessageFn(async (state) =>
|
||||
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
|
||||
)
|
||||
return rich
|
||||
}
|
||||
|
||||
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
|
||||
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
|
||||
let _onMissingMd: ((path: string) => void) | null = null
|
||||
|
||||
export function setMissingMdHandler(handler: (path: string) => void) {
|
||||
_onMissingMd = handler
|
||||
}
|
||||
|
||||
export function setMessageProject(
|
||||
project: Ref<Labrinth.Projects.v3.Project>,
|
||||
projectV2: Ref<Labrinth.Projects.v2.Project>,
|
||||
) {
|
||||
_project = project
|
||||
_projectV2 = projectV2
|
||||
}
|
||||
|
||||
export function md(
|
||||
path: string | ((state: Record<string, NodeState>) => string),
|
||||
getVars?: (state: Record<string, any>) => Record<string, any>,
|
||||
): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(state) : path
|
||||
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
// ─── Fix builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createTrackedPatch<T extends object>(
|
||||
source: T,
|
||||
): { proxy: T; changes: () => Partial<T> } {
|
||||
const written = new Set<string | symbol>()
|
||||
const data = { ...source }
|
||||
const proxy = new Proxy(data, {
|
||||
set(target, key, value) {
|
||||
if (value !== (source as Record<string | symbol, unknown>)[key]) {
|
||||
written.add(key)
|
||||
} else {
|
||||
written.delete(key)
|
||||
}
|
||||
;(target as Record<string | symbol, unknown>)[key] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
return {
|
||||
proxy,
|
||||
changes: () =>
|
||||
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
|
||||
}
|
||||
}
|
||||
|
||||
export class FixBuilder {
|
||||
_projectFn?: (
|
||||
patch: Labrinth.Projects.v3.EditProjectRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
_versionFn?: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
|
||||
project(
|
||||
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
|
||||
): this {
|
||||
this._projectFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
version(
|
||||
fn: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void,
|
||||
): this {
|
||||
this._versionFn = fn
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export function fix(): FixBuilder {
|
||||
return new FixBuilder()
|
||||
}
|
||||
|
||||
// ─── Message segments ─────────────────────────────────────────────────────────
|
||||
|
||||
type GetVarsFn = (state: Record<string, any>) => Record<string, any>
|
||||
|
||||
export type MessageSegment =
|
||||
| { type: 'fn'; fn: ContentFn }
|
||||
| { type: 'auto'; getVars?: GetVarsFn }
|
||||
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
|
||||
| { type: 'collect'; fallback?: MessageSegment }
|
||||
|
||||
export function resolveRelativeMessagePath(
|
||||
messagePath: string | (() => string),
|
||||
statePath: string[],
|
||||
): string {
|
||||
const name = typeof messagePath === 'function' ? messagePath() : messagePath
|
||||
if (name.startsWith('/')) return `checklist/messages${name}`
|
||||
const parts = [...statePath.slice(0, -1), ...name.split('/')]
|
||||
const normalized = parts.reduce<string[]>((acc, p) => {
|
||||
if (p === '..') acc.pop()
|
||||
else if (p) acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
return `checklist/messages/${normalized.join('/')}`
|
||||
}
|
||||
|
||||
export async function evalSegment(
|
||||
seg: MessageSegment,
|
||||
state: Record<string, NodeState>,
|
||||
statePath: string[],
|
||||
): Promise<string> {
|
||||
if (seg.type === 'collect') return ''
|
||||
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
|
||||
if (seg.type === 'auto')
|
||||
return loadMd(
|
||||
`checklist/messages/${statePath.join('/')}`,
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
return loadMd(
|
||||
resolveRelativeMessagePath(seg.path, statePath),
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Node builders ────────────────────────────────────────────────────────────
|
||||
|
||||
export abstract class NodeBuilder {
|
||||
abstract readonly type: NodeType
|
||||
_shown?: Reactive<boolean>
|
||||
_title?: Reactive<string>
|
||||
_icon?: FunctionalComponent<SVGAttributes>
|
||||
_tooltip?: Reactive<string> | ((state: Record<string, NodeState>) => string)
|
||||
|
||||
shown(condition: Reactive<boolean>): this {
|
||||
this._shown = condition
|
||||
return this
|
||||
}
|
||||
|
||||
title(t: Reactive<string>): this {
|
||||
this._title = t
|
||||
return this
|
||||
}
|
||||
|
||||
icon(i: FunctionalComponent<SVGAttributes>): this {
|
||||
this._icon = markRaw(i)
|
||||
return this
|
||||
}
|
||||
|
||||
tooltip(t: Reactive<string> | ((state: Record<string, NodeState>) => string)): this {
|
||||
this._tooltip = t
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonNodeBuilder extends NodeBuilder {
|
||||
readonly type = 'button' as const
|
||||
readonly label?: string
|
||||
_onClick?: (state: Record<string, NodeState>) => void
|
||||
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
|
||||
|
||||
constructor(nodeLabel?: string) {
|
||||
super()
|
||||
this.label = nodeLabel
|
||||
}
|
||||
|
||||
onClick(fn: (state: Record<string, NodeState>) => void): this {
|
||||
this._onClick = fn
|
||||
return this
|
||||
}
|
||||
|
||||
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
|
||||
this._enabled = condition
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class IdentifiedNodeBuilder extends NodeBuilder {
|
||||
readonly id: string | undefined
|
||||
_children: ChildEntry[] = []
|
||||
_childrenFn?: ChildrenFn
|
||||
_computingChildren = false
|
||||
_segments: MessageSegment[] = []
|
||||
_suggestedStatus?: ModerationStatus
|
||||
_severity?: ModerationSeverity
|
||||
_priority?: Priority
|
||||
_fixes: FixBuilder[] = []
|
||||
_applyFixes = false
|
||||
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
|
||||
_statePath?: string[]
|
||||
|
||||
constructor(id: string | undefined) {
|
||||
super()
|
||||
this.id = id
|
||||
}
|
||||
|
||||
children(fn: ChildrenFn): this
|
||||
children(...entries: ChildEntry[]): this
|
||||
children(...args: [ChildrenFn] | ChildEntry[]): this {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
this._childrenFn = args[0] as ChildrenFn
|
||||
} else {
|
||||
this._children.push(...(args as ChildEntry[]))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
message(path?: string | (() => string), getVars?: GetVarsFn): this {
|
||||
const segment: MessageSegment =
|
||||
path === undefined
|
||||
? { type: 'auto', ...(getVars && { getVars }) }
|
||||
: { type: 'path', path, ...(getVars && { getVars }) }
|
||||
this._segments.push(segment)
|
||||
return this
|
||||
}
|
||||
|
||||
rawMessage(content: string | ContentFn): this {
|
||||
const fn: ContentFn = typeof content === 'string' ? () => content : content
|
||||
this._segments.push({ type: 'fn', fn })
|
||||
return this
|
||||
}
|
||||
|
||||
collect(path?: string | (() => string), getVars?: GetVarsFn): this {
|
||||
const fallback =
|
||||
path !== undefined || getVars !== undefined
|
||||
? path === undefined
|
||||
? { type: 'auto' as const, ...(getVars && { getVars }) }
|
||||
: { type: 'path' as const, path, ...(getVars && { getVars }) }
|
||||
: undefined
|
||||
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
|
||||
return this
|
||||
}
|
||||
|
||||
suggestedStatus(s: ModerationStatus): this {
|
||||
this._suggestedStatus = s
|
||||
return this
|
||||
}
|
||||
|
||||
severity(s: ModerationSeverity): this {
|
||||
this._severity = s
|
||||
return this
|
||||
}
|
||||
|
||||
priority(p: Priority): this {
|
||||
this._priority = p
|
||||
return this
|
||||
}
|
||||
|
||||
fix(f: FixBuilder): this {
|
||||
this._fixes.push(f)
|
||||
return this
|
||||
}
|
||||
|
||||
applyFixes(): this {
|
||||
this._applyFixes = true
|
||||
return this
|
||||
}
|
||||
|
||||
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
|
||||
this._enabled = condition
|
||||
return this
|
||||
}
|
||||
|
||||
statePath(path: string[]): this {
|
||||
this._statePath = path
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class LabeledNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly label: string
|
||||
|
||||
constructor(id: string, label: string) {
|
||||
super(id)
|
||||
this.label = label
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ValueNodeBuilder extends LabeledNodeBuilder {
|
||||
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
|
||||
_required?: boolean
|
||||
|
||||
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
|
||||
this._defaultValue = v
|
||||
return this
|
||||
}
|
||||
|
||||
required(v = true): this {
|
||||
this._required = v
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class BooleanNodeBuilder extends ValueNodeBuilder {
|
||||
readonly type: 'toggle' | 'check'
|
||||
|
||||
constructor(id: string, nodeLabel: string, type: 'toggle' | 'check') {
|
||||
super(id, nodeLabel)
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
|
||||
export type OverrideValue = { readonly __override: string }
|
||||
|
||||
export type OnChangeFn = (
|
||||
value: string,
|
||||
helpers: { override: (value: string) => OverrideValue },
|
||||
) => OverrideValue | void
|
||||
|
||||
export class InputNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly type: 'text' | 'markdown'
|
||||
_placeholder?: Reactive<string>
|
||||
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
|
||||
_required?: boolean
|
||||
_onChange?: OnChangeFn
|
||||
|
||||
constructor(id: string, type: 'text' | 'markdown') {
|
||||
super(id)
|
||||
this.type = type
|
||||
}
|
||||
|
||||
placeholder(p: Reactive<string>): this {
|
||||
this._placeholder = p
|
||||
return this
|
||||
}
|
||||
|
||||
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
|
||||
this._defaultValue = v
|
||||
return this
|
||||
}
|
||||
|
||||
required(v = true): this {
|
||||
this._required = v
|
||||
return this
|
||||
}
|
||||
|
||||
onChange(fn: OnChangeFn): this {
|
||||
this._onChange = fn
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly type = 'group' as const
|
||||
_layout?: 'flex' | 'column'
|
||||
_required?: boolean
|
||||
_selectMode?: 'single' | 'multi'
|
||||
_selectId?: string
|
||||
|
||||
layout(l: 'flex' | 'column'): this {
|
||||
this._layout = l
|
||||
return this
|
||||
}
|
||||
|
||||
required(v = true): this {
|
||||
this._required = v
|
||||
return this
|
||||
}
|
||||
|
||||
singleSelect(id?: string): this {
|
||||
this._selectMode = 'single'
|
||||
if (id !== undefined) this._selectId = id
|
||||
return this
|
||||
}
|
||||
|
||||
multiSelect(id?: string): this {
|
||||
this._selectMode = 'multi'
|
||||
if (id !== undefined) this._selectId = id
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class DropdownNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly type = 'dropdown' as const
|
||||
_none?: string
|
||||
|
||||
override children(fn: ChildrenFn): this
|
||||
override children(...nodes: OptionNodeBuilder[]): this
|
||||
override children(...args: [ChildrenFn] | OptionNodeBuilder[]): this {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
super.children(args[0] as ChildrenFn)
|
||||
} else {
|
||||
super.children(...(args as OptionNodeBuilder[]))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
none(text: string): this {
|
||||
this._none = text
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class OptionNodeBuilder extends LabeledNodeBuilder {
|
||||
readonly type = 'option' as const
|
||||
}
|
||||
|
||||
export class StageNodeBuilder extends LabeledNodeBuilder {
|
||||
readonly type = 'stage' as const
|
||||
_hint?: string
|
||||
_guidanceUrl?: string
|
||||
_icon?: FunctionalComponent<SVGAttributes>
|
||||
_navigate?: string
|
||||
|
||||
hint(h: string): this {
|
||||
this._hint = h
|
||||
return this
|
||||
}
|
||||
|
||||
guidance(url: string): this {
|
||||
this._guidanceUrl = url
|
||||
return this
|
||||
}
|
||||
|
||||
icon(i: FunctionalComponent<SVGAttributes>): this {
|
||||
this._icon = markRaw(i)
|
||||
return this
|
||||
}
|
||||
|
||||
navigate(path: string): this {
|
||||
this._navigate = path
|
||||
return this
|
||||
}
|
||||
|
||||
override children(fn: ChildrenFn): this
|
||||
override children(...entries: ChildEntry[]): this
|
||||
override children(...args: [ChildrenFn] | ChildEntry[]): this {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
super.children(args[0] as ChildrenFn)
|
||||
if (!this._statePath) this._statePath = [this.id!]
|
||||
return this
|
||||
}
|
||||
const entries = args as ChildEntry[]
|
||||
super.children(...entries)
|
||||
if (!this._statePath) this._statePath = [this.id!]
|
||||
stampChildPaths(entries, [this.id!])
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export const STAGES_KEY: InjectionKey<Ref<StageNodeBuilder[]>> = Symbol('checklistStages')
|
||||
export const GLOBAL_STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
|
||||
Symbol('checklistGlobalState')
|
||||
|
||||
// ─── Node traversal ───────────────────────────────────────────────────────────
|
||||
|
||||
function childrenScopePath(node: IdentifiedNodeBuilder): string[] | null {
|
||||
if (!node._statePath) return null
|
||||
switch (node.type) {
|
||||
case 'toggle':
|
||||
case 'check':
|
||||
case 'option':
|
||||
case 'stage':
|
||||
return node._statePath
|
||||
case 'group': {
|
||||
const g = node as GroupNodeBuilder
|
||||
if (g._selectMode) {
|
||||
// Option ids become namespaces at the same level as the group
|
||||
return node._statePath.slice(0, -1)
|
||||
}
|
||||
return node._statePath
|
||||
}
|
||||
case 'dropdown':
|
||||
// Option ids become namespaces at the same level as the dropdown
|
||||
return node._statePath.slice(0, -1)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stampChildPaths(entries: ChildEntry[], scopePath: string[]): void {
|
||||
for (const entry of entries) {
|
||||
// Skip reactive entries (refs, functions, nulls) — they get stamped on resolution
|
||||
if (!(entry instanceof NodeBuilder)) continue
|
||||
if (!(entry instanceof IdentifiedNodeBuilder)) continue
|
||||
const selectId = entry instanceof GroupNodeBuilder ? entry._selectId : undefined
|
||||
if (!entry.id && !selectId) {
|
||||
// Truly transparent: no structural id and no select id
|
||||
stampChildPaths(entry._children, scopePath)
|
||||
continue
|
||||
}
|
||||
if (!entry._statePath) {
|
||||
// Structural id contributes to path first, then select id
|
||||
const pathComponents = entry.id
|
||||
? selectId
|
||||
? [...scopePath, entry.id, selectId]
|
||||
: [...scopePath, entry.id]
|
||||
: [...scopePath, selectId!]
|
||||
entry._statePath = pathComponents
|
||||
}
|
||||
const childScope = childrenScopePath(entry)
|
||||
if (childScope) stampChildPaths(entry._children, childScope)
|
||||
}
|
||||
}
|
||||
|
||||
export function isNodeActive(node: NodeBuilder, state: NodeState): boolean {
|
||||
switch (node.type) {
|
||||
case 'toggle':
|
||||
case 'check':
|
||||
case 'option': {
|
||||
if (typeof state === 'boolean') return state
|
||||
if (state && typeof state === 'object' && !(state instanceof Set)) {
|
||||
const v = (state as NodeStateWithChildren).value
|
||||
if (typeof v === 'boolean') return v
|
||||
}
|
||||
return (node as BooleanNodeBuilder)._defaultValue === true
|
||||
}
|
||||
case 'group': {
|
||||
const g = node as GroupNodeBuilder
|
||||
if (g._selectMode === 'single') return typeof state === 'string' && state !== ''
|
||||
if (g._selectMode === 'multi') return state instanceof Set && state.size > 0
|
||||
return false
|
||||
}
|
||||
case 'dropdown':
|
||||
return typeof state === 'string' && state !== ''
|
||||
case 'text':
|
||||
case 'markdown':
|
||||
return typeof state === 'string' && state !== ''
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
|
||||
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
|
||||
return nodeState as Record<string, NodeState>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function resolveChildren(
|
||||
node: IdentifiedNodeBuilder,
|
||||
state: Record<string, NodeState>,
|
||||
): ChildNode[] {
|
||||
let entries: ChildEntry[]
|
||||
let stampStatic = false
|
||||
|
||||
if (node._childrenFn) {
|
||||
if (node._computingChildren) return []
|
||||
node._computingChildren = true
|
||||
try {
|
||||
entries = node._childrenFn(state)
|
||||
stampStatic = true // childrenFn results haven't been pre-stamped
|
||||
} finally {
|
||||
node._computingChildren = false
|
||||
}
|
||||
} else {
|
||||
entries = node._children
|
||||
}
|
||||
|
||||
const scopePath = childrenScopePath(node)
|
||||
const result: ChildNode[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry == null) continue
|
||||
if (entry instanceof NodeBuilder) {
|
||||
if (entry._shown !== undefined && !resolve(entry._shown)) continue
|
||||
if (stampStatic && scopePath) stampChildPaths([entry], scopePath)
|
||||
result.push(entry)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
result.push(entry)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'function') {
|
||||
if (entry.length === 0) {
|
||||
result.push(entry as () => unknown)
|
||||
continue
|
||||
}
|
||||
const resolved = entry(state)
|
||||
if (resolved == null) continue
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const r of resolved) {
|
||||
if (r == null) continue
|
||||
if (r._shown !== undefined && !resolve(r._shown)) continue
|
||||
if (scopePath) stampChildPaths([r], scopePath)
|
||||
result.push(r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
|
||||
if (scopePath) stampChildPaths([resolved], scopePath)
|
||||
result.push(resolved)
|
||||
continue
|
||||
}
|
||||
// Ref<NodeBuilder | null>
|
||||
const resolved = toValue(entry as Ref<NodeBuilder | null>)
|
||||
if (resolved == null) continue
|
||||
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
|
||||
if (scopePath) stampChildPaths([resolved], scopePath)
|
||||
result.push(resolved)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function walkNodes(
|
||||
nodes: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
visitor: (
|
||||
node: IdentifiedNodeBuilder,
|
||||
nodeState: NodeState,
|
||||
localState: Record<string, NodeState>,
|
||||
) => void,
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof NodeBuilder)) continue
|
||||
if (node._shown !== undefined && !resolve(node._shown)) continue
|
||||
|
||||
if (node.type === 'stage') {
|
||||
const identified = node as IdentifiedNodeBuilder
|
||||
if (identified.id) {
|
||||
const raw = stageState[identified.id]
|
||||
const childState =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
walkNodes(resolveChildren(identified, childState), childState, visitor)
|
||||
} else {
|
||||
walkNodes(resolveChildren(identified, stageState), stageState, visitor)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.type === 'group') {
|
||||
const g = node as GroupNodeBuilder
|
||||
if (!g._selectMode) {
|
||||
// Plain container: traverse children in group's own state scope
|
||||
if (g.id) {
|
||||
const raw = stageState[g.id]
|
||||
const childState =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
walkNodes(resolveChildren(g, childState), childState, visitor)
|
||||
} else {
|
||||
walkNodes(resolveChildren(g, stageState), stageState, visitor)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Fall through to value-node path for groups with selectMode
|
||||
}
|
||||
|
||||
if (node.type === 'button') continue
|
||||
|
||||
const identified = node as IdentifiedNodeBuilder
|
||||
const stateKey =
|
||||
node.type === 'group'
|
||||
? ((node as GroupNodeBuilder)._selectId ?? identified.id!)
|
||||
: identified.id!
|
||||
const nodeState = stageState[stateKey]
|
||||
visitor(identified, nodeState, stageState)
|
||||
|
||||
const active = isNodeActive(node, nodeState)
|
||||
const children = resolveChildren(identified, stageState)
|
||||
if (children.length === 0 || !active) continue
|
||||
|
||||
if (node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'multi') {
|
||||
const selected = nodeState instanceof Set ? nodeState : new Set<string>()
|
||||
for (const child of children) {
|
||||
const childId = child as IdentifiedNodeBuilder
|
||||
if (!selected.has(childId.id!) || (child._shown !== undefined && !resolve(child._shown)))
|
||||
continue
|
||||
const rawChildState = stageState[childId.id!]
|
||||
// Option children are active by virtue of being in the selected Set, not by their own
|
||||
// boolean state. If they have child state but no explicit value, inject value: true
|
||||
// so isNodeActive returns true even after child state has been written to their path.
|
||||
const childState: NodeState =
|
||||
rawChildState !== null &&
|
||||
rawChildState !== undefined &&
|
||||
typeof rawChildState === 'object' &&
|
||||
!(rawChildState instanceof Set) &&
|
||||
(rawChildState as NodeStateWithChildren).value === undefined
|
||||
? { ...(rawChildState as NodeStateWithChildren), value: true }
|
||||
: (rawChildState ?? true)
|
||||
visitor(childId, childState, stageState)
|
||||
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
|
||||
}
|
||||
} else if (node.type === 'toggle' || node.type === 'check') {
|
||||
const childState = getBooleanChildState(nodeState)
|
||||
walkNodes(children, childState, visitor)
|
||||
} else if (
|
||||
(node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'single') ||
|
||||
node.type === 'dropdown'
|
||||
) {
|
||||
const selectedId = typeof nodeState === 'string' ? nodeState : undefined
|
||||
if (selectedId) {
|
||||
for (const child of children) {
|
||||
const childId = child as IdentifiedNodeBuilder
|
||||
if (childId.id !== selectedId || (child._shown !== undefined && !resolve(child._shown)))
|
||||
continue
|
||||
const rawChildState = stageState[childId.id!]
|
||||
const childState: NodeState =
|
||||
rawChildState !== null &&
|
||||
rawChildState !== undefined &&
|
||||
typeof rawChildState === 'object' &&
|
||||
!(rawChildState instanceof Set) &&
|
||||
(rawChildState as NodeStateWithChildren).value === undefined
|
||||
? { ...(rawChildState as NodeStateWithChildren), value: true }
|
||||
: (rawChildState ?? true)
|
||||
visitor(childId, childState, stageState)
|
||||
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
walkNodes(children, stageState, visitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Factory functions ────────────────────────────────────────────────────────
|
||||
|
||||
export type StageFn = (
|
||||
project: Labrinth.Projects.v3.Project,
|
||||
projectV2: Labrinth.Projects.v2.Project,
|
||||
) => StageNodeBuilder
|
||||
|
||||
export function stageFn(factory: StageFn): StageFn {
|
||||
const cache = new WeakMap<object, StageNodeBuilder>()
|
||||
return (project, projectV2) => {
|
||||
if (!cache.has(project)) cache.set(project, factory(project, projectV2))
|
||||
return cache.get(project)!
|
||||
}
|
||||
}
|
||||
|
||||
export const toggle = (id: string, nodeLabel: string) =>
|
||||
new BooleanNodeBuilder(id, nodeLabel, 'toggle')
|
||||
export const check = (id: string, nodeLabel: string) =>
|
||||
new BooleanNodeBuilder(id, nodeLabel, 'check')
|
||||
export const button = (nodeLabel?: string) => new ButtonNodeBuilder(nodeLabel)
|
||||
export const text = (id: string) => new InputNodeBuilder(id, 'text')
|
||||
export const markdown = (id: string) => new InputNodeBuilder(id, 'markdown')
|
||||
export const group = (id?: string) => new GroupNodeBuilder(id)
|
||||
export const dropdown = (id: string) => new DropdownNodeBuilder(id)
|
||||
export const option = (id: string, nodeLabel: string) => new OptionNodeBuilder(id, nodeLabel)
|
||||
export const stage = (id: string, title: string) => new StageNodeBuilder(id, title)
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { Configurable, Visible } from './capabilities'
|
||||
import type { NodeState } from './state'
|
||||
|
||||
export type AnyNode = Visible
|
||||
|
||||
export type ChildNode = AnyNode | (() => unknown) | string
|
||||
|
||||
export type ChildEntry =
|
||||
| AnyNode
|
||||
| string
|
||||
| (() => unknown)
|
||||
| null
|
||||
| Ref<AnyNode | null>
|
||||
| ((state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null)
|
||||
|
||||
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
|
||||
|
||||
export interface HasChildren {
|
||||
_children: ChildEntry[]
|
||||
_childrenFn?: ChildrenFn
|
||||
children(this: this, fn: ChildrenFn): this
|
||||
children(this: this, ...entries: ChildEntry[]): this
|
||||
}
|
||||
|
||||
export function withChildren<T extends object>(node: T): T & HasChildren {
|
||||
return Object.assign(node, {
|
||||
_children: [] as ChildEntry[],
|
||||
children(this: HasChildren, ...args: [ChildrenFn] | ChildEntry[]) {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
this._childrenFn = args[0] as ChildrenFn
|
||||
} else {
|
||||
this._children.push(...(args as ChildEntry[]))
|
||||
}
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function withAutoProps<T extends Configurable>(
|
||||
node: T,
|
||||
): T & { [key: string]: (value: unknown) => T } {
|
||||
return new Proxy(node, {
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop === 'string' && !prop.startsWith('_') && !(prop in target)) {
|
||||
return (value: unknown) => {
|
||||
const prev = target._extraProps
|
||||
target._extraProps = (ctx) => ({ ...prev?.(ctx), [prop]: value })
|
||||
return receiver
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
}) as T & { [key: string]: (value: unknown) => T }
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import type { Component, FunctionalComponent, SVGAttributes } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import { Priority } from '../priority.ts'
|
||||
import type { FixBuilder } from './fix'
|
||||
import type { GetVarsFn, MessageSegment, ModerationStatus, NodeState, Reactive } from './state'
|
||||
|
||||
export interface Visible {
|
||||
_shown: Reactive<boolean> | undefined
|
||||
shown(this: this, condition: Reactive<boolean>): this
|
||||
}
|
||||
|
||||
export function withShown<T extends object>(node: T): T & Visible {
|
||||
return Object.assign(node, {
|
||||
_shown: undefined as Reactive<boolean> | undefined,
|
||||
shown(this: Visible, condition: Reactive<boolean>) {
|
||||
this._shown = condition
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Iconable {
|
||||
_icon: FunctionalComponent<SVGAttributes> | undefined
|
||||
icon(this: this, icon: FunctionalComponent<SVGAttributes> | undefined): this
|
||||
}
|
||||
|
||||
export function withIcon<T extends object>(node: T): T & Iconable {
|
||||
return Object.assign(node, {
|
||||
_icon: undefined as FunctionalComponent<SVGAttributes> | undefined,
|
||||
icon(this: Iconable, icon: FunctionalComponent<SVGAttributes> | undefined) {
|
||||
this._icon = icon ? markRaw(icon) : undefined
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Tooltippable {
|
||||
_tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined
|
||||
tooltip(
|
||||
this: this,
|
||||
tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined,
|
||||
): this
|
||||
}
|
||||
|
||||
export function withTooltip<T extends object>(node: T): T & Tooltippable {
|
||||
return Object.assign(node, {
|
||||
_tooltip: undefined as Tooltippable['_tooltip'],
|
||||
tooltip(this: Tooltippable, tooltip: Tooltippable['_tooltip']) {
|
||||
this._tooltip = tooltip
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Titled {
|
||||
_title: Reactive<string> | undefined
|
||||
title(this: this, title: Reactive<string>): this
|
||||
}
|
||||
|
||||
export function withTitle<T extends object>(node: T): T & Titled {
|
||||
return Object.assign(node, {
|
||||
_title: undefined as Reactive<string> | undefined,
|
||||
title(this: Titled, title: Reactive<string>) {
|
||||
this._title = title
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Identified {
|
||||
id: string
|
||||
_statePath?: string[]
|
||||
statePath(this: this, path: string[]): this
|
||||
}
|
||||
|
||||
export function withId<T extends object>(node: T, id: string): T & Identified {
|
||||
return Object.assign(node, {
|
||||
id,
|
||||
statePath(this: Identified, path: string[]) {
|
||||
this._statePath = path
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Prioritizable {
|
||||
_priority: Priority
|
||||
priority(this: this, p: Priority): this
|
||||
}
|
||||
|
||||
export function withPriority<T extends object>(node: T): T & Prioritizable {
|
||||
return Object.assign(node, {
|
||||
_priority: new Priority(),
|
||||
priority(this: Prioritizable, p: Priority) {
|
||||
this._priority = p
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Messageable {
|
||||
_segments: MessageSegment[]
|
||||
_suggestedStatus?: ModerationStatus
|
||||
suggestedStatus(this: this, status: ModerationStatus): this
|
||||
message(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
|
||||
rawMessage(
|
||||
this: this,
|
||||
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
|
||||
): this
|
||||
collect(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
|
||||
}
|
||||
|
||||
function splitPathAndVars(
|
||||
pathOrGetVars: string | (() => string) | GetVarsFn | undefined,
|
||||
getVars: GetVarsFn | undefined,
|
||||
): { path: string | (() => string) | undefined; getVars: GetVarsFn | undefined } {
|
||||
if (typeof pathOrGetVars === 'function' && pathOrGetVars.length >= 1) {
|
||||
return { path: undefined, getVars: pathOrGetVars as GetVarsFn }
|
||||
}
|
||||
return { path: pathOrGetVars as string | (() => string) | undefined, getVars }
|
||||
}
|
||||
|
||||
export function withMessaging<T extends object>(node: T): T & Messageable {
|
||||
return Object.assign(node, {
|
||||
_segments: [] as MessageSegment[],
|
||||
suggestedStatus(this: Messageable, status: ModerationStatus) {
|
||||
this._suggestedStatus = status
|
||||
return this
|
||||
},
|
||||
message(
|
||||
this: Messageable,
|
||||
pathOrGetVars?: string | (() => string) | GetVarsFn,
|
||||
getVarsArg?: GetVarsFn,
|
||||
) {
|
||||
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
|
||||
const segment: MessageSegment =
|
||||
path === undefined
|
||||
? { type: 'auto', ...(getVars && { getVars }) }
|
||||
: { type: 'path', path, ...(getVars && { getVars }) }
|
||||
this._segments.push(segment)
|
||||
return this
|
||||
},
|
||||
rawMessage(
|
||||
this: Messageable,
|
||||
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
|
||||
) {
|
||||
const fn = typeof content === 'string' ? () => content : content
|
||||
this._segments.push({ type: 'fn', fn })
|
||||
return this
|
||||
},
|
||||
collect(
|
||||
this: Messageable,
|
||||
pathOrGetVars?: string | (() => string) | GetVarsFn,
|
||||
getVarsArg?: GetVarsFn,
|
||||
) {
|
||||
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
|
||||
const fallback: MessageSegment | undefined =
|
||||
path !== undefined || getVars !== undefined
|
||||
? path === undefined
|
||||
? { type: 'auto', ...(getVars && { getVars }) }
|
||||
: { type: 'path', path, ...(getVars && { getVars }) }
|
||||
: undefined
|
||||
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Requireable {
|
||||
_required: boolean | undefined
|
||||
required(this: this, v?: boolean): this
|
||||
}
|
||||
|
||||
export function withRequired<T extends object>(node: T): T & Requireable {
|
||||
return Object.assign(node, {
|
||||
_required: undefined as boolean | undefined,
|
||||
required(this: Requireable, v = true) {
|
||||
this._required = v
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Fixable {
|
||||
_fixes: FixBuilder[]
|
||||
_applyFixes?: boolean
|
||||
fix(this: this, f: FixBuilder): this
|
||||
applyFixes(this: this): this
|
||||
}
|
||||
|
||||
export function withFix<T extends object>(node: T): T & Fixable {
|
||||
return Object.assign(node, {
|
||||
_fixes: [] as FixBuilder[],
|
||||
fix(this: Fixable, f: FixBuilder) {
|
||||
this._fixes.push(f)
|
||||
return this
|
||||
},
|
||||
applyFixes(this: Fixable) {
|
||||
this._applyFixes = true
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Enableable {
|
||||
_enabled: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean) | undefined
|
||||
enabled(this: this, condition: Enableable['_enabled']): this
|
||||
}
|
||||
|
||||
export function withEnabled<T extends object>(node: T): T & Enableable {
|
||||
return Object.assign(node, {
|
||||
_enabled: undefined as Enableable['_enabled'],
|
||||
enabled(this: Enableable, condition: Enableable['_enabled']) {
|
||||
this._enabled = condition
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface HasValue<V = unknown> {
|
||||
_defaultValue?: V | ((state: Record<string, NodeState>) => V)
|
||||
_getValue: (raw: NodeState) => V
|
||||
_setValue: (raw: NodeState, next: V, isDefault?: boolean) => NodeState
|
||||
_isActive: (value: V) => boolean
|
||||
initial(this: this, v: V | ((state: Record<string, NodeState>) => V)): this
|
||||
}
|
||||
|
||||
export function withValue<T extends object, V>(
|
||||
node: T,
|
||||
behavior: Pick<HasValue<V>, '_getValue' | '_setValue' | '_isActive'>,
|
||||
): T & HasValue<V> {
|
||||
return Object.assign(node, {
|
||||
...behavior,
|
||||
initial(this: HasValue<V>, v: V | ((state: Record<string, NodeState>) => V)) {
|
||||
this._defaultValue = v
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Clickable {
|
||||
_onClick: ((state: Record<string, NodeState>) => void) | undefined
|
||||
onClick(this: this, fn: Clickable['_onClick']): this
|
||||
}
|
||||
|
||||
export function withOnClick<T extends object>(node: T): T & Clickable {
|
||||
return Object.assign(node, {
|
||||
_onClick: undefined as Clickable['_onClick'],
|
||||
onClick(this: Clickable, fn: Clickable['_onClick']) {
|
||||
this._onClick = fn
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface ComponentNodePropsContext {
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
toggleSetValue?: (value: string) => void
|
||||
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
|
||||
tooltip?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Renderable {
|
||||
_component: Component | undefined
|
||||
_rendererKey: string | undefined
|
||||
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_modelProp: string
|
||||
}
|
||||
|
||||
export function withComponent<T extends object>(
|
||||
node: T,
|
||||
opts: {
|
||||
component?: Component
|
||||
rendererKey?: string
|
||||
modelProp?: string
|
||||
componentProps?: Renderable['_componentProps']
|
||||
},
|
||||
): T & Renderable {
|
||||
return Object.assign(node, {
|
||||
_component: opts.component,
|
||||
_rendererKey: opts.rendererKey,
|
||||
_componentProps: opts.componentProps,
|
||||
_modelProp: opts.modelProp ?? 'modelValue',
|
||||
})
|
||||
}
|
||||
|
||||
export interface Configurable {
|
||||
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined
|
||||
}
|
||||
|
||||
export function withExtraProps<T extends object>(node: T): T & Configurable {
|
||||
return Object.assign(node, {
|
||||
_extraProps: undefined as Configurable['_extraProps'],
|
||||
})
|
||||
}
|
||||
|
||||
export interface HasNoneLabel {
|
||||
_none: string | undefined
|
||||
none(this: this, text: string): this
|
||||
}
|
||||
|
||||
export function withNoneLabel<T extends object>(node: T): T & HasNoneLabel {
|
||||
return Object.assign(node, {
|
||||
_none: undefined as string | undefined,
|
||||
none(this: HasNoneLabel, text: string) {
|
||||
this._none = text
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Layoutable {
|
||||
_layout: 'flex' | 'column' | undefined
|
||||
layout(this: this, value: 'flex' | 'column'): this
|
||||
}
|
||||
|
||||
export function withLayout<T extends object>(node: T): T & Layoutable {
|
||||
return Object.assign(node, {
|
||||
_layout: undefined as 'flex' | 'column' | undefined,
|
||||
layout(this: Layoutable, value: 'flex' | 'column') {
|
||||
this._layout = value
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Selectable {
|
||||
_exclusive: boolean | undefined
|
||||
singleSelect(this: this): this
|
||||
}
|
||||
|
||||
export function withSelectable<T extends object>(node: T): T & Selectable {
|
||||
return Object.assign(node, {
|
||||
_exclusive: undefined as boolean | undefined,
|
||||
singleSelect(this: Selectable) {
|
||||
this._exclusive = true
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface StageMeta {
|
||||
_hint: string | undefined
|
||||
_guidanceUrl: string | undefined
|
||||
_navigate: string | undefined
|
||||
_shownSticky: boolean | undefined
|
||||
hint(this: this, hint: string): this
|
||||
guidance(this: this, url: string): this
|
||||
navigate(this: this, path?: string): this
|
||||
sticky(this: this): this
|
||||
}
|
||||
|
||||
export function withStageMeta<T extends object>(node: T): T & StageMeta {
|
||||
return Object.assign(node, {
|
||||
_hint: undefined as string | undefined,
|
||||
_guidanceUrl: undefined as string | undefined,
|
||||
_navigate: undefined as string | undefined,
|
||||
_shownSticky: undefined as boolean | undefined,
|
||||
hint(this: StageMeta, hint: string) {
|
||||
this._hint = hint
|
||||
return this
|
||||
},
|
||||
guidance(this: StageMeta, url: string) {
|
||||
this._guidanceUrl = url
|
||||
return this
|
||||
},
|
||||
navigate(this: StageMeta, path?: string) {
|
||||
this._navigate = path ?? ''
|
||||
return this
|
||||
},
|
||||
sticky(this: StageMeta) {
|
||||
this._shownSticky = true
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type OverrideValue = { readonly __override: string }
|
||||
|
||||
export type OnChangeFn = (
|
||||
value: string,
|
||||
helpers: { override: (value: string) => OverrideValue },
|
||||
) => OverrideValue | void
|
||||
|
||||
export interface Editable {
|
||||
_placeholder: Reactive<string> | undefined
|
||||
_onChange: OnChangeFn | undefined
|
||||
_showTooltip: boolean | undefined
|
||||
_imperativeSync: boolean | undefined
|
||||
placeholder(this: this, p: Reactive<string>): this
|
||||
onChange(this: this, fn: OnChangeFn): this
|
||||
}
|
||||
|
||||
export function withEditable<T extends object>(node: T): T & Editable {
|
||||
return Object.assign(node, {
|
||||
_placeholder: undefined as Reactive<string> | undefined,
|
||||
_onChange: undefined as OnChangeFn | undefined,
|
||||
_showTooltip: undefined as boolean | undefined,
|
||||
_imperativeSync: undefined as boolean | undefined,
|
||||
placeholder(this: Editable, p: Reactive<string>) {
|
||||
this._placeholder = p
|
||||
return this
|
||||
},
|
||||
onChange(this: Editable, fn: OnChangeFn) {
|
||||
this._onChange = fn
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface TweakDef<V = unknown> {
|
||||
icon: FunctionalComponent<SVGAttributes>
|
||||
compute: (current: V, state: Record<string, NodeState>) => V | null | undefined
|
||||
}
|
||||
|
||||
export interface Tweakable<V = unknown> {
|
||||
_tweaks: TweakDef<V>[]
|
||||
tweak(this: this, icon: FunctionalComponent<SVGAttributes>, compute: TweakDef<V>['compute']): this
|
||||
}
|
||||
|
||||
export function withTweak<T extends HasValue<V>, V>(node: T): T & Tweakable<V> {
|
||||
return Object.assign(node, {
|
||||
_tweaks: [] as TweakDef<V>[],
|
||||
tweak(
|
||||
this: Tweakable<V>,
|
||||
icon: FunctionalComponent<SVGAttributes>,
|
||||
compute: TweakDef<V>['compute'],
|
||||
) {
|
||||
this._tweaks.push({ icon: markRaw(icon), compute })
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface StateOrigin {
|
||||
_stateOrigin: string[] | undefined
|
||||
stateOrigin(this: this, path: string[]): this
|
||||
}
|
||||
|
||||
export function withStateOrigin<T extends object>(node: T): T & StateOrigin {
|
||||
return Object.assign(node, {
|
||||
_stateOrigin: undefined as string[] | undefined,
|
||||
stateOrigin(this: StateOrigin, path: string[]) {
|
||||
this._stateOrigin = path
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ChildNode } from './builder'
|
||||
import { evalSegment } from './messages'
|
||||
import { hasCap, isNodeActive, resolveActionState, walkNodes } from './resolve'
|
||||
import type { MessageSegment, NodeState } from './state'
|
||||
|
||||
export interface ActiveAction {
|
||||
node: object
|
||||
state: Record<string, NodeState>
|
||||
statePath: string[]
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function collectActiveActions(
|
||||
children: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
basePath: string[] = [],
|
||||
): ActiveAction[] {
|
||||
const actions: ActiveAction[] = []
|
||||
walkNodes(
|
||||
children,
|
||||
stageState,
|
||||
(node, nodeState, localState, path) => {
|
||||
if (!hasCap(node, '_segments')) return
|
||||
const segments = node._segments as MessageSegment[]
|
||||
if (segments.length === 0) return
|
||||
if (!isNodeActive(node, nodeState, localState)) return
|
||||
actions.push({
|
||||
node,
|
||||
state: resolveActionState(node, nodeState, localState),
|
||||
statePath: path,
|
||||
active: true,
|
||||
})
|
||||
},
|
||||
basePath,
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
export function collectMessageNodes(
|
||||
children: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
basePath: string[] = [],
|
||||
): ActiveAction[] {
|
||||
const actions: ActiveAction[] = []
|
||||
walkNodes(
|
||||
children,
|
||||
stageState,
|
||||
(node, nodeState, localState, path) => {
|
||||
if (!hasCap(node, '_segments')) return
|
||||
const segments = node._segments as MessageSegment[]
|
||||
if (segments.length === 0) return
|
||||
const active = isNodeActive(node, nodeState, localState)
|
||||
actions.push({
|
||||
node,
|
||||
state: resolveActionState(node, nodeState, localState),
|
||||
statePath: path,
|
||||
active,
|
||||
})
|
||||
},
|
||||
basePath,
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
function isDescendant(childPath: string[], ancestorPath: string[]): boolean {
|
||||
return (
|
||||
childPath.length > ancestorPath.length && ancestorPath.every((key, i) => childPath[i] === key)
|
||||
)
|
||||
}
|
||||
|
||||
export async function evalActiveAction(
|
||||
entry: ActiveAction,
|
||||
allActions: ActiveAction[],
|
||||
consumed: Set<object>,
|
||||
): Promise<string> {
|
||||
let result = ''
|
||||
const segments = (entry.node as { _segments: MessageSegment[] })._segments
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'collect') {
|
||||
let collected = ''
|
||||
for (const child of allActions) {
|
||||
if (consumed.has(child.node)) continue
|
||||
if (!isDescendant(child.statePath, entry.statePath)) continue
|
||||
consumed.add(child.node)
|
||||
if (!child.active) continue
|
||||
collected += await evalActiveAction(child, allActions, consumed)
|
||||
}
|
||||
if (!collected.trim() && seg.fallback) {
|
||||
collected = await evalSegment(seg.fallback, entry.state, entry.statePath)
|
||||
}
|
||||
result += collected
|
||||
} else {
|
||||
result += await evalSegment(seg, entry.state, entry.statePath)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip="tooltip"
|
||||
:type="color === 'standard' ? 'base' : 'colored'"
|
||||
:color="color === 'standard' ? undefined : color"
|
||||
:disabled="disabled"
|
||||
:aria-label="icon ? label : undefined"
|
||||
@click="emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<component :is="icon" v-if="icon" />
|
||||
<template v-else>{{ label }}</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
label?: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
needsAttention?: boolean
|
||||
fixActionable?: boolean
|
||||
tooltip?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
}>()
|
||||
|
||||
const color = computed(() => {
|
||||
if (!props.modelValue) return 'standard'
|
||||
if (props.needsAttention) return 'orange'
|
||||
return props.fixActionable ? 'blue' : 'brand'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,401 @@
|
||||
<script lang="ts" setup>
|
||||
import { Button, IconButton } from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, inject, watchEffect } from 'vue'
|
||||
|
||||
import type { AnyNode, ChildNode, HasChildren } from '../builder'
|
||||
import type {
|
||||
ComponentNodePropsContext,
|
||||
Enableable,
|
||||
HasValue,
|
||||
Identified,
|
||||
OnChangeFn,
|
||||
TweakDef,
|
||||
} from '../capabilities'
|
||||
import { CHECKLIST_META_KEY } from '../context'
|
||||
import type { Writer } from '../mutate'
|
||||
import { childWriter, originScope, writeNodeValue } from '../mutate'
|
||||
import {
|
||||
getBooleanChildState,
|
||||
getEffectiveValue,
|
||||
hasCap,
|
||||
hasChildrenCap,
|
||||
hasIdCap,
|
||||
hasOptionsCap,
|
||||
hasValueCap,
|
||||
isNodeActive,
|
||||
isShown,
|
||||
resolveChildren,
|
||||
withStateDefaults,
|
||||
} from '../resolve'
|
||||
import type { NodeState, Reactive } from '../state'
|
||||
import { resolve } from '../state'
|
||||
import ActionButton from './ActionButton.vue'
|
||||
|
||||
const metaCtx = inject(CHECKLIST_META_KEY)
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: ChildNode[]
|
||||
state: Record<string, NodeState>
|
||||
write: Writer
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
flex?: boolean
|
||||
titleDepth?: number
|
||||
appComponents?: Record<string, Component>
|
||||
globalState?: Record<string, Record<string, NodeState>>
|
||||
}>()
|
||||
|
||||
type RenderableValueNode = AnyNode &
|
||||
HasValue &
|
||||
Identified &
|
||||
Partial<Enableable> & {
|
||||
_component: Component | undefined
|
||||
_rendererKey: string | undefined
|
||||
_modelProp: string
|
||||
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_extraProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_tweaks?: TweakDef[]
|
||||
}
|
||||
|
||||
function resolveComponent(node: RenderableValueNode): Component | undefined {
|
||||
return (
|
||||
node._component ?? (node._rendererKey ? props.appComponents?.[node._rendererKey] : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function titleClass(depth: number): string {
|
||||
if (depth === 0) return 'text-lg font-extrabold text-contrast'
|
||||
if (depth === 1) return 'text-base font-semibold'
|
||||
if (depth === 2) return 'text-sm font-semibold'
|
||||
return ''
|
||||
}
|
||||
|
||||
function getTitle(node: object): string | undefined {
|
||||
if (!hasCap(node, '_title')) return undefined
|
||||
const title = node._title as Reactive<string> | undefined
|
||||
if (title === undefined) return undefined
|
||||
return resolve(title) || undefined
|
||||
}
|
||||
|
||||
function needsAttention(node: object): boolean {
|
||||
return metaCtx?.value.attentionMap.get(node) ?? false
|
||||
}
|
||||
|
||||
function isFixActionable(node: object): boolean {
|
||||
return metaCtx?.value.metaMap.get(node)?.isFixActionable ?? false
|
||||
}
|
||||
|
||||
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
|
||||
|
||||
function isEnabled(node: Partial<Enableable>): boolean {
|
||||
if (node._enabled === undefined) return true
|
||||
if (typeof node._enabled === 'function') return node._enabled(wrappedState.value)
|
||||
return resolve(node._enabled)
|
||||
}
|
||||
|
||||
function toggleSetValue(node: RenderableValueNode, value: string): void {
|
||||
const current = getEffectiveValue(
|
||||
node,
|
||||
props.state[node.id],
|
||||
wrappedState.value,
|
||||
) as unknown as string[]
|
||||
const set = new Set(Array.isArray(current) ? current : [])
|
||||
if (set.has(value)) set.delete(value)
|
||||
else set.add(value)
|
||||
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
|
||||
}
|
||||
|
||||
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
|
||||
let measureEl: HTMLSpanElement | null = null
|
||||
function measureLabelWidth(label: string): number {
|
||||
if (typeof document === 'undefined') return 0
|
||||
if (!measureEl) {
|
||||
measureEl = document.createElement('span')
|
||||
measureEl.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
|
||||
Object.assign(measureEl.style, {
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
left: '-9999px',
|
||||
top: '0',
|
||||
})
|
||||
document.body.appendChild(measureEl)
|
||||
}
|
||||
measureEl.textContent = label
|
||||
return measureEl.getBoundingClientRect().width
|
||||
}
|
||||
|
||||
const dropdownMinWidthCache = new Map<string, string>()
|
||||
function getDropdownMinWidth(options: { label: string }[]): string {
|
||||
const key = options.map((o) => o.label).join(' ')
|
||||
const cached = dropdownMinWidthCache.get(key)
|
||||
if (cached) return cached
|
||||
const maxLabelWidth = Math.max(0, ...options.map((o) => measureLabelWidth(o.label)))
|
||||
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
|
||||
dropdownMinWidthCache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function componentProps(node: RenderableValueNode): Record<string, unknown> {
|
||||
const ctx: ComponentNodePropsContext = {
|
||||
onImageUpload: props.onImageUpload,
|
||||
toggleSetValue: (value) => toggleSetValue(node, value),
|
||||
nodeFacts: { needsAttention: needsAttention(node), fixActionable: isFixActionable(node) },
|
||||
tooltip: resolveTooltip(node),
|
||||
}
|
||||
const dropdownStyle = hasOptionsCap(node)
|
||||
? {
|
||||
class: '!w-auto max-w-full',
|
||||
style: { minWidth: getDropdownMinWidth(node._options as unknown as { label: string }[]) },
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
disabled: !isEnabled(node),
|
||||
...dropdownStyle,
|
||||
...node._componentProps?.(ctx),
|
||||
...node._extraProps?.(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
function containerScope(node: HasChildren & Partial<Identified>): {
|
||||
state: Record<string, NodeState>
|
||||
write: Writer
|
||||
} {
|
||||
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
|
||||
return originScope(props.globalState, node._stateOrigin as string[])
|
||||
}
|
||||
if (!hasIdCap(node)) return { state: props.state, write: props.write }
|
||||
const raw = props.state[node.id]
|
||||
const state =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
return { state, write: childWriter(props.state, props.write, node.id) }
|
||||
}
|
||||
|
||||
function valueScope(node: HasValue & Identified): {
|
||||
state: Record<string, NodeState>
|
||||
write: Writer
|
||||
} {
|
||||
const state = getBooleanChildState(props.state[node.id])
|
||||
return { state, write: childWriter(props.state, props.write, node.id) }
|
||||
}
|
||||
|
||||
const TOOLTIP_BASE = {
|
||||
delay: { show: 500, hide: 0 },
|
||||
triggers: ['hover', 'focus'],
|
||||
placement: 'top',
|
||||
}
|
||||
|
||||
function resolveTooltip(node: object): Record<string, unknown> | undefined {
|
||||
if (hasCap(node, '_tooltip')) {
|
||||
const t = node._tooltip as
|
||||
| Reactive<string>
|
||||
| ((state: Record<string, NodeState>) => string)
|
||||
| undefined
|
||||
if (t !== undefined) {
|
||||
const content = typeof t === 'function' ? t(wrappedState.value) : resolve(t)
|
||||
if (content) return { ...TOOLTIP_BASE, content }
|
||||
}
|
||||
}
|
||||
const hasSegments = hasCap(node, '_segments')
|
||||
const html = hasSegments ? metaCtx?.value.tooltipHtml.get(node) : undefined
|
||||
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
|
||||
}
|
||||
|
||||
function clickButton(node: object): void {
|
||||
if (!hasCap(node, '_onClick')) return
|
||||
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
|
||||
}
|
||||
|
||||
function tweakCurrent(node: RenderableValueNode): unknown {
|
||||
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
|
||||
}
|
||||
|
||||
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
|
||||
return tweak.compute(tweakCurrent(node), wrappedState.value)
|
||||
}
|
||||
|
||||
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
|
||||
const result = tweakResult(tweak, node)
|
||||
return result !== null && result !== undefined && result !== tweakCurrent(node)
|
||||
}
|
||||
|
||||
function tweakTooltip(
|
||||
tweak: TweakDef,
|
||||
node: RenderableValueNode,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!tweakEnabled(tweak, node)) return undefined
|
||||
const content = tweakResult(tweak, node)
|
||||
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
|
||||
}
|
||||
|
||||
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
|
||||
const result = tweakResult(tweak, node)
|
||||
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
|
||||
}
|
||||
|
||||
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
|
||||
const result = tweakResult(tweak, node)
|
||||
if (result !== null && result !== undefined) {
|
||||
updateValue(node, result)
|
||||
}
|
||||
}
|
||||
|
||||
function nodeKey(item: ChildNode, idx: number): string {
|
||||
return typeof item === 'object' && item !== null && hasIdCap(item) ? item.id : `n-${idx}`
|
||||
}
|
||||
|
||||
function modelProp(item: object): string {
|
||||
return (item as RenderableValueNode)._modelProp
|
||||
}
|
||||
|
||||
function updateEvent(item: object): string {
|
||||
return `update:${modelProp(item)}`
|
||||
}
|
||||
|
||||
function updateValue(item: RenderableValueNode, v: unknown): void {
|
||||
const onChange = hasCap(item, '_onChange')
|
||||
? (item._onChange as OnChangeFn | undefined)
|
||||
: undefined
|
||||
if (onChange) {
|
||||
const result = onChange(v as string, { override: (ov) => ({ __override: ov }) })
|
||||
if (result && typeof result === 'object' && '__override' in result) {
|
||||
writeNodeValue(item, props.state, props.write, result.__override as never, wrappedState.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeNodeValue(item, props.state, props.write, v as never, wrappedState.value)
|
||||
}
|
||||
|
||||
const seenOnChangeValues = new Map<object, unknown>()
|
||||
|
||||
watchEffect(() => {
|
||||
for (const node of props.nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (!hasCap(node, '_onChange') || !(node as { _onChange: unknown })._onChange) continue
|
||||
if (!hasValueCap(node) || !hasIdCap(node)) continue
|
||||
if (!isShown(node as AnyNode)) continue
|
||||
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
|
||||
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
|
||||
seenOnChangeValues.set(node, value)
|
||||
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
|
||||
onChange?.(value as never, { override: (ov) => ({ __override: ov }) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
|
||||
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
|
||||
<template v-if="typeof item !== 'object' || item === null">
|
||||
<template v-if="typeof item === 'string'">{{ item }}</template>
|
||||
<component :is="item" v-else />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isShown(item)">
|
||||
<div
|
||||
:class="
|
||||
hasChildrenCap(item) && !hasValueCap(item)
|
||||
? 'w-full'
|
||||
: !getTitle(item)
|
||||
? 'contents'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
|
||||
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
|
||||
<span
|
||||
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
|
||||
/><span v-if="needsAttention(item)" class="text-red">*</span>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
|
||||
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
|
||||
<NodeRenderer
|
||||
:nodes="resolveChildren(item, containerScope(item).state)"
|
||||
:state="containerScope(item).state"
|
||||
:write="containerScope(item).write"
|
||||
:on-image-upload="onImageUpload"
|
||||
:app-components="appComponents"
|
||||
:global-state="globalState"
|
||||
:flex="(item as any)._layout !== 'column'"
|
||||
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
|
||||
<component
|
||||
:is="resolveComponent(item as RenderableValueNode)"
|
||||
v-if="resolveComponent(item as RenderableValueNode) === ActionButton"
|
||||
v-bind="componentProps(item as RenderableValueNode)"
|
||||
:[modelProp(item)]="
|
||||
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
|
||||
"
|
||||
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
|
||||
/>
|
||||
<component
|
||||
:is="resolveComponent(item as RenderableValueNode)"
|
||||
v-else
|
||||
v-tooltip="resolveTooltip(item)"
|
||||
v-bind="componentProps(item as RenderableValueNode)"
|
||||
:[modelProp(item)]="
|
||||
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
|
||||
"
|
||||
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
|
||||
/>
|
||||
<template
|
||||
v-for="(tweak, tIdx) in (item as RenderableValueNode)._tweaks ?? []"
|
||||
:key="`tweak-${tIdx}`"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
|
||||
:label="tweakLabel(tweak, item as RenderableValueNode)"
|
||||
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
|
||||
@click="applyTweak(tweak, item as RenderableValueNode)"
|
||||
>
|
||||
<component :is="tweak.icon" />
|
||||
</IconButton>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasCap(item, '_onClick')">
|
||||
<Button
|
||||
v-tooltip="resolveTooltip(item)"
|
||||
:disabled="!isEnabled(item as any)"
|
||||
:aria-label="(item as any)._icon ? (item as any).label : undefined"
|
||||
@click="clickButton(item)"
|
||||
>
|
||||
<component :is="(item as any)._icon" v-if="(item as any)._icon" />
|
||||
<template v-else>{{ (item as any).label }}</template>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
|
||||
<NodeRenderer
|
||||
v-if="
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
isShown(item) &&
|
||||
hasValueCap(item) &&
|
||||
hasIdCap(item) &&
|
||||
hasChildrenCap(item) &&
|
||||
isNodeActive(item, state[item.id], wrappedState) &&
|
||||
resolveChildren(item, valueScope(item).state).length
|
||||
"
|
||||
:nodes="resolveChildren(item, valueScope(item).state)"
|
||||
:state="valueScope(item).state"
|
||||
:write="valueScope(item).write"
|
||||
:on-image-upload="onImageUpload"
|
||||
:app-components="appComponents"
|
||||
:global-state="globalState"
|
||||
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { NodeMeta } from './node-meta'
|
||||
|
||||
export interface ChecklistMetaContext {
|
||||
metaMap: Map<object, NodeMeta>
|
||||
attentionMap: Map<object, boolean>
|
||||
tooltipHtml: Map<object, string>
|
||||
}
|
||||
|
||||
export const CHECKLIST_META_KEY: InjectionKey<Ref<ChecklistMetaContext>> = Symbol('checklistMeta')
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import { withAutoProps, withChildren } from './builder'
|
||||
import type { ComponentNodePropsContext, Configurable } from './capabilities'
|
||||
import {
|
||||
withComponent,
|
||||
withEditable,
|
||||
withEnabled,
|
||||
withExtraProps,
|
||||
withFix,
|
||||
withIcon,
|
||||
withId,
|
||||
withLayout,
|
||||
withMessaging,
|
||||
withNoneLabel,
|
||||
withOnClick,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withSelectable,
|
||||
withShown,
|
||||
withStageMeta,
|
||||
withStateOrigin,
|
||||
withTitle,
|
||||
withTooltip,
|
||||
withTweak,
|
||||
withValue,
|
||||
} from './capabilities'
|
||||
import ActionButton from './components/ActionButton.vue'
|
||||
import { pipe } from './pipe'
|
||||
import type { NodeState, NodeStateWithChildren } from './state'
|
||||
|
||||
function getBooleanValue(raw: NodeState): boolean {
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const v = (raw as NodeStateWithChildren).value
|
||||
if (typeof v === 'boolean') return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function setBooleanValue(raw: NodeState, next: boolean, isDefault?: boolean): NodeState {
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
|
||||
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
|
||||
}
|
||||
return isDefault ? undefined : next
|
||||
}
|
||||
|
||||
export const booleanValue = {
|
||||
_getValue: getBooleanValue,
|
||||
_setValue: setBooleanValue,
|
||||
_isActive: (v: boolean) => v === true,
|
||||
}
|
||||
|
||||
export function toggle(id: string, label: string) {
|
||||
return pipe(
|
||||
{ label } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withIcon,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(ActionButton),
|
||||
componentProps: (ctx) => ({
|
||||
label: n.label,
|
||||
icon: n._icon,
|
||||
needsAttention: ctx.nodeFacts?.needsAttention ?? false,
|
||||
fixActionable: ctx.nodeFacts?.fixActionable ?? false,
|
||||
tooltip: ctx.tooltip,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function button(label?: string) {
|
||||
return pipe(
|
||||
{ label } as { label?: string },
|
||||
withShown,
|
||||
withIcon,
|
||||
withTooltip,
|
||||
withEnabled,
|
||||
withOnClick,
|
||||
)
|
||||
}
|
||||
|
||||
export function check(id: string, label: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{ label } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(Checkbox),
|
||||
componentProps: () => ({ label }),
|
||||
}),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function toggleSwitch(id: string, label: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{ label } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) => withComponent(n, { component: markRaw(Toggle) }),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function group(id?: string) {
|
||||
const base = pipe(
|
||||
{} as Record<string, never>,
|
||||
withChildren,
|
||||
withShown,
|
||||
withTitle,
|
||||
withRequired,
|
||||
withSelectable,
|
||||
withLayout,
|
||||
)
|
||||
return id === undefined ? base : withId(base, id)
|
||||
}
|
||||
|
||||
export type GroupNode = ReturnType<typeof group>
|
||||
|
||||
export function externalGroup(path: string[]) {
|
||||
return pipe({} as Record<string, never>, withChildren, withShown, (n) =>
|
||||
withStateOrigin(n).stateOrigin(path),
|
||||
)
|
||||
}
|
||||
|
||||
const optionValue = {
|
||||
_getValue: () => true,
|
||||
_setValue: () => undefined,
|
||||
_isActive: () => true,
|
||||
}
|
||||
|
||||
export function option(value: string, label: string) {
|
||||
return pipe(
|
||||
{ value, label } as { value: string; label: string },
|
||||
withChildren,
|
||||
withShown,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
(n) => withValue(n, optionValue),
|
||||
)
|
||||
}
|
||||
|
||||
type OptionNode = ReturnType<typeof option>
|
||||
|
||||
interface HasOptions {
|
||||
_options: OptionNode[]
|
||||
options(this: this, ...opts: OptionNode[]): this
|
||||
}
|
||||
|
||||
function withOptions<T extends object>(node: T): T & HasOptions {
|
||||
return Object.assign(node, {
|
||||
_options: [] as OptionNode[],
|
||||
options(this: HasOptions, ...opts: OptionNode[]) {
|
||||
this._options = opts
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getStringValue(raw: NodeState): string {
|
||||
if (typeof raw === 'string') return raw
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const v = (raw as NodeStateWithChildren).value
|
||||
if (typeof v === 'string') return v
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function setStringValue(raw: NodeState, next: string, isDefault?: boolean): NodeState {
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
|
||||
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
|
||||
}
|
||||
return isDefault ? undefined : next
|
||||
}
|
||||
|
||||
const dropdownValue = {
|
||||
_getValue: getStringValue,
|
||||
_setValue: setStringValue,
|
||||
_isActive: (v: string) => v !== '',
|
||||
}
|
||||
|
||||
export function dropdown(id: string) {
|
||||
return pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withShown,
|
||||
withTitle,
|
||||
withRequired,
|
||||
(n) => withValue(n, dropdownValue),
|
||||
withNoneLabel,
|
||||
withOptions,
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(Combobox),
|
||||
componentProps: () => ({
|
||||
options: [
|
||||
...(n._none !== undefined ? [{ value: '', label: n._none }] : []),
|
||||
...n._options.map((o) => ({ value: o.value, label: o.label })),
|
||||
],
|
||||
triggerClass:
|
||||
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
|
||||
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function stage(id: string, title: string) {
|
||||
return pipe(
|
||||
{ label: title } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withIcon,
|
||||
withStageMeta,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
)
|
||||
}
|
||||
|
||||
export type StageNode = ReturnType<typeof stage>
|
||||
|
||||
const stringValue = dropdownValue
|
||||
|
||||
export function text(id: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
withEditable,
|
||||
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
|
||||
(n) => withValue(n, stringValue),
|
||||
withTweak,
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(StyledInput),
|
||||
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
|
||||
}),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function markdown(id: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
withEditable,
|
||||
(n) => withValue(n, stringValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(MarkdownEditor),
|
||||
componentProps: (ctx) => ({
|
||||
maxHeight: 300,
|
||||
disabled: false,
|
||||
headingButtons: false,
|
||||
onImageUpload: ctx.onImageUpload,
|
||||
}),
|
||||
}),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getSetValue(raw: NodeState): string[] {
|
||||
return raw instanceof Set ? Array.from(raw) : []
|
||||
}
|
||||
|
||||
function setSetValue(_raw: NodeState, next: string[], isDefault?: boolean): NodeState {
|
||||
return isDefault || next.length === 0 ? undefined : new Set(next)
|
||||
}
|
||||
|
||||
const setValue = {
|
||||
_getValue: getSetValue,
|
||||
_setValue: setSetValue,
|
||||
_isActive: (v: string[]) => v.length > 0,
|
||||
}
|
||||
|
||||
const stringValueBehavior = {
|
||||
_getValue: getStringValue,
|
||||
_setValue: setStringValue,
|
||||
_isActive: (v: string) => v !== '',
|
||||
}
|
||||
|
||||
export function appComponent(id: string, rendererKey: string) {
|
||||
const node = pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, stringValueBehavior),
|
||||
(n) => withComponent(n, { rendererKey }),
|
||||
withExtraProps,
|
||||
)
|
||||
return Object.assign(node, {
|
||||
valueKind(this: Configurable, kind: 'boolean' | 'string' | 'set') {
|
||||
Object.assign(
|
||||
this,
|
||||
kind === 'boolean' ? booleanValue : kind === 'set' ? setValue : stringValueBehavior,
|
||||
)
|
||||
return this
|
||||
},
|
||||
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
|
||||
this._extraProps = fn
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { NodeState } from './state'
|
||||
|
||||
export class FixBuilder {
|
||||
_projectFn?: (
|
||||
patch: Labrinth.Projects.v3.EditProjectRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
_versionFn?: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
|
||||
project(
|
||||
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
|
||||
): this {
|
||||
this._projectFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
version(
|
||||
fn: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void,
|
||||
): this {
|
||||
this._versionFn = fn
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export function fix(): FixBuilder {
|
||||
return new FixBuilder()
|
||||
}
|
||||
|
||||
export function createTrackedPatch<T extends object>(
|
||||
source: T,
|
||||
): { proxy: T; changes: () => Partial<T> } {
|
||||
const written = new Set<string | symbol>()
|
||||
const data = { ...source }
|
||||
const proxy = new Proxy(data, {
|
||||
set(target, key, value) {
|
||||
if (value !== (source as Record<string | symbol, unknown>)[key]) {
|
||||
written.add(key)
|
||||
} else {
|
||||
written.delete(key)
|
||||
}
|
||||
;(target as Record<string | symbol, unknown>)[key] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
return {
|
||||
proxy,
|
||||
changes: () =>
|
||||
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './builder'
|
||||
export * from './capabilities'
|
||||
export * from './collect'
|
||||
export * from './context'
|
||||
export * from './factories'
|
||||
export * from './fix'
|
||||
export * from './messages'
|
||||
export * from './mutate'
|
||||
export * from './node-meta'
|
||||
export * from './pipe'
|
||||
export * from './resolve'
|
||||
export * from './state'
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
expandVariables,
|
||||
flattenProjectV3Variables,
|
||||
flattenProjectVariables,
|
||||
flattenStaticVariables,
|
||||
} from '../../utils'
|
||||
import type { GetVarsFn, MessageSegment, NodeState } from './state'
|
||||
|
||||
const messageFiles = import.meta.glob('../../data/messages/**/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
|
||||
concat(...others: MessageFn[]): MessageFn
|
||||
}
|
||||
|
||||
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
|
||||
const rich = fn as MessageFn
|
||||
rich.concat = (...others) =>
|
||||
makeMessageFn(async (state) =>
|
||||
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
|
||||
)
|
||||
return rich
|
||||
}
|
||||
|
||||
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
|
||||
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
|
||||
let _onMissingMd: ((path: string) => void) | null = null
|
||||
|
||||
export function setMissingMdHandler(handler: (path: string) => void) {
|
||||
_onMissingMd = handler
|
||||
}
|
||||
|
||||
export function setMessageProject(
|
||||
project: Ref<Labrinth.Projects.v3.Project>,
|
||||
projectV2: Ref<Labrinth.Projects.v2.Project>,
|
||||
) {
|
||||
_project = project
|
||||
_projectV2 = projectV2
|
||||
}
|
||||
|
||||
export function mdEscape(text: string): string {
|
||||
return text.replace(/[\\*_`[~]/g, '\\$&')
|
||||
}
|
||||
|
||||
const USER_CONTENT_KEYS = [
|
||||
'PROJECT_TITLE',
|
||||
'PROJECT_SLUG',
|
||||
'PROJECT_SUMMARY',
|
||||
'PROJECT_TYPE',
|
||||
'PROJECT_STATUS',
|
||||
]
|
||||
|
||||
export async function loadMd(
|
||||
path: string,
|
||||
state: Record<string, NodeState>,
|
||||
project: Labrinth.Projects.v3.Project,
|
||||
projectV2: Labrinth.Projects.v2.Project,
|
||||
getVars?: GetVarsFn,
|
||||
): Promise<string> {
|
||||
const extraVars = getVars ? getVars(state) : null
|
||||
const loader = messageFiles[`../../data/messages/${path}.md`]
|
||||
if (!loader) {
|
||||
_onMissingMd?.(path)
|
||||
return ''
|
||||
}
|
||||
const raw = (await loader()) as string
|
||||
const vars: Record<string, string> = {
|
||||
...flattenStaticVariables(),
|
||||
...flattenProjectVariables(projectV2),
|
||||
...flattenProjectV3Variables(project),
|
||||
}
|
||||
for (const key of USER_CONTENT_KEYS) {
|
||||
if (key in vars) vars[key] = mdEscape(vars[key])
|
||||
}
|
||||
if (extraVars) {
|
||||
for (const [k, v] of Object.entries(extraVars)) {
|
||||
vars[k] = String(v ?? '')
|
||||
}
|
||||
}
|
||||
const expanded = expandVariables(raw, projectV2, project, vars)
|
||||
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
|
||||
}
|
||||
|
||||
export function mdOptional(path: string, getVars?: GetVarsFn): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const loader = messageFiles[`../../data/messages/${path}.md`]
|
||||
if (!loader) return ''
|
||||
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
|
||||
export function md(
|
||||
path: string | ((state: Record<string, NodeState>) => string),
|
||||
getVars?: GetVarsFn,
|
||||
): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(state) : path
|
||||
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRelativeMessagePath(
|
||||
messagePath: string | (() => string),
|
||||
statePath: string[],
|
||||
): string {
|
||||
const name = typeof messagePath === 'function' ? messagePath() : messagePath
|
||||
if (name.startsWith('/')) return `checklist/messages${name}`
|
||||
const parts = [...statePath.slice(0, -1), ...name.split('/')]
|
||||
const normalized = parts.reduce<string[]>((acc, p) => {
|
||||
if (p === '..') acc.pop()
|
||||
else if (p) acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
return `checklist/messages/${normalized.join('/')}`
|
||||
}
|
||||
|
||||
export async function evalSegment(
|
||||
seg: MessageSegment,
|
||||
state: Record<string, NodeState>,
|
||||
statePath: string[],
|
||||
): Promise<string> {
|
||||
if (seg.type === 'collect') return ''
|
||||
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
|
||||
if (seg.type === 'auto') {
|
||||
return loadMd(
|
||||
`checklist/messages/${statePath.join('/')}`,
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
}
|
||||
return loadMd(
|
||||
resolveRelativeMessagePath(seg.path, statePath),
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { HasValue, Identified } from './capabilities'
|
||||
import { getEffectiveValue } from './resolve'
|
||||
import type { NodeState } from './state'
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false
|
||||
const setB = new Set(b)
|
||||
return a.every((v) => setB.has(v))
|
||||
}
|
||||
return a === b
|
||||
}
|
||||
|
||||
export type Writer = (id: string, value: NodeState) => void
|
||||
|
||||
export function childWriter(
|
||||
parentRead: Record<string, NodeState>,
|
||||
parentWrite: Writer,
|
||||
containerId: string,
|
||||
): Writer {
|
||||
return (childId, value) => {
|
||||
const existing = parentRead[containerId]
|
||||
const container: Record<string, NodeState> =
|
||||
existing && typeof existing === 'object' && !(existing instanceof Set)
|
||||
? { ...(existing as Record<string, NodeState>) }
|
||||
: existing !== undefined
|
||||
? { value: existing }
|
||||
: {}
|
||||
if (value === undefined) Reflect.deleteProperty(container, childId)
|
||||
else container[childId] = value
|
||||
parentWrite(containerId, Object.keys(container).length === 0 ? undefined : container)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeNodeValue<V>(
|
||||
node: HasValue<V> & Identified,
|
||||
read: Record<string, NodeState>,
|
||||
write: Writer,
|
||||
next: V,
|
||||
contextState: Record<string, NodeState> = read,
|
||||
): void {
|
||||
const isDefault = valuesEqual(next, getEffectiveValue(node, undefined, contextState))
|
||||
write(node.id, node._setValue(read[node.id], next, isDefault))
|
||||
}
|
||||
|
||||
export function originScope(
|
||||
root: Record<string, NodeState>,
|
||||
path: string[],
|
||||
): { state: Record<string, NodeState>; write: Writer } {
|
||||
let state = root
|
||||
let write: Writer = (id, value) => {
|
||||
if (value === undefined) Reflect.deleteProperty(root, id)
|
||||
else root[id] = value
|
||||
}
|
||||
for (const segment of path) {
|
||||
write = childWriter(state, write, segment)
|
||||
const raw = state[segment]
|
||||
state =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
}
|
||||
return { state, write }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AnyNode, ChildNode, HasChildren } from './builder'
|
||||
import type { FixBuilder } from './fix'
|
||||
import {
|
||||
getBooleanChildState,
|
||||
hasCap,
|
||||
hasChildrenCap,
|
||||
hasIdCap,
|
||||
hasOptionsCap,
|
||||
hasValueCap,
|
||||
isNodeActive,
|
||||
isShown,
|
||||
resolveActionState,
|
||||
resolveChildren,
|
||||
walkNodes,
|
||||
} from './resolve'
|
||||
import type { NodeState } from './state'
|
||||
|
||||
export interface NodeMeta {
|
||||
hasRequiredMissing: boolean
|
||||
isFixActionable: boolean
|
||||
}
|
||||
|
||||
export function computeNodeMeta(
|
||||
children: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
isFixActionable: (fixes: FixBuilder[], state: Record<string, NodeState>) => boolean,
|
||||
): Map<object, NodeMeta> {
|
||||
const map = new Map<object, NodeMeta>()
|
||||
|
||||
walkNodes(children, stageState, (node, nodeState, localState) => {
|
||||
const active = isNodeActive(node, nodeState, localState)
|
||||
|
||||
const required = hasCap(node, '_required') && node._required === true
|
||||
const hasRequiredMissing = required && !active
|
||||
|
||||
let fixActionable = false
|
||||
if (active && hasCap(node, '_fixes')) {
|
||||
const fixes = node._fixes as FixBuilder[]
|
||||
if (fixes.length > 0) {
|
||||
fixActionable = isFixActionable(fixes, resolveActionState(node, nodeState, localState))
|
||||
}
|
||||
}
|
||||
|
||||
map.set(node, { hasRequiredMissing, isFixActionable: fixActionable })
|
||||
})
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export function computeAttentionMap(
|
||||
nodes: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
metaMap: Map<object, NodeMeta>,
|
||||
attention: Map<object, boolean> = new Map(),
|
||||
): Map<object, boolean> {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (!isShown(node as AnyNode)) continue
|
||||
|
||||
if (hasChildrenCap(node) && !hasValueCap(node)) {
|
||||
const childState = childScopeFor(node, stageState)
|
||||
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
|
||||
const childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
|
||||
const ownRequired = hasCap(node, '_required') && node._required === true
|
||||
const unsatisfied = ownRequired && !hasActiveChild(node, childState)
|
||||
attention.set(node, unsatisfied || childrenNeedAttention)
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasValueCap(node) && hasIdCap(node)) {
|
||||
const rawNodeState = stageState[node.id]
|
||||
const active = isNodeActive(node, rawNodeState, stageState)
|
||||
let childrenNeedAttention = false
|
||||
|
||||
if (active) {
|
||||
if (hasOptionsCap(node)) {
|
||||
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
|
||||
const selected = node._options.find((o) => o.value === selectedValue)
|
||||
if (selected && isShown(selected) && hasChildrenCap(selected)) {
|
||||
computeAttentionMap(
|
||||
resolveChildren(selected, stageState),
|
||||
stageState,
|
||||
metaMap,
|
||||
attention,
|
||||
)
|
||||
childrenNeedAttention = someChildNeedsAttention(selected, stageState, attention)
|
||||
}
|
||||
} else if (hasChildrenCap(node)) {
|
||||
const childState = getBooleanChildState(rawNodeState)
|
||||
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
|
||||
childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
|
||||
}
|
||||
}
|
||||
|
||||
const ownMissing = metaMap.get(node)?.hasRequiredMissing ?? false
|
||||
attention.set(node, ownMissing || childrenNeedAttention)
|
||||
}
|
||||
}
|
||||
return attention
|
||||
}
|
||||
|
||||
function childScopeFor(
|
||||
node: HasChildren & { id?: string },
|
||||
state: Record<string, NodeState>,
|
||||
): Record<string, NodeState> {
|
||||
if (!hasIdCap(node)) return state
|
||||
const raw = state[node.id]
|
||||
return raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function hasActiveChild(node: HasChildren, childState: Record<string, NodeState>): boolean {
|
||||
return resolveChildren(node, childState).some((child) => {
|
||||
if (typeof child !== 'object' || child === null) return false
|
||||
const raw = hasIdCap(child) ? childState[child.id] : undefined
|
||||
return isNodeActive(child, raw, childState)
|
||||
})
|
||||
}
|
||||
|
||||
function someChildNeedsAttention(
|
||||
node: HasChildren,
|
||||
childState: Record<string, NodeState>,
|
||||
attention: Map<object, boolean>,
|
||||
): boolean {
|
||||
return resolveChildren(node, childState).some(
|
||||
(child) => typeof child === 'object' && child !== null && attention.get(child) === true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function pipe<A>(a: A): A
|
||||
export function pipe<A, B>(a: A, f1: (a: A) => B,): B
|
||||
export function pipe<A, B, C>(a: A, f1: (a: A) => B, f2: (b: B) => C,): C
|
||||
export function pipe<A, B, C, D>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D,): D
|
||||
export function pipe<A, B, C, D, E>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E,): E
|
||||
export function pipe<A, B, C, D, E, F>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F,): F
|
||||
export function pipe<A, B, C, D, E, F, G>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G,): G
|
||||
export function pipe<A, B, C, D, E, F, G, H>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H,): H
|
||||
export function pipe<A, B, C, D, E, F, G, H, I>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I,): I
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J,): J
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K,): K
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L,): L
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M,): M
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N,): N
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O,): O
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P,): P
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q,): Q
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R,): R
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S,): S
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T,): T
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U,): U
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V,): V
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W,): W
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X,): X
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y,): Y
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y, f25: (y: Y) => Z,): Z
|
||||
export function pipe(a: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
|
||||
return fns.reduce((acc, fn) => fn(acc), a)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import type { AnyNode, ChildEntry, ChildNode, HasChildren } from './builder'
|
||||
import type { HasValue, Identified } from './capabilities'
|
||||
import type { Writer } from './mutate'
|
||||
import { childWriter, writeNodeValue } from './mutate'
|
||||
import type { NodeState } from './state'
|
||||
import { resolve } from './state'
|
||||
|
||||
export function hasCap<K extends string>(node: object, key: K): node is Record<K, unknown> {
|
||||
return key in node
|
||||
}
|
||||
|
||||
export function hasValueCap(node: object): node is HasValue {
|
||||
return hasCap(node, '_getValue') && hasCap(node, '_isActive')
|
||||
}
|
||||
|
||||
export function hasChildrenCap(node: object): node is HasChildren {
|
||||
return hasCap(node, '_children')
|
||||
}
|
||||
|
||||
export function hasOptionsCap(node: object): node is { _options: OptionLike[] } {
|
||||
return hasCap(node, '_options')
|
||||
}
|
||||
|
||||
export function hasIdCap(node: object): node is Identified {
|
||||
return hasCap(node, 'id')
|
||||
}
|
||||
|
||||
interface OptionLike extends AnyNode, Partial<HasChildren> {
|
||||
value: string
|
||||
_segments?: unknown[]
|
||||
}
|
||||
|
||||
export function isShown(node: AnyNode): boolean {
|
||||
return node._shown === undefined || resolve(node._shown)
|
||||
}
|
||||
|
||||
export function getEffectiveValue<V>(
|
||||
node: HasValue<V>,
|
||||
rawState: NodeState,
|
||||
contextState: Record<string, NodeState> = {},
|
||||
): V {
|
||||
if (rawState !== undefined) return node._getValue(rawState)
|
||||
if (node._defaultValue !== undefined) {
|
||||
const def =
|
||||
typeof node._defaultValue === 'function'
|
||||
? (node._defaultValue as (state: Record<string, NodeState>) => NodeState)(contextState)
|
||||
: (node._defaultValue as unknown as NodeState)
|
||||
return node._getValue(def)
|
||||
}
|
||||
return node._getValue(rawState)
|
||||
}
|
||||
|
||||
export function isNodeActive(
|
||||
node: object,
|
||||
rawState: NodeState,
|
||||
contextState: Record<string, NodeState> = {},
|
||||
): boolean {
|
||||
if (hasValueCap(node)) return node._isActive(getEffectiveValue(node, rawState, contextState))
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
|
||||
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
|
||||
return nodeState as Record<string, NodeState>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function resolveChildren(node: HasChildren, state: Record<string, NodeState>): ChildNode[] {
|
||||
const entries: ChildEntry[] = node._childrenFn ? node._childrenFn(state) : node._children
|
||||
|
||||
const result: ChildNode[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry == null) continue
|
||||
if (typeof entry === 'string') {
|
||||
result.push(entry)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'function') {
|
||||
if (entry.length === 0) {
|
||||
result.push(entry as () => unknown)
|
||||
continue
|
||||
}
|
||||
const fn = entry as (state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null
|
||||
const resolved = fn(state)
|
||||
if (resolved == null) continue
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const r of resolved) {
|
||||
if (r != null && isShown(r)) result.push(r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isShown(resolved)) result.push(resolved)
|
||||
continue
|
||||
}
|
||||
const resolved = toValue(entry as AnyNode | Ref<AnyNode | null>)
|
||||
if (resolved != null && isShown(resolved)) result.push(resolved)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildChildMap(
|
||||
children: ChildNode[],
|
||||
stateForFlattening: Record<string, NodeState>,
|
||||
): Map<string, object> {
|
||||
const map = new Map<string, object>()
|
||||
function collect(nodes: ChildNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (hasIdCap(node)) {
|
||||
map.set(node.id, node)
|
||||
continue
|
||||
}
|
||||
if (hasChildrenCap(node)) collect(resolveChildren(node, stateForFlattening))
|
||||
}
|
||||
}
|
||||
collect(children)
|
||||
return map
|
||||
}
|
||||
|
||||
function optionChildrenFor(node: object, state: Record<string, NodeState>): ChildNode[] {
|
||||
if (!hasOptionsCap(node) || !hasIdCap(node)) return []
|
||||
const raw = state[node.id]
|
||||
const selectedValue = typeof raw === 'string' ? raw : undefined
|
||||
const selected = node._options.find((o) => o.value === selectedValue)
|
||||
if (!selected || !hasChildrenCap(selected)) return []
|
||||
return resolveChildren(selected, state)
|
||||
}
|
||||
|
||||
export function withStateDefaults<T extends Record<string, NodeState>>(
|
||||
rawState: T,
|
||||
children: ChildNode[],
|
||||
write?: Writer,
|
||||
): T {
|
||||
if (rawState == null || typeof rawState !== 'object' || rawState instanceof Set) return rawState
|
||||
|
||||
const childMap = buildChildMap(children, rawState)
|
||||
for (const [, node] of childMap) {
|
||||
for (const c of optionChildrenFor(node, rawState)) {
|
||||
if (typeof c === 'object' && c !== null && hasIdCap(c)) childMap.set(c.id, c)
|
||||
}
|
||||
}
|
||||
const resolving = new Set<string>()
|
||||
|
||||
const proxy = new Proxy(rawState, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key !== 'string') return Reflect.get(target, key, receiver)
|
||||
const child = childMap.get(key)
|
||||
let value: NodeState = Reflect.get(target, key, receiver)
|
||||
|
||||
if (value === undefined && child && hasValueCap(child) && !resolving.has(key)) {
|
||||
resolving.add(key)
|
||||
try {
|
||||
const effective = getEffectiveValue(child, undefined, proxy)
|
||||
const grandchildren = hasChildrenCap(child) ? resolveChildren(child, {}) : []
|
||||
if (grandchildren.length > 0) {
|
||||
return withStateDefaults(
|
||||
{ value: effective } as Record<string, NodeState>,
|
||||
grandchildren,
|
||||
write && childWriter(target, write, key),
|
||||
)
|
||||
}
|
||||
value = child._setValue(undefined, effective)
|
||||
} finally {
|
||||
resolving.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
child &&
|
||||
hasChildrenCap(child) &&
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!(value instanceof Set)
|
||||
) {
|
||||
const nested = value as Record<string, NodeState>
|
||||
return withStateDefaults(
|
||||
nested,
|
||||
resolveChildren(child, nested),
|
||||
write && childWriter(target, write, key),
|
||||
)
|
||||
}
|
||||
|
||||
if (value === undefined && child && hasChildrenCap(child)) {
|
||||
const grandchildren = resolveChildren(child, {})
|
||||
if (grandchildren.length > 0) return emptyScope(grandchildren)
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
set(target, key, value, receiver) {
|
||||
if (typeof key !== 'string') return Reflect.set(target, key, value, receiver)
|
||||
const child = childMap.get(key)
|
||||
if (child && hasValueCap(child) && write) {
|
||||
writeNodeValue(child as HasValue & Identified, target, write, value as never, proxy)
|
||||
return true
|
||||
}
|
||||
return Reflect.set(target, key, value, receiver)
|
||||
},
|
||||
}) as T
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
export function resolveActionState(
|
||||
node: object,
|
||||
nodeState: NodeState,
|
||||
localState: Record<string, NodeState>,
|
||||
): Record<string, NodeState> {
|
||||
if (!hasValueCap(node)) return localState
|
||||
const childState = getBooleanChildState(nodeState)
|
||||
const base = hasChildrenCap(node)
|
||||
? withStateDefaults(childState, resolveChildren(node, childState))
|
||||
: childState
|
||||
return new Proxy(base, {
|
||||
get(target, key, receiver) {
|
||||
if (key === 'value') return getEffectiveValue(node, nodeState, localState) as NodeState
|
||||
return Reflect.get(target, key, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function emptyScope(children: ChildNode[]): Record<string, NodeState> {
|
||||
const childMap = buildChildMap(children, {})
|
||||
const resolving = new Set<string>()
|
||||
const cache = new Map<string, Record<string, NodeState>>()
|
||||
|
||||
const proxy: Record<string, NodeState> = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, key) {
|
||||
if (typeof key !== 'string') return undefined
|
||||
const child = childMap.get(key)
|
||||
if (!child) return undefined
|
||||
|
||||
if (hasValueCap(child) && !resolving.has(key)) {
|
||||
resolving.add(key)
|
||||
try {
|
||||
const def = getEffectiveValue(child, undefined, proxy)
|
||||
if (def !== undefined) return child._setValue(undefined, def)
|
||||
} finally {
|
||||
resolving.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChildrenCap(child)) return undefined
|
||||
|
||||
const cached = cache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const grandchildren = resolveChildren(child, {})
|
||||
if (grandchildren.length === 0) return undefined
|
||||
|
||||
const nested = emptyScope(grandchildren)
|
||||
cache.set(key, nested)
|
||||
return nested
|
||||
},
|
||||
},
|
||||
) as Record<string, NodeState>
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
export type Visitor = (
|
||||
node: object,
|
||||
nodeState: NodeState,
|
||||
localState: Record<string, NodeState>,
|
||||
path: string[],
|
||||
) => void
|
||||
|
||||
export function walkNodes(
|
||||
nodes: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
visitor: Visitor,
|
||||
path: string[] = [],
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (!isShown(node as AnyNode)) continue
|
||||
|
||||
if (hasChildrenCap(node) && !hasValueCap(node)) {
|
||||
if (hasIdCap(node)) {
|
||||
const raw = stageState[node.id]
|
||||
const childState =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
walkNodes(resolveChildren(node, childState), childState, visitor, [...path, node.id])
|
||||
} else {
|
||||
walkNodes(resolveChildren(node, stageState), stageState, visitor, path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasValueCap(node) && hasIdCap(node)) {
|
||||
const rawNodeState = stageState[node.id]
|
||||
const nodePath = [...path, node.id]
|
||||
visitor(node, rawNodeState, stageState, nodePath)
|
||||
|
||||
const active = isNodeActive(node, rawNodeState, stageState)
|
||||
if (!active) continue
|
||||
|
||||
if (hasOptionsCap(node)) {
|
||||
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
|
||||
const selected = node._options.find((o) => o.value === selectedValue)
|
||||
if (selected && isShown(selected)) {
|
||||
const selectedPath = [...path, selected.value]
|
||||
visitor(selected, undefined, stageState, selectedPath)
|
||||
if (hasChildrenCap(selected)) {
|
||||
walkNodes(resolveChildren(selected, stageState), stageState, visitor, selectedPath)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasChildrenCap(node)) {
|
||||
const childState = getBooleanChildState(rawNodeState)
|
||||
walkNodes(resolveChildren(node, childState), childState, visitor, nodePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
|
||||
|
||||
export type NodeState =
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| Set<string>
|
||||
| NodeStateWithChildren
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export interface NodeStateWithChildren {
|
||||
value?: NodeState
|
||||
[childId: string]: NodeState
|
||||
}
|
||||
|
||||
export type Reactive<T> = T | Ref<T>
|
||||
|
||||
export function resolve<T>(value: Reactive<T>): T {
|
||||
return toValue(value as T | Ref<T>)
|
||||
}
|
||||
|
||||
export type GetVarsFn = (state: Record<string, NodeState>) => Record<string, unknown>
|
||||
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
|
||||
|
||||
export type MessageSegment =
|
||||
| { type: 'fn'; fn: ContentFn }
|
||||
| { type: 'auto'; getVars?: GetVarsFn }
|
||||
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
|
||||
| { type: 'collect'; fallback?: MessageSegment }
|
||||
@@ -37,14 +37,14 @@ export const setting = {
|
||||
asString: (data: StringSettingDefinition) => data,
|
||||
}
|
||||
|
||||
export function isValidFor(definitionBase: SettingDefinitionBase<any>, value: any): boolean {
|
||||
export function isValidFor(
|
||||
definitionBase: SettingDefinitionBase<unknown>,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
if (value != null) {
|
||||
// Tried my best with type safety but sadly having the `SettingDefinitions` as the type leads to issues handling types else where...
|
||||
const definition = definitionBase as SettingDefinitions
|
||||
if (
|
||||
definition.type == 'enum' &&
|
||||
definition.entries.map((entry) => entry.value).includes(value)
|
||||
) {
|
||||
if (definition.type == 'enum' && definition.entries.some((entry) => entry.value === value)) {
|
||||
return true
|
||||
} else if (definition.type == 'toggle' && typeof value === 'boolean') {
|
||||
return true
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||
|
||||
import type { Action } from './actions'
|
||||
|
||||
/**
|
||||
* Represents a moderation stage with associated actions and optional navigation logic.
|
||||
*/
|
||||
export interface Stage {
|
||||
/**
|
||||
* The title of the stage, displayed in the checklist header.
|
||||
*/
|
||||
title: string
|
||||
|
||||
/**
|
||||
* The hint for this stage, tells the moderator what to do.
|
||||
*/
|
||||
hint: string
|
||||
|
||||
/**
|
||||
* An optional description or additional text for the stage.
|
||||
*/
|
||||
text?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3?: Labrinth.Projects.v3.Project,
|
||||
) => 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: Labrinth.Projects.v2.Project,
|
||||
projectV3?: Labrinth.Projects.v3.Project,
|
||||
) => boolean
|
||||
}
|
||||
@@ -1,181 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
Action,
|
||||
AdditionalTextInput,
|
||||
ButtonAction,
|
||||
ConditionalMessage,
|
||||
ToggleAction,
|
||||
} from './types/actions'
|
||||
|
||||
export interface ActionState {
|
||||
selected: boolean
|
||||
value?: Set<string | number> | number | string | unknown
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
weight: number
|
||||
content: string
|
||||
actionId: string
|
||||
stageIndex: number
|
||||
}
|
||||
|
||||
export function getActionIdForStage(
|
||||
action: Action,
|
||||
stageIndex: number,
|
||||
actionIndex?: number,
|
||||
enabledIndex?: number,
|
||||
): string {
|
||||
if (action.id) {
|
||||
return `stage-${stageIndex}-${action.id}`
|
||||
}
|
||||
const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : ''
|
||||
return `stage-${stageIndex}-action-${actionIndex}${suffix}`
|
||||
}
|
||||
|
||||
export function getActionId(action: Action, currentStage: number, index?: number): string {
|
||||
return getActionIdForStage(action, currentStage, index)
|
||||
}
|
||||
|
||||
export function getActionKey(
|
||||
action: Action,
|
||||
currentStage: number,
|
||||
visibleActions: Action[],
|
||||
): string {
|
||||
const index = visibleActions.indexOf(action)
|
||||
return `${currentStage}-${index}-${getActionId(action, currentStage)}`
|
||||
}
|
||||
|
||||
export function initializeActionState(action: Action): ActionState {
|
||||
if (action.type === 'toggle') {
|
||||
return {
|
||||
selected: action.defaultChecked || false,
|
||||
}
|
||||
} else if (action.type === 'dropdown') {
|
||||
return {
|
||||
selected: true,
|
||||
value: action.defaultOption || 0,
|
||||
}
|
||||
} else if (action.type === 'multi-select-chips') {
|
||||
return {
|
||||
selected: false,
|
||||
value: new Set<string | number>(),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function processMessage(
|
||||
message: string,
|
||||
action: Action,
|
||||
stageIndex: number,
|
||||
textInputValues: Record<string, string>,
|
||||
): string {
|
||||
let processedMessage = message
|
||||
|
||||
if (action.relevantExtraInput) {
|
||||
action.relevantExtraInput.forEach((input, index) => {
|
||||
if (input.variable) {
|
||||
const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}`
|
||||
const value = textInputValues[inputKey] || ''
|
||||
|
||||
const regex = new RegExp(`%${input.variable}%`, 'g')
|
||||
processedMessage = processedMessage.replace(regex, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return processedMessage
|
||||
}
|
||||
|
||||
export function findMatchingVariant(
|
||||
variants: ConditionalMessage[],
|
||||
selectedActionIds: string[],
|
||||
allValidActionIds?: string[],
|
||||
currentStageIndex?: number,
|
||||
): ConditionalMessage | null {
|
||||
for (const variant of variants) {
|
||||
const conditions = variant.conditions
|
||||
|
||||
const meetsRequired =
|
||||
!conditions.requiredActions ||
|
||||
conditions.requiredActions.every((id) => {
|
||||
let fullId = id
|
||||
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
|
||||
fullId = `stage-${currentStageIndex}-${id}`
|
||||
}
|
||||
|
||||
if (allValidActionIds && !allValidActionIds.includes(fullId)) {
|
||||
return false
|
||||
}
|
||||
return selectedActionIds.includes(fullId)
|
||||
})
|
||||
|
||||
const meetsExcluded =
|
||||
!conditions.excludedActions ||
|
||||
!conditions.excludedActions.some((id) => {
|
||||
let fullId = id
|
||||
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
|
||||
fullId = `stage-${currentStageIndex}-${id}`
|
||||
}
|
||||
return selectedActionIds.includes(fullId)
|
||||
})
|
||||
|
||||
if (meetsRequired && meetsExcluded) {
|
||||
return variant
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getActionMessage(
|
||||
action: ButtonAction | ToggleAction,
|
||||
selectedActionIds: string[],
|
||||
allValidActionIds?: string[],
|
||||
): Promise<string> {
|
||||
if (action.conditionalMessages && action.conditionalMessages.length > 0) {
|
||||
const matchingConditional = findMatchingVariant(
|
||||
action.conditionalMessages,
|
||||
selectedActionIds,
|
||||
allValidActionIds,
|
||||
)
|
||||
if (matchingConditional) {
|
||||
return (await matchingConditional.message()) as string
|
||||
}
|
||||
}
|
||||
|
||||
return (await action.message()) as string
|
||||
}
|
||||
|
||||
export function getVisibleInputs(
|
||||
action: Action,
|
||||
actionStates: Record<string, ActionState>,
|
||||
): AdditionalTextInput[] {
|
||||
if (!action.relevantExtraInput) return []
|
||||
|
||||
const selectedActionIds = Object.entries(actionStates)
|
||||
.filter(([, state]) => state.selected)
|
||||
.map(([id]) => id)
|
||||
|
||||
return action.relevantExtraInput.filter((input) => {
|
||||
if (!input.showWhen) return true
|
||||
|
||||
const meetsRequired =
|
||||
!input.showWhen.requiredActions ||
|
||||
input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id))
|
||||
|
||||
const meetsExcluded =
|
||||
!input.showWhen.excludedActions ||
|
||||
!input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id))
|
||||
|
||||
return meetsRequired && meetsExcluded
|
||||
})
|
||||
}
|
||||
|
||||
export function expandVariables(
|
||||
template: string,
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
@@ -283,35 +107,6 @@ export function formatProjectTypes(type: string, lower: boolean = false) {
|
||||
return value
|
||||
}
|
||||
|
||||
export function formatEnvironments(environment: string, lower: boolean = false) {
|
||||
let value = environment
|
||||
try {
|
||||
value = value
|
||||
.replaceAll('client_only_server_optional', 'Client and server: Optional on server')
|
||||
.replaceAll('client_and_server', 'Required on both')
|
||||
.replaceAll('client_only', 'Client-side only')
|
||||
.replaceAll('server_only_client_optional', 'Client optional')
|
||||
.replaceAll('dedicated_server_only', 'Dedicated server only')
|
||||
.replaceAll('server_only', 'Servers and Singleplayer')
|
||||
.replaceAll('singleplayer_only', 'Singleplayer only')
|
||||
.replaceAll(
|
||||
'client_or_server_prefers_both',
|
||||
'Optional on both, works best when installed on both sides',
|
||||
)
|
||||
.replaceAll(
|
||||
'client_or_server',
|
||||
'Optional on both, works the same if installed on either side',
|
||||
)
|
||||
// This shouldn't come up for this use but yk
|
||||
.replaceAll('unknown', 'Unknown')
|
||||
} catch {
|
||||
return 'No project environment'
|
||||
}
|
||||
|
||||
if (lower === true) value = value.toLowerCase()
|
||||
return value
|
||||
}
|
||||
|
||||
export function requiresEnvironmentInfo(projectTypes): boolean {
|
||||
return projectTypes.includes('mod') || projectTypes.includes('modpack')
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
v-if="isOpen"
|
||||
:id="menuId"
|
||||
ref="panelElement"
|
||||
class="fixed isolate z-[9999] flex min-w-48 flex-col gap-1 rounded-[14px] bg-surface-3 p-2 shadow-lg ring-1 ring-surface-5"
|
||||
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 shadow-lg ring-1 ring-surface-5"
|
||||
:style="[panelStyle, { transformOrigin: menuTransformOrigin }]"
|
||||
role="menu"
|
||||
:aria-label="props.label"
|
||||
@@ -326,76 +326,85 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
:data-side="resolvedSide"
|
||||
:style="anchorStyle"
|
||||
/>
|
||||
<template v-for="(option, index) in visibleOptions" :key="option.id ?? `divider-${index}`">
|
||||
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<RouterLink
|
||||
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
|
||||
v-tooltip="option.tooltip"
|
||||
:to="option.to"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
<div
|
||||
data-anchored-scroll-region
|
||||
class="flex min-w-48 flex-col gap-1 overflow-y-auto p-2"
|
||||
:style="{ maxHeight: panelStyle.maxHeight }"
|
||||
>
|
||||
<template
|
||||
v-for="(option, index) in visibleOptions"
|
||||
:key="option.id ?? `divider-${index}`"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</RouterLink>
|
||||
<div v-if="isDivider(option)" role="separator" class="my-1 h-px bg-surface-5" />
|
||||
|
||||
<a
|
||||
v-else-if="isLink(option)"
|
||||
v-tooltip="option.tooltip"
|
||||
:href="option.disabled ? undefined : option.href"
|
||||
:target="option.target"
|
||||
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
|
||||
:download="option.download"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else-if="isLink(option) && option.to !== undefined && !option.disabled"
|
||||
v-tooltip="option.tooltip"
|
||||
:to="option.to"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</RouterLink>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="option.tooltip"
|
||||
type="button"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleAction(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
<a
|
||||
v-else-if="isLink(option)"
|
||||
v-tooltip="option.tooltip"
|
||||
:href="option.disabled ? undefined : option.href"
|
||||
:target="option.target"
|
||||
:rel="option.rel ?? (option.target === '_blank' ? 'noopener noreferrer' : undefined)"
|
||||
:download="option.download"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleLink(option, $event)"
|
||||
@keydown="handleLinkKeydown(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</a>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip="option.tooltip"
|
||||
type="button"
|
||||
:aria-disabled="option.disabled || undefined"
|
||||
:class="menuItemClasses"
|
||||
:style="getMenuItemStyle(option)"
|
||||
:data-tone="option.tone && option.tone !== 'default' ? option.tone : undefined"
|
||||
:data-hover-filled="option.hoverFilled || option.hoverFilledOnly || undefined"
|
||||
:data-hover-filled-only="option.hoverFilledOnly || undefined"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
@click="handleAction(option, $event)"
|
||||
@focus="selectedIndex = getMenuItems().indexOf($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<slot :name="option.id" :option="option">
|
||||
<component :is="option.icon" v-if="option.icon" aria-hidden="true" />
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
@@ -128,7 +128,7 @@ defineExpose({ open: openMenu, close: closeMenu })
|
||||
v-if="isOpen"
|
||||
:id="panelId"
|
||||
ref="panelElement"
|
||||
class="fixed isolate z-[9999] rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
|
||||
class="fixed isolate z-[9999] overflow-y-auto rounded-[14px] bg-surface-3 p-4 text-primary shadow-lg ring-1 ring-surface-5"
|
||||
:style="panelStyle"
|
||||
:role="props.panelRole"
|
||||
:aria-label="props.label"
|
||||
|
||||
@@ -474,7 +474,6 @@ import {
|
||||
import {
|
||||
type GameVersionTag,
|
||||
getVersionGroupsForDisplay,
|
||||
type Version,
|
||||
type VersionDisplayGroup,
|
||||
} from '@modrinth/utils'
|
||||
import { Menu } from 'floating-vue'
|
||||
@@ -500,15 +499,12 @@ const formatBytes = useFormatBytes()
|
||||
const MAX_GAME_VERSION_TAGS = 5
|
||||
const MAX_PLATFORM_TAGS = 3
|
||||
|
||||
type VersionWithDisplayUrlEnding = Version & {
|
||||
type VersionWithDisplayUrlEnding = Labrinth.Versions.v3.Version & {
|
||||
displayUrlEnding: string
|
||||
environment?: Labrinth.Projects.v3.Environment
|
||||
mrpack_loaders?: string[]
|
||||
}
|
||||
|
||||
type DisplayVersion = VersionWithDisplayUrlEnding & {
|
||||
noModLoader: boolean
|
||||
files_missing_attribution?: boolean
|
||||
}
|
||||
|
||||
type VersionTableColumn =
|
||||
@@ -536,7 +532,7 @@ const props = withDefaults(
|
||||
currentMember?: boolean
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
gameVersions: GameVersionTag[]
|
||||
versionLink?: (version: Version) => string
|
||||
versionLink?: (version: Labrinth.Versions.v3.Version) => string
|
||||
openModal?: () => void
|
||||
createVersionButtonSecondary?: boolean
|
||||
}>(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<AutoLink
|
||||
:to="`/project/${project.slug}`"
|
||||
:target="newTab ? '_blank' : undefined"
|
||||
class="group inline-flex min-w-0 max-w-full items-center gap-1"
|
||||
:style="{ color: `var(--color-${getProjectStatusColor(project.status)})` }"
|
||||
>
|
||||
<component :is="getProjectStatusIcon(project.status)" class="size-4 shrink-0" />
|
||||
<span class="min-w-0 truncate font-medium group-hover:underline">{{ project.name }}</span>
|
||||
</AutoLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import { getProjectStatusColor, getProjectStatusIcon } from '../../utils'
|
||||
import AutoLink from '../base/AutoLink.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
project: Pick<Labrinth.Projects.v3.Project, 'name' | 'slug' | 'status'>
|
||||
newTab?: boolean
|
||||
}>(),
|
||||
{
|
||||
newTab: false,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -17,4 +17,5 @@ export { default as ProjectSidebarLinks } from './ProjectSidebarLinks.vue'
|
||||
export { default as ProjectSidebarServerInfo } from './ProjectSidebarServerInfo.vue'
|
||||
export { default as ProjectSidebarTags } from './ProjectSidebarTags.vue'
|
||||
export { default as ProjectStatusBadge } from './ProjectStatusBadge.vue'
|
||||
export { default as ProjectStatusLink } from './ProjectStatusLink.vue'
|
||||
export { default as TagsOverflow } from './TagsOverflow.vue'
|
||||
|
||||
@@ -120,16 +120,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ChevronLeftIcon, FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import type { MultiSelectOption } from '@modrinth/ui'
|
||||
import { Checkbox, formatLoader, FormattedTag, MultiSelect, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import type { GameVersionTag, Version } from '@modrinth/utils'
|
||||
import type { GameVersionTag } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { LocationQueryValue } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const props = defineProps<{
|
||||
versions: Version[]
|
||||
versions: Labrinth.Versions.v3.Version[]
|
||||
gameVersions: GameVersionTag[]
|
||||
baseId?: string
|
||||
}>()
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface ProjectPageContext {
|
||||
allMembers: Ref<Labrinth.Projects.v3.TeamMember[]>
|
||||
organization: Ref<Labrinth.Projects.v3.Organization | null>
|
||||
// Lazy version loading (client-side only)
|
||||
versions: Ref<Labrinth.Versions.v2.Version[] | null>
|
||||
versions: Ref<Labrinth.Versions.v3.Version[] | null>
|
||||
versionsLoading: Ref<boolean>
|
||||
versionsLoaded: Ref<boolean>
|
||||
// Lazy dependencies loading (client-side only)
|
||||
|
||||
@@ -89,6 +89,22 @@ export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {
|
||||
unknown: UnknownIcon,
|
||||
}
|
||||
|
||||
// this should probably be abstracted or something idk
|
||||
export type BadgeColor = 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'gray'
|
||||
|
||||
export const PROJECT_STATUS_COLORS: Record<ProjectStatus, BadgeColor> = {
|
||||
approved: 'blue',
|
||||
unlisted: 'purple',
|
||||
withheld: 'red',
|
||||
private: 'gray',
|
||||
scheduled: 'orange',
|
||||
draft: 'gray',
|
||||
archived: 'gray',
|
||||
rejected: 'red',
|
||||
processing: 'orange',
|
||||
unknown: 'gray',
|
||||
}
|
||||
|
||||
export const DIRECTORY_ICONS: Record<string, Component> = {
|
||||
config: FolderOpenIcon,
|
||||
world: FolderOpenIcon,
|
||||
@@ -124,6 +140,10 @@ export function getProjectStatusIcon(status: ProjectStatus): Component {
|
||||
return PROJECT_STATUS_ICONS[status] ?? UnknownIcon
|
||||
}
|
||||
|
||||
export function getProjectStatusColor(status: ProjectStatus): BadgeColor {
|
||||
return PROJECT_STATUS_COLORS[status] ?? `gray`
|
||||
}
|
||||
|
||||
export function getDirectoryIcon(name: string): Component {
|
||||
return DIRECTORY_ICONS[name.toLowerCase()] ?? DIRECTORY_ICONS._default
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
export type AnchoredTeleportPlacement =
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'bottom-center'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'top-center'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'left-start'
|
||||
@@ -37,40 +39,63 @@ export function useAnchoredTeleport(
|
||||
if (!isOpen.value || !trigger.value || !panel.value) return
|
||||
|
||||
const triggerRect = trigger.value.getBoundingClientRect()
|
||||
const panelWidth = panel.value.offsetWidth
|
||||
const panelHeight = panel.value.offsetHeight
|
||||
const scrollRegion =
|
||||
panel.value.querySelector<HTMLElement>('[data-anchored-scroll-region]') ?? panel.value
|
||||
const panelRect = { width: panel.value.offsetWidth, height: scrollRegion.scrollHeight }
|
||||
const offset = distance.value
|
||||
const alignsEnd = placement.value.endsWith('end')
|
||||
const alignsCenter = placement.value.endsWith('center')
|
||||
const isHorizontal = placement.value.startsWith('right') || placement.value.startsWith('left')
|
||||
let idealTop: number
|
||||
let idealLeft: number
|
||||
let maxHeight: number | undefined
|
||||
|
||||
if (isHorizontal) {
|
||||
const prefersRight = placement.value.startsWith('right')
|
||||
const spaceRight = window.innerWidth - triggerRect.right - viewportPadding
|
||||
const spaceLeft = triggerRect.left - viewportPadding
|
||||
const opensRight = prefersRight
|
||||
? panelWidth + offset <= spaceRight || spaceRight > spaceLeft
|
||||
: panelWidth + offset > spaceLeft && spaceRight > spaceLeft
|
||||
? panelRect.width + offset <= spaceRight || spaceRight > spaceLeft
|
||||
: panelRect.width + offset > spaceLeft && spaceRight > spaceLeft
|
||||
|
||||
resolvedSide.value = opensRight ? 'right' : 'left'
|
||||
idealTop = alignsEnd ? triggerRect.bottom - panelHeight : triggerRect.top
|
||||
idealLeft = opensRight ? triggerRect.right + offset : triggerRect.left - panelWidth - offset
|
||||
idealTop = alignsEnd ? triggerRect.bottom - panelRect.height : triggerRect.top
|
||||
idealLeft = opensRight
|
||||
? triggerRect.right + offset
|
||||
: triggerRect.left - panelRect.width - offset
|
||||
} else {
|
||||
const prefersTop = placement.value.startsWith('top')
|
||||
const spaceBelow = window.innerHeight - triggerRect.bottom - viewportPadding
|
||||
const spaceAbove = triggerRect.top - viewportPadding
|
||||
const opensAbove = prefersTop
|
||||
? panelHeight + offset <= spaceAbove || spaceAbove > spaceBelow
|
||||
: panelHeight + offset > spaceBelow && spaceAbove > spaceBelow
|
||||
? panelRect.height + offset <= spaceAbove || spaceAbove > spaceBelow
|
||||
: panelRect.height + offset > spaceBelow && spaceAbove > spaceBelow
|
||||
|
||||
resolvedSide.value = opensAbove ? 'top' : 'bottom'
|
||||
idealTop = opensAbove ? triggerRect.top - panelHeight - offset : triggerRect.bottom + offset
|
||||
idealLeft = alignsEnd ? triggerRect.right - panelWidth : triggerRect.left
|
||||
const availableHeight = opensAbove ? spaceAbove : spaceBelow
|
||||
if (panelRect.height + offset > availableHeight) {
|
||||
maxHeight = Math.max(0, availableHeight - offset)
|
||||
}
|
||||
idealTop = opensAbove
|
||||
? triggerRect.top - (maxHeight ?? panelRect.height) - offset
|
||||
: triggerRect.bottom + offset
|
||||
if (alignsCenter) {
|
||||
const centered = triggerRect.left + triggerRect.width / 2 - panelRect.width / 2
|
||||
if (centered < viewportPadding) {
|
||||
idealLeft = triggerRect.left
|
||||
} else if (centered + panelRect.width > window.innerWidth - viewportPadding) {
|
||||
idealLeft = triggerRect.right - panelRect.width
|
||||
} else {
|
||||
idealLeft = centered
|
||||
}
|
||||
} else {
|
||||
idealLeft = alignsEnd ? triggerRect.right - panelRect.width : triggerRect.left
|
||||
}
|
||||
}
|
||||
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
|
||||
const effectiveHeight = maxHeight ?? panelRect.height
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - effectiveHeight - viewportPadding)
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelRect.width - viewportPadding)
|
||||
const panelTop = Math.min(Math.max(idealTop, viewportPadding), maxTop)
|
||||
const panelLeft = Math.min(Math.max(idealLeft, viewportPadding), maxLeft)
|
||||
|
||||
@@ -78,18 +103,19 @@ export function useAnchoredTeleport(
|
||||
top: `${panelTop}px`,
|
||||
left: `${panelLeft}px`,
|
||||
visibility: 'visible',
|
||||
...(maxHeight !== undefined ? { maxHeight: `${maxHeight}px` } : {}),
|
||||
}
|
||||
anchorStyle.value = isHorizontal
|
||||
? {
|
||||
top: `${Math.min(
|
||||
Math.max(triggerRect.top + triggerRect.height / 2 - panelTop, anchorPadding),
|
||||
Math.max(anchorPadding, panelHeight - anchorPadding),
|
||||
Math.max(anchorPadding, panelRect.height - anchorPadding),
|
||||
)}px`,
|
||||
}
|
||||
: {
|
||||
left: `${Math.min(
|
||||
Math.max(triggerRect.left + triggerRect.width / 2 - panelLeft, anchorPadding),
|
||||
Math.max(anchorPadding, panelWidth - anchorPadding),
|
||||
Math.max(anchorPadding, panelRect.width - anchorPadding),
|
||||
)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user