Compare commits

...
Author SHA1 Message Date
ProspectorandGitHub 8f63f9c450 Merge branch 'main' into coolbot/moderation-big-checklist-updates
Signed-off-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-20 13:47:12 -07:00
coolbot 6a1ffff46e prepr 2026-07-20 13:26:07 -07:00
chyzman bf71e4fc63 use environment name(s) in checklist info 2026-07-20 16:15:11 -04:00
chyzman 53dab378cb incorrect environment message tweak 2026-07-20 15:43:08 -04:00
chyzman 56c1415a03 better keybind navigation 2026-07-20 15:27:00 -04:00
chyzman e038982cc2 Merge branch 'main' of github.com:modrinth/code into coolbot/moderation-big-checklist-updates
# Conflicts:
#	packages/ui/src/components/base/TeleportOverflowMenu.vue
2026-07-20 15:10:44 -04:00
chyzman 0907b6f590 slug correction input reset button resets to current slug again instead of auto 2026-07-20 14:31:10 -04:00
chyzman 1c93b5e4cb better inaccurate slug message 2026-07-19 22:33:26 -04:00
chyzman d61239047e remove random empty lines in some situations 2026-07-19 22:10:16 -04:00
chyzman 05a1099549 Merge branch 'coolbot/moderation-big-checklist-updates' of github.com:modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-19 21:25:01 -04:00
chyzman a77d4bca08 differentiate reset and return to start 2026-07-19 21:23:03 -04:00
chyzandGitHub 9361ecaf08 Merge branch 'main' into coolbot/moderation-big-checklist-updates 2026-07-19 20:57:57 -04:00
chyzman ceb1c1e381 apply quick fixes before refreshing page 2026-07-19 20:36:14 -04:00
chyzman 49d527c1ec pseudo stages no longer interact with non-pseudo stages at all
also query projects better I think?
fix repetitive message spam in links stage
2026-07-19 17:34:11 -04:00
coolbot 9ae9d674c2 prepr 2026-07-19 12:37:24 -07:00
coolbot 25baa18687 Merge branch 'main' into coolbot/moderation-big-checklist-updates 2026-07-19 12:19:06 -07:00
chyzman 272427b459 I think slug stuff is working?
also like actually 100% garunteed markdown escaping works now
2026-07-19 09:40:56 -04:00
chyzman 4ff7b7e713 Merge branch 'main' of github.com:modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-18 17:50:44 -04:00
chyzman a6892be43f fix donation links not being in a column 2026-07-17 16:43:27 -04:00
chyzman 996e5cdf57 fix versions stage messages not working 2026-07-17 16:00:34 -04:00
chyzman 6fc6367128 make checklist reset button reset only current stage if there's modifications to it instead of full checklist 2026-07-17 15:26:24 -04:00
chyzman 9a1449d223 ok like redirect but like don't lose state when slug changes :smart: 2026-07-17 15:15:28 -04:00
chyzman e95faa24b8 actually navigate when we change project slug 2026-07-17 13:43:47 -04:00
coolbot100s e060fe7d3c fix: display suggested slug in slug/misused message 2026-07-16 23:35:11 -07:00
coolbot100s 170aacebee fix: status alert priority 2026-07-16 23:06:02 -07:00
coolbot100s 5e4916bf86 update metadata environment group shown 2026-07-16 22:57:28 -07:00
coolbot100s 247f01709a fix: formatEnvironments
fix: formatEnvironments
2026-07-16 22:54:32 -07:00
coolbot100s d63279c166 add mixed option back for env info 2026-07-16 22:51:02 -07:00
coolbot100s d51ef15523 Update license stage handling and info 2026-07-16 21:41:57 -07:00
coolbot100s a8f9758fd1 tweak re-review wording. 2026-07-16 19:26:31 -07:00
chyzman 99257d6fbc maybe fixed markdown escaping? 2026-07-16 22:21:09 -04:00
chyzman 58c35b9ca8 make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db
2026-07-16 22:09:20 -04:00
chyzman b44b62a546 make complex nodes less complex (for the most part)
fix re-review stage shown condition
new fancy message collection system
all buttons that contribute to messages now have relevant tooltips
don't store default values in checklist db
2026-07-16 21:51:52 -04:00
coolbot100s 234a6fc915 Merge branch 'main' into coolbot/moderation-big-checklist-updates 2026-07-16 17:59:12 -07:00
Michael H. 0d54496ac1 fix: pr comment if storybook fails 2026-07-16 21:12:37 +02:00
chyzman 6120eae344 Merge branch 'main' of github.com:modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-16 14:54:10 -04:00
Gravy Boat 2644614df6 fix(ci): tell typo check to ignore moderation utils 2026-07-16 11:17:20 -02:30
Gravy Boat bf8aef02a7 chore: run prepr 2026-07-16 11:13:57 -02:30
Gravy Boat f0cfebd54e fix: link stage using page context too late 2026-07-16 11:12:21 -02:30
coolbot100s d001d9d52a reupload stage navigates to project Description. 2026-07-16 01:02:05 -07:00
coolbot100s 53b9366a72 update re-review button 2026-07-16 00:58:42 -07:00
coolbot100s 73c94f4928 links stage bonus buttons, src required flag, show link value in msgs, fix msg groupings. 2026-07-16 00:56:51 -07:00
chyzman 9fd241e52a Merge remote-tracking branch 'upstream/coolbot/moderation-big-checklist-updates' into coolbot/moderation-big-checklist-updates 2026-07-16 03:00:47 -04:00
chyzman 0a822fcd46 make metadata page go to non setting versions page 2026-07-16 02:23:56 -04:00
chyzman 29cdb171ba temp priority on corrections applied 2026-07-16 02:09:04 -04:00
coolbot100s d0ff665b0f move custom description advice stage to first button. 2026-07-15 23:02:43 -07:00
chyzman 2dcb1f72cb ok maybe lets use kebab 2026-07-15 23:55:16 -04:00
chyzman 8de42330cc Merge remote-tracking branch 'upstream/coolbot/moderation-big-checklist-updates' into coolbot/moderation-big-checklist-updates
# Conflicts:
#	packages/moderation/src/data/messages/checklist/messages/description/headersAsBody.md
#	packages/moderation/src/data/messages/checklist/messages/description/imageOnly.md
#	packages/moderation/src/data/messages/checklist/messages/description/nonEnglish-server.md
#	packages/moderation/src/data/messages/checklist/messages/description/nonEnglish.md
#	packages/moderation/src/data/messages/checklist/messages/description/nonStandardText.md
#	packages/moderation/src/data/messages/checklist/messages/gallery/notRelevant.md
#	packages/moderation/src/data/messages/checklist/messages/gallery/showcaseClarity.md
#	packages/moderation/src/data/messages/checklist/messages/license/invalidLink.md
#	packages/moderation/src/data/messages/checklist/messages/license/invalidLink/customLicense.md
#	packages/moderation/src/data/messages/checklist/messages/license/noSource-fork.md
#	packages/moderation/src/data/messages/checklist/messages/license/noSource.md
#	packages/moderation/src/data/messages/checklist/messages/links/inaccessibleHeader.md
#	packages/moderation/src/data/messages/checklist/messages/links/misusedHeader.md
#	packages/moderation/src/data/messages/checklist/messages/links/note/discordExpiring.md
#	packages/moderation/src/data/messages/checklist/messages/links/note/discordInaccessible.md
#	packages/moderation/src/data/messages/checklist/messages/links/note/issuesDisabled.md
#	packages/moderation/src/data/messages/checklist/messages/links/note/source404.md
#	packages/moderation/src/data/messages/checklist/messages/links/note/sourceEmpty.md
#	packages/moderation/src/data/messages/checklist/messages/links/note/wikiDisabled.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/cheatOrHackAdvertising.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/excessiveLanguages.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/paidAccessServer.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/discriminatory.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/falseEndorsement.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/harmful.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/illegalActivity.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/impersonation.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/ipInfringement.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/legalRights.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/misleading.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/mojangBypass.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/objectionable.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/profanity.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContent/undisclosedUpload.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/prohibitedContentHeader.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/ruleBreakingOther.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptIn/aimBot.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptIn/hidingMods.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptIn/itemDuplication.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptIn/movement.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptIn/pvp.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptIn/xRay.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptInHeader.md
#	packages/moderation/src/data/messages/checklist/messages/otherRules/serverSideOptOut.md
#	packages/moderation/src/data/messages/checklist/messages/permissions/invalidPermissions.md
#	packages/moderation/src/data/messages/checklist/messages/permissions/missingPermissions.md
#	packages/moderation/src/data/messages/checklist/messages/permissions/nonCommercialExternalContent.md
#	packages/moderation/src/data/messages/checklist/messages/permissions/prohibitedExternalContent.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/issueWarning.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/metadataIssue.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/metadataIssue/dependencies.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/metadataIssue/license.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/metadataIssue/loaders.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/metadataIssue/mcVersions.md
#	packages/moderation/src/data/messages/checklist/messages/postApproval/missedDeadline.md
#	packages/moderation/src/data/messages/checklist/messages/reReview/ignored.md
#	packages/moderation/src/data/messages/checklist/messages/reReview/ignored/warning.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/customPackProhibited.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/customPackVerification.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/customPackVerification/list.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/identityVerification-server.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/identityVerification.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/insufficientFork.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/requestProof-server.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/requestProof.md
#	packages/moderation/src/data/messages/checklist/messages/reupload/unclearFork.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/accountIssues.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/correctionsApplied-approved.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/correctionsApplied.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/demonetized-modpack.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/demonetized.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/privateUse-server.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/privateUse.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/serverUse.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/tec/sourceRequest-bins.md
#	packages/moderation/src/data/messages/checklist/messages/statusAlerts/tec/sourceRequest-obfs.md
#	packages/moderation/src/data/messages/checklist/messages/summary/nonEnglish.md
#	packages/moderation/src/data/messages/checklist/messages/summary/repeatIp.md
#	packages/moderation/src/data/messages/checklist/messages/summary/repeatTitle.md
#	packages/moderation/src/data/messages/checklist/messages/tags/optimizationMisused.md
#	packages/moderation/src/data/messages/checklist/messages/tags/resolutionsMisused.md
#	packages/moderation/src/data/messages/checklist/messages/titleSlug/slug/misused.md
#	packages/moderation/src/data/messages/checklist/messages/titleSlug/title/minecraftBranding.md
#	packages/moderation/src/data/messages/checklist/messages/titleSlug/title/similarities.md
#	packages/moderation/src/data/messages/checklist/messages/titleSlug/title/similarities/fork.md
#	packages/moderation/src/data/messages/checklist/messages/titleSlug/title/similarities/modpack.md
#	packages/moderation/src/data/messages/checklist/messages/titleSlug/title/uselessInfo.md
#	packages/moderation/src/data/messages/checklist/messages/undefinedProject/noVersions.md
#	packages/moderation/src/data/messages/checklist/messages/versions/alternateVersions/additional.md
#	packages/moderation/src/data/messages/checklist/messages/versions/alternateVersions/mono.md
#	packages/moderation/src/data/messages/checklist/messages/versions/alternateVersions/primary.md
#	packages/moderation/src/data/messages/checklist/messages/versions/alternateVersions/server-additional.md
#	packages/moderation/src/data/messages/checklist/messages/versions/alternateVersions/server.md
#	packages/moderation/src/data/messages/checklist/messages/versions/alternateVersions/zip.md
#	packages/moderation/src/data/messages/checklist/messages/versions/duplicatePrimaryFiles.md
#	packages/moderation/src/data/messages/checklist/messages/versions/incorrectAdditionalFiles.md
#	packages/moderation/src/data/messages/checklist/messages/versions/incorrectLoader.md
#	packages/moderation/src/data/messages/checklist/messages/versions/incorrectProjectType/datapack.md
#	packages/moderation/src/data/messages/checklist/messages/versions/incorrectProjectType/modpack.md
#	packages/moderation/src/data/messages/checklist/messages/versions/incorrectProjectType/resourcepack.md
#	packages/moderation/src/data/messages/checklist/messages/versions/redistLibs.md
#	packages/moderation/src/data/messages/checklist/messages/versions/vanillaAssets.md
#	packages/moderation/src/data/messages/checklist/text/statusAlerts/text.md
#	packages/moderation/src/data/messages/checklist/text/titleSlug/slug.md
#	packages/moderation/src/data/messages/checklist/text/titleSlug/title.md
#	packages/moderation/src/data/stages/otherRules.ts
#	packages/moderation/src/data/stages/postApproval.ts
#	packages/moderation/src/data/stages/reReview.ts
#	packages/moderation/src/data/stages/statusAlerts.ts
#	packages/moderation/src/data/stages/titleSlug.ts
#	packages/moderation/src/data/stages/undefinedProject.ts
2026-07-15 23:37:26 -04:00
chyzman 24f054a51b redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme
2026-07-15 23:30:37 -04:00
chyzman 4f5498d9e3 redirect when checklist updates slug
fix initial stage not being first if first stage is only shown under certain conditions
move to a consistent naming scheme
2026-07-15 23:26:22 -04:00
coolbot100s 5f3adcae79 fix showcase clarity msg 2026-07-15 18:51:03 -07:00
chyzman c7aa081912 actually fix the stage selector this time 2026-07-15 20:22:04 -04:00
coolbot100s f6f1cf48ad Merge branch 'coolbot/moderation-big-checklist-updates' of https://github.com/modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-15 17:05:56 -07:00
coolbot100s 7a2a4494c9 passing off version stage fixing. 2026-07-15 17:05:52 -07:00
chyzman e5d8cc64bb unbreak stage selector 2026-07-15 20:01:40 -04:00
chyzman 1f649fd9ee re-review stage 2026-07-15 19:10:15 -04:00
coolbot100s ab24bd6dc6 Update links name to match the links settings 2026-07-15 14:38:39 -07:00
coolbot100s aec379cfe1 Merge branch 'coolbot/moderation-big-checklist-updates' of https://github.com/modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-15 14:32:20 -07:00
coolbot100s 3a0ad53ede rename horror button to spoilers button 2026-07-15 14:32:09 -07:00
chyzman d5dc5635bd make messages only actually count as messages if they actually have content 2026-07-15 17:11:20 -04:00
chyzman 735a753f19 fix quick fixes (again) (idk how they keep breaking) 2026-07-15 14:34:32 -04:00
chyzman f32bd79771 not sure why but link's links weren't links 2026-07-15 12:28:18 -04:00
Calum H. (IMB11) b8f4ba0b97 fix: lint 2026-07-15 17:15:31 +01:00
Calum H. (IMB11) 6119b324e5 fix: some issues 2026-07-15 17:13:25 +01:00
coolbot100s 56377effe3 stop linter from trying to break my markdown 2026-07-15 01:14:45 -07:00
coolbot100s a03f06310b Merge branch 'main' into coolbot/moderation-big-checklist-updates 2026-07-15 00:56:12 -07:00
coolbot100s b413bf5233 prepr 2026-07-15 00:52:27 -07:00
coolbot100s 379af9f00e Merge branch 'coolbot/moderation-big-checklist-updates' of https://github.com/modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-14 23:39:08 -07:00
coolbot100s 1160799367 don't show non-english buttons on servers without english support. 2026-07-14 23:39:03 -07:00
chyzman d0a4876966 fix stage selector 2026-07-15 02:35:03 -04:00
coolbot100s f8e46def41 fix some metadata stuff 2026-07-14 23:24:31 -07:00
coolbot100s 838430fe81 Move conditional stages to the top where they belong. 2026-07-14 23:09:56 -07:00
coolbot100s 5bfaee8251 Update rules stage 2026-07-14 22:57:26 -07:00
coolbot100s c5a6216c8a Change stage order 2026-07-14 21:59:51 -07:00
coolbot100s 886a01ade8 setup links bonus buttons 2026-07-14 21:58:07 -07:00
chyzman 7c6a7cabcf fix links lag + actions on stages 2026-07-15 00:42:45 -04:00
coolbot100s 78ce4b5dec Fix links stage mostly, and tweak metadata stage. 2026-07-14 21:23:16 -07:00
coolbot100s a7ccd9b583 update description stage, add showcase clarity 2026-07-14 19:41:27 -07:00
chyzman 6c9c560156 nested state access 2026-07-14 22:22:23 -04:00
chyzman 58868ba947 more bugs 2026-07-14 21:55:11 -04:00
chyzman 03eb5944fd fix log spam + concat md 2026-07-14 21:25:23 -04:00
chyzman 14cf1afe4a better required display + vueify + reorganize messages + tag quick fixes + todo 2026-07-14 20:11:20 -04:00
chyzman a53dff40dc move loaders to metadata (from versions) 2026-07-14 14:56:56 -04:00
chyzman 4d6c2ac9b8 tidy up after tidying up after today's refactoring 2026-07-14 00:47:36 -04:00
chyzman b8c799ba2c tidy up after today's refactoring 2026-07-14 00:34:33 -04:00
chyzman 716fc7e187 futureproof™️ environment metadata + just give me project context everywhere 2026-07-13 20:40:33 -04:00
chyzman a24f843a21 start of metadata stage + optimize checklist parsing + a couple of qol features 2026-07-13 18:43:30 -04:00
chyzman b6cab27f81 quickest fixes in the west 2026-07-12 19:49:18 -04:00
chyzman 152a4c7e58 Merge branch 'coolbot/moderation-big-checklist-updates' of github.com:modrinth/code into coolbot/moderation-big-checklist-updates 2026-07-12 17:15:04 -04:00
chyzman 20f40c6dc4 Prioritization™️ 2026-07-12 17:13:00 -04:00
coolbot100s ef835223cf remove vestigial function 2026-07-12 00:01:01 -07:00
coolbot100s 6cb81b688f Allow moderators to suggest correct environment. 2026-07-11 23:58:45 -07:00
chyzman b436af8f31 Merge remote-tracking branch 'upstream/coolbot/moderation-big-checklist-updates' into coolbot/moderation-big-checklist-updates
# Conflicts:
#	packages/moderation/src/data/stages/environment.ts
2026-07-11 21:42:18 -04:00
chyzman da05b1fa64 dropdown + "functional" quick fixes 2026-07-11 21:35:57 -04:00
chyzman 0c44a71b49 dropdown + "functional" quick fixes 2026-07-11 21:34:57 -04:00
chyzman e5b24d031a actual node type hierarchy + action abstraction + improve message/text handling 2026-07-11 17:29:53 -04:00
chyzman b85d56f805 Idk why I wasn't just mutating the builder 2026-07-11 13:39:12 -04:00
coolbot100s e319b1fe16 Merge branch 'main' into coolbot/moderation-big-checklist-updates 2026-07-10 21:20:29 -07:00
chyzman 6fbb11f51a forgot to only do modpack permissions for modpacks 2026-07-10 23:02:39 -04:00
chyzman 09acaad118 checklist fully migrated to new system, not much new has been added (yet) 2026-07-10 22:59:54 -04:00
chyzman 8f957ac83c do state and variablse in tooltips 2026-07-10 22:21:23 -04:00
chyzman 81d873fb71 put stageoptions before nodes 2026-07-10 21:59:52 -04:00
chyzman ee6ca6d804 Title & Slug, Summary Description, Links, License, Gallery, Versions and Reupload migrated to new system™️ 2026-07-10 21:52:35 -04:00
chyzman 3eb355a64b Merge remote-tracking branch 'origin/chyz/moderation-checklist-may-1' into coolbot/moderation-big-checklist-updates 2026-07-10 15:35:53 -04:00
chyzman d18eef5a2d give stages actual titles + rename what was previously called titles to hints 2026-07-10 14:41:58 -04:00
chyzman c2426be85b store moderation stuff separately + compact checklist stuff 2026-07-09 19:57:15 -04:00
chyzman b48db5574e only use indexedDb for moderation storage, localStorage forces a bunch of cringe 2026-07-09 17:53:14 -04:00
chyzman d6185e4065 Merge remote-tracking branch 'origin/chyz/moderation-checklist-may-1' into chyz/moderation-checklist-may-1
# Conflicts:
#	apps/frontend/src/components/ui/moderation/checklist/ModerationChecklist.vue
#	packages/moderation/src/data/messages/checklist-messages/versions/incorrect_loader.md
#	packages/moderation/src/data/stages/links.ts
#	packages/moderation/src/data/stages/rule-following.ts
#	packages/moderation/src/data/stages/versions.ts
2026-07-09 16:16:58 -04:00
chyzman 2c9d2b8be5 "i forgor" checklist tooltips tooltips (kinda jank but they work)
update to latest
2026-07-09 16:11:08 -04:00
chyzman aa5fe16b82 Insufficient description improvement test 2026-07-09 14:31:43 -04:00
chyzman e7beb9e953 oops i forgot 3.1 2026-07-09 14:31:43 -04:00
chyzman 014b359053 Make Stage Title Good 2026-07-09 14:31:43 -04:00
chyzman 6300f470d5 Checklist Stage Title 2026-07-09 14:31:43 -04:00
chyzman 6eb95d9f7b Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am 2026-07-09 14:31:43 -04:00
chyzman d28b550658 reorganize 2026-07-09 14:31:43 -04:00
chyzman e6f6faa420 Rule 1.x, 3.2 and 3.3x support 2026-07-09 14:31:43 -04:00
chyzman 2e0af202ff Make Stage Title Good 2026-05-03 02:58:00 -04:00
chyzman abad49e6d8 Checklist Stage Title 2026-05-03 02:09:14 -04:00
chyzman f1c6ef599a Kinda Jank dynamic list stuff that should probably be implemented better but its 1:30 am 2026-05-03 01:29:34 -04:00
chyzman 792e194d3f reorganize 2026-05-02 23:02:25 -04:00
chyzman 1eabbf0c28 Rule 1.x, 3.2 and 3.3x support 2026-05-02 20:53:22 -04:00
205 changed files with 4689 additions and 3574 deletions
+15 -2
View File
@@ -87,7 +87,11 @@ jobs:
command: versions upload --preview-alias git-${{ steps.meta.outputs.sha_short }}
comment:
if: github.repository_owner == 'modrinth' && github.event.pull_request.head.repo.full_name == github.repository
if: |
!cancelled()
&& github.repository_owner == 'modrinth'
&& github.event.pull_request.head.repo.full_name == github.repository
&& needs.deploy.result == 'success'
runs-on: ubuntu-latest
needs: [deploy, deploy-storybook]
steps:
@@ -101,16 +105,25 @@ jobs:
id: urls
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
STORYBOOK_RESULT: ${{ needs.deploy-storybook.result }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
STAGING_PREVIEW_URL=$(cat deployment-url-staging-preview.txt)
PRODUCTION_PREVIEW_URL=$(cat deployment-url-production-preview.txt)
if [ "$STORYBOOK_RESULT" = "success" ]; then
STORYBOOK_PREVIEW_URL="https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev"
else
STORYBOOK_PREVIEW_URL="⚠️ Deployment $STORYBOOK_RESULT ([logs]($RUN_URL))"
fi
echo "Production preview URL: $PRODUCTION_PREVIEW_URL"
echo "Staging preview URL: $STAGING_PREVIEW_URL"
echo "Storybook preview URL: $STORYBOOK_PREVIEW_URL"
echo "staging-preview-url=$STAGING_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "production-preview-url=$PRODUCTION_PREVIEW_URL" >> $GITHUB_OUTPUT
echo "storybook-preview-url=https://git-${HEAD_SHA::8}-storybook.modrinth.workers.dev" >> $GITHUB_OUTPUT
echo "storybook-preview-url=$STORYBOOK_PREVIEW_URL" >> $GITHUB_OUTPUT
- name: Find comment
if: github.event_name == 'pull_request'
+1
View File
@@ -8,6 +8,7 @@ extend-exclude = [
"packages/blog/",
# contains licenses like `CC-BY-ND-4.0`
"packages/moderation/src/data/stages/license.ts",
"packages/moderation/src/utils.ts",
# contains payment card IDs like `IY1VMST1MOXS` which are flagged
"apps/labrinth/src/queue/payouts/mod.rs",
# contains domain names with deliberate typos
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,780 @@
<script lang="ts" setup>
import type {
BooleanNodeBuilder,
ButtonNodeBuilder,
ChildNode,
DropdownNodeBuilder,
GroupNodeBuilder,
IdentifiedNodeBuilder,
InputNodeBuilder,
LabeledNodeBuilder,
NodeState,
NodeStateWithChildren,
OverrideValue,
ValueNodeBuilder,
} from '@modrinth/moderation'
import {
evalSegment,
expandVariables,
flattenProjectV3Variables,
flattenProjectVariables,
flattenStaticVariables,
getBooleanChildState,
NodeBuilder,
resolve,
resolveChildren,
setMessageProject,
} from '@modrinth/moderation'
import {
ButtonStyled,
Checkbox,
Combobox,
injectProjectPageContext,
MarkdownEditor,
StyledInput,
} from '@modrinth/ui'
import { renderHighlightedString, renderString } from '@modrinth/utils'
import { inject, nextTick, onMounted, reactive, watchEffect } from 'vue'
import { NODE_META_KEY, STATE_KEY } from './checklist-context'
const nodeMetaMap = inject(NODE_META_KEY)
const injectedGlobalState = inject(STATE_KEY)
const { projectV3: project, projectV2 } = injectProjectPageContext()
setMessageProject(project, projectV2)
const props = defineProps<{
nodes: ChildNode[]
showContext: Record<string, NodeState>
onImageUpload?: (file: File) => Promise<string>
flex?: boolean
titleDepth?: number
parentStatePath?: string[]
}>()
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 isVisible(node: NodeBuilder): boolean {
if (node._shown !== undefined) return resolve(node._shown)
if (node.type === 'group') {
const children = getChildren(node as IdentifiedNodeBuilder)
return children.some((c) => !(c instanceof NodeBuilder) || isVisible(c))
}
return true
}
function isEnabled(node: IdentifiedNodeBuilder): boolean {
const e = node._enabled
if (e === undefined) return true
if (typeof e === 'function') return e(props.showContext)
return resolve(e)
}
function isButtonEnabled(node: ButtonNodeBuilder): boolean {
const enabled = node._enabled
if (enabled === undefined) return true
if (typeof enabled === 'function') return enabled(props.showContext)
return resolve(enabled)
}
function asBool(node: NodeBuilder): BooleanNodeBuilder {
return node as BooleanNodeBuilder
}
function asButton(node: NodeBuilder): ButtonNodeBuilder {
return node as ButtonNodeBuilder
}
function asIdentified(node: NodeBuilder): IdentifiedNodeBuilder {
return node as IdentifiedNodeBuilder
}
function asLabeled(node: NodeBuilder): LabeledNodeBuilder {
return node as LabeledNodeBuilder
}
function asGroup(node: NodeBuilder): GroupNodeBuilder {
return node as GroupNodeBuilder
}
function asDropdown(node: NodeBuilder): DropdownNodeBuilder {
return node as DropdownNodeBuilder
}
function asInput(node: NodeBuilder): InputNodeBuilder {
return node as InputNodeBuilder
}
function getAtPath(path: string[]): NodeState {
let current: unknown = injectedGlobalState!.value
for (const key of path) {
if (current == null || typeof current !== 'object' || current instanceof Set) return undefined
current = (current as Record<string, unknown>)[key]
}
return current as NodeState
}
function setAtPath(path: string[], value: NodeState): void {
if (path.length === 0) return
const global = injectedGlobalState!.value as unknown as Record<string, unknown>
let current = global
const stack: [Record<string, unknown>, string][] = []
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const next = current[key]
if (!next || typeof next !== 'object' || next instanceof Set) {
if (value === undefined) return
current[key] = next !== null && next !== undefined ? { value: next } : {}
current = current[key] as Record<string, unknown>
} else {
stack.push([current, key])
current = next as Record<string, unknown>
}
}
const lastKey = path[path.length - 1]
if (value === undefined) {
Reflect.deleteProperty(current, lastKey)
for (let i = stack.length - 1; i >= 0; i--) {
const [parent, key] = stack[i]
const child = parent[key]
if (
child &&
typeof child === 'object' &&
!(child instanceof Set) &&
Object.keys(child as object).length === 0
) {
Reflect.deleteProperty(parent, key)
} else {
break
}
}
} else {
current[lastKey] = value as unknown
}
}
function getNodeState(node: IdentifiedNodeBuilder): NodeState {
return node._statePath ? getAtPath(node._statePath) : undefined
}
function setNodeState(node: IdentifiedNodeBuilder, value: NodeState): void {
if (node._statePath) setAtPath(node._statePath, value)
}
function getBooleanState(node: BooleanNodeBuilder): boolean {
const state = getNodeState(node)
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
}
const def = resolveDefault(node)
return (def as boolean | undefined) ?? false
}
function getMultiSelectState(node: IdentifiedNodeBuilder): Set<string> {
const state = getNodeState(node)
return state instanceof Set ? state : new Set<string>()
}
function getSelectState(node: IdentifiedNodeBuilder): string | undefined {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : undefined
}
function getDropdownOptions(node: DropdownNodeBuilder) {
return [
...(node._none !== undefined ? [{ value: '', label: node._none }] : []),
...visibleChildren(node).map((c) => ({
value: asIdentified(c).id!,
label: asLabeled(c).label,
})),
]
}
function getDropdownModelValue(node: DropdownNodeBuilder) {
return getSelectState(node) ?? (node._none !== undefined ? '' : undefined)
}
function toggleSelect(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const current = getSelectState(parent)
setNodeState(parent, current === child.id ? undefined : child.id)
}
function resolveDefault(node: ValueNodeBuilder): NodeState {
const d = node._defaultValue
return typeof d === 'function' ? d(props.showContext) : d
}
function getTextState(node: IdentifiedNodeBuilder): string {
const state = getNodeState(node)
if (typeof state === 'string') return state
const def = resolveDefault(node as ValueNodeBuilder)
return typeof def === 'string' ? def : ''
}
function getNodeTitle(node: NodeBuilder): string | undefined {
if (node._title === undefined) return undefined
return resolve(node._title) || undefined
}
function getPlaceholder(node: InputNodeBuilder): string | undefined {
if (node._placeholder !== undefined) return resolve(node._placeholder)
const def = resolveDefault(node)
if (typeof def === 'string') return def
return undefined
}
function hasActionableFixes(node: IdentifiedNodeBuilder): boolean {
return nodeMetaMap?.value.get(node)?.isFixActionable ?? false
}
function hasRequiredMissingDescendants(node: IdentifiedNodeBuilder): boolean {
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
const identified = child as IdentifiedNodeBuilder
if (nodeMetaMap?.value.get(identified)?.hasRequiredMissing) return true
if (identified.id !== undefined && hasRequiredMissingDescendants(identified)) return true
}
return false
}
function nodeHasRequiredMissing(node: IdentifiedNodeBuilder): boolean {
return !!nodeMetaMap?.value.get(node)?.hasRequiredMissing || hasRequiredMissingDescendants(node)
}
function getBooleanColor(node: BooleanNodeBuilder): string {
if (!getBooleanState(node)) return 'standard'
if (hasRequiredMissingDescendants(node)) return 'orange'
return hasActionableFixes(node) ? 'blue' : 'brand'
}
function setTextState(node: IdentifiedNodeBuilder, v: string): void {
const inputNode = node as InputNodeBuilder
const result = inputNode._onChange?.(v, overrideHelpers)
if (isOverrideValue(result)) {
const ov = result.__override
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, ov === defStr ? undefined : ov || undefined)
nextTick(() => textInputRefs.get(node)?.setValue(ov))
return
}
const def = resolveDefault(node as ValueNodeBuilder)
const defStr = typeof def === 'string' ? def : ''
setNodeState(node, v === defStr ? undefined : defStr ? v : v || undefined)
}
function handleButtonClick(node: ButtonNodeBuilder): void {
const before = new Map<NodeBuilder, string>()
for (const inputNode of textInputRefs.keys()) {
before.set(inputNode, getTextState(asIdentified(inputNode)))
}
node._onClick?.(props.showContext)
for (const [inputNode, beforeVal] of before) {
const after = getTextState(asIdentified(inputNode))
if (after !== beforeVal) setTextState(asIdentified(inputNode), after)
}
}
onMounted(() => {
for (const inputNode of textInputRefs.keys()) {
setTextState(asIdentified(inputNode), getTextState(asIdentified(inputNode)))
}
})
function toggleBoolean(node: BooleanNodeBuilder) {
const raw = getNodeState(node)
const next = !getBooleanState(node)
const defaultVal = (resolveDefault(node) as boolean | undefined) ?? false
const isDefault = next === defaultVal
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
const hasChildren = Object.keys(children).length > 0
setNodeState(
node,
isDefault && !hasChildren
? undefined
: ({ ...children, ...(isDefault ? {} : { value: next }) } as NodeState),
)
} else {
setNodeState(node, isDefault ? undefined : next)
}
}
function toggleChip(parent: IdentifiedNodeBuilder, child: IdentifiedNodeBuilder) {
const selected = new Set(getMultiSelectState(parent))
if (selected.has(child.id!)) {
selected.delete(child.id!)
if (child._statePath) {
setAtPath(child._statePath, undefined)
}
} else {
selected.add(child.id!)
}
setNodeState(parent, selected.size > 0 ? selected : undefined)
}
const textInputRefs = new Map<NodeBuilder, { setValue: (v: string) => void }>()
const overrideHelpers = { override: (v: string): OverrideValue => ({ __override: v }) }
function isOverrideValue(v: unknown): v is OverrideValue {
return v !== null && typeof v === 'object' && '__override' in v
}
const scopedContextFallbacks = new WeakMap<IdentifiedNodeBuilder, Record<string, NodeState>>()
function childScopedContext(child: IdentifiedNodeBuilder): Record<string, NodeState> {
if (!child._statePath) return props.showContext
const basePath = child._statePath
const state = getAtPath(basePath)
if (state && typeof state === 'object' && !(state instanceof Set)) {
return state as Record<string, NodeState>
}
const existing = scopedContextFallbacks.get(child)
if (existing) return existing
const fallback = new Proxy({} as Record<string, NodeState>, {
set(_target, key, value) {
setAtPath([...basePath, key as string], value as NodeState)
return true
},
})
scopedContextFallbacks.set(child, fallback)
return fallback
}
function getChildrenContext(node: IdentifiedNodeBuilder): Record<string, NodeState> {
if (node.type === 'dropdown') return props.showContext
if (node.type === 'group' && asGroup(node)._selectMode) return props.showContext
return childScopedContext(node)
}
function getChildren(node: IdentifiedNodeBuilder): ChildNode[] {
return resolveChildren(node, getChildrenContext(node))
}
function visibleChildren(node: IdentifiedNodeBuilder): NodeBuilder[] {
return getChildren(node).filter((c): c is NodeBuilder => c instanceof NodeBuilder && isVisible(c))
}
const tooltipHtml = reactive(new Map<NodeBuilder, string>())
function getTooltipConfig(node: NodeBuilder, state?: Record<string, NodeState>) {
const t = node._tooltip
const manual = t === undefined ? undefined : typeof t === 'function' ? t(state ?? {}) : resolve(t)
if (manual)
return {
content: manual,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
const html = tooltipHtml.get(node)
if (!html) return undefined
return {
content: html,
html: true,
delay: { show: 500, hide: 0 },
triggers: ['hover', 'focus'],
placement: 'top',
}
}
watchEffect(async () => {
// Read all reactive state synchronously before any await so Vue tracks dependencies
const buttonTasks: Array<{ node: BooleanNodeBuilder; state: Record<string, NodeState> }> = []
for (const node of props.nodes) {
if (!(node instanceof NodeBuilder)) continue
if (node.type === 'toggle' && isVisible(node)) {
const boolNode = asBool(node)
if (boolNode._segments.some((s) => s.type !== 'collect')) {
const nodeState = getNodeState(boolNode)
const childState =
nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)
? (() => {
const { value: _v, ...rest } = nodeState as NodeStateWithChildren &
Record<string, NodeState>
return rest
})()
: {}
buttonTasks.push({ node: boolNode, state: childState })
}
}
if (node.type === 'group' && asGroup(node)._selectMode === 'multi' && isVisible(node)) {
for (const child of visibleChildren(asIdentified(node))) {
const opt = child as IdentifiedNodeBuilder
if (opt._segments.some((s) => s.type !== 'collect')) {
const childState = getBooleanChildState(getNodeState(opt)) as Record<string, NodeState>
buttonTasks.push({ node: opt as BooleanNodeBuilder, state: childState })
}
}
}
}
async function evalCollectedChildren(node: IdentifiedNodeBuilder): Promise<string> {
let result = ''
for (const child of getChildren(node)) {
if (!(child instanceof NodeBuilder)) continue
if (!isVisible(child)) continue
const childNode = asIdentified(child)
if (child.type === 'group') {
const grp = asGroup(childNode)
if (grp._selectMode === 'multi') {
const selected = getMultiSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (!optNode.id || !selected.has(optNode.id)) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (grp._selectMode === 'single') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else {
result += await evalCollectedChildren(childNode)
}
} else if (child.type === 'dropdown') {
const selected = getSelectState(childNode)
for (const opt of getChildren(childNode)) {
if (!(opt instanceof NodeBuilder) || !isVisible(opt)) continue
const optNode = asIdentified(opt)
if (optNode.id !== selected) continue
result += await evalNodeTooltip(
optNode,
getBooleanChildState(getNodeState(optNode)) as Record<string, NodeState>,
)
}
} else if (child.type === 'toggle' || child.type === 'check') {
if (!getBooleanState(asBool(childNode))) continue
result += await evalNodeTooltip(
childNode,
getBooleanChildState(getNodeState(childNode)) as Record<string, NodeState>,
)
}
}
return result
}
async function evalNodeTooltip(
node: IdentifiedNodeBuilder,
state: Record<string, NodeState>,
): Promise<string> {
let result = ''
for (const seg of node._segments) {
if (seg.type === 'collect') {
let collected = await evalCollectedChildren(node)
if (!collected.trim() && seg.fallback) {
collected = await evalSegment(seg.fallback, state, node._statePath ?? [])
}
result += collected
} else {
result += await evalSegment(seg, state, node._statePath ?? [])
}
}
return result
}
for (const { node, state } of buttonTasks) {
try {
const raw = await evalNodeTooltip(node as unknown as IdentifiedNodeBuilder, state)
const expanded = expandVariables(raw, projectV2.value, project.value, {
...flattenStaticVariables(),
...flattenProjectVariables(projectV2.value),
...flattenProjectV3Variables(project.value),
})
const trimmed = expanded.trim()
tooltipHtml.set(
node,
trimmed
? `<div class="markdown-body moderation-tooltip-markdown">${renderHighlightedString(trimmed)}</div>`
: '',
)
} catch {
tooltipHtml.set(node, '')
}
}
})
</script>
<template>
<div :class="flex ? 'flex flex-wrap gap-2' : 'space-y-4'">
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `button-${asButton(item).label}`
: (asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type)
: typeof item === 'string'
? `s-${item}`
: `display-${idx}`
"
>
<!-- Display items: plain strings or zero-arg render functions -->
<template v-if="!(item instanceof NodeBuilder)">
<template v-if="typeof item === 'string'">{{ item }}</template>
<component :is="item" v-else />
</template>
<template v-else-if="isVisible(item)">
<div :class="item.type !== 'group' && !getNodeTitle(item) ? 'contents' : undefined">
<div v-if="getNodeTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
<span
v-html="renderString(getNodeTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
/><span v-if="nodeHasRequiredMissing(asIdentified(item))" class="text-red">*</span>
</div>
<!-- group -->
<template v-if="item.type === 'group'">
<!-- multi-select (chips) mode -->
<template v-if="asGroup(item)._selectMode === 'multi'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!)
? hasActionableFixes(asIdentified(child))
? 'blue'
: 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
@click="toggleChip(asIdentified(item), asIdentified(child))"
>
<button
v-tooltip="getTooltipConfig(asIdentified(child))"
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getMultiSelectState(asIdentified(item)).has(asIdentified(child).id!) &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- single-select (button-style) mode -->
<template v-else-if="asGroup(item)._selectMode === 'single'">
<div class="flex flex-wrap gap-2">
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="asIdentified(child).id"
>
<ButtonStyled
:color="
getSelectState(asIdentified(item)) === asIdentified(child).id
? 'brand'
: 'standard'
"
:circular="!!asIdentified(child)._icon"
>
<button
:aria-label="asIdentified(child)._icon ? asLabeled(child).label : undefined"
@click="toggleSelect(asIdentified(item), asIdentified(child))"
>
<component :is="asIdentified(child)._icon" v-if="asIdentified(child)._icon" />
<template v-else>{{ asLabeled(child).label }}</template>
</button>
</ButtonStyled>
</template>
</div>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- plain container mode -->
<NodeRenderer
v-else
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:flex="asGroup(item)._layout !== 'column'"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
/>
</template>
<!-- dropdown -->
<template v-else-if="item.type === 'dropdown'">
<Combobox
class="!w-80"
:options="getDropdownOptions(asDropdown(item))"
:model-value="getDropdownModelValue(asDropdown(item))"
trigger-class="!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]"
dropdown-class="!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0"
@update:model-value="(v) => setNodeState(asIdentified(item), v || undefined)"
/>
<template
v-for="child in visibleChildren(asIdentified(item))"
:key="`sub-${asIdentified(child).id}`"
>
<NodeRenderer
v-if="
getSelectState(asIdentified(item)) === asIdentified(child).id &&
getChildren(asIdentified(child)).length
"
:nodes="getChildren(asIdentified(child))"
:show-context="getChildrenContext(asIdentified(child))"
:on-image-upload="onImageUpload"
:title-depth="titleDepth"
:parent-state-path="asIdentified(child)._statePath ?? props.parentStatePath ?? []"
class="mt-2"
/>
</template>
</template>
<!-- button -->
<template v-else-if="item.type === 'button'">
<ButtonStyled :circular="!!item._icon && !asButton(item).label">
<button
v-tooltip="getTooltipConfig(item, showContext)"
:disabled="!isButtonEnabled(asButton(item))"
:aria-label="item._icon && !asButton(item).label ? asButton(item).label : undefined"
@click="handleButtonClick(asButton(item))"
>
<component :is="item._icon" v-if="item._icon" />
{{ asButton(item).label }}
</button>
</ButtonStyled>
</template>
<!-- toggle -->
<template v-else-if="item.type === 'toggle'">
<ButtonStyled :color="getBooleanColor(asBool(item))" :circular="!!item._icon">
<button
v-tooltip="getTooltipConfig(asBool(item))"
:disabled="!isEnabled(asIdentified(item))"
:aria-label="item._icon ? asLabeled(item).label : undefined"
@click="toggleBoolean(asBool(item))"
>
<component :is="item._icon" v-if="item._icon" />
<template v-else>{{ asLabeled(item).label }}</template>
</button>
</ButtonStyled>
</template>
<!-- check -->
<template v-else-if="item.type === 'check'">
<Checkbox
:model-value="getBooleanState(asBool(item))"
:label="asLabeled(item).label"
:disabled="!isEnabled(asIdentified(item))"
@update:model-value="isEnabled(asIdentified(item)) && toggleBoolean(asBool(item))"
/>
</template>
<!-- text -->
<template v-else-if="item.type === 'text'">
<StyledInput
:id="`node-${asIdentified(item).id}`"
:ref="(el: any) => (el ? textInputRefs.set(item, el) : textInputRefs.delete(item))"
v-tooltip="getTooltipConfig(item, showContext)"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
autocomplete="off"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
<!-- markdown -->
<template v-else-if="item.type === 'markdown'">
<MarkdownEditor
:id="`node-${asIdentified(item).id}`"
:aria-label="asLabeled(item).label || undefined"
:model-value="getTextState(asIdentified(item))"
:placeholder="getPlaceholder(asInput(item))"
:max-height="300"
:disabled="false"
:heading-buttons="false"
:on-image-upload="onImageUpload"
@update:model-value="(v: string) => setTextState(asIdentified(item), v)"
/>
</template>
</div>
</template>
</template>
<!-- children of active boolean nodes, rendered after all siblings -->
<template
v-for="(item, idx) in nodes"
:key="
item instanceof NodeBuilder
? item.type === 'button'
? `children-button-${asButton(item).label}`
: `children-${asIdentified(item)._statePath?.join('/') ?? asIdentified(item).id ?? item.type}`
: `children-display-${idx}`
"
>
<NodeRenderer
v-if="
item instanceof NodeBuilder &&
isVisible(item) &&
(item.type === 'toggle' || item.type === 'check') &&
getBooleanState(asBool(item)) &&
getChildren(asIdentified(item)).length
"
:nodes="getChildren(asIdentified(item))"
:show-context="getChildrenContext(asIdentified(item))"
:on-image-upload="onImageUpload"
:title-depth="item._title !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
:parent-state-path="asIdentified(item)._statePath ?? props.parentStatePath ?? []"
class="w-full"
/>
</template>
</div>
</template>
@@ -0,0 +1,23 @@
import type { IdentifiedNodeBuilder, NodeState } from '@modrinth/moderation'
import type { ComputedRef, InjectionKey, Ref } from 'vue'
export interface ActiveAction {
node: IdentifiedNodeBuilder
state: Record<string, NodeState>
statePath: string[]
}
export interface LiveNode {
isActive: boolean
isVisible: boolean
isFixActionable: boolean
messageCount: number
fixCount: number
hasRequiredMissing: boolean
activeActions: ActiveAction[]
}
export const NODE_META_KEY: InjectionKey<ComputedRef<Map<IdentifiedNodeBuilder, LiveNode>>> =
Symbol('nodeMeta')
export const STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
Symbol('checklistState')
+13 -15
View File
@@ -665,10 +665,7 @@ import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { injectCurrentProjectId } from '~/providers/current-project.ts'
import {
loadChecklistOpenState,
saveChecklistOpenState,
} from '~/services/moderation-checklist-storage.ts'
import { loadChecklistState } from '~/services/moderation-checklist-storage.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
import { getReportPath, reportProject } from '~/utils/report-helpers.ts'
@@ -1212,6 +1209,12 @@ const { data: organizationRaw } = useQuery({
// Return null when the project no longer belongs to an organization.
const organization = computed(() => (projectRaw.value?.organization ? organizationRaw.value : null))
const { data: thread } = useQuery({
queryKey: computed(() => ['thread', projectRaw.value?.thread_id]),
queryFn: () => client.labrinth.threads_v3.getThread(projectRaw.value.thread_id),
enabled: computed(() => !!projectRaw.value?.thread_id),
})
const isSettings = computed(() => route.name.startsWith('type-project-settings'))
// Transform versionsV3 to be same shape as versionsV2 for compatibility in project pages
@@ -2001,11 +2004,8 @@ function consumeShowChecklistHistoryState() {
return true
}
function setModerationChecklistOpen(open, projectId = project.value?.id) {
function setModerationChecklistOpen(open) {
showModerationChecklist.value = open
if (projectId) {
void saveChecklistOpenState(projectId, open)
}
}
function isProjectInActiveModerationQueue(projectId = project.value?.id) {
@@ -2047,21 +2047,17 @@ watch(
return
}
const storedOpen = await loadChecklistOpenState(projectId)
const storedState = await loadChecklistState(projectId)
if (cancelled) return
if (storedOpen !== null) {
showModerationChecklist.value = storedOpen
if (storedState !== null) {
showModerationChecklist.value = storedState.open ?? false
return
}
const shouldRecoverFromQueue =
moderationQueue.isQueueMode && moderationQueue.getCurrentProjectId() === projectId
showModerationChecklist.value = shouldRecoverFromQueue
if (shouldRecoverFromQueue) {
void saveChecklistOpenState(projectId, true)
}
},
{ immediate: true },
)
@@ -2145,6 +2141,8 @@ provideProjectPageContext({
dependenciesLoading: computed(() => dependenciesLoading.value),
cdnDownloadReason: readonly(downloadReason),
thread,
// Invalidate all project queries (auto-refetches active ones)
invalidate: invalidateProject,
@@ -150,12 +150,14 @@ import {
useVIntl,
} from '@modrinth/ui'
import { isStaff } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import { getProjectLink, isApproved, isRejected, isUnderReview } from '~/helpers/projects.js'
defineEmits(['on-download', 'delete-version'])
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
@@ -207,7 +209,13 @@ const messages = defineMessages({
})
const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, invalidate, allMembers } = injectProjectPageContext()
const {
projectV2: project,
currentMember,
invalidate,
allMembers,
thread,
} = injectProjectPageContext()
const canAccess = computed(() => !!currentMember.value)
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
@@ -424,12 +432,7 @@ watch(
const auth = await useAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { data: thread, isPending: pending } = useQuery({
queryKey: computed(() => ['thread', project.value?.thread_id]),
queryFn: () => client.labrinth.threads_v3.getThread(project.value.thread_id),
enabled: computed(() => !!project.value?.thread_id),
})
const pending = computed(() => thread.value === undefined)
function updateThread(newThread: Labrinth.Threads.v3.Thread | null | undefined) {
const threadId = newThread?.id ?? project.value?.thread_id
@@ -1,596 +1,118 @@
import {
type ActionState,
deserializeActionStates,
serializeActionStates,
} from '@modrinth/moderation'
import type { NodeState } from '@modrinth/moderation'
interface PersistedChecklistValue<T> {
version: 1
import { dbDelete, dbGet, dbPut, dbScan } from './moderation-db.ts'
export interface PersistedChecklistState {
savedAt: string
value: T
open?: boolean
reviewAnyway?: boolean
stage?: string
message?: string
state?: Record<string, Record<string, NodeState>>
}
export interface ModerationChecklistGeneratedMessageState {
generated: boolean
message: string
}
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 1
const STORE_NAME = 'kv'
const CHECKLIST_OPEN_KEY_PREFIX = 'show-moderation-checklist-'
const STAGE_KEY_PREFIX = 'moderation-stage-'
const ACTION_STATES_KEY_PREFIX = 'moderation-actions-'
const TEXT_INPUTS_KEY_PREFIX = 'moderation-inputs-'
const GENERATED_MESSAGE_KEY_PREFIX = 'moderation-generated-message-'
const STORE = 'checklist'
const CHECKLIST_STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
const CHECKLIST_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000
const CHECKLIST_CLEANUP_LAST_RUN_KEY = 'moderation-checklist-cleanup:last-run'
const CHECKLIST_STATE_KEY_PREFIXES = [
CHECKLIST_OPEN_KEY_PREFIX,
STAGE_KEY_PREFIX,
ACTION_STATES_KEY_PREFIX,
TEXT_INPUTS_KEY_PREFIX,
GENERATED_MESSAGE_KEY_PREFIX,
]
const indexedDbSaveChains = new Map<string, Promise<void>>()
const saveChain = new Map<string, Promise<void>>()
let checklistCleanupPromise: Promise<void> | null = null
let checklistCleanupLastRunAt = 0
export function createEmptyGeneratedMessageState(): ModerationChecklistGeneratedMessageState {
return {
generated: false,
message: '',
}
function isPersistedChecklistState(value: unknown): value is PersistedChecklistState {
if (!value || typeof value !== 'object') return false
const v = value as PersistedChecklistState
if (typeof v.savedAt !== 'string') return false
if (v.stage !== undefined && typeof v.stage !== 'string') return false
if (v.message !== undefined && typeof v.message !== 'string') return false
if (v.state !== undefined && typeof v.state !== 'object') return false
return true
}
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
function isStale(savedAt: string, now = Date.now()): boolean {
const time = Date.parse(savedAt)
return !Number.isNaN(time) && now - time > CHECKLIST_STATE_MAX_AGE_MS
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
function wrapValue<T>(value: T, savedAt = new Date().toISOString()): PersistedChecklistValue<T> {
return {
version: 1,
savedAt,
value,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'
}
function isPersistedValue<T>(
value: unknown,
isValue: (value: unknown) => value is T,
): value is PersistedChecklistValue<T> {
if (!isRecord(value)) return false
if (value.version !== 1) return false
if (typeof value.savedAt !== 'string') return false
return isValue(value.value)
}
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean'
}
function isNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function isString(value: unknown): value is string {
return typeof value === 'string'
}
function isGeneratedMessageState(
value: unknown,
): value is ModerationChecklistGeneratedMessageState {
if (!isRecord(value)) return false
return typeof value.generated === 'boolean' && typeof value.message === 'string'
}
function sanitizeStage(value: number): number {
return Math.max(0, Math.trunc(value))
}
function sanitizeTextInputs(value: unknown): Record<string, string> | null {
if (!isRecord(value)) return null
const result: Record<string, string> = {}
for (const [key, entry] of Object.entries(value)) {
if (typeof entry === 'string') {
result[key] = entry
}
}
return result
}
function normalizeChecklistOpen(value: unknown): PersistedChecklistValue<boolean> | null {
if (isPersistedValue(value, isBoolean)) return value
if (isBoolean(value)) return wrapValue(value, '')
return null
}
function normalizeStage(value: unknown): PersistedChecklistValue<number> | null {
if (isPersistedValue(value, isNumber)) {
return {
...value,
value: sanitizeStage(value.value),
}
}
if (isNumber(value)) return wrapValue(sanitizeStage(value), '')
return null
}
function normalizeActionStates(
value: unknown,
): PersistedChecklistValue<Record<string, ActionState>> | null {
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
if (isString(value.value)) {
return {
version: 1,
savedAt: value.savedAt,
value: deserializeActionStates(value.value),
}
}
if (isRecord(value.value)) {
return {
version: 1,
savedAt: value.savedAt,
value: deserializeActionStates(JSON.stringify(value.value)),
}
}
}
if (isString(value)) return wrapValue(deserializeActionStates(value), '')
if (isRecord(value)) return wrapValue(deserializeActionStates(JSON.stringify(value)), '')
return null
}
function normalizeTextInputs(
value: unknown,
): PersistedChecklistValue<Record<string, string>> | null {
if (isRecord(value) && value.version === 1 && typeof value.savedAt === 'string') {
const textInputs = sanitizeTextInputs(value.value)
if (textInputs) {
return {
version: 1,
savedAt: value.savedAt,
value: textInputs,
}
}
}
const textInputs = sanitizeTextInputs(value)
return textInputs ? wrapValue(textInputs, '') : null
}
function normalizeGeneratedMessage(
value: unknown,
): PersistedChecklistValue<ModerationChecklistGeneratedMessageState> | null {
if (isPersistedValue(value, isGeneratedMessageState)) return value
if (isGeneratedMessageState(value)) return wrapValue(value, '')
return null
}
function savedAtTime<T>(state: PersistedChecklistValue<T>): number {
const time = Date.parse(state.savedAt)
return Number.isNaN(time) ? 0 : time
}
function newestState<T>(
first: PersistedChecklistValue<T> | null,
second: PersistedChecklistValue<T> | null,
): PersistedChecklistValue<T> | null {
if (!first) return second
if (!second) return first
return savedAtTime(second) > savedAtTime(first) ? second : first
}
function isChecklistStateKey(key: string): boolean {
return CHECKLIST_STATE_KEY_PREFIXES.some((prefix) => key.startsWith(prefix))
}
function isStaleState<T>(
state: PersistedChecklistValue<T>,
now = Date.now(),
maxAgeMs = CHECKLIST_STATE_MAX_AGE_MS,
): boolean {
const savedAt = savedAtTime(state)
if (savedAt === 0) return false
return now - savedAt > maxAgeMs
}
function isStaleRawState(value: unknown, now = Date.now()): boolean {
if (!isRecord(value)) return false
if (value.version !== 1 || typeof value.savedAt !== 'string') return false
const savedAt = Date.parse(value.savedAt)
if (Number.isNaN(savedAt)) return false
return now - savedAt > CHECKLIST_STATE_MAX_AGE_MS
}
async function loadFromIndexedDb<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
): Promise<PersistedChecklistValue<T> | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
return normalize(await requestToPromise(store.get(key)))
} finally {
db.close()
}
}
async function cleanupIndexedDb(now = Date.now()): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const request = store.openCursor()
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
const cursor = request.result
if (!cursor) return
const key = typeof cursor.key === 'string' ? cursor.key : null
if (key && isChecklistStateKey(key) && isStaleRawState(cursor.value, now)) {
cursor.delete()
}
cursor.continue()
}
request.onerror = () => reject(request.error ?? new Error('IndexedDB cursor failed'))
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
async function cleanupStaleStates(now = Date.now()): Promise<void> {
const entries = await dbScan<unknown>(STORE)
const staleKeys = entries
.filter(({ value }) => {
if (!isPersistedChecklistState(value)) return false
return isStale(value.savedAt, now)
})
} finally {
db.close()
}
}
async function saveToIndexedDb<T>(key: string, state: PersistedChecklistValue<T>): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(state, key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function clearIndexedDbKey(key: string): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function saveToIndexedDbInOrder<T>(
key: string,
state: PersistedChecklistValue<T>,
): Promise<void> {
const run = () => saveToIndexedDb(key, state)
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
indexedDbSaveChains.set(
key,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
async function clearIndexedDbKeyInOrder(key: string): Promise<void> {
const run = () => clearIndexedDbKey(key)
const result = (indexedDbSaveChains.get(key) ?? Promise.resolve()).then(run, run)
indexedDbSaveChains.set(
key,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
function loadFromLocalStorage<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
): PersistedChecklistValue<T> | null {
const storage = getLocalStorage()
if (!storage) return null
const raw = storage.getItem(key)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
const state = normalize(parsed)
if (state) return state
} catch (error) {
console.debug('Failed to parse moderation checklist state from localStorage:', error)
}
try {
storage.removeItem(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from localStorage:', error)
}
return null
}
function safeSaveLocalStorage<T>(key: string, state: PersistedChecklistValue<T>): void {
try {
getLocalStorage()?.setItem(key, JSON.stringify(state))
} catch (error) {
console.debug('Failed to save moderation checklist state to localStorage:', error)
}
}
function safeClearLocalStorage(key: string): void {
try {
getLocalStorage()?.removeItem(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from localStorage:', error)
}
}
function cleanupLocalStorage(now = Date.now()): void {
const storage = getLocalStorage()
if (!storage) return
const keysToRemove: string[] = []
for (let index = 0; index < storage.length; index++) {
const key = storage.key(index)
if (!key || !isChecklistStateKey(key)) continue
const raw = storage.getItem(key)
if (!raw) continue
try {
if (isStaleRawState(JSON.parse(raw), now)) {
keysToRemove.push(key)
}
} catch {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => safeClearLocalStorage(key))
.map(({ key }) => key)
await Promise.all(staleKeys.map((key) => dbDelete(STORE, key)))
}
function scheduleStaleChecklistCleanup(): void {
if (!import.meta.client || checklistCleanupPromise) return
const storage = getLocalStorage()
const now = Date.now()
const persistedLastRun = Number(storage?.getItem(CHECKLIST_CLEANUP_LAST_RUN_KEY) ?? 0)
const lastRun = Math.max(
checklistCleanupLastRunAt,
Number.isFinite(persistedLastRun) ? persistedLastRun : 0,
)
if (Number.isFinite(lastRun) && now - lastRun < CHECKLIST_CLEANUP_INTERVAL_MS) return
if (now - checklistCleanupLastRunAt < CHECKLIST_CLEANUP_INTERVAL_MS) return
checklistCleanupLastRunAt = now
try {
storage?.setItem(CHECKLIST_CLEANUP_LAST_RUN_KEY, String(now))
} catch (error) {
console.debug('Failed to save moderation checklist cleanup timestamp:', error)
}
checklistCleanupPromise = (async () => {
cleanupLocalStorage(now)
try {
await cleanupIndexedDb(now)
} catch (error) {
console.debug('Failed to cleanup stale moderation checklist state from IndexedDB:', error)
}
})().finally(() => {
checklistCleanupPromise = null
})
checklistCleanupPromise = cleanupStaleStates(now)
.catch((error) => {
console.debug('Failed to cleanup stale moderation checklist states from IndexedDB:', error)
})
.finally(() => {
checklistCleanupPromise = null
})
}
async function loadState<T>(
key: string,
normalize: (value: unknown) => PersistedChecklistValue<T> | null,
touch = true,
): Promise<T | null> {
if (!import.meta.client) return null
async function enqueueOp(projectId: string, op: () => Promise<void>): Promise<void> {
const result = (saveChain.get(projectId) ?? Promise.resolve()).then(op, op)
saveChain.set(
projectId,
result.then(
() => undefined,
() => undefined,
),
)
return result
}
export async function loadChecklistState(
projectId: string,
): Promise<PersistedChecklistState | null> {
if (!import.meta.client) return null
scheduleStaleChecklistCleanup()
let indexedDbState: PersistedChecklistValue<T> | null = null
try {
indexedDbState = await loadFromIndexedDb(key, normalize)
const raw = await dbGet<unknown>(STORE, projectId)
if (!isPersistedChecklistState(raw)) return null
if (isStale(raw.savedAt)) {
await clearChecklistState(projectId)
return null
}
return raw
} catch (error) {
console.debug('Failed to load moderation checklist state from IndexedDB:', error)
}
let localStorageState: PersistedChecklistValue<T> | null = null
try {
localStorageState = loadFromLocalStorage(key, normalize)
} catch (error) {
console.debug('Failed to load moderation checklist state from localStorage:', error)
}
const state = newestState(indexedDbState, localStorageState)
if (!state) return null
if (isStaleState(state)) {
await clearState(key)
console.debug('Failed to load checklist state from IndexedDB:', error)
return null
}
if (touch) {
void saveState(key, state.value)
}
return state.value
}
async function saveState<T>(key: string, value: T): Promise<void> {
export async function saveChecklistState(
projectId: string,
state: Omit<PersistedChecklistState, 'savedAt'>,
): Promise<void> {
if (!import.meta.client) return
scheduleStaleChecklistCleanup()
const state = wrapValue(value)
safeSaveLocalStorage(key, state)
if (hasIndexedDb()) {
try {
await saveToIndexedDbInOrder(key, state)
} catch (error) {
console.debug('Failed to save moderation checklist state to IndexedDB:', error)
}
const record: PersistedChecklistState = { ...state, savedAt: new Date().toISOString() }
try {
await enqueueOp(projectId, () => dbPut(STORE, projectId, record))
} catch (error) {
console.debug('Failed to save checklist state to IndexedDB:', error)
}
}
async function clearState(key: string): Promise<void> {
export async function clearChecklistState(projectId: string): Promise<void> {
if (!import.meta.client) return
safeClearLocalStorage(key)
if (hasIndexedDb()) {
try {
await clearIndexedDbKeyInOrder(key)
} catch (error) {
console.debug('Failed to clear moderation checklist state from IndexedDB:', error)
}
try {
await enqueueOp(projectId, () => dbDelete(STORE, projectId))
} catch (error) {
console.debug('Failed to clear checklist state from IndexedDB:', error)
}
}
export async function loadChecklistOpenState(projectId: string): Promise<boolean | null> {
return loadState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, normalizeChecklistOpen, false)
}
export async function saveChecklistOpenState(projectId: string, open: boolean): Promise<void> {
await saveState(`${CHECKLIST_OPEN_KEY_PREFIX}${projectId}`, open)
}
export async function loadChecklistStage(projectSlug: string): Promise<number | null> {
return loadState(`${STAGE_KEY_PREFIX}${projectSlug}`, normalizeStage)
}
export async function saveChecklistStage(projectSlug: string, stage: number): Promise<void> {
await saveState(`${STAGE_KEY_PREFIX}${projectSlug}`, sanitizeStage(stage))
}
export async function loadChecklistActionStates(
projectSlug: string,
): Promise<Record<string, ActionState>> {
const actionStates =
(await loadState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, normalizeActionStates, false)) ??
{}
if (Object.keys(actionStates).length > 0) {
void saveChecklistActionStates(projectSlug, actionStates)
}
return actionStates
}
export async function saveChecklistActionStates(
projectSlug: string,
actionStates: Record<string, ActionState>,
): Promise<void> {
await saveState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`, serializeActionStates(actionStates))
}
export async function loadChecklistTextInputs(
projectSlug: string,
): Promise<Record<string, string>> {
return (await loadState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, normalizeTextInputs)) ?? {}
}
export async function saveChecklistTextInputs(
projectSlug: string,
textInputs: Record<string, string>,
): Promise<void> {
await saveState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`, textInputs)
}
export async function clearChecklistProgressState(projectSlug: string): Promise<void> {
await Promise.all([
clearState(`${STAGE_KEY_PREFIX}${projectSlug}`),
clearState(`${ACTION_STATES_KEY_PREFIX}${projectSlug}`),
clearState(`${TEXT_INPUTS_KEY_PREFIX}${projectSlug}`),
])
}
export async function loadGeneratedMessageState(
projectSlug: string,
): Promise<ModerationChecklistGeneratedMessageState> {
return (
(await loadState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, normalizeGeneratedMessage)) ??
createEmptyGeneratedMessageState()
)
}
export async function saveGeneratedMessageState(
projectSlug: string,
state: ModerationChecklistGeneratedMessageState,
): Promise<void> {
await saveState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`, state)
}
export async function clearGeneratedMessageState(projectSlug: string): Promise<void> {
await clearState(`${GENERATED_MESSAGE_KEY_PREFIX}${projectSlug}`)
}
@@ -0,0 +1,94 @@
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 2
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = request.result
if (event.oldVersion < 2 && db.objectStoreNames.contains('kv')) {
db.deleteObjectStore('kv')
}
if (!db.objectStoreNames.contains('checklist')) {
db.createObjectStore('checklist')
}
if (!db.objectStoreNames.contains('queue')) {
db.createObjectStore('queue')
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
export async function dbGet<T>(store: string, key: IDBValidKey): Promise<T | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readonly')
const result = await requestToPromise<T | undefined>(tx.objectStore(store).get(key))
return result ?? null
} finally {
db.close()
}
}
export async function dbPut<T>(store: string, key: IDBValidKey, value: T): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readwrite')
tx.objectStore(store).put(value, key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
export async function dbDelete(store: string, key: IDBValidKey): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readwrite')
tx.objectStore(store).delete(key)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
export async function dbScan<T>(store: string): Promise<{ key: IDBValidKey; value: T }[]> {
if (!hasIndexedDb()) return []
const db = await openDatabase()
try {
const tx = db.transaction(store, 'readonly')
const s = tx.objectStore(store)
const [keys, values] = await Promise.all([
requestToPromise(s.getAllKeys()),
requestToPromise(s.getAll()),
])
return keys.map((key, i) => ({ key, value: values[i] as T }))
} finally {
db.close()
}
}
@@ -1,3 +1,5 @@
import { dbDelete, dbGet, dbPut } from './moderation-db.ts'
export interface PersistedModerationQueueState {
version: 1
savedAt: string
@@ -11,25 +13,9 @@ export interface PersistedModerationQueueState {
isQueueMode: boolean
}
const DB_NAME = 'modrinth-moderation'
const DB_VERSION = 1
const STORE_NAME = 'kv'
const STORE = 'queue'
export const MODERATION_QUEUE_KEY = 'moderation-queue:v1'
function hasIndexedDb(): boolean {
return typeof window !== 'undefined' && typeof indexedDB !== 'undefined'
}
function getLocalStorage(): Storage | null {
if (typeof window === 'undefined') return null
try {
return window.localStorage
} catch {
return null
}
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
}
@@ -53,189 +39,35 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
return true
}
function savedAtTime(state: PersistedModerationQueueState): number {
const time = Date.parse(state.savedAt)
return Number.isNaN(time) ? 0 : time
}
function newestState(
first: PersistedModerationQueueState | null,
second: PersistedModerationQueueState | null,
): PersistedModerationQueueState | null {
if (!first) return second
if (!second) return first
return savedAtTime(second) > savedAtTime(first) ? second : first
}
function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'))
request.onblocked = () => reject(new Error('IndexedDB open request blocked'))
})
}
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
})
}
async function loadFromIndexedDb(): Promise<PersistedModerationQueueState | null> {
if (!hasIndexedDb()) return null
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const raw = await requestToPromise(store.get(MODERATION_QUEUE_KEY))
if (!isPersistedStateCandidate(raw)) return null
return raw
} finally {
db.close()
}
}
async function saveToIndexedDb(state: PersistedModerationQueueState): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).put(state, MODERATION_QUEUE_KEY)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
async function clearIndexedDb(): Promise<void> {
if (!hasIndexedDb()) return
const db = await openDatabase()
try {
const tx = db.transaction(STORE_NAME, 'readwrite')
tx.objectStore(STORE_NAME).delete(MODERATION_QUEUE_KEY)
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed'))
})
} finally {
db.close()
}
}
function loadFromLocalStorage(): PersistedModerationQueueState | null {
const storage = getLocalStorage()
if (!storage) return null
const raw = storage.getItem(MODERATION_QUEUE_KEY)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
if (isPersistedStateCandidate(parsed)) return parsed
} catch (error) {
console.debug('Failed to parse moderation queue from localStorage:', error)
}
safeClearLocalStorage()
return null
}
function saveToLocalStorage(state: PersistedModerationQueueState): void {
const storage = getLocalStorage()
if (!storage) return
storage.setItem(MODERATION_QUEUE_KEY, JSON.stringify(state))
}
function clearLocalStorage(): void {
const storage = getLocalStorage()
if (!storage) return
storage.removeItem(MODERATION_QUEUE_KEY)
}
function safeClearLocalStorage(): void {
try {
clearLocalStorage()
} catch (error) {
console.debug('Failed to clear moderation queue from localStorage:', error)
}
}
function safeSaveLocalStorage(state: PersistedModerationQueueState): void {
try {
saveToLocalStorage(state)
} catch (error) {
console.debug('Failed to save moderation queue to localStorage:', error)
}
}
export async function loadQueueState(): Promise<PersistedModerationQueueState | null> {
if (!import.meta.client) return null
let indexedDbState: PersistedModerationQueueState | null = null
try {
indexedDbState = await loadFromIndexedDb()
const raw = await dbGet<unknown>(STORE, MODERATION_QUEUE_KEY)
if (!isPersistedStateCandidate(raw)) return null
return raw
} catch (error) {
console.debug('Failed to load moderation queue from IndexedDB:', error)
return null
}
let localStorageState: PersistedModerationQueueState | null = null
try {
localStorageState = loadFromLocalStorage()
} catch (error) {
console.debug('Failed to load moderation queue from localStorage:', error)
}
return newestState(indexedDbState, localStorageState)
}
export async function saveQueueState(state: PersistedModerationQueueState): Promise<void> {
if (!import.meta.client) return
if (hasIndexedDb()) {
try {
await saveToIndexedDb(state)
safeSaveLocalStorage(state)
return
} catch (error) {
console.debug(
'Failed to save moderation queue to IndexedDB, using localStorage fallback:',
error,
)
}
try {
await dbPut(STORE, MODERATION_QUEUE_KEY, state)
} catch (error) {
console.debug('Failed to save moderation queue to IndexedDB:', error)
}
safeSaveLocalStorage(state)
}
export async function clearQueueState(): Promise<void> {
if (!import.meta.client) return
if (hasIndexedDb()) {
try {
await clearIndexedDb()
} catch (error) {
console.debug('Failed to clear moderation queue from IndexedDB:', error)
}
try {
await dbDelete(STORE, MODERATION_QUEUE_KEY)
} catch (error) {
console.debug('Failed to clear moderation queue from IndexedDB:', error)
}
safeClearLocalStorage()
}
+48 -35
View File
@@ -1,36 +1,49 @@
import type { Stage } from '../types/stage'
import categories from './stages/categories'
import description from './stages/description'
import environment from './stages/environment/environment'
import environmentMultiple from './stages/environment/environment-multiple'
import gallery from './stages/gallery'
import license from './stages/license'
import links from './stages/links'
import permissions from './stages/permissions'
import postApproval from './stages/post-approval'
import reupload from './stages/reupload'
import ruleFollowing from './stages/rule-following'
import statusAlerts from './stages/status-alerts'
import summary from './stages/summary'
import titleSlug from './stages/title-slug'
import undefinedProject from './stages/undefined-project'
import versions from './stages/versions'
import type { Ref } from 'vue'
import { provide, ref } from 'vue'
export default [
titleSlug,
summary,
description,
links,
license,
categories,
environment,
environmentMultiple,
gallery,
versions,
reupload,
permissions,
ruleFollowing,
statusAlerts,
undefinedProject,
postApproval,
] as ReadonlyArray<Stage>
import type { NodeState, StageFn, StageNodeBuilder } from '../types/node'
import { group, STAGES_KEY } from '../types/node'
import useCategoriesStage from './stages/categories'
import useDescriptionStage from './stages/description'
import useGalleryStage from './stages/gallery'
import useLicenseStage from './stages/license'
import useLinksStage from './stages/links'
import useMetadataStage from './stages/metadata'
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 useStatusAlertsStage from './stages/status-alerts'
import useSummaryStage from './stages/summary'
import useTitleSlugStage from './stages/title-slug'
import useUndefinedProjectStage from './stages/undefined-project'
import useVersionsStage from './stages/versions'
export function useStages(
globalState: Ref<Record<string, Record<string, NodeState>>>,
): StageNodeBuilder[] {
const mainStages: StageNodeBuilder[] = [
usePostApprovalStage(),
useUndefinedProjectStage(),
useReReviewStage(),
useTitleSlugStage(),
useSummaryStage(),
useDescriptionStage(),
useGalleryStage(),
useLinksStage(),
useLicenseStage(),
useCategoriesStage(),
useMetadataStage(),
useVersionsStage(),
useReuploadsStage(),
usePermissionsStage(),
useOtherRulesStage(),
]
provide(STAGES_KEY, ref(mainStages))
return [...mainStages, useStatusAlertsStage(mainStages, globalState)]
}
export const stages: ReadonlyArray<StageFn> = []
export default group()
+2 -2
View File
@@ -4,13 +4,13 @@ const keybinds: { [id: string]: KeybindListener } = {
'next-stage': {
keybind: 'ArrowRight',
description: 'Go to next stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoNext(),
},
'previous-stage': {
keybind: 'ArrowLeft',
description: 'Go to previous stage',
enabled: (ctx) => !ctx.state.isDone && !ctx.state.hasGeneratedMessage,
enabled: (ctx) => !ctx.state.isDone,
action: (ctx) => ctx.actions.tryGoBack(),
},
'generate-message': {
@@ -1,8 +0,0 @@
## Insufficient Description
Per section 2.1 of %RULES%, your project's %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your project.
Currently, it looks like there are some missing details.
What does your modpack add? What features does it have? Why would a user want to download it?
Check out descriptions like [What Is My FPS??](https://modrinth.com/modpack/WIMP) or [Aged](https://modrinth.com/modpack/aged) for inspiration when creating a quality and informative description.
@@ -1,8 +0,0 @@
## Insufficient Description
Per section 2.1 of %RULES%, your project's %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your project.
Currently, it looks like there are some missing details.
What does your project add? What features does it have? Why would a user want to download it?
Check out descriptions like [Sodium](https://modrinth.com/mod/sodium) or [The Aether](https://modrinth.com/mod/aether) for inspiration when creating a quality and informative description.
@@ -1,6 +0,0 @@
## Insufficient Description
Per section 2.1 of %RULES%, your project's Description should clearly inform the reader of the content, purpose, and appeal of your project.
Currently, it looks like there are some missing details.
%EXPLAINER%
@@ -1,4 +0,0 @@
## Unfinished Description
It looks like your project Description is still a Work In Progress.
Please remember to submit only when ready, as it is important your project meets the requirements of Section 2.1 of %RULES%.
@@ -1,4 +0,0 @@
## Misuse of Links
Per section 5.4 of %RULES%, all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
Currently it looks like your %MISUSED_LINKS% link(s) are misused or incorrectly labeled.
@@ -1 +0,0 @@
Currently, your Discord link directs to an invalid invite, likely because your invite has expired. Make sure to set your invite link to permanent with unlimited uses before resubmitting your project.
@@ -1 +0,0 @@
Currently, your Source link directs to a Page Not Found error, likely because your repository is private. Make sure to set your repository to public before resubmitting your project.
@@ -1,5 +0,0 @@
## Misuse of custom URL
We ask that you ensure your project's %PROJECT_SLUG_FLINK% accurately represents your project.
Your current slug of `%PROJECT_SLUG%` may not accurately match your project's Name or contain excess information.
A mismatched URL may make it more difficult for users to find your content. Abbreviations or similar are fine to use if applicable. If your preferred URL is not available, and you cannot find a matching public project, let us know in this moderation thread when you resubmit your project, and our moderation team may be able to free it up for your project.
@@ -1,5 +0,0 @@
## Warnings from AutoMod
Unfortunately, our AutoMod cannot read your project's Description or your messages to moderation.
AutoMod will warn both you and our Moderation Staff about potential issues, but if you've already followed the necessary steps these warnings can safely be ignored.
Note that if your project is being rejected by AutoMod this means your project has content that can not be included in your modpack and must be removed before resubmission, including deleting versions of your modpack that include the content.
@@ -1,2 +0,0 @@
**Featured Tags:** %PROJECT_CATEGORIES% \
**Additional Tags:** %PROJECT_ADDITIONAL_CATEGORIES%
@@ -1,2 +0,0 @@
**Unique environments:** %PROJECT_V3_ENVIRONMENT_COUNT% \
**Environments:** `%PROJECT_V3_ALL_ENVIRONMENTS%`
@@ -1,4 +0,0 @@
**Environment:** `%PROJECT_V3_ENVIRONMENT_0%`
**Client:** `%PROJECT_CLIENT_SIDE%` \
**Server:** `%PROJECT_SERVER_SIDE%`
@@ -1,2 +0,0 @@
**License id:** %PROJECT_LICENSE_ID% \
**License Link:** %PROJECT_LICENSE_URL%
@@ -1,4 +0,0 @@
**Issues:** %PROJECT_ISSUES_URL% \
**Source:** %PROJECT_SOURCE_URL% \
**Wiki:** %PROJECT_WIKI_URL% \
**Discord:** %PROJECT_DISCORD_URL%
@@ -1 +0,0 @@
> **{PLATFORM}:** {URL}<br />
@@ -1,2 +0,0 @@
<br />
<u>**Donation Links:**</u><br />
@@ -1,4 +0,0 @@
**Website:** %PROJECT_SITE_URL% \
**Store:** %PROJECT_STORE_URL% \
**Wiki:** %PROJECT_WIKI_URL% \
**Discord:** %PROJECT_DISCORD_URL%
@@ -1 +0,0 @@
**Applying for:** `%PROJECT_REQUESTED_STATUS%`
@@ -1,2 +0,0 @@
**Summary:**
`%PROJECT_SUMMARY%`
@@ -1,4 +0,0 @@
**Title:** %PROJECT_TITLE% \
**Slug:** `%PROJECT_SLUG%`
**Title issues?**
@@ -1,3 +0,0 @@
**Slug:** `%PROJECT_SLUG%` </br>
**Title issues?**
@@ -1 +0,0 @@
**Title:** %PROJECT_TITLE% </br>
@@ -0,0 +1,3 @@
What does your modpack add? What features does it have? Why would a user want to download it?
Check out descriptions like [What Is My FPS??](https://modrinth.com/modpack/WIMP) or [Aged](https://modrinth.com/modpack/aged) for inspiration when creating a quality and informative description.
@@ -0,0 +1,3 @@
What does your project add? What features does it have? Why would a user want to download it?
Check out descriptions like [Sodium](https://modrinth.com/mod/sodium) or [The Aether](https://modrinth.com/mod/aether) for inspiration when creating a quality and informative description.
@@ -1,8 +1,3 @@
## Insufficient Description
What is your server about? What features does it have? Why would a user want to join?
Per section 2.1 of %RULES%, your server's %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your server.
Currently, it looks like there are some missing details.
What is your server about? What features does it have? Why would a user want to join?
Check out descriptions like [Flatnet](https://modrinth.com/server/flatnet) or [Cobblemon Islands](https://modrinth.com/server/cobblemon-islands-server) for inspiration when creating a quality and informative description.
@@ -0,0 +1,6 @@
## Insufficient Description
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 @@
- Since your project is a fork, you should briefly describe the function or features of the %PROJECT_TYPE% your project is forked from, in addition to what changes you've made in your version.</br>
@@ -0,0 +1,19 @@
</br>
**How to setup spoilers blocks:**
```md
<details>
<summary>Spoilers!</summary>
- Features!
</details>
```
<details>
<summary>Spoilers!</summary>
- Features!
</details>
@@ -0,0 +1 @@
- We understand you may not want to spoil some of the details of your projects contents, you can put important details in a spoiler!</br>
@@ -0,0 +1 @@
- It looks like your project Description is still a work in progress, please remember to submit only when ready.
@@ -0,0 +1,10 @@
## Showcase Clarity
Per section 2 of %RULES%, it's important that your project page accurately and honestly represent the content of your project.
Currently, it looks like some images do not accurately represent the content of your %PROJECT_TYPE_FORMATTED_LOWER%.
Please make sure you use authentic images such as in-game screenshots when showcasing the content or functionality of your work.
Images such as renders, AI generated or assisted images, or heavily modified images should not be used to demonstrate the functionality of your project in the %PROJECT_DESCRIPTION_FLINK% or %PROJECT_GALLERY_FLINK%.
You may still include these types of branding assets or unrelated images on your project page, so long as it is clear they are not authentic representations of the content.
@@ -0,0 +1 @@
The current Discord invite is set to expire, we strongly recommend setting your public discord invite to be permanent and unlimited.
@@ -0,0 +1 @@
The current Discord invite is invalid, likely because your invite has expired. Make sure to set your invite link to permanent with unlimited uses before resubmitting your project.
@@ -1,3 +1,4 @@
## Unreachable Links
## Links
It looks like some of your %PROJECT_TYPE_FORMATTED_LOWER%'s %PROJECT_LINKS_FLINK% are misused or inaccessible.</br>
Per section 5.4 of %RULES%, all %PROJECT_LINKS_FLINK% must lead to correctly labeled publicly available resources that are directly related to your project.
@@ -0,0 +1,3 @@
</br>
**These links appear to be inaccessible:**
@@ -0,0 +1 @@
The issues tab for your project's source repo appears to be disabled.
@@ -0,0 +1,3 @@
</br>
**These links appear to be misused or not related to your project:**
@@ -0,0 +1 @@
The current Source link directs to a repository that does not contain the source code for your %PROJECT_TYPE_FORMATTED_LOWER%.
@@ -0,0 +1 @@
The current Source link directs to a Page Not Found error, likely because your repository is private. Make sure to set your repository to public before resubmitting your project.
@@ -0,0 +1 @@
The wiki tab for your project's source repo appears to be disabled.
@@ -0,0 +1 @@
It looks like this project is probably "%SUGGESTED_ENVIRONMENT%".
@@ -4,3 +4,5 @@ Per section 5.1 of %RULES%, it is important that the metadata of your projects i
We've recently overhauled how environment metadata works on Modrinth, you can now edit this in your project's [Version Settings](https://modrinth.com/project/%PROJECT_ID%/settings/versions).
Please [read this blogpost](%NEW_ENVIRONMENTS_LINK%) for full details and information on how to ensure your project is labeled correctly.
%CORRECT%
@@ -0,0 +1 @@
It looks like some %PROJECT_VERSIONS_FLINK% of your project should have unique environments from other versions, please ensure each version is set correctly.
@@ -0,0 +1 @@
Please address all moderation concerns or correct all issues before resubmitting this project.
@@ -0,0 +1 @@
Continued resubmission without abiding by %RULES% or any other attempts at spam may result in account suspension.
@@ -0,0 +1,5 @@
## Prohibited Content
This project may violate section 3.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules).
We ask that you ensure your project does not endorse, promote, or enable hacks or the use of hacks. Additionally, your project page should not contain language that endorses or promotes hacks or the use of hacks.
@@ -0,0 +1,3 @@
## Prohibited Content
This project contains content which may violate section 1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules):
@@ -0,0 +1 @@
- 1.2: Sexually explicit or pornographic material, or content promoting violence or discrimination based on race, sex, gender, religion, nationality, disability, sexual orientation, or age
@@ -0,0 +1 @@
- 1.9: Content giving a false impression of endorsement by Modrinth or any other person or entity
@@ -0,0 +1 @@
- 1.6: Content likely to cause annoyance, harm, embarrassment, alarm, or deception to others
@@ -0,0 +1 @@
- 1.5: Promotion of illegal activity, or advocacy of unlawful acts including real-life drugs or illicit substances
@@ -0,0 +1 @@
- 1.8: Impersonation of a person, or misrepresentation of identity or affiliation with any person or organization
@@ -0,0 +1 @@
- 1.3: Content infringing patents, trademarks, trade secrets, copyright, or other intellectual property rights

Some files were not shown because too many files have changed in this diff Show More