Files
Rocketmc/apps/frontend/src/providers/version/manage-version-modal.ts
Truman Gao 9958600121 feat: managing project versions (#4811)
* start modal, working show modal

* add stages and implement MultiModalStage component

* add project versions context and add file button

* implement add files stage

* export interfaces

* move MultiStageModal to /base

* small update to file input

* add version types to api-client

* wrap version namespace under v3

* implement add details stage fields and loaders component

* start create MC versions stage

* implement changelog stage and bring width into a per stage concern

* implement loader picker with grouping

* improve grouping and sorting for loader picker

* use chips component

* small updaets

* fix loader icon color

* componentize mc version picker

* initial version of shift click to select range

* use newModal for markdown editor

* start add dependencies stage with search

* implement showing mod options in search

* componentize modselect and add version/dependency relation select

* hide version and dependency relation when no project selected

* fix project facet search

* implement api-client versions requests

* fix search api request facet type to be string

* fix new modal outer container scroll

* implement add dependency stage

* fix parse error

* add placeholders

* fix types

* update dependency row styles

* small change

* fix the types on manage versions to be correct with labrinth request bodies

* fix create version file parts

* use draft version ref in flow and implement proper file handlling

* use draft version ref for mc versions select

* implement reactive modal state and conditionally disabled next buttons

* ensure all data is using draftVersion ref

* remove shift click to select range since it sucks

* fix up add dependencies stage state/types

* small fixes

* implement adding dependencies connected to api calls and make adding dependencies work

* add final create version button config

* start create version backend call and bring versions table to project settings

* set add files stage width

* remove version file upload in project page

* small fix

* fix create version api call

* implement error handling

* implement mc versions search

* implement showing all mc versions

* small fix

* implement prefill data

* add success notification

* add cancel button

* add new dropzone file input

* run pnpm run fix

* add tailwind preset in ui package

* polish file version row

* fix modal widths

* hide added versions when no versions added

* implement add loaders stage

* implement small chips and small fixes

* implement grouping for all releases

* implement new all releases grouping

* implement better shift click for version select

* small fixes

* fix search input style

* delete versions provider and start project type inferring

* implement getting project type

* add versions empty state, add folder up icon and pnpm run fix

* implement create version in project versions table

* update side nav

* implement dynamic create version flow depending on project type and detected data

* add id to stages and fix calling setStage not working

* move added loaded out of loader picker

* remove selected and detected MC versions

* add loading message to dependency search and fix dependency type always being "required"

* fix components in ref

* fix width on dropdown

* implement toggle all mc versions based on state of last in range

* fix mc version text colour

* do proper clean up

* update loaders to use tag item

* update UI to use TagItem and better match styles

* handle detected data when setting primary file

* add progress bar

* hide progress bar for non-progress stage

* add loading state on submit

* properly cache dependencies projects/versions

* pnpm run fix

* add dragover show purple border on dropzone file input

* better handle added dependencies

* move versions in side nav

* implement adding file type

* fix api body format for file type

* implement working edit existing version
- working add/remove file
- working edit version details

* a step towards proper versions refresh

* add gallery to project settings

* actually figured out refresh versions

* move checklist into settings page

* remove editing version from version page and add button to versions table in project settings

* remove edit and delete buttons from gallery in project page

* add empty state messages for project page

* add default scroll bar styles

* implement support for new file types

* remove edit from dropdown in project page versions table

* redirect to settings page

* move changelog to row with actions

* fix overflow on added dependencies

* fix redirect

* update scroll styles

* implement add environment stage (create and modify version not persisting environment to backend)

* small style fixes

* small spacing fix

* small style fixes

* add a flag for loading dependency projects

* address PR comments

* fix modrinth ui imports

* use magic keys instead of window.addeventlistener

* add spacing in bottom of settings page

* useDebounceFn from vue

* fix inconsistent stroke

* persist scroll through

* fix remove button

* fix api fields

* fix version file dropdown: hide primary option in edit mode and fix setting initial value

* fix links in nags

* implement skipped field for skipping steps instead of mutating stages array's elements

* implement suggested dependencies components

* implement suggested dependencies api call

* refactor cached get project and get version calls

* always hide environments

* update links

* set scroll in 10ms

* update links

* fix links pt2

* fix shadow

* fix progress bar

* dont include mc versions in suggested versions finder

* fix text overflow styles

* use tooltip

* fix change version name api

* implement set environment api call

* delete unused vue pages

* implement detected environment, edit environment step, and fix showing loaders in details for no loader projects

* small fix

* no loaders project wrong check

* fix not having 'minecraft' loader for resource pack

* implement updating existing files file type

* move add minecraft loader outside try catch

* add datapack to have environment

* fix being able to select duplicate MC versions

* remove datapack project from environment

* fix version fetch

* fix having detected environment not properly skipping step

* only add detected data when primary file changes

* fix unknown environemtn

* implement gallery and versions have moved admonition

* update project page for creator view

* small copy update

* merge fixes

* pnpm run fix

* fix checkmark squished

* fix version type can be deselected

* refactor: DI context + better typed MultistageModal

* fix type import

* Misc QA fixes

* fix allowed file types with no project type

* implement new add files stage

* fix versiosn header with new pagination

* hide buttons when no files for add file stage

* use prettier formatter

* allow signature file types

* add detecting primary file

* fix progress bar in firefox

* fix environment not correctly being hidden/shown

* remove environment missing nag

* temp bring back environment page

* remove delete version action from project page

* replace "continue" next button label with actual next step

* fix types

* pnpm run fix

* move supplementary files alert up and update border radius style on dropzone

* copy updates

* small update on version num placeholder

* update placeholder

* make timeout on upload routes 2 minutes

* fix lint issues

* run pnpm intl:extract

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2025-12-18 19:56:15 +00:00

419 lines
12 KiB
TypeScript

import type { Labrinth } from '@modrinth/api-client'
import {
createContext,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
type MultiStageModal,
resolveCtxFn,
type StageConfigInput,
} from '@modrinth/ui'
import JSZip from 'jszip'
import type { ComputedRef, Ref, ShallowRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { useGeneratedState } from '~/composables/generated'
import { inferVersionInfo } from '~/helpers/infer'
import { stageConfigs } from './stages'
// this interface should be in infer.js, but gotta refactor that to ts first
export interface InferredVersionInfo {
name?: string
version_number?: string
version_type?: 'alpha' | 'beta' | 'release'
loaders?: string[]
game_versions?: string[]
project_type?: Labrinth.Projects.v2.ProjectType
environment?: Labrinth.Projects.v3.Environment
}
const EMPTY_DRAFT_VERSION: Labrinth.Versions.v3.DraftVersion = {
project_id: '',
name: '',
version_number: '',
version_type: 'release',
loaders: [],
game_versions: [],
featured: false,
status: 'draft',
changelog: '',
dependencies: [],
}
export type VersionStage =
| 'add-files'
| 'add-details'
| 'add-loaders'
| 'add-mc-versions'
| 'add-environment'
| 'add-dependencies'
| 'add-changelog'
| 'edit-loaders'
| 'edit-mc-versions'
| 'edit-environment'
export interface ManageVersionContextValue {
// State
draftVersion: Ref<Labrinth.Versions.v3.DraftVersion>
filesToAdd: Ref<Labrinth.Versions.v3.DraftVersionFile[]>
existingFilesToDelete: Ref<Labrinth.Versions.v3.VersionFileHash['sha1'][]>
inferredVersionData: Ref<InferredVersionInfo | undefined>
projectType: Ref<Labrinth.Projects.v2.ProjectType | undefined>
dependencyProjects: Ref<Record<string, Labrinth.Projects.v3.Project>>
dependencyVersions: Ref<Record<string, Labrinth.Versions.v3.Version>>
// Stage management
stageConfigs: StageConfigInput<ManageVersionContextValue>[]
isSubmitting: Ref<boolean>
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
// Computed state
editingVersion: ComputedRef<boolean>
noLoadersProject: ComputedRef<boolean>
noEnvironmentProject: ComputedRef<boolean>
// Stage helpers
getNextLabel: (currentIndex?: number | null) => string
// Version methods
newDraftVersion: (projectId: string, version?: Labrinth.Versions.v3.DraftVersion | null) => void
setPrimaryFile: (index: number) => void
setInferredVersionData: (
file: File,
project: Labrinth.Projects.v2.Project,
) => Promise<InferredVersionInfo>
setProjectType: (
project: Labrinth.Projects.v2.Project,
file?: File | null,
) => Promise<Labrinth.Projects.v2.ProjectType | undefined>
getProject: (projectId: string) => Promise<Labrinth.Projects.v3.Project>
getVersion: (versionId: string) => Promise<Labrinth.Versions.v3.Version>
// Submission methods
handleCreateVersion: () => Promise<void>
handleSaveVersionEdits: () => Promise<void>
}
export const [injectManageVersionContext, provideManageVersionContext] =
createContext<ManageVersionContextValue>('CreateProjectVersionModal')
export function createManageVersionContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): ManageVersionContextValue {
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext()
// State
const draftVersion = ref<Labrinth.Versions.v3.DraftVersion>(structuredClone(EMPTY_DRAFT_VERSION))
const filesToAdd = ref<Labrinth.Versions.v3.DraftVersionFile[]>([])
const existingFilesToDelete = ref<Labrinth.Versions.v3.VersionFileHash['sha1'][]>([])
const inferredVersionData = ref<InferredVersionInfo>()
const projectType = ref<Labrinth.Projects.v2.ProjectType>()
const dependencyProjects = ref<Record<string, Labrinth.Projects.v3.Project>>({})
const dependencyVersions = ref<Record<string, Labrinth.Versions.v3.Version>>({})
const isSubmitting = ref(false)
// Computed state
const editingVersion = computed(() => Boolean(draftVersion.value.version_id))
// Helper functions for project type detection
// TODO: move to infer.js
async function setProjectType(
project: Labrinth.Projects.v2.Project,
file: File | null = null,
): Promise<Labrinth.Projects.v2.ProjectType | undefined> {
if (project.project_type && project.project_type !== 'project') {
projectType.value = project.project_type
return projectType.value
}
if (
(file && file.name.toLowerCase().endsWith('.mrpack')) ||
(file && file.name.toLowerCase().endsWith('.mrpack-primary'))
) {
projectType.value = 'modpack'
return projectType.value
}
if (
draftVersion.value.loaders?.some((loader) =>
[
'fabric',
'neoforge',
'forge',
'quilt',
'liteloader',
'rift',
'ornithe',
'nilloader',
'legacy-fabric',
'bta-babric',
'babric',
'modloader',
'java-agent',
].includes(loader),
)
) {
projectType.value = 'mod'
return projectType.value
}
try {
if (file) {
const jszip = await JSZip.loadAsync(file)
const hasMcmeta = Object.keys(jszip.files).some(
(f) => f.toLowerCase() === 'pack.mcmeta' || f.toLowerCase().endsWith('/pack.mcmeta'),
)
const hasAssetsDir = Object.keys(jszip.files).some(
(f) => f.toLowerCase() === 'assets/' || f.toLowerCase().startsWith('assets/'),
)
if (hasMcmeta && hasAssetsDir) {
projectType.value = 'resourcepack'
return projectType.value
}
}
} catch {
// not a zip
}
projectType.value = undefined
return undefined
}
// Version management methods
function newDraftVersion(
projectId: string,
version: Labrinth.Versions.v3.DraftVersion | null = null,
) {
draftVersion.value = structuredClone(version ?? EMPTY_DRAFT_VERSION)
draftVersion.value.project_id = projectId
filesToAdd.value = []
existingFilesToDelete.value = []
inferredVersionData.value = undefined
projectType.value = undefined
}
function setPrimaryFile(index: number) {
const files = filesToAdd.value
if (index <= 0 || index >= files.length) return
files[0].fileType = 'unknown'
files[index].fileType = 'unknown'
;[files[0], files[index]] = [files[index], files[0]]
}
const tags = useGeneratedState()
async function setInferredVersionData(
file: File,
project: Labrinth.Projects.v2.Project,
): Promise<InferredVersionInfo> {
const inferred = (await inferVersionInfo(
file,
project,
tags.value.gameVersions,
)) as InferredVersionInfo
try {
const versions = await labrinth.versions_v3.getProjectVersions(project.id, {
loaders: inferred.loaders ?? [],
})
if (versions.length > 0) {
const mostRecentVersion = versions[0]
const version = await labrinth.versions_v3.getVersion(mostRecentVersion.id)
inferred.environment = version.environment !== 'unknown' ? version.environment : undefined
}
} catch (error) {
console.error('Error fetching versions for environment inference:', error)
}
inferredVersionData.value = inferred
projectType.value = await setProjectType(project, file)
return inferred
}
const getProject = async (projectId: string) => {
if (dependencyProjects.value[projectId]) {
return dependencyProjects.value[projectId]
}
const proj = await labrinth.projects_v3.get(projectId)
dependencyProjects.value[projectId] = proj
return proj
}
const getVersion = async (versionId: string) => {
if (dependencyVersions.value[versionId]) {
return dependencyVersions.value[versionId]
}
const version = await labrinth.versions_v3.getVersion(versionId)
dependencyVersions.value[versionId] = version
return version
}
// Submission handlers
async function handleCreateVersion() {
const version = toRaw(draftVersion.value)
const files = toRaw(filesToAdd.value)
isSubmitting.value = true
if (noEnvironmentProject.value) version.environment = undefined
try {
await labrinth.versions_v3.createVersion(version, files)
modal.value?.hide()
addNotification({
title: 'Project version created',
text: 'The version has been successfully added to your project.',
type: 'success',
})
await refreshVersions()
} catch (err: any) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
isSubmitting.value = false
}
async function handleSaveVersionEdits() {
const version = toRaw(draftVersion.value)
const files = toRaw(filesToAdd.value)
const filesToDelete = toRaw(existingFilesToDelete.value)
isSubmitting.value = true
if (noEnvironmentProject.value) version.environment = undefined
try {
if (!version.version_id) throw new Error('Version ID is required to save edits.')
await labrinth.versions_v3.modifyVersion(version.version_id, {
name: version.name || version.version_number,
version_number: version.version_number,
changelog: version.changelog,
version_type: version.version_type,
dependencies: version.dependencies || [],
game_versions: version.game_versions,
loaders: version.loaders,
environment: version.environment,
file_types: version.existing_files
?.filter((file) => file.file_type)
.map((file) => ({
algorithm: 'sha1',
hash: file.hashes.sha1,
file_type: file.file_type ?? null,
})),
})
if (files.length > 0) {
await labrinth.versions_v3.addFilesToVersion(version.version_id, files)
}
// Delete files that were marked for deletion
for (const hash of filesToDelete) {
await useBaseFetch(`version_file/${hash}?version_id=${version.version_id}`, {
method: 'DELETE',
})
}
modal.value?.hide()
addNotification({
title: 'Project version saved',
text: 'The version has been successfully saved to your project.',
type: 'success',
})
await refreshVersions()
} catch (err: any) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
isSubmitting.value = false
}
// Stage visibility computeds (inlined)
const noLoadersProject = computed(() => projectType.value === 'resourcepack')
const noEnvironmentProject = computed(
() => projectType.value !== 'mod' && projectType.value !== 'modpack',
)
// Dynamic next button label
function getNextLabel(currentIndex: number | null = null) {
const currentStageIndex = currentIndex ? currentIndex : modal.value?.currentStageIndex || 0
let nextIndex = currentStageIndex + 1
while (nextIndex < stageConfigs.length) {
const skip = stageConfigs[nextIndex]?.skip
if (!skip || !resolveCtxFn(skip, contextValue)) break
nextIndex++
}
const next = stageConfigs[nextIndex]
if (!next) return 'Done'
switch (next.id) {
case 'add-details':
return editingVersion.value ? 'Edit details' : 'Add details'
case 'add-files':
return editingVersion.value ? 'Edit files' : 'Add files'
case 'add-loaders':
return editingVersion.value ? 'Edit loaders' : 'Set loaders'
case 'add-mc-versions':
return editingVersion.value ? 'Edit game versions' : 'Set game versions'
case 'add-dependencies':
return editingVersion.value ? 'Edit dependencies' : 'Set dependencies'
case 'add-environment':
return editingVersion.value ? 'Edit environment' : 'Add environment'
case 'add-changelog':
return editingVersion.value ? 'Edit changelog' : 'Add changelog'
default:
return 'Next'
}
}
const contextValue: ManageVersionContextValue = {
// State
draftVersion,
filesToAdd,
existingFilesToDelete,
inferredVersionData,
projectType,
dependencyProjects,
dependencyVersions,
// Stage management
stageConfigs,
isSubmitting,
modal,
// Computed
editingVersion,
noLoadersProject,
noEnvironmentProject,
// Stage helpers
getNextLabel,
// Methods
newDraftVersion,
setPrimaryFile,
setInferredVersionData,
setProjectType,
getProject,
getVersion,
handleCreateVersion,
handleSaveVersionEdits,
}
return contextValue
}