@@ -1353,7 +1335,6 @@ export default defineNuxtComponent({
display: flex;
flex-wrap: wrap;
align-items: center;
- margin-bottom: 1rem;
gap: var(--spacing-card-md);
h2,
diff --git a/apps/frontend/src/pages/[type]/[id]/version/[version]/edit.vue b/apps/frontend/src/pages/[type]/[id]/version/[version]/edit.vue
deleted file mode 100644
index 751cd13d04..0000000000
--- a/apps/frontend/src/pages/[type]/[id]/version/[version]/edit.vue
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
diff --git a/apps/frontend/src/pages/[type]/[id]/versions.vue b/apps/frontend/src/pages/[type]/[id]/versions.vue
index 2eb25a0637..2ee991ca00 100644
--- a/apps/frontend/src/pages/[type]/[id]/versions.vue
+++ b/apps/frontend/src/pages/[type]/[id]/versions.vue
@@ -1,34 +1,37 @@
-
-
-
-
-
-
- Click to choose a file or drag one onto this page
-
-
-
+
+ Managing project versions has moved! You can now add and edit versions in the
+ project settings.
+
+
+
+
+
+
+
+
+
+
+
+
{
- selectedVersion = version.id
- deleteVersionModal.show()
- },
- shown: currentMember,
- },
]"
aria-label="More options"
>
@@ -155,14 +140,6 @@
Report
-
-
- Edit
-
-
-
- Delete
-
Copy ID
@@ -175,6 +152,15 @@
+
+
+ No versions in project. Visit
+
+ project settings to
+
+ upload your first version.
+
+
@@ -182,27 +168,16 @@
import {
ClipboardCopyIcon,
DownloadIcon,
- EditIcon,
ExternalIcon,
- InfoIcon,
LinkIcon,
MoreVerticalIcon,
ReportIcon,
+ SettingsIcon,
ShareIcon,
- TrashIcon,
- UploadIcon,
} from '@modrinth/assets'
-import {
- ButtonStyled,
- ConfirmModal,
- DropArea,
- FileInput,
- OverflowMenu,
- ProjectPageVersions,
-} from '@modrinth/ui'
+import { Admonition, ButtonStyled, OverflowMenu, ProjectPageVersions } from '@modrinth/ui'
+import { useLocalStorage } from '@vueuse/core'
-import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
-import { isPermission } from '~/utils/permissions.ts'
import { reportVersion } from '~/utils/report-helpers.ts'
const props = defineProps({
@@ -230,8 +205,10 @@ const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
-const deleteVersionModal = ref()
-const selectedVersion = ref(null)
+const hideVersionsAdmonition = useLocalStorage(
+ 'hideVersionsHasMovedAdmonition',
+ !props.versions.length,
+)
const emit = defineEmits(['onDownload', 'deleteVersion'])
@@ -243,26 +220,7 @@ function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
}
-async function handleFiles(files) {
- await router.push({
- name: 'type-id-version-version',
- params: {
- type: props.project.project_type,
- id: props.project.slug ? props.project.slug : props.project.id,
- version: 'create',
- },
- state: {
- newPrimaryFile: files[0],
- },
- })
-}
-
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}
-
-function deleteVersion() {
- emit('deleteVersion', selectedVersion.value)
- selectedVersion.value = null
-}
diff --git a/apps/frontend/src/providers/version/manage-version-modal.ts b/apps/frontend/src/providers/version/manage-version-modal.ts
new file mode 100644
index 0000000000..9e350adb0f
--- /dev/null
+++ b/apps/frontend/src/providers/version/manage-version-modal.ts
@@ -0,0 +1,418 @@
+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
+ filesToAdd: Ref
+ existingFilesToDelete: Ref
+ inferredVersionData: Ref
+ projectType: Ref
+ dependencyProjects: Ref>
+ dependencyVersions: Ref>
+
+ // Stage management
+ stageConfigs: StageConfigInput[]
+ isSubmitting: Ref
+ modal: ShallowRef | null>
+
+ // Computed state
+ editingVersion: ComputedRef
+ noLoadersProject: ComputedRef
+ noEnvironmentProject: ComputedRef
+
+ // 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
+ setProjectType: (
+ project: Labrinth.Projects.v2.Project,
+ file?: File | null,
+ ) => Promise
+ getProject: (projectId: string) => Promise
+ getVersion: (versionId: string) => Promise
+
+ // Submission methods
+ handleCreateVersion: () => Promise
+ handleSaveVersionEdits: () => Promise
+}
+
+export const [injectManageVersionContext, provideManageVersionContext] =
+ createContext('CreateProjectVersionModal')
+
+export function createManageVersionContext(
+ modal: ShallowRef | null>,
+): ManageVersionContextValue {
+ const { labrinth } = injectModrinthClient()
+ const { addNotification } = injectNotificationManager()
+ const { refreshVersions } = injectProjectPageContext()
+
+ // State
+ const draftVersion = ref(structuredClone(EMPTY_DRAFT_VERSION))
+ const filesToAdd = ref([])
+ const existingFilesToDelete = ref([])
+ const inferredVersionData = ref()
+ const projectType = ref()
+ const dependencyProjects = ref>({})
+ const dependencyVersions = ref>({})
+ 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 {
+ 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 {
+ 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
+}
diff --git a/apps/frontend/src/providers/version/stages/add-changelog.ts b/apps/frontend/src/providers/version/stages/add-changelog.ts
new file mode 100644
index 0000000000..abca4830d1
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-changelog.ts
@@ -0,0 +1,28 @@
+import { LeftArrowIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddChangelogStage from '~/components/ui/create-project-version/stages/AddChangelogStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-changelog',
+ stageContent: markRaw(AddChangelogStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit changelog' : 'Add changelog'),
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.editingVersion.value ? 'Save changes' : 'Create version',
+ icon: ctx.isSubmitting.value ? SpinnerIcon : PlusIcon,
+ iconPosition: 'before',
+ iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
+ color: 'green',
+ disabled: ctx.isSubmitting.value,
+ onClick: () =>
+ ctx.editingVersion.value ? ctx.handleSaveVersionEdits() : ctx.handleCreateVersion(),
+ }),
+}
diff --git a/apps/frontend/src/providers/version/stages/add-dependencies.ts b/apps/frontend/src/providers/version/stages/add-dependencies.ts
new file mode 100644
index 0000000000..3b7ff6d0bd
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-dependencies.ts
@@ -0,0 +1,25 @@
+import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddDependenciesStage from '~/components/ui/create-project-version/stages/AddDependenciesStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-dependencies',
+ stageContent: markRaw(AddDependenciesStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit dependencies' : 'Add dependencies'),
+ skip: (ctx) => ctx.projectType.value === 'modpack',
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ onClick: () => ctx.modal.value?.nextStage(),
+ }),
+}
diff --git a/apps/frontend/src/providers/version/stages/add-details.ts b/apps/frontend/src/providers/version/stages/add-details.ts
new file mode 100644
index 0000000000..095e7e42fa
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-details.ts
@@ -0,0 +1,25 @@
+import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddDetailsStage from '~/components/ui/create-project-version/stages/AddDetailsStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-details',
+ stageContent: markRaw(AddDetailsStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit details' : 'Add details'),
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: ctx.draftVersion.value.version_number.trim().length === 0,
+ onClick: () => ctx.modal.value?.nextStage(),
+ }),
+}
diff --git a/apps/frontend/src/providers/version/stages/add-environment.ts b/apps/frontend/src/providers/version/stages/add-environment.ts
new file mode 100644
index 0000000000..a953e016fc
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-environment.ts
@@ -0,0 +1,49 @@
+import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddEnvironmentStage from '~/components/ui/create-project-version/stages/AddEnvironmentStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-environment',
+ stageContent: markRaw(AddEnvironmentStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit environment' : 'Add environment'),
+ skip: (ctx) =>
+ ctx.noEnvironmentProject.value ||
+ (!ctx.editingVersion.value && !!ctx.inferredVersionData.value?.environment) ||
+ (ctx.editingVersion.value && !!ctx.draftVersion.value.environment),
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: !ctx.draftVersion.value.environment,
+ onClick: () => ctx.modal.value?.nextStage(),
+ }),
+}
+
+export const editStageConfig: StageConfigInput = {
+ id: 'edit-environment',
+ stageContent: markRaw(AddEnvironmentStage),
+ title: 'Edit environment',
+ nonProgressStage: true,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ disabled: !ctx.draftVersion.value.environment,
+ onClick: () => ctx.modal.value?.setStage('add-details'),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(2),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: !ctx.draftVersion.value.environment,
+ onClick: () => ctx.modal.value?.setStage(2),
+ }),
+}
diff --git a/apps/frontend/src/providers/version/stages/add-files.ts b/apps/frontend/src/providers/version/stages/add-files.ts
new file mode 100644
index 0000000000..471484fddd
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-files.ts
@@ -0,0 +1,41 @@
+import { RightArrowIcon, XIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddFilesStage from '~/components/ui/create-project-version/stages/AddFilesStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-files',
+ stageContent: markRaw(AddFilesStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit files' : 'Add files'),
+ leftButtonConfig: (ctx) => {
+ const hasFiles =
+ ctx.filesToAdd.value.length !== 0 ||
+ (ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
+
+ if (!hasFiles) return null
+
+ return {
+ label: 'Cancel',
+ icon: XIcon,
+ onClick: () => ctx.modal.value?.hide(),
+ }
+ },
+ rightButtonConfig: (ctx) => {
+ const hasFiles =
+ ctx.filesToAdd.value.length !== 0 ||
+ (ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
+
+ if (!hasFiles) return null
+
+ return {
+ label: ctx.getNextLabel(),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: !hasFiles,
+ onClick: () => ctx.modal.value?.nextStage(),
+ }
+ },
+}
diff --git a/apps/frontend/src/providers/version/stages/add-loaders.ts b/apps/frontend/src/providers/version/stages/add-loaders.ts
new file mode 100644
index 0000000000..c24d1b7aa9
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-loaders.ts
@@ -0,0 +1,49 @@
+import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddLoadersStage from '~/components/ui/create-project-version/stages/AddLoadersStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-loaders',
+ stageContent: markRaw(AddLoadersStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit loaders' : 'Add loaders'),
+ skip: (ctx) =>
+ ctx.noLoadersProject.value ||
+ (ctx.inferredVersionData.value?.loaders?.length ?? 0) > 0 ||
+ ctx.editingVersion.value,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: ctx.draftVersion.value.loaders.length === 0,
+ onClick: () => ctx.modal.value?.nextStage(),
+ }),
+}
+
+export const editStageConfig: StageConfigInput = {
+ id: 'edit-loaders',
+ stageContent: markRaw(AddLoadersStage),
+ title: 'Edit loaders',
+ nonProgressStage: true,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ disabled: ctx.draftVersion.value.loaders.length === 0,
+ onClick: () => ctx.modal.value?.setStage('add-details'),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(2),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: ctx.draftVersion.value.loaders.length === 0,
+ onClick: () => ctx.modal.value?.setStage(2),
+ }),
+}
diff --git a/apps/frontend/src/providers/version/stages/add-mc-versions.ts b/apps/frontend/src/providers/version/stages/add-mc-versions.ts
new file mode 100644
index 0000000000..b7444f4fa7
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/add-mc-versions.ts
@@ -0,0 +1,47 @@
+import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
+import type { StageConfigInput } from '@modrinth/ui'
+import { markRaw } from 'vue'
+
+import AddMcVersionsStage from '~/components/ui/create-project-version/stages/AddMcVersionsStage.vue'
+
+import type { ManageVersionContextValue } from '../manage-version-modal'
+
+export const stageConfig: StageConfigInput = {
+ id: 'add-mc-versions',
+ stageContent: markRaw(AddMcVersionsStage),
+ title: (ctx) => (ctx.editingVersion.value ? 'Edit game versions' : 'Add game versions'),
+ skip: (ctx) =>
+ (ctx.inferredVersionData.value?.game_versions?.length ?? 0) > 0 || ctx.editingVersion.value,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ onClick: () => ctx.modal.value?.prevStage(),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: ctx.draftVersion.value.game_versions.length === 0,
+ onClick: () => ctx.modal.value?.nextStage(),
+ }),
+}
+
+export const editStageConfig: StageConfigInput = {
+ id: 'edit-mc-versions',
+ stageContent: markRaw(AddMcVersionsStage),
+ title: 'Edit game versions',
+ nonProgressStage: true,
+ leftButtonConfig: (ctx) => ({
+ label: 'Back',
+ icon: LeftArrowIcon,
+ disabled: ctx.draftVersion.value.game_versions.length === 0,
+ onClick: () => ctx.modal.value?.setStage('add-details'),
+ }),
+ rightButtonConfig: (ctx) => ({
+ label: ctx.getNextLabel(2),
+ icon: RightArrowIcon,
+ iconPosition: 'after',
+ disabled: ctx.draftVersion.value.game_versions.length === 0,
+ onClick: () => ctx.modal.value?.setStage(2),
+ }),
+}
diff --git a/apps/frontend/src/providers/version/stages/index.ts b/apps/frontend/src/providers/version/stages/index.ts
new file mode 100644
index 0000000000..f3200b6999
--- /dev/null
+++ b/apps/frontend/src/providers/version/stages/index.ts
@@ -0,0 +1,30 @@
+import { stageConfig as addChangelogStageConfig } from './add-changelog'
+import { stageConfig as addDependenciesStageConfig } from './add-dependencies'
+import { stageConfig as addDetailsStageConfig } from './add-details'
+import {
+ editStageConfig as editEnvironmentStageConfig,
+ stageConfig as addEnvironmentStageConfig,
+} from './add-environment'
+import { stageConfig as addFilesStageConfig } from './add-files'
+import {
+ editStageConfig as editLoadersStageConfig,
+ stageConfig as addLoadersStageConfig,
+} from './add-loaders'
+import {
+ editStageConfig as editMcVersionsStageConfig,
+ stageConfig as addMcVersionsStageConfig,
+} from './add-mc-versions'
+
+export const stageConfigs = [
+ addFilesStageConfig,
+ addDetailsStageConfig,
+ addLoadersStageConfig,
+ addMcVersionsStageConfig,
+ addEnvironmentStageConfig,
+ addDependenciesStageConfig,
+ addChangelogStageConfig,
+ // Non-progress stages for editing from details page
+ editLoadersStageConfig,
+ editMcVersionsStageConfig,
+ editEnvironmentStageConfig,
+]
diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts
index 123d3d14db..f3fe9f3c22 100644
--- a/packages/api-client/src/core/abstract-client.ts
+++ b/packages/api-client/src/core/abstract-client.ts
@@ -124,6 +124,11 @@ export abstract class AbstractModrinthClient {
},
}
+ const headers = mergedOptions.headers
+ if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') {
+ delete headers['Content-Type']
+ }
+
const context = this.buildContext(url, path, mergedOptions)
try {
diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts
index e78a8b7541..9aa3a21ba8 100644
--- a/packages/api-client/src/modules/index.ts
+++ b/packages/api-client/src/modules/index.ts
@@ -6,6 +6,7 @@ import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
import { KyrosFilesV0Module } from './kyros/files/v0'
+import { LabrinthVersionsV3Module } from './labrinth'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
@@ -35,6 +36,7 @@ export const MODULE_REGISTRY = {
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
labrinth_state: LabrinthStateModule,
+ labrinth_versions_v3: LabrinthVersionsV3Module,
} as const satisfies Record
export type ModuleID = keyof typeof MODULE_REGISTRY
diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts
index 2909dca0e6..1b5c1d6f08 100644
--- a/packages/api-client/src/modules/labrinth/index.ts
+++ b/packages/api-client/src/modules/labrinth/index.ts
@@ -3,3 +3,4 @@ export * from './collections'
export * from './projects/v2'
export * from './projects/v3'
export * from './state'
+export * from './versions/v3'
diff --git a/packages/api-client/src/modules/labrinth/projects/v2.ts b/packages/api-client/src/modules/labrinth/projects/v2.ts
index c36b71c8d6..5a65edd493 100644
--- a/packages/api-client/src/modules/labrinth/projects/v2.ts
+++ b/packages/api-client/src/modules/labrinth/projects/v2.ts
@@ -68,7 +68,10 @@ export class LabrinthProjectsV2Module extends AbstractModule {
api: 'labrinth',
version: 2,
method: 'GET',
- params: params as Record,
+ params: {
+ ...params,
+ facets: params.facets ? JSON.stringify(params.facets) : undefined,
+ },
})
}
diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts
index 46ba1ca6e1..238b6fdb2f 100644
--- a/packages/api-client/src/modules/labrinth/types.ts
+++ b/packages/api-client/src/modules/labrinth/types.ts
@@ -172,6 +172,7 @@ export namespace Labrinth {
| 'shader'
| 'plugin'
| 'datapack'
+ | 'project'
export type GalleryImage = {
url: string
@@ -264,7 +265,7 @@ export namespace Labrinth {
export type ProjectSearchParams = {
query?: string
- facets?: string[][]
+ facets?: string[][] // in the format of [["categories:forge"],["versions:1.17.1"]]
filters?: string
index?: 'relevance' | 'downloads' | 'follows' | 'newest' | 'updated'
offset?: number
@@ -420,23 +421,38 @@ export namespace Labrinth {
}
}
+ // TODO: consolidate duplicated types between v2 and v3 versions
export namespace v3 {
- export type VersionType = 'release' | 'beta' | 'alpha'
+ export interface Dependency {
+ dependency_type: Labrinth.Versions.v2.DependencyType
+ project_id?: string
+ file_name?: string
+ version_id?: string
+ }
- export type VersionStatus =
- | 'listed'
- | 'archived'
- | 'draft'
- | 'unlisted'
- | 'scheduled'
- | 'unknown'
+ export interface GetProjectVersionsParams {
+ game_versions?: string[]
+ loaders?: string[]
+ }
- export type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
+ export type VersionChannel = 'release' | 'beta' | 'alpha'
- export type FileType = 'required-resource-pack' | 'optional-resource-pack' | 'unknown'
+ export type FileType =
+ | 'required-resource-pack'
+ | 'optional-resource-pack'
+ | 'sources-jar'
+ | 'dev-jar'
+ | 'javadoc-jar'
+ | 'signature'
+ | 'unknown'
- export type VersionFile = {
- hashes: Record
+ export interface VersionFileHash {
+ sha512: string
+ sha1: string
+ }
+
+ interface VersionFile {
+ hashes: VersionFileHash
url: string
filename: string
primary: boolean
@@ -444,35 +460,75 @@ export namespace Labrinth {
file_type?: FileType
}
- export type Dependency = {
- version_id?: string
- project_id?: string
- file_name?: string
- dependency_type: DependencyType
- }
-
- export type Version = {
+ export interface Version {
+ name: string
+ version_number: string
+ changelog?: string
+ dependencies: Dependency[]
+ game_versions: string[]
+ version_type: VersionChannel
+ loaders: string[]
+ featured: boolean
+ status: Labrinth.Versions.v2.VersionStatus
id: string
project_id: string
author_id: string
- featured: boolean
- name: string
- version_number: string
- project_types: string[]
- games: string[]
- changelog: string
date_published: string
downloads: number
- version_type: VersionType
- status: VersionStatus
- requested_status?: VersionStatus | null
files: VersionFile[]
- dependencies: Dependency[]
+ environment?: Labrinth.Projects.v3.Environment
+ }
+
+ export interface DraftVersionFile {
+ fileType?: FileType
+ file: File
+ }
+
+ export type DraftVersion = Omit<
+ Labrinth.Versions.v3.CreateVersionRequest,
+ 'file_parts' | 'primary_file' | 'file_types'
+ > & {
+ existing_files?: VersionFile[]
+ version_id?: string
+ environment?: Labrinth.Projects.v3.Environment
+ }
+
+ export interface CreateVersionRequest {
+ name: string
+ version_number: string
+ changelog: string
+ dependencies?: Array<{
+ version_id?: string
+ project_id?: string
+ file_name?: string
+ dependency_type: Labrinth.Versions.v2.DependencyType
+ }>
+ game_versions: string[]
+ version_type: 'release' | 'beta' | 'alpha'
loaders: string[]
- ordering?: number | null
- game_versions?: string[]
- mrpack_loaders?: string[]
- environment?: string
+ featured?: boolean
+ status?: 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
+ requested_status?: 'listed' | 'archived' | 'draft' | 'unlisted' | null
+ project_id: string
+ file_parts: string[]
+ primary_file?: string
+ file_types?: Record
+ environment?: Labrinth.Projects.v3.Environment
+ }
+
+ export type ModifyVersionRequest = Partial<
+ Omit
+ > & {
+ file_types?: {
+ algorithm: string
+ hash: string
+ file_type: Labrinth.Versions.v3.FileType | null
+ }[]
+ }
+
+ export type AddFilesToVersionRequest = {
+ file_parts: string[]
+ file_types?: Record
}
}
}
@@ -566,7 +622,7 @@ export namespace Labrinth {
export interface GameVersion {
version: string
version_type: string
- date: string // RFC 3339 DateTime
+ date: string
major: boolean
}
diff --git a/packages/api-client/src/modules/labrinth/versions/v3.ts b/packages/api-client/src/modules/labrinth/versions/v3.ts
new file mode 100644
index 0000000000..8371722602
--- /dev/null
+++ b/packages/api-client/src/modules/labrinth/versions/v3.ts
@@ -0,0 +1,282 @@
+import { AbstractModule } from '../../../core/abstract-module'
+import type { Labrinth } from '../types'
+
+export class LabrinthVersionsV3Module extends AbstractModule {
+ public getModuleID(): string {
+ return 'labrinth_versions_v3'
+ }
+
+ /**
+ * Get versions for a project (v3)
+ *
+ * @param id - Project ID or slug (e.g., 'sodium' or 'AANobbMI')
+ * @param options - Optional query parameters to filter versions
+ * @returns Promise resolving to an array of v3 versions
+ *
+ * @example
+ * ```typescript
+ * const versions = await client.labrinth.versions_v3.getProjectVersions('sodium')
+ * const filteredVersions = await client.labrinth.versions_v3.getProjectVersions('sodium', {
+ * game_versions: ['1.20.1'],
+ * loaders: ['fabric']
+ * })
+ * console.log(versions[0].version_number)
+ * ```
+ */
+ public async getProjectVersions(
+ id: string,
+ options?: Labrinth.Versions.v3.GetProjectVersionsParams,
+ ): Promise {
+ const params: Record = {}
+ if (options?.game_versions?.length) {
+ params.game_versions = JSON.stringify(options.game_versions)
+ }
+ if (options?.loaders?.length) {
+ params.loaders = JSON.stringify(options.loaders)
+ }
+
+ return this.client.request(`/project/${id}/version`, {
+ api: 'labrinth',
+ version: 2, // TODO: move this to a versions v2 module to keep api-client clean and organized
+ method: 'GET',
+ params: Object.keys(params).length > 0 ? params : undefined,
+ })
+ }
+
+ /**
+ * Get a specific version by ID (v3)
+ *
+ * @param id - Version ID
+ * @returns Promise resolving to the v3 version data
+ *
+ * @example
+ * ```typescript
+ * const version = await client.labrinth.versions_v3.getVersion('DXtmvS8i')
+ * console.log(version.version_number)
+ * ```
+ */
+ public async getVersion(id: string): Promise {
+ return this.client.request(`/version/${id}`, {
+ api: 'labrinth',
+ version: 3,
+ method: 'GET',
+ })
+ }
+
+ /**
+ * Get multiple versions by IDs (v3)
+ *
+ * @param ids - Array of version IDs
+ * @returns Promise resolving to an array of v3 versions
+ *
+ * @example
+ * ```typescript
+ * const versions = await client.labrinth.versions_v3.getVersions(['DXtmvS8i', 'abc123'])
+ * console.log(versions[0].version_number)
+ * ```
+ */
+ public async getVersions(ids: string[]): Promise {
+ return this.client.request(`/versions`, {
+ api: 'labrinth',
+ version: 3,
+ method: 'GET',
+ params: { ids: JSON.stringify(ids) },
+ })
+ }
+
+ /**
+ * Get a version from a project by version ID or number (v3)
+ *
+ * @param projectId - Project ID or slug
+ * @param versionId - Version ID or version number
+ * @returns Promise resolving to the v3 version data
+ *
+ * @example
+ * ```typescript
+ * const version = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', 'DXtmvS8i')
+ * const versionByNumber = await client.labrinth.versions_v3.getVersionFromIdOrNumber('sodium', '0.4.12')
+ * ```
+ */
+ public async getVersionFromIdOrNumber(
+ projectId: string,
+ versionId: string,
+ ): Promise {
+ return this.client.request(
+ `/project/${projectId}/version/${versionId}`,
+ {
+ api: 'labrinth',
+ version: 3,
+ method: 'GET',
+ },
+ )
+ }
+
+ /**
+ * Create a new version for a project (v3)
+ *
+ * Creates a new version on an existing project. At least one file must be
+ * attached unless the version is created as a draft.
+ *
+ * @param data - JSON metadata payload for the version (must include file_parts)
+ * @param files - Array of uploaded files, in the same order as `data.file_parts`
+ *
+ * @returns A promise resolving to the newly created version data
+ *
+ * @example
+ * ```ts
+ * const version = await client.labrinth.versions_v3.createVersion('sodium', {
+ * name: 'v0.5.0',
+ * version_number: '0.5.0',
+ * version_type: 'release',
+ * loaders: ['fabric'],
+ * game_versions: ['1.20.1'],
+ * project_id: 'sodium',
+ * file_parts: ['primary']
+ * }, [fileObject])
+ * ```
+ */
+
+ public async createVersion(
+ draftVersion: Labrinth.Versions.v3.DraftVersion,
+ versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
+ ): Promise {
+ const formData = new FormData()
+
+ const files = versionFiles.map((vf) => vf.file)
+ const fileTypes = versionFiles.map((vf) => vf.fileType || null)
+
+ const fileParts = files.map((file, i) => {
+ return `${file.name}-${i === 0 ? 'primary' : i}`
+ })
+
+ const fileTypeMap = fileParts.reduce>(
+ (acc, key, i) => {
+ acc[key] = fileTypes[i]
+ return acc
+ },
+ {},
+ )
+
+ const data: Labrinth.Versions.v3.CreateVersionRequest = {
+ project_id: draftVersion.project_id,
+ version_number: draftVersion.version_number,
+ name: draftVersion.name || draftVersion.version_number,
+ changelog: draftVersion.changelog,
+ dependencies: draftVersion.dependencies || [],
+ game_versions: draftVersion.game_versions,
+ loaders: draftVersion.loaders,
+ version_type: draftVersion.version_type,
+ featured: !!draftVersion.featured,
+ file_parts: fileParts,
+ file_types: fileTypeMap,
+ primary_file: fileParts[0],
+ environment: draftVersion.environment,
+ }
+
+ formData.append('data', JSON.stringify(data))
+
+ files.forEach((file, i) => {
+ formData.append(fileParts[i], new Blob([file]), file.name)
+ })
+
+ return this.client.request(`/version`, {
+ api: 'labrinth',
+ version: 3,
+ method: 'POST',
+ body: formData,
+ timeout: 120000,
+ headers: {
+ 'Content-Type': '',
+ },
+ })
+ }
+
+ /**
+ * Modify an existing version by ID (v3)
+ *
+ * Partially updates a version’s metadata. Only JSON fields may be modified.
+ * To update files, use the separate "Add files to version" endpoint.
+ *
+ * @param versionId - The version ID to update
+ * @param data - PATCH metadata for this version (all fields optional)
+ *
+ * @returns A promise resolving to the updated version data
+ *
+ * @example
+ * ```ts
+ * const updated = await client.labrinth.versions_v3.modifyVersion('DXtmvS8i', {
+ * name: 'v1.0.1',
+ * changelog: 'Updated changelog',
+ * featured: true,
+ * status: 'listed'
+ * })
+ * ```
+ */
+
+ public async modifyVersion(
+ versionId: string,
+ data: Labrinth.Versions.v3.ModifyVersionRequest,
+ ): Promise {
+ return this.client.request(`/version/${versionId}`, {
+ api: 'labrinth',
+ version: 3,
+ method: 'PATCH',
+ body: data,
+ })
+ }
+
+ /**
+ * Delete a version by ID (v3)
+ *
+ * @param versionId - Version ID
+ *
+ * @example
+ * ```typescript
+ * await client.labrinth.versions_v3.deleteVersion('DXtmvS8i')
+ * ```
+ */
+ public async deleteVersion(versionId: string): Promise {
+ return this.client.request(`/version/${versionId}`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'DELETE',
+ })
+ }
+
+ public async addFilesToVersion(
+ versionId: string,
+ versionFiles: Labrinth.Versions.v3.DraftVersionFile[],
+ ): Promise {
+ const formData = new FormData()
+
+ const files = versionFiles.map((vf) => vf.file)
+ const fileTypes = versionFiles.map((vf) => vf.fileType || null)
+
+ const fileParts = files.map((file, i) => `${file.name}-${i}`)
+
+ const fileTypeMap = fileParts.reduce>(
+ (acc, key, i) => {
+ acc[key] = fileTypes[i]
+ return acc
+ },
+ {},
+ )
+
+ formData.append('data', JSON.stringify({ file_types: fileTypeMap }))
+
+ files.forEach((file, i) => {
+ formData.append(fileParts[i], new Blob([file]), file.name)
+ })
+
+ return this.client.request(`/version/${versionId}/file`, {
+ api: 'labrinth',
+ version: 2,
+ method: 'POST',
+ body: formData,
+ timeout: 120000,
+ headers: {
+ 'Content-Type': '',
+ },
+ })
+ }
+}
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index 99c9df5be2..6c9987620b 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -75,6 +75,7 @@ import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
+import _FolderUpIcon from './icons/folder-up.svg?component'
import _GameIcon from './icons/game.svg?component'
import _GapIcon from './icons/gap.svg?component'
import _GaugeIcon from './icons/gauge.svg?component'
@@ -292,6 +293,7 @@ export const FilterIcon = _FilterIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
+export const FolderUpIcon = _FolderUpIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
export const GaugeIcon = _GaugeIcon
diff --git a/packages/assets/icons/folder-up.svg b/packages/assets/icons/folder-up.svg
new file mode 100644
index 0000000000..576b88c48d
--- /dev/null
+++ b/packages/assets/icons/folder-up.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/assets/styles/defaults.scss b/packages/assets/styles/defaults.scss
index cd0229bb20..eee83e8fca 100644
--- a/packages/assets/styles/defaults.scss
+++ b/packages/assets/styles/defaults.scss
@@ -235,3 +235,23 @@ h3 {
margin-block: var(--gap-md) var(--gap-md);
color: var(--color-contrast);
}
+
+// Scrollbar styles
+::-webkit-scrollbar {
+ width: 0.75rem;
+ height: 0.75rem;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-button-bg);
+}
+
+// Firefox scrollbar
+* {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-button-bg) transparent;
+}
diff --git a/packages/moderation/src/data/nags/core.ts b/packages/moderation/src/data/nags/core.ts
index da7d416f54..67e72b6c14 100644
--- a/packages/moderation/src/data/nags/core.ts
+++ b/packages/moderation/src/data/nags/core.ts
@@ -39,7 +39,7 @@ export const coreNags: Nag[] = [
status: 'required',
shouldShow: (context: NagContext) => context.versions.length < 1,
link: {
- path: 'versions',
+ path: 'settings/versions',
title: defineMessage({
id: 'nags.versions.title',
defaultMessage: 'Visit versions page',
@@ -126,7 +126,7 @@ export const coreNags: Nag[] = [
)
},
link: {
- path: 'gallery',
+ path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
@@ -151,7 +151,7 @@ export const coreNags: Nag[] = [
return context.project?.gallery?.length === 0 || !featuredGalleryImage
},
link: {
- path: 'gallery',
+ path: 'settings/gallery',
title: defineMessage({
id: 'nags.gallery.title',
defaultMessage: 'Visit gallery page',
@@ -211,46 +211,6 @@ export const coreNags: Nag[] = [
shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-links',
},
},
- {
- id: 'select-environments',
- title: defineMessage({
- id: 'nags.select-environments.title',
- defaultMessage: 'Select environments',
- }),
- description: (context: NagContext) => {
- const { formatMessage } = useVIntl()
-
- return formatMessage(
- defineMessage({
- id: 'nags.select-environments.description',
- defaultMessage: `Select the environments your {type, select, mod {mod} modpack {modpack} other {project}} functions on.`,
- }),
- {
- type: context.project.project_type,
- },
- )
- },
- status: 'required',
- shouldShow: (context: NagContext) => {
- const excludedTypes = ['resourcepack', 'plugin', 'shader', 'datapack']
- return (
- context.project.versions.length > 0 &&
- !excludedTypes.includes(context.project.project_type) &&
- (context.project.client_side === 'unknown' ||
- context.project.server_side === 'unknown' ||
- (context.project.client_side === 'unsupported' &&
- context.project.server_side === 'unsupported'))
- )
- },
- link: {
- path: 'settings/environment',
- title: defineMessage({
- id: 'nags.settings.environments.title',
- defaultMessage: 'Visit environment settings',
- }),
- shouldShow: (context: NagContext) => context.currentRoute !== 'type-id-settings-environment',
- },
- },
{
id: 'select-license',
title: defineMessage({
diff --git a/packages/moderation/src/locales/en-US/index.json b/packages/moderation/src/locales/en-US/index.json
index 31bb3c38f5..f1a5982c98 100644
--- a/packages/moderation/src/locales/en-US/index.json
+++ b/packages/moderation/src/locales/en-US/index.json
@@ -122,12 +122,6 @@
"nags.multiple-resolution-tags.title": {
"defaultMessage": "Select correct resolution"
},
- "nags.select-environments.description": {
- "defaultMessage": "Select the environments your {type, select, mod {mod} modpack {modpack} other {project}} functions on."
- },
- "nags.select-environments.title": {
- "defaultMessage": "Select environments"
- },
"nags.select-license.description": {
"defaultMessage": "Select the license your {type, select, mod {mod} modpack {modpack} resourcepack {resource pack} shader {shader} plugin {plugin} datapack {data pack} other {project}} is distributed under."
},
@@ -143,9 +137,6 @@
"nags.settings.description.title": {
"defaultMessage": "Visit description settings"
},
- "nags.settings.environments.title": {
- "defaultMessage": "Visit environment settings"
- },
"nags.settings.license.title": {
"defaultMessage": "Visit license settings"
},
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 9c1a2083d5..43040e4343 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -29,6 +29,7 @@
"stripe": "^18.1.1",
"typescript": "^5.4.5",
"vue": "^3.5.13",
+ "vue-component-type-helpers": "^3.1.8",
"vue-router": "4.3.0"
},
"dependencies": {
diff --git a/packages/ui/src/components/base/Chips.vue b/packages/ui/src/components/base/Chips.vue
index 77eda12bae..af95a51461 100644
--- a/packages/ui/src/components/base/Chips.vue
+++ b/packages/ui/src/components/base/Chips.vue
@@ -3,8 +3,12 @@
@@ -107,7 +108,7 @@