Files
AstralRinth/packages/ui/src/components/project/settings/environment/ProjectSettingsEnvSelector.vue
Truman Gao 9958600121 feat: managing project versions (#4811)
* start modal, working show modal

* add stages and implement MultiModalStage component

* add project versions context and add file button

* implement add files stage

* export interfaces

* move MultiStageModal to /base

* small update to file input

* add version types to api-client

* wrap version namespace under v3

* implement add details stage fields and loaders component

* start create MC versions stage

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

* implement loader picker with grouping

* improve grouping and sorting for loader picker

* use chips component

* small updaets

* fix loader icon color

* componentize mc version picker

* initial version of shift click to select range

* use newModal for markdown editor

* start add dependencies stage with search

* implement showing mod options in search

* componentize modselect and add version/dependency relation select

* hide version and dependency relation when no project selected

* fix project facet search

* implement api-client versions requests

* fix search api request facet type to be string

* fix new modal outer container scroll

* implement add dependency stage

* fix parse error

* add placeholders

* fix types

* update dependency row styles

* small change

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

* fix create version file parts

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

* use draft version ref for mc versions select

* implement reactive modal state and conditionally disabled next buttons

* ensure all data is using draftVersion ref

* remove shift click to select range since it sucks

* fix up add dependencies stage state/types

* small fixes

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

* add final create version button config

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

* set add files stage width

* remove version file upload in project page

* small fix

* fix create version api call

* implement error handling

* implement mc versions search

* implement showing all mc versions

* small fix

* implement prefill data

* add success notification

* add cancel button

* add new dropzone file input

* run pnpm run fix

* add tailwind preset in ui package

* polish file version row

* fix modal widths

* hide added versions when no versions added

* implement add loaders stage

* implement small chips and small fixes

* implement grouping for all releases

* implement new all releases grouping

* implement better shift click for version select

* small fixes

* fix search input style

* delete versions provider and start project type inferring

* implement getting project type

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

* implement create version in project versions table

* update side nav

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

* add id to stages and fix calling setStage not working

* move added loaded out of loader picker

* remove selected and detected MC versions

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

* fix components in ref

* fix width on dropdown

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

* fix mc version text colour

* do proper clean up

* update loaders to use tag item

* update UI to use TagItem and better match styles

* handle detected data when setting primary file

* add progress bar

* hide progress bar for non-progress stage

* add loading state on submit

* properly cache dependencies projects/versions

* pnpm run fix

* add dragover show purple border on dropzone file input

* better handle added dependencies

* move versions in side nav

* implement adding file type

* fix api body format for file type

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

* a step towards proper versions refresh

* add gallery to project settings

* actually figured out refresh versions

* move checklist into settings page

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

* remove edit and delete buttons from gallery in project page

* add empty state messages for project page

* add default scroll bar styles

* implement support for new file types

* remove edit from dropdown in project page versions table

* redirect to settings page

* move changelog to row with actions

* fix overflow on added dependencies

* fix redirect

* update scroll styles

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

* small style fixes

* small spacing fix

* small style fixes

* add a flag for loading dependency projects

* address PR comments

* fix modrinth ui imports

* use magic keys instead of window.addeventlistener

* add spacing in bottom of settings page

* useDebounceFn from vue

* fix inconsistent stroke

* persist scroll through

* fix remove button

* fix api fields

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

* fix links in nags

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

* implement suggested dependencies components

* implement suggested dependencies api call

* refactor cached get project and get version calls

* always hide environments

* update links

* set scroll in 10ms

* update links

* fix links pt2

* fix shadow

* fix progress bar

* dont include mc versions in suggested versions finder

* fix text overflow styles

* use tooltip

* fix change version name api

* implement set environment api call

* delete unused vue pages

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

* small fix

* no loaders project wrong check

* fix not having 'minecraft' loader for resource pack

* implement updating existing files file type

* move add minecraft loader outside try catch

* add datapack to have environment

* fix being able to select duplicate MC versions

* remove datapack project from environment

* fix version fetch

* fix having detected environment not properly skipping step

* only add detected data when primary file changes

* fix unknown environemtn

* implement gallery and versions have moved admonition

* update project page for creator view

* small copy update

* merge fixes

* pnpm run fix

* fix checkmark squished

* fix version type can be deselected

* refactor: DI context + better typed MultistageModal

* fix type import

* Misc QA fixes

* fix allowed file types with no project type

* implement new add files stage

* fix versiosn header with new pagination

* hide buttons when no files for add file stage

* use prettier formatter

* allow signature file types

* add detecting primary file

* fix progress bar in firefox

* fix environment not correctly being hidden/shown

* remove environment missing nag

* temp bring back environment page

* remove delete version action from project page

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

* fix types

* pnpm run fix

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

* copy updates

* small update on version num placeholder

* update placeholder

* make timeout on upload routes 2 minutes

* fix lint issues

* run pnpm intl:extract

---------

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

308 lines
8.6 KiB
Vue

<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import { commonProjectSettingsMessages } from '../../../../utils'
import LargeRadioButton from '../../../base/LargeRadioButton.vue'
const { formatMessage } = useVIntl()
const value = defineModel<Labrinth.Projects.v3.Environment | undefined>({ required: true })
withDefaults(
defineProps<{
disabled?: boolean
}>(),
{
disabled: false,
},
)
type EnvironmentRadioOption = {
title: MessageDescriptor
description?: MessageDescriptor
}
const subOptionLabel = defineMessage({
id: 'project.settings.environment.suboption.accessibility-suboption-group-label',
defaultMessage: 'Suboptions of {option}',
})
const optionLabelFormat = defineMessage({
id: 'project.settings.environment.suboption.accessibility-option-label',
defaultMessage: '{title}: {description}',
})
const OUTER_OPTIONS = {
client: {
title: defineMessage({
id: 'project.settings.environment.client_only.title',
defaultMessage: 'Client-side only',
}),
description: defineMessage({
id: 'project.settings.environment.client_only.description',
defaultMessage:
'All functionality is done client-side and is compatible with vanilla servers.',
}),
suboptions: {},
},
server: {
title: defineMessage({
id: 'project.settings.environment.server_only.title',
defaultMessage: 'Server-side only',
}),
description: defineMessage({
id: 'project.settings.environment.server_only.description',
defaultMessage:
'All functionality is done server-side and is compatible with vanilla clients.',
}),
suboptions: {
singleplayer: {
title: defineMessage({
id: 'project.settings.environment.server_only.supports_singleplayer.title',
defaultMessage: 'Works in singleplayer too',
}),
},
dedicated: {
title: defineMessage({
id: 'project.settings.environment.server_only.dedicated_only.title',
defaultMessage: 'Dedicated server only',
}),
},
},
},
client_and_server: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.title',
defaultMessage: 'Client and server',
}),
description: defineMessage({
id: 'project.settings.environment.client_and_server.description',
defaultMessage:
'Has some functionality on both the client and server, even if only partially.',
}),
suboptions: {
required_both: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.required_both.title',
defaultMessage: 'Required on both',
}),
},
optional_client: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_client.title',
defaultMessage: 'Optional on client',
}),
},
optional_server: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_server.title',
defaultMessage: 'Optional on server',
}),
},
optional_both_prefers_both: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_both_prefers_both.title',
defaultMessage: 'Optional on both, works best when installed on both sides',
}),
},
optional_both: {
title: defineMessage({
id: 'project.settings.environment.client_and_server.optional_both.title',
defaultMessage: 'Optional on both, works the same if installed on either side',
}),
},
},
},
singleplayer: {
title: defineMessage({
id: 'project.settings.environment.singleplayer.title',
defaultMessage: 'Singleplayer only',
}),
description: defineMessage({
id: 'project.settings.environment.singleplayer.description',
defaultMessage: `Only functions in Singleplayer or when not connected to a Multiplayer server.`,
}),
suboptions: {},
},
} as const satisfies Record<
string,
EnvironmentRadioOption & { suboptions: Record<string, EnvironmentRadioOption> }
>
type OuterOptionKey = keyof typeof OUTER_OPTIONS
type SubOptionKey = ValidKeys<(typeof OUTER_OPTIONS)[keyof typeof OUTER_OPTIONS]['suboptions']>
const currentOuterOption = ref<OuterOptionKey>()
const currentSubOption = ref<SubOptionKey>()
const computedOption = computed<Labrinth.Projects.v3.Environment>(() => {
switch (currentOuterOption.value) {
case 'client':
return 'client_only'
case 'server':
switch (currentSubOption.value) {
case 'singleplayer':
return 'server_only'
case 'dedicated':
return 'dedicated_server_only'
default:
return 'unknown'
}
case 'client_and_server':
switch (currentSubOption.value) {
case 'required_both':
return 'client_and_server'
case 'optional_client':
return 'server_only_client_optional'
case 'optional_server':
return 'client_only_server_optional'
case 'optional_both_prefers_both':
return 'client_or_server_prefers_both'
case 'optional_both':
return 'client_or_server'
default:
return 'unknown'
}
case 'singleplayer':
return 'singleplayer_only'
default:
return 'unknown'
}
})
function loadEnvironmentValues(env?: Labrinth.Projects.v3.Environment) {
switch (env) {
case 'client_and_server':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'required_both'
break
case 'client_only':
currentOuterOption.value = 'client'
currentSubOption.value = undefined
break
case 'client_only_server_optional':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_server'
break
case 'singleplayer_only':
currentOuterOption.value = 'singleplayer'
currentSubOption.value = undefined
break
case 'server_only':
currentOuterOption.value = 'server'
currentSubOption.value = 'singleplayer'
break
case 'server_only_client_optional':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_client'
break
case 'dedicated_server_only':
currentOuterOption.value = 'server'
currentSubOption.value = 'dedicated'
break
case 'client_or_server':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_both'
break
case 'client_or_server_prefers_both':
currentOuterOption.value = 'client_and_server'
currentSubOption.value = 'optional_both_prefers_both'
break
default:
currentOuterOption.value = undefined
currentSubOption.value = undefined
break
}
}
// Keep parent in sync when local radio selections change
watch(computedOption, (newValue) => {
if (value.value !== newValue) {
value.value = newValue
}
})
// Keep local selections in sync when parent model changes
watch(
() => value.value,
(newVal) => {
loadEnvironmentValues(newVal)
},
{ immediate: true },
)
const simulateSave = ref(false)
</script>
<template>
<div role="radiogroup" :aria-label="formatMessage(commonProjectSettingsMessages.environment)">
<template
v-for="({ title, description, suboptions }, key, index) in OUTER_OPTIONS"
:key="`env-option-${key}`"
>
<LargeRadioButton
class="!w-full"
:class="{ 'mt-2': index > 0 }"
:selected="currentOuterOption === key"
:disabled="disabled"
:aria-label="
formatMessage(optionLabelFormat, {
title: formatMessage(title),
description: formatMessage(description),
})
"
@select="
() => {
if (currentOuterOption !== key) {
currentSubOption = suboptions
? (Object.keys(suboptions)[0] as SubOptionKey)
: undefined
}
currentOuterOption = key
simulateSave = false
}
"
>
<span class="flex flex-col">
<span>{{ formatMessage(title) }}</span>
<span v-if="description" class="text-sm text-secondary">{{
formatMessage(description)
}}</span>
</span>
</LargeRadioButton>
<div
v-if="suboptions"
class="pl-8"
role="radiogroup"
:aria-label="formatMessage(subOptionLabel, { option: formatMessage(title) })"
>
<LargeRadioButton
v-for="(
{ title: suboptionTitle, description: suboptionDescription }, suboptionKey
) in suboptions"
:key="`env-option-${key}-${suboptionKey}`"
class="!w-full mt-2"
:class="{
'opacity-50': currentOuterOption !== key,
}"
:selected="currentSubOption === suboptionKey"
:disabled="disabled"
@select="
() => {
currentOuterOption = key
currentSubOption = suboptionKey
}
"
>
<span class="flex flex-col">
<span>{{ formatMessage(suboptionTitle) }}</span>
<span v-if="suboptionDescription" class="text-sm text-secondary">{{
formatMessage(suboptionDescription)
}}</span>
</span>
</LargeRadioButton>
</div>
</template>
</div>
</template>