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>
This commit is contained in:
Truman Gao
2025-12-18 11:56:15 -08:00
committed by GitHub
parent 9ad01723a2
commit 9958600121
69 changed files with 4954 additions and 585 deletions

View File

@@ -20,20 +20,6 @@
</ButtonStyled>
</div>
</div>
<ModerationProjectNags
v-if="
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
"
:project="project"
:versions="versions"
:current-member="currentMember"
:collapsed="collapsed"
:route-name="routeName"
:tags="tags"
@toggle-collapsed="handleToggleCollapsed"
@set-processing="handleSetProcessing"
/>
</template>
<script setup lang="ts">
@@ -45,8 +31,6 @@ import { computed } from 'vue'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
import ModerationProjectNags from './moderation/ModerationProjectNags.vue'
const { addNotification } = injectNotificationManager()
interface Tags {
@@ -71,12 +55,9 @@ interface Props {
currentMember?: Member | null
allMembers?: Member[] | null
isSettings?: boolean
collapsed?: boolean
routeName?: string
auth: Auth
tags: Tags
setProcessing?: (processing: boolean) => void
toggleCollapsed?: () => void
updateMembers?: () => void | Promise<void>
}
@@ -144,7 +125,6 @@ const props = withDefaults(defineProps<Props>(), {
allMembers: null,
isSettings: false,
collapsed: false,
routeName: '',
setProcessing: () => {},
toggleCollapsed: () => {},
updateMembers: async () => {},
@@ -164,14 +144,6 @@ const showInvitation = computed<boolean>(() => {
return false
})
function handleToggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed()
} else {
emit('toggleCollapsed')
}
}
async function handleUpdateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers()

View File

@@ -0,0 +1,34 @@
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectProjectPageContext, MultiStageModal } from '@modrinth/ui'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
createManageVersionContext,
provideManageVersionContext,
} from '~/providers/version/manage-version-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const ctx = createManageVersionContext(modal)
provideManageVersionContext(ctx)
const { newDraftVersion, setProjectType } = ctx
const { projectV2 } = injectProjectPageContext()
function showCreateVersionModal(version: Labrinth.Versions.v3.DraftVersion | null = null) {
newDraftVersion(projectV2.value.id, version)
setProjectType(projectV2.value)
modal.value?.setStage(0)
modal.value?.show()
}
defineExpose({
show: showCreateVersionModal,
})
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
<span v-tooltip="name || projectId" class="truncate font-semibold text-contrast">
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
{{ dependencyType }}
</TagItem>
</div>
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Remove file" class="!shadow-none" @click="emitRemove">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, TagItem } from '@modrinth/ui'
const emit = defineEmits<{
(e: 'fileTypeChange', type: string): void
(e: 'remove'): void
}>()
const { projectId, name, icon, dependencyType, versionName } = defineProps<{
projectId: string
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
}>()
function emitRemove() {
emit('remove')
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">Loaders <span class="text-red">*</span></span>
<Chips
v-model="loaderGroup"
:items="groupLabels"
:never-empty="false"
:capitalize="true"
size="small"
/>
<div
class="flex min-h-[150px] flex-1 flex-col gap-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3"
>
<div v-if="groupedLoaders[loaderGroup].length" class="flex flex-col gap-1.5">
<div class="flex flex-wrap gap-2">
<TagItem
v-for="loader in groupedLoaders[loaderGroup]"
:key="`loader-${loader.name}`"
:action="() => toggleLoader(loader.name)"
class="border !border-solid !transition-all hover:bg-button-bgHover hover:no-underline"
:class="
selectedLoaders.includes(loader.name)
? 'border-brand bg-highlight-green text-brand'
: 'border-surface-5'
"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</div>
</div>
</div>
<span>Select one or more loaders this version supports.</span>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { Chips, TagItem } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
const selectedLoaders = defineModel<string[]>({ default: [] })
const { loaders } = defineProps<{
loaders: Labrinth.Tags.v2.Loader[]
toggleLoader: (loader: string) => void
}>()
const loaderGroup = ref<GroupLabels>('mods')
type GroupLabels = 'mods' | 'plugins' | 'packs' | 'shaders' | 'other'
const groupLabels: GroupLabels[] = ['mods', 'plugins', 'packs', 'shaders']
function groupLoaders(loaders: Labrinth.Tags.v2.Loader[]) {
const groups: Record<GroupLabels, Labrinth.Tags.v2.Loader[]> = {
mods: [],
plugins: [],
packs: [],
shaders: [],
other: [],
}
const MOD_SORT = [
'fabric',
'neoforge',
'forge',
'quilt',
'liteloader',
'rift',
'ornithe',
'nilloader',
'risugami',
'legacy-fabric',
'bta-babric',
'babric',
'modloader',
'java-agent',
]
const PLUGIN_SORT = [
'paper',
'purpur',
'spigot',
'bukkit',
'sponge',
'folia',
'bungeecord',
'velocity',
'waterfall',
'geyser',
]
const SHADER_SORT = ['optifine', 'iris', 'canvas', 'vanilla']
const PACKS_SORT = ['minecraft', 'datapack']
for (const loader of loaders) {
const name = loader.name.toLowerCase()
if (PACKS_SORT.includes(name)) groups.packs.push(loader)
else if (SHADER_SORT.includes(name)) groups.shaders.push(loader)
else if (PLUGIN_SORT.includes(name)) groups.plugins.push(loader)
else if (MOD_SORT.includes(name)) groups.mods.push(loader)
else groups.other.push(loader)
}
const sortByOrder = (arr: any[], order: string[]) =>
arr.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
sortByOrder(groups.mods, MOD_SORT)
sortByOrder(groups.plugins, PLUGIN_SORT)
sortByOrder(groups.shaders, SHADER_SORT)
return groups
}
const groupedLoaders = computed(() => groupLoaders(loaders))
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
Minecraft versions <span class="text-red">*</span>
</span>
<Chips
v-model="versionType"
:items="['release', 'all']"
:never-empty="true"
:capitalize="true"
size="small"
/>
</div>
<div class="iconified-input w-full">
<SearchIcon aria-hidden="true" />
<input v-model="searchQuery" type="text" placeholder="Search versions" />
</div>
<div
class="flex h-72 select-none flex-col gap-3 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div v-for="group in groupedGameVersions" :key="group.key" class="space-y-1.5">
<span class="font-semibold">{{ group.key }}</span>
<div class="flex flex-wrap gap-2 gap-x-1.5">
<ButtonStyled
v-for="version in group.versions"
:key="version"
:color="
holdingShift && version === anchorVersion
? 'purple'
: modelValue.includes(version)
? 'green'
: 'standard'
"
:highlighted="modelValue.includes(version)"
type="chip"
>
<button
class="!py-1.5 focus:outline-none"
:class="[
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
modelValue.includes(version) ? '!text-contrast' : '',
]"
@click="() => handleToggleVersion(version)"
@blur="
() => {
if (!holdingShift) anchorVersion = ''
}
"
>
{{ version }}
</button>
</ButtonStyled>
</div>
</div>
<span v-if="!filteredVersions.length">No versions found.</span>
</div>
<div>Hold shift and click to select range.</div>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Chips } from '@modrinth/ui'
import { useMagicKeys } from '@vueuse/core'
import { computed, ref } from 'vue'
type GameVersion = Labrinth.Tags.v2.GameVersion
const props = defineProps<{
modelValue: string[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const keys = useMagicKeys()
const holdingShift = computed(() => keys.shift.value)
const versionType = ref<string | null>('release')
const searchQuery = ref('')
const filteredVersions = computed(() =>
props.gameVersions
.filter((v) => versionType.value === 'all' || v.version_type === versionType.value)
.filter(searchFilter),
)
const groupedGameVersions = computed(() => groupVersions(filteredVersions.value))
const allVersionsFlat = computed(() => groupedGameVersions.value.flatMap((group) => group.versions))
const anchorVersion = ref<string | null>(null)
const handleToggleVersion = (version: string) => {
const flat = allVersionsFlat.value
if (holdingShift.value && anchorVersion.value && flat.length) {
const anchorIdx = flat.indexOf(anchorVersion.value)
const targetIdx = flat.indexOf(version)
if (anchorIdx === -1 || targetIdx === -1) {
return toggleVersion(version)
}
const start = Math.min(anchorIdx, targetIdx)
const end = Math.max(anchorIdx, targetIdx)
const range = flat.slice(start, end + 1)
const isTargetSelected = props.modelValue.includes(version)
if (isTargetSelected) {
emit(
'update:modelValue',
props.modelValue.filter((v) => !range.includes(v)),
)
} else {
const newVersions = range.filter((v) => !props.modelValue.includes(v))
emit('update:modelValue', [...props.modelValue, ...newVersions])
}
anchorVersion.value = ''
return
}
toggleVersion(version)
anchorVersion.value = version
}
const toggleVersion = (version: string) => {
const isSelected = props.modelValue.includes(version)
const next = isSelected
? props.modelValue.filter((v) => v !== version)
: [...props.modelValue, version]
emit('update:modelValue', next)
}
const DEV_RELEASE_KEY = 'Snapshots'
function groupVersions(gameVersions: GameVersion[]) {
gameVersions = [...gameVersions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
)
const getGroupKey = (v: string) => v.split('.').slice(0, 2).join('.')
const groups: Record<string, string[]> = {}
let currentGroupKey = getGroupKey(gameVersions.find((v) => v.major)?.version || '')
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
currentGroupKey = getGroupKey(gameVersion.version)
if (!groups[currentGroupKey]) groups[currentGroupKey] = []
groups[currentGroupKey].push(gameVersion.version)
} else {
const key = `${currentGroupKey} ${DEV_RELEASE_KEY}`
if (!groups[key]) groups[key] = []
groups[key].push(gameVersion.version)
}
})
const sortedKeys = Object.keys(groups).sort(compareGroupKeys)
return sortedKeys.map((key) => ({
key,
versions: groups[key].sort((a, b) => compareVersions(b, a)),
isReleaseGroup: !key.includes(DEV_RELEASE_KEY),
}))
}
const getBaseVersion = (key: string) => key.split(' ')[0]
function compareVersions(a: string, b: string) {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < 2; i++) {
const na = pa[i] || 0
const nb = pb[i] || 0
if (na > nb) return 1
if (na < nb) return -1
}
return 0
}
function compareGroupKeys(a: string, b: string) {
const aBase = getBaseVersion(a)
const bBase = getBaseVersion(b)
const versionSort = compareVersions(aBase, bBase)
if (versionSort !== 0) return -versionSort // descending
const isADev = a.includes(DEV_RELEASE_KEY)
const isBDev = b.includes(DEV_RELEASE_KEY)
if (isADev && !isBDev) return 1
if (!isADev && isBDev) return -1
return 0
}
function searchFilter(gameVersion: Labrinth.Tags.v2.GameVersion) {
return gameVersion.version.toLowerCase().includes(searchQuery.value.toLowerCase())
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<Combobox
v-model="projectId"
placeholder="Select project"
:options="options"
:searchable="true"
search-placeholder="Search by name, slug, or paste ID..."
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
@search-input="(query) => handleSearch(query)"
/>
</template>
<script lang="ts" setup>
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import { useDebounceFn } from '@vueuse/core'
import { defineAsyncComponent, h } from 'vue'
const { addNotification } = injectNotificationManager()
const projectId = defineModel<string>()
const searchLoading = ref(false)
const options = ref<DropdownOption<string>[]>([])
const { labrinth } = injectModrinthClient()
const search = async (query: string) => {
if (!query.trim()) {
searchLoading.value = false
return
}
try {
const results = await labrinth.projects_v2.search({
query: query,
limit: 20,
facets: [['project_type:mod']],
})
options.value = results.hits.map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
} catch (error: any) {
addNotification({
title: 'An error occurred',
text: error.data ? error.data.description : error,
type: 'error',
})
}
searchLoading.value = false
}
const throttledSearch = useDebounceFn(search, 500)
const handleSearch = async (query: string) => {
searchLoading.value = true
await throttledSearch(query)
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div v-if="visibleDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<div class="flex flex-col gap-2">
<template v-for="(dependency, index) in visibleDependencies">
<SuggestedDependency
v-if="dependency"
:key="index"
:project-id="dependency.project_id"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
@on-add-suggestion="
() =>
handleAddSuggestion({
dependency_type: dependency.dependency_type,
project_id: dependency.project_id,
version_id: dependency.version_id,
})
"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import SuggestedDependency from './SuggestedDependency.vue'
export interface SuggestedDependency extends Labrinth.Versions.v3.Dependency {
icon?: string
name?: string
versionName?: string
}
const props = defineProps<{
suggestedDependencies: SuggestedDependency[]
}>()
const { draftVersion } = injectManageVersionContext()
const visibleDependencies = computed<SuggestedDependency[]>(() =>
props.suggestedDependencies
.filter(
(dep) =>
!draftVersion.value.dependencies?.some(
(d) => d.project_id === dep.project_id && d.version_id === dep.version_id,
),
)
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
)
const emit = defineEmits<{
(e: 'onAddSuggestion', dependency: Labrinth.Versions.v3.Dependency): void
}>()
function handleAddSuggestion(dependency: Labrinth.Versions.v3.Dependency) {
emit('onAddSuggestion', dependency)
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
<span v-tooltip="name || 'Unknown Project'" class="truncate font-semibold text-contrast">
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
{{ dependencyType }}
</TagItem>
</div>
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
<PlusIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, TagItem } from '@modrinth/ui'
const emit = defineEmits<{
(e: 'onAddSuggestion'): void
}>()
const { name, icon, dependencyType, versionName } = defineProps<{
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
}>()
function emitAddSuggestion() {
emit('onAddSuggestion')
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
>
<div class="flex items-center gap-2 overflow-hidden">
<div class="grid h-5 min-h-5 w-5 min-w-5 place-content-center rounded-full bg-green">
<CheckIcon class="text-sm text-black" />
</div>
<span v-tooltip="name" class="overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{{ name }}
</span>
</div>
<div class="flex items-center gap-1">
<template v-if="!isPrimary">
<div class="w-36">
<Combobox
v-model="selectedType"
:searchable="false"
class="rounded-xl border border-solid border-surface-5 text-sm"
:options="versionTypes"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="emitFileTypeChange"
/>
</div>
</template>
<ButtonStyled v-if="onRemove" size="standard" :circular="true">
<button aria-label="Remove file" class="!shadow-none" @click="onRemove">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled v-if="isPrimary" size="standard" :circular="true">
<button
v-tooltip="
editingVersion
? 'Primary file cannot be changed after version is uploaded'
: 'Replace primary file'
"
aria-label="Change primary file"
class="!shadow-none"
:disabled="editingVersion"
@click="primaryFileInput?.click()"
>
<ArrowLeftRightIcon aria-hidden="true" />
<input
ref="primaryFileInput"
class="hidden"
type="file"
:accept="acceptFileFromProjectType(projectV2.project_type)"
:disabled="editingVersion"
@change="
(e) => {
emit('setPrimaryFile', (e.target as HTMLInputElement)?.files?.[0])
;(e.target as HTMLInputElement).value = ''
}
"
/>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftRightIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectProjectPageContext } from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import { acceptFileFromProjectType } from '@modrinth/utils'
const { projectV2 } = injectProjectPageContext()
const emit = defineEmits<{
(e: 'setPrimaryFile', file?: File): void
(e: 'setFileType', type: Labrinth.Versions.v3.FileType): void
}>()
const { name, isPrimary, onRemove, initialFileType, editingVersion } = defineProps<{
name: string
isPrimary: boolean
onRemove?: () => void
initialFileType?: Labrinth.Versions.v3.FileType | 'primary'
editingVersion: boolean
}>()
const selectedType = ref<Labrinth.Versions.v3.FileType | 'primary'>(initialFileType || 'unknown')
const primaryFileInput = ref<HTMLInputElement>()
const versionTypes = [
!editingVersion && { class: 'text-sm', value: 'primary', label: 'Primary' },
{ class: 'text-sm', value: 'unknown', label: 'Other' },
{ class: 'text-sm', value: 'required-resource-pack', label: 'Required RP' },
{ class: 'text-sm', value: 'optional-resource-pack', label: 'Optional RP' },
{ class: 'text-sm', value: 'sources-jar', label: 'Sources JAR' },
{ class: 'text-sm', value: 'dev-jar', label: 'Dev JAR' },
{ class: 'text-sm', value: 'javadoc-jar', label: 'Javadoc JAR' },
{ class: 'text-sm', value: 'signature', label: 'Signature' },
].filter(Boolean) as DropdownOption<Labrinth.Versions.v3.FileType | 'primary'>[]
function emitFileTypeChange() {
if (selectedType.value === 'primary') emit('setPrimaryFile')
else emit('setFileType', selectedType.value)
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<MarkdownEditor
v-model="draftVersion.changelog"
:on-image-upload="onImageUpload"
:max-height="500"
/>
</template>
<script lang="ts" setup>
import { MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
async function onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
return response.url
}
</script>

View File

@@ -0,0 +1,283 @@
<template>
<div class="flex w-full max-w-full flex-col gap-6">
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Add dependency</span>
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast">Project <span class="text-red">*</span></span>
<ModSelect v-model="newDependencyProjectId" />
</div>
<template v-if="newDependencyProjectId">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Version </span>
<Combobox
v-model="newDependencyVersionId"
placeholder="Select version"
:options="[{ label: 'Any version', value: null }, ...newDependencyVersions]"
:searchable="true"
/>
</div>
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Dependency relation </span>
<Combobox
v-model="newDependencyType"
placeholder="Select dependency type"
:options="[
{ label: 'Required', value: 'required' },
{ label: 'Optional', value: 'optional' },
{ label: 'Incompatible', value: 'incompatible' },
{ label: 'Embedded', value: 'embedded' },
]"
/>
</div>
<ButtonStyled>
<button
class="self-start"
:disabled="!newDependencyProjectId"
@click="
() =>
addDependency(
toRaw({
project_id: newDependencyProjectId,
version_id: newDependencyVersionId || undefined,
dependency_type: newDependencyType,
}),
)
"
>
Add Dependency
</button>
</ButtonStyled>
</template>
</div>
</div>
<SuggestedDependencies
:suggested-dependencies="suggestedDependencies"
@on-add-suggestion="handleAddSuggestedDependency"
/>
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Added dependencies</span>
<div class="5 flex flex-col gap-2">
<template v-for="(dependency, index) in addedDependencies">
<AddedDependencyRow
v-if="dependency"
:key="index"
:project-id="dependency.projectId"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
@remove="() => removeDependency(index)"
/>
</template>
<span v-if="!addedDependencies.length"> No dependencies added. </span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import ModSelect from '~/components/ui/create-project-version/components/ModSelect.vue'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import AddedDependencyRow from '../components/AddedDependencyRow.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
const errorNotification = (err: any) => {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
const newDependencyProjectId = ref<string>()
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
const newDependencyVersionId = ref<string | null>(null)
const newDependencyVersions = ref<DropdownOption<string>[]>([])
const projectsFetchLoading = ref(false)
const suggestedDependencies = ref<
Array<Labrinth.Versions.v3.Dependency & { name?: string; icon?: string; versionName?: string }>
>([])
// reset to defaults when select different project
watch(newDependencyProjectId, async () => {
newDependencyVersionId.value = null
newDependencyType.value = 'required'
if (!newDependencyProjectId.value) {
newDependencyVersions.value = []
} else {
try {
const versions = await labrinth.versions_v3.getProjectVersions(newDependencyProjectId.value)
newDependencyVersions.value = versions.map((version) => ({
label: version.name,
value: version.id,
}))
} catch (error: any) {
errorNotification(error)
}
}
})
const { draftVersion, dependencyProjects, dependencyVersions, getProject, getVersion } =
injectManageVersionContext()
const { projectV2: project } = injectProjectPageContext()
const getSuggestedDependencies = async () => {
try {
suggestedDependencies.value = []
if (!draftVersion.value.game_versions?.length || !draftVersion.value.loaders?.length) {
return
}
try {
const versions = await labrinth.versions_v3.getProjectVersions(project.value.id, {
loaders: draftVersion.value.loaders,
})
// Get the most recent matching version and extract its dependencies
if (versions.length > 0) {
const mostRecentVersion = versions[0]
for (const dep of mostRecentVersion.dependencies) {
suggestedDependencies.value.push({
project_id: dep.project_id,
version_id: dep.version_id,
dependency_type: dep.dependency_type,
file_name: dep.file_name,
})
}
}
} catch (error: any) {
console.error(`Failed to get versions for project ${project.value.id}:`, error)
}
for (const dep of suggestedDependencies.value) {
try {
if (dep.project_id) {
const proj = await getProject(dep.project_id)
dep.name = proj.name
dep.icon = proj.icon_url
}
if (dep.version_id) {
const version = await getVersion(dep.version_id)
dep.versionName = version.name
}
} catch (error: any) {
console.error(`Failed to fetch project/version data for dependency:`, error)
}
}
} catch (error: any) {
errorNotification(error)
}
}
onMounted(() => {
getSuggestedDependencies()
})
watch(
draftVersion,
async (draftVersion) => {
const deps = draftVersion.dependencies || []
for (const dep of deps) {
if (dep?.project_id) {
try {
await getProject(dep.project_id)
} catch (error: any) {
errorNotification(error)
}
}
if (dep?.version_id) {
try {
await getVersion(dep.version_id)
} catch (error: any) {
errorNotification(error)
}
}
}
projectsFetchLoading.value = false
},
{ immediate: true, deep: true },
)
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
if (!dep.project_id) return null
const dependencyProject = dependencyProjects.value[dep.project_id]
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
if (!dependencyProject && projectsFetchLoading.value) return null
return {
projectId: dep.project_id,
name: dependencyProject?.name,
icon: dependencyProject?.icon_url,
dependencyType: dep.dependency_type,
versionName,
}
})
.filter(Boolean),
)
const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
// already added
if (
draftVersion.value.dependencies.find(
(d) => d.project_id === dependency.project_id && d.version_id === dependency.version_id,
)
) {
addNotification({
title: 'Dependency already added',
text: 'You cannot add the same dependency twice.',
type: 'error',
})
return
}
projectsFetchLoading.value = true
draftVersion.value.dependencies.push(dependency)
newDependencyProjectId.value = undefined
}
const removeDependency = (index: number) => {
if (!draftVersion.value.dependencies) return
draftVersion.value.dependencies.splice(index, 1)
}
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
draftVersion.value.dependencies?.push({
project_id: dependency.project_id,
version_id: dependency.version_id,
dependency_type: dependency.dependency_type,
})
}
</script>

View File

@@ -0,0 +1,269 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
:items="['release', 'alpha', 'beta']"
:never-empty="true"
:capitalize="true"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
</span>
<input
id="version-number"
v-model="draftVersion.version_number"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
type="text"
autocomplete="off"
maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<input
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
type="text"
autocomplete="off"
maxlength="32"
/>
</div>
<template v-if="!noLoadersProject && (inferredVersionData?.loaders?.length || editingVersion)">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editLoaders"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersion.loaders.map((selectedLoader) =>
loaders.find((loader) => selectedLoader === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
class="border !border-solid border-surface-5 hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
</template>
<template v-if="inferredVersionData?.game_versions?.length || editingVersion">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
class="border !border-solid border-surface-5 hover:no-underline"
>
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
</div>
</div>
</div>
</template>
<template
v-if="
!noEnvironmentProject &&
((!editingVersion && inferredVersionData?.environment) ||
(editingVersion && draftVersion.environment))
"
>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Environment </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<div v-if="draftVersion.environment" class="flex flex-col gap-1">
<div class="font-semibold text-contrast">
{{ environmentCopy.title }}
</div>
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { EditIcon } from '@modrinth/assets'
import { ButtonStyled, Chips, TagItem } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const {
draftVersion,
inferredVersionData,
projectType,
editingVersion,
noLoadersProject,
noEnvironmentProject,
modal,
} = injectManageVersionContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const isModpack = computed(() => projectType.value === 'modpack')
const editLoaders = () => {
modal.value?.setStage('edit-loaders')
}
const editVersions = () => {
modal.value?.setStage('edit-mc-versions')
}
const editEnvironment = () => {
modal.value?.setStage('edit-environment')
}
const usingDetectedVersions = computed(() => {
if (!inferredVersionData.value?.game_versions) return false
const versionsMatch =
draftVersion.value.game_versions.length === inferredVersionData.value.game_versions.length &&
draftVersion.value.game_versions.every((version) =>
inferredVersionData.value?.game_versions?.includes(version),
)
return versionsMatch
})
const usingDetectedLoaders = computed(() => {
if (!inferredVersionData.value?.loaders) return false
const loadersMatch =
draftVersion.value.loaders.length === inferredVersionData.value.loaders.length &&
draftVersion.value.loaders.every((loader) =>
inferredVersionData.value?.loaders?.includes(loader),
)
return loadersMatch
})
const environmentCopy = computed(() => {
const emptyMessage = {
title: 'No environment set',
description: 'The environment for this version has not been specified.',
}
if (!draftVersion.value.environment) return emptyMessage
const envCopy: Record<string, { title: string; description: string }> = {
client_only: {
title: 'Client-side only',
description: 'All functionality is done client-side and is compatible with vanilla servers.',
},
server_only: {
title: 'Server-side only',
description: 'All functionality is done server-side and is compatible with vanilla clients.',
},
singleplayer_only: {
title: 'Singleplayer only',
description: 'Only functions in Singleplayer or when not connected to a Multiplayer server.',
},
dedicated_server_only: {
title: 'Server-side only',
description: 'All functionality is done server-side and is compatible with vanilla clients.',
},
client_and_server: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_only_server_optional: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
server_only_client_optional: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_or_server: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_or_server_prefers_both: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
unknown: {
title: 'Unknown environment',
description: 'The environment for this version could not be determined.',
},
}
return (
envCopy[draftVersion.value.environment] || {
title: 'Unknown environment',
description: `The environment: "${draftVersion.value.environment}" is not recognized.`,
}
)
})
</script>

View File

@@ -0,0 +1,11 @@
<template>
<ProjectSettingsEnvSelector v-model="draftVersion.environment" />
</template>
<script lang="ts" setup>
import { ProjectSettingsEnvSelector } from '@modrinth/ui'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
</script>

View File

@@ -0,0 +1,240 @@
<template>
<div class="flex w-full flex-col gap-4">
<template v-if="!(filesToAdd.length || draftVersion.existing_files?.length)">
<DropzoneFileInput
aria-label="Upload file"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
@change="handleNewFiles"
/>
</template>
<template v-else>
<div class="flex flex-col gap-2">
<span class="text-base font-semibold text-contrast">Primary file</span>
<div class="flex flex-col gap-2.5">
<VersionFileRow
v-if="primaryFile"
:key="primaryFile.name"
:name="primaryFile.name"
:is-primary="true"
:editing-version="editingVersion"
:on-remove="undefined"
@set-primary-file="
(file) => {
if (file && !editingVersion) filesToAdd[0] = { file }
}
"
/>
</div>
<span>
The primary file is the default file a user downloads when installing the project.
</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<Admonition v-if="hasSupplementaryFiles" type="warning">
{{ formatMessage(messages.addFilesAdmonition) }}
</Admonition>
<span class="text-base font-semibold text-contrast">Supplementary files</span>
<DropzoneFileInput
aria-label="Upload additional file"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
size="small"
:primary-prompt="null"
secondary-prompt="Drag and drop files or click to browse"
@change="handleNewFiles"
/>
<div v-if="hasSupplementaryFiles" class="flex flex-col gap-2.5">
<VersionFileRow
v-for="versionFile in supplementaryExistingFiles"
:key="versionFile.filename"
:name="versionFile.filename"
:is-primary="false"
:initial-file-type="versionFile.file_type"
:editing-version="editingVersion"
:on-remove="() => handleRemoveExistingFile(versionFile.hashes.sha1 || '')"
@set-file-type="(type) => (versionFile.file_type = type)"
/>
<VersionFileRow
v-for="(versionFile, idx) in supplementaryNewFiles"
:key="versionFile.file.name"
:name="versionFile.file.name"
:is-primary="false"
:initial-file-type="versionFile.fileType"
:editing-version="editingVersion"
:on-remove="() => handleRemoveFile(idx + (primaryFile?.existing ? 0 : 1))"
@set-file-type="(type) => (versionFile.fileType = type)"
@set-primary-file="handleSetPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
/>
</div>
</div>
<span>
You can optionally add supplementary files such as source code, documentation, or required
resource packs.
</span>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { Admonition, DropzoneFileInput, injectProjectPageContext } from '@modrinth/ui'
import { acceptFileFromProjectType } from '@modrinth/utils'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import VersionFileRow from '../components/VersionFileRow.vue'
const { projectV2 } = injectProjectPageContext()
const { formatMessage } = useVIntl()
const {
draftVersion,
filesToAdd,
existingFilesToDelete,
setPrimaryFile,
setInferredVersionData,
setProjectType,
editingVersion,
projectType,
} = injectManageVersionContext()
const addDetectedData = async () => {
if (editingVersion.value) return
const primaryFile = filesToAdd.value[0]?.file
if (!primaryFile) return
try {
const inferredData = await setInferredVersionData(primaryFile, projectV2.value)
const mappedInferredData: Partial<Labrinth.Versions.v3.DraftVersion> = {
...inferredData,
name: inferredData.name || '',
}
draftVersion.value = {
...draftVersion.value,
...mappedInferredData,
}
setProjectType(projectV2.value, primaryFile)
} catch (err) {
console.error('Error parsing version file data', err)
}
if (projectType.value === 'resourcepack') {
draftVersion.value.loaders = ['minecraft']
}
}
// add detected data when the primary file changes
watch(
() => filesToAdd.value[0]?.file,
() => addDetectedData(),
)
function handleNewFiles(newFiles: File[]) {
// detect primary file if no primary file is set
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
newFiles.forEach((file) => filesToAdd.value.push({ file }))
if (primaryFileIndex !== null) {
if (primaryFileIndex) setPrimaryFile(primaryFileIndex)
}
}
function handleRemoveFile(index: number) {
filesToAdd.value.splice(index, 1)
}
function detectPrimaryFileIndex(files: File[]): number {
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
for (const ext of extensionPriority) {
const matches = files.filter((file) => file.name.toLowerCase().endsWith(ext))
if (matches.length > 0) {
const shortest = matches.reduce((a, b) => (a.name.length < b.name.length ? a : b))
return files.indexOf(shortest)
}
}
return 0
}
function handleRemoveExistingFile(sha1: string) {
existingFilesToDelete.value.push(sha1)
draftVersion.value.existing_files = draftVersion.value.existing_files?.filter(
(file) => file.hashes.sha1 !== sha1,
)
}
function handleSetPrimaryFile(index: number) {
setPrimaryFile(index)
}
interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
const supplementaryNewFiles = computed(() => {
if (primaryFile.value?.existing) {
return filesToAdd.value
} else {
return filesToAdd.value.slice(1)
}
})
const supplementaryExistingFiles = computed(() => {
if (primaryFile.value?.existing) {
return draftVersion.value.existing_files?.slice(1)
} else {
return draftVersion.value.existing_files
}
})
const hasSupplementaryFiles = computed(
() => filesToAdd.value.length + (draftVersion.value.existing_files?.length || 0) > 1,
)
const messages = defineMessages({
addFilesAdmonition: {
id: 'create-project-version.create-modal.stage.add-files.admonition',
defaultMessage:
'Supplementary files are for supporting resources like source code, not for alternative versions or variants.',
},
})
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="space-y-6">
<LoaderPicker
v-model="draftVersion.loaders"
:loaders="generatedState.loaders"
:toggle-loader="toggleLoader"
/>
<div v-if="draftVersion.loaders.length" class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Added loaders </span>
<ButtonStyled type="transparent" size="standard">
<button @click="onClearAll()">Clear all</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersion.loaders.map((loaderName) =>
loaders.find((loader) => loaderName === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
:action="() => toggleLoader(loader.name)"
class="border !border-solid border-surface-5 !transition-all hover:bg-button-bgHover hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
<XIcon class="text-secondary" />
</TagItem>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { XIcon } from '@modrinth/assets'
import { ButtonStyled, injectProjectPageContext, TagItem } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import LoaderPicker from '../components/LoaderPicker.vue'
const generatedState = useGeneratedState()
const { projectV2 } = injectProjectPageContext()
const loaders = computed(() => generatedState.value.loaders)
const { draftVersion, setProjectType } = injectManageVersionContext()
const toggleLoader = (loader: string) => {
if (draftVersion.value.loaders.includes(loader)) {
draftVersion.value.loaders = draftVersion.value.loaders.filter((l) => l !== loader)
} else {
draftVersion.value.loaders = [...draftVersion.value.loaders, loader]
}
setProjectType(projectV2.value)
}
const onClearAll = () => {
draftVersion.value.loaders = []
setProjectType(projectV2.value)
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="flex flex-col gap-6">
<McVersionPicker v-model="draftVersion.game_versions" :game-versions="gameVersions" />
<div v-if="draftVersion.game_versions.length" class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Added versions </span>
<ButtonStyled type="transparent" size="standard">
<button @click="clearAllVersions()">Clear all</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template v-if="draftVersion.game_versions.length">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
:action="() => toggleVersion(version)"
class="border !border-solid border-surface-5 !transition-all hover:bg-button-bgHover hover:no-underline"
>
{{ version }}
<XIcon />
</TagItem>
</template>
<template v-else>
<span>No versions selected.</span>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { XIcon } from '@modrinth/assets'
import { ButtonStyled, TagItem } from '@modrinth/ui'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import McVersionPicker from '../components/McVersionPicker.vue'
const generatedState = useGeneratedState()
const gameVersions = generatedState.value.gameVersions
const { draftVersion } = injectManageVersionContext()
const toggleVersion = (version: string) => {
if (draftVersion.value.game_versions.includes(version)) {
draftVersion.value.game_versions = draftVersion.value.game_versions.filter((v) => v !== version)
} else {
draftVersion.value.game_versions.push(version)
}
}
const clearAllVersions = () => {
draftVersion.value.game_versions = []
}
</script>

View File

@@ -247,13 +247,7 @@ async function createProject() {
})
modal.value.hide()
await router.push({
name: 'type-id',
params: {
type: 'project',
id: slug.value,
},
})
await router.push(`/project/${slug.value}/settings`)
} catch (err) {
addNotification({
title: formatMessage(messages.errorTitle),