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

@@ -58,6 +58,7 @@
"tailwindcss": "^3.4.4",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0",

View File

@@ -32,6 +32,7 @@
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite-svg-loader": "^5.1.0",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.0.24"
},
"dependencies": {

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),

View File

@@ -437,6 +437,9 @@
"common.yes": {
"message": "Yes"
},
"create-project-version.create-modal.stage.add-files.admonition": {
"message": "Supplementary files are for supporting resources like source code, not for alternative versions or variants."
},
"create.collection.cancel": {
"message": "Cancel"
},
@@ -2246,12 +2249,6 @@
"project.status.archived.message": {
"message": "{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project."
},
"project.version.all-versions": {
"message": "All versions"
},
"project.version.back-to-versions": {
"message": "Back to versions"
},
"project.versions.title": {
"message": "Versions"
},

View File

@@ -32,10 +32,7 @@
:versions="versions"
:current-member="currentMember"
:is-settings="route.name.startsWith('type-id-settings')"
:route-name="route.name"
:set-processing="setProcessing"
:collapsed="collapsedChecklist"
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
:all-members="allMembers"
:update-members="updateMembers"
:auth="auth"
@@ -55,6 +52,7 @@
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
@@ -447,14 +445,34 @@
<div class="normal-page__header relative my-4">
<ProjectHeader :project="project" :member="!!currentMember">
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold"
>
<SettingsIcon aria-hidden="true" />
Edit project
</nuxt-link>
</ButtonStyled>
<div class="hidden sm:contents">
<ButtonStyled
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
size="large"
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
? `standard`
: `brand`
"
:circular="auth.user && currentMember"
>
<button @click="(event) => downloadModal.show(event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(commonMessages.downloadButton) }}
{{
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
}}
</button>
</ButtonStyled>
</div>
@@ -641,14 +659,7 @@
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-if="auth.user && currentMember" size="large" circular>
<nuxt-link
v-tooltip="formatMessage(commonMessages.settingsLabel)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
>
<SettingsIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
@@ -903,6 +914,7 @@
v-model:organization="organization"
:current-member="currentMember"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
@@ -1446,6 +1458,7 @@ let project,
resetMembers,
dependencies,
versions,
resetVersions,
organization,
resetOrganization,
projectV2Error,
@@ -1459,7 +1472,7 @@ try {
{ data: projectV3, error: projectV3Error, refresh: resetProjectV3 },
{ data: allMembers, error: membersError, refresh: resetMembers },
{ data: dependencies, error: dependenciesError },
{ data: versions, error: versionsError },
{ data: versions, error: versionsError, refresh: resetVersions },
{ data: organization, refresh: resetOrganization },
] = await Promise.all([
useAsyncData(`project/${projectId.value}`, () => useBaseFetch(`project/${projectId.value}`), {
@@ -1917,6 +1930,7 @@ provideProjectPageContext({
projectV2: project,
projectV3,
refreshProject: resetProject,
refreshVersions: resetVersions,
currentMember,
})
</script>

View File

@@ -195,28 +195,36 @@
</div>
</div>
</div>
<div v-if="currentMember" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
prompt="Upload an image"
aria-label="Upload an image"
class="iconified-button brand-button"
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="indicator">
<InfoIcon aria-hidden="true" /> Click to choose an image or drag one onto this page
</span>
<DropArea
:accept="acceptFileTypes"
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
@change="handleFiles"
/>
</div>
<div class="items">
<Admonition v-if="!hideGalleryAdmonition" type="info" class="mb-4">
Managing gallery has moved! You can now add and edit gallery images in the
<NuxtLink to="settings/gallery" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => $router.push('settings/gallery')"
>
<SettingsIcon />
Edit gallery
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideGalleryAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<div v-if="project.gallery.length" class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
@@ -239,40 +247,18 @@
<CalendarIcon aria-hidden="true" aria-label="Date created" />
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
</div>
<div v-if="currentMember" class="gallery-buttons input-group">
<button
class="iconified-button"
@click="
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
}
"
>
<EditIcon aria-hidden="true" />
Edit
</button>
<button
class="iconified-button"
@click="
() => {
deleteIndex = index
$refs.modal_confirm.show()
}
"
>
<TrashIcon aria-hidden="true" />
Remove
</button>
</div>
</div>
</div>
</div>
<template v-else>
<p class="ml-2">
No images in gallery. Visit
<NuxtLink to="settings/gallery">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
upload images.
</p>
</template>
</div>
</template>
@@ -280,30 +266,27 @@
import {
CalendarIcon,
ContractIcon,
EditIcon,
ExpandIcon,
ExternalIcon,
ImageIcon,
InfoIcon,
LeftArrowIcon,
PlusIcon,
RightArrowIcon,
SaveIcon,
SettingsIcon,
StarIcon,
TransferIcon,
TrashIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
NewModal as Modal,
} from '@modrinth/ui'
import { isPermission } from '~/utils/permissions.ts'
import { useLocalStorage } from '@vueuse/core'
const props = defineProps({
project: {
@@ -334,6 +317,11 @@ useSeoMeta({
ogTitle: title,
ogDescription: description,
})
const hideGalleryAdmonition = useLocalStorage(
'hideGalleryHasMovedAdmonition',
!props.project.gallery.length,
)
</script>
<script>

View File

@@ -3,12 +3,21 @@
<div v-if="project.body" class="card">
<ProjectPageDescription :description="project.body" />
</div>
<p v-else class="ml-2">
No description provided. Visit
<NuxtLink :to="`${route.fullPath}/settings/description`">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
add your description.
</p>
</section>
</template>
<script setup>
import { ProjectPageDescription } from '@modrinth/ui'
const route = useRoute()
defineProps({
project: {
type: Object,

View File

@@ -3,7 +3,6 @@ import {
AlignLeftIcon,
BookTextIcon,
ChartIcon,
GlobeIcon,
ImageIcon,
InfoIcon,
LinkIcon,
@@ -11,11 +10,17 @@ import {
UsersIcon,
VersionIcon,
} from '@modrinth/assets'
import { commonMessages, commonProjectSettingsMessages } from '@modrinth/ui'
import {
commonMessages,
commonProjectSettingsMessages,
injectNotificationManager,
} from '@modrinth/ui'
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { useLocalStorage, useScroll } from '@vueuse/core'
import { computed } from 'vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import NavStack from '~/components/ui/NavStack.vue'
const { formatMessage } = useVIntl()
@@ -25,6 +30,7 @@ defineProps<{
patchProject: any
patchIcon: any
resetProject: any
resetVersions: any
resetOrganization: any
resetMembers: any
}>()
@@ -55,15 +61,6 @@ const navItems = computed(() => {
icon: InfoIcon,
}
: null,
flags.value.newProjectEnvironmentSettings &&
projectV3.value.project_types.some((type: string) => ['mod', 'modpack'].includes(type))
? {
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
badge: formatMessage(commonMessages.newBadge),
icon: GlobeIcon,
}
: null,
{
link: `/${base}/settings/tags`,
label: formatMessage(commonProjectSettingsMessages.tags),
@@ -74,11 +71,21 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
},
{
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
icon: BookTextIcon,
},
{
link: `/${base}/settings/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
icon: ImageIcon,
},
{
link: `/${base}/settings/links`,
label: formatMessage(commonProjectSettingsMessages.links),
@@ -89,51 +96,91 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.members),
icon: UsersIcon,
},
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.view) },
{
link: `/${base}/settings/analytics`,
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
chevron: true,
},
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.upload) },
{
link: `/${base}/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
icon: ImageIcon,
chevron: true,
},
{
link: `/${base}/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
chevron: true,
},
]
return items.filter(Boolean) as any[]
})
const { addNotification } = injectNotificationManager()
const tags = useGeneratedState()
const route = useRoute()
const collapsedChecklist = useLocalStorage(`project-checklist-collapsed-${project.value.id}`, false)
async function setProcessing() {
startLoading()
try {
await useBaseFetch(`project/${project.value.id}`, {
method: 'PATCH',
body: {
status: 'processing',
},
})
project.value.status = 'processing'
} catch (err: any) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
// To persist scroll position through settings pages
// This scroll code is jank asf, if anyone has a better way please do suggest it
const scroll = useScroll(window)
watch(route, () => {
const scrollY = scroll.y.value
setTimeout(() => window.scrollTo(0, scrollY), 10)
})
</script>
<template>
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
</div>
<div class="min-w-0">
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
<div class="mb-8 flex w-full flex-col gap-4">
<ModerationProjectNags
v-if="
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
"
:project="project"
:versions="versions"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name as string"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
</div>
<div class="min-w-0">
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,810 @@
<template>
<div>
<Modal
v-if="currentMember"
ref="modal_edit_item"
:header="editIndex === -1 ? 'Upload gallery image' : 'Edit gallery item'"
>
<div class="modal-gallery universal-labels">
<div class="gallery-file-input">
<div class="file-header">
<ImageIcon aria-hidden="true" />
<strong>{{ editFile ? editFile.name : 'Current image' }}</strong>
<FileInput
v-if="editIndex === -1"
class="iconified-button raised-button"
prompt="Replace"
:accept="acceptFileTypes"
:max-size="5242880"
should-always-reset
aria-label="Replace image"
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
>
<TransferIcon aria-hidden="true" />
</FileInput>
</div>
<img
:src="
previewImage
? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url
? project.gallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
/>
</div>
<label for="gallery-image-title">
<span class="label__title">Title</span>
</label>
<input
id="gallery-image-title"
v-model="editTitle"
type="text"
maxlength="64"
placeholder="Enter title..."
/>
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
<div class="textarea-wrapper">
<textarea
id="gallery-image-desc"
v-model="editDescription"
maxlength="255"
placeholder="Enter description..."
/>
</div>
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
<input
id="gallery-image-ordering"
v-model="editOrder"
type="number"
placeholder="Enter order index..."
/>
<label for="gallery-image-featured">
<span class="label__title">Featured</span>
<span class="label__description">
A featured gallery image shows up in search and your project card. Only one gallery
image can be featured.
</span>
</label>
<button
v-if="!editFeatured"
id="gallery-image-featured"
class="iconified-button"
@click="editFeatured = true"
>
<StarIcon aria-hidden="true" />
Feature image
</button>
<button
v-else
id="gallery-image-featured"
class="iconified-button"
@click="editFeatured = false"
>
<StarIcon fill="currentColor" aria-hidden="true" />
Unfeature image
</button>
<div class="button-group">
<button class="iconified-button" @click="$refs.modal_edit_item.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
<button
v-if="editIndex === -1"
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="createGalleryItem"
>
<PlusIcon aria-hidden="true" />
Add gallery image
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="editGalleryItem"
>
<SaveIcon aria-hidden="true" />
Save changes
</button>
</div>
</div>
</Modal>
<ConfirmModal
v-if="currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this gallery image?"
description="This will remove this gallery image forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteGalleryImage"
/>
<div
v-if="expandedGalleryItem != null"
class="expanded-image-modal"
@click="expandedGalleryItem = null"
>
<div class="content">
<img
class="image"
:class="{ 'zoomed-in': zoomedIn }"
:src="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop
/>
<div class="floating" @click.stop>
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
</h2>
<p v-if="expandedGalleryItem.description">
{{ expandedGalleryItem.description }}
</p>
</div>
<div class="controls">
<div class="buttons">
<button class="close circle-button" @click="expandedGalleryItem = null">
<XIcon aria-hidden="true" />
</button>
<a
class="open circle-button"
target="_blank"
:href="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
>
<ExternalIcon aria-hidden="true" />
</a>
<button class="circle-button" @click="zoomedIn = !zoomedIn">
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
<RightArrowIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="currentMember" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
prompt="Upload an image"
aria-label="Upload an image"
class="iconified-button brand-button"
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="indicator">
<InfoIcon aria-hidden="true" /> Click to choose an image or drag one onto this page
</span>
<DropArea
:accept="acceptFileTypes"
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
@change="handleFiles"
/>
</div>
<div class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
:alt="item.title ? item.title : 'gallery-image'"
/>
</a>
<div class="gallery-body">
<div class="gallery-info">
<h2 v-if="item.title">
{{ item.title }}
</h2>
<p v-if="item.description">
{{ item.description }}
</p>
</div>
</div>
<div class="gallery-bottom">
<div class="gallery-created">
<CalendarIcon aria-hidden="true" aria-label="Date created" />
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
</div>
<div v-if="currentMember" class="gallery-buttons input-group">
<button
class="iconified-button"
@click="
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
}
"
>
<EditIcon aria-hidden="true" />
Edit
</button>
<button
class="iconified-button"
@click="
() => {
deleteIndex = index
$refs.modal_confirm.show()
}
"
>
<TrashIcon aria-hidden="true" />
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
CalendarIcon,
ContractIcon,
EditIcon,
ExpandIcon,
ExternalIcon,
ImageIcon,
InfoIcon,
LeftArrowIcon,
PlusIcon,
RightArrowIcon,
SaveIcon,
StarIcon,
TransferIcon,
TrashIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
NewModal as Modal,
} from '@modrinth/ui'
import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const title = `${props.project.title} - Gallery`
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.`
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
})
</script>
<script>
export default defineNuxtComponent({
setup() {
const { addNotification } = injectNotificationManager()
return {
addNotification,
}
},
data() {
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1,
editIndex: -1,
editTitle: '',
editDescription: '',
editFeatured: false,
editOrder: null,
editFile: null,
previewImage: null,
shouldPreventActions: false,
}
},
computed: {
acceptFileTypes() {
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
},
},
mounted() {
this._keyListener = function (e) {
if (this.expandedGalleryItem) {
e.preventDefault()
if (e.key === 'Escape') {
this.expandedGalleryItem = null
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
this.previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
this.nextImage()
}
}
}
document.addEventListener('keydown', this._keyListener.bind(this))
},
methods: {
nextImage() {
this.expandedGalleryIndex++
if (this.expandedGalleryIndex >= this.project.gallery.length) {
this.expandedGalleryIndex = 0
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
previousImage() {
this.expandedGalleryIndex--
if (this.expandedGalleryIndex < 0) {
this.expandedGalleryIndex = this.project.gallery.length - 1
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
expandImage(item, index) {
this.expandedGalleryItem = item
this.expandedGalleryIndex = index
this.zoomedIn = false
},
resetEdit() {
this.editIndex = -1
this.editTitle = ''
this.editDescription = ''
this.editFeatured = false
this.editOrder = null
this.editFile = null
this.previewImage = null
},
handleFiles(files) {
this.resetEdit()
this.editFile = files[0]
this.showPreviewImage()
this.$refs.modal_edit_item.show()
},
showPreviewImage() {
const reader = new FileReader()
if (this.editFile instanceof Blob) {
reader.readAsDataURL(this.editFile)
reader.onload = (event) => {
this.previewImage = event.target.result
}
}
},
async createGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?ext=${
this.editFile
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
})
await this.resetProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async editGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url,
)}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'PATCH',
})
await this.resetProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async deleteGalleryImage() {
startLoading()
try {
await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url,
)}`,
{
method: 'DELETE',
},
)
await this.resetProject()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
},
},
})
</script>
<style lang="scss" scoped>
.header-buttons {
display: flex;
align-items: center;
gap: 1rem;
.indicator {
display: flex;
gap: 0.5ch;
align-items: center;
color: var(--color-text-inactive);
}
}
.expanded-image-modal {
position: fixed;
z-index: 20;
overflow: auto;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000000;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
.content {
position: relative;
width: calc(100vw - 2 * var(--spacing-card-lg));
height: calc(100vh - 2 * var(--spacing-card-lg));
.circle-button {
padding: 0.5rem;
line-height: 1;
display: flex;
max-width: 2rem;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-max);
margin: 0;
box-shadow: inset 0px -1px 1px rgb(17 24 39 / 10%);
&:not(:last-child) {
margin-right: 0.5rem;
}
&:hover {
background-color: var(--color-button-bg-hover) !important;
svg {
color: var(--color-button-text-hover) !important;
}
}
&:active {
background-color: var(--color-button-bg-active) !important;
svg {
color: var(--color-button-text-active) !important;
}
}
svg {
height: 1rem;
width: 1rem;
}
}
.image {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100vw - 2 * var(--spacing-card-lg));
max-height: calc(100vh - 2 * var(--spacing-card-lg));
border-radius: var(--size-rounded-card);
&.zoomed-in {
object-fit: cover;
width: auto;
height: calc(100vh - 2 * var(--spacing-card-lg));
max-width: calc(100vw - 2 * var(--spacing-card-lg));
}
}
.floating {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-card-md);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-card-sm);
transition: opacity 0.25s ease-in-out;
opacity: 1;
padding: 2rem 2rem 0 2rem;
&:not(&:hover) {
opacity: 0.4;
.text {
transform: translateY(2.5rem) scale(0.8);
opacity: 0;
}
.controls {
transform: translateY(0.25rem) scale(0.9);
}
}
.text {
display: flex;
flex-direction: column;
max-width: 40rem;
transition:
opacity 0.25s ease-in-out,
transform 0.25s ease-in-out;
text-shadow: 1px 1px 10px #000000d4;
margin-bottom: 0.25rem;
gap: 0.5rem;
h2 {
color: var(--dark-color-text-dark);
font-size: 1.25rem;
text-align: center;
margin: 0;
}
p {
color: var(--dark-color-text);
margin: 0;
}
}
.controls {
background-color: var(--color-raised-bg);
padding: var(--spacing-card-md);
border-radius: var(--size-rounded-card);
transition:
opacity 0.25s ease-in-out,
transform 0.25s ease-in-out;
}
}
}
}
.buttons {
display: flex;
button {
margin-right: 0.5rem;
}
}
.items {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-gap: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr 1fr;
}
}
.gallery-item {
display: flex;
flex-direction: column;
padding: 0;
img {
width: 100%;
margin-top: 0;
margin-bottom: 0;
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
min-height: 10rem;
object-fit: cover;
}
.gallery-body {
width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md);
overflow-wrap: anywhere;
.gallery-info {
h2 {
margin-bottom: 0.5rem;
}
p {
margin: 0 0 0.5rem 0;
}
}
}
.gallery-thumbnail {
cursor: pointer;
img {
transition: filter 0.25s ease-in-out;
&:hover {
filter: brightness(0.7);
}
}
}
.gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
.gallery-created {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
color: var(--color-icon);
svg {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
}
.gallery-buttons {
display: flex;
}
.columns {
margin-bottom: 0.5rem;
}
}
}
.modal-gallery {
display: flex;
flex-direction: column;
.gallery-file-input {
.file-header {
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--color-button-bg);
padding: var(--spacing-card-md);
svg {
min-width: 1rem;
}
strong {
word-wrap: anywhere;
}
.iconified-button {
margin-left: auto;
}
}
img {
border-radius: 0 0 var(--size-rounded-card) var(--size-rounded-card);
width: 100%;
height: auto;
max-height: 15rem;
object-fit: contain;
background-color: #000000;
}
}
}
.brand-button {
color: var(--color-accent-contrast);
}
</style>

View File

@@ -0,0 +1,366 @@
<template>
<div>
<CreateProjectVersionModal ref="modal"></CreateProjectVersionModal>
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<ProjectPageVersions
v-if="versions.length > 0"
:project="project"
:versions="versionsWithDisplayUrl"
:show-files="flags.showVersionFilesInTable"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
:base-id="baseDropdownId"
:version-link="
(version: any) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
"
:open-modal="openModal"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
aria-label="Download"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Edit version`"
aria-label="Edit"
@click="() => handleOpenEditVersionModal(version)"
>
<EditIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
class="group-hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emit('onDownload')
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
shown: !currentMember,
},
{ divider: true, shown: !!currentMember || flags.developerMode },
{
id: 'copy-id',
action: () => {
copyToClipboard(version.id)
},
shown: !!currentMember || flags.developerMode,
},
{
id: 'copy-maven',
action: () => {
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`)
},
shown: flags.developerMode,
},
{ divider: true, shown: !!currentMember },
{
id: 'edit',
action: () => handleOpenEditVersionModal(version),
shown: !!currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal?.show()
},
shown: !!currentMember,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit>
<EditIcon aria-hidden="true" />
Edit
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectPageVersions>
<template v-if="!versions.length">
<div class="grid place-content-center py-10">
<svg
width="250"
height="200"
viewBox="0 0 250 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="h-[200px] w-[250px]"
>
<path
d="M136 64C139.866 64 143 67.134 143 71C143 74.866 139.866 78 136 78H200C203.866 78 207 81.134 207 85C207 88.866 203.866 92 200 92H222C225.866 92 229 95.134 229 99C229 102.866 225.866 106 222 106H203C199.134 106 196 109.134 196 113C196 116.866 199.134 120 203 120H209C212.866 120 216 123.134 216 127C216 130.866 212.866 134 209 134H157C156.485 134 155.983 133.944 155.5 133.839C155.017 133.944 154.515 134 154 134H63C59.134 134 56 130.866 56 127C56 123.134 59.134 120 63 120H24C20.134 120 17 116.866 17 113C17 109.134 20.134 106 24 106H64C67.866 106 71 102.866 71 99C71 95.134 67.866 92 64 92H39C35.134 92 32 88.866 32 85C32 81.134 35.134 78 39 78H79C75.134 78 72 74.866 72 71C72 67.134 75.134 64 79 64H136ZM226 120C229.866 120 233 123.134 233 127C233 130.866 229.866 134 226 134C222.134 134 219 130.866 219 127C219 123.134 222.134 120 226 120Z"
class="fill-surface-2"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M113.119 112.307C113.04 112.86 113 113.425 113 114C113 120.627 118.373 126 125 126C131.627 126 137 120.627 137 114C137 113.425 136.96 112.86 136.881 112.307H166V139C166 140.657 164.657 142 163 142H87C85.3431 142 84 140.657 84 139V112.307H113.119Z"
class="fill-surface-1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M138 112C138 119.18 132.18 125 125 125C117.82 125 112 119.18 112 112C112 111.767 112.006 111.536 112.018 111.307H84L93.5604 83.0389C93.9726 81.8202 95.1159 81 96.4023 81H153.598C154.884 81 156.027 81.8202 156.44 83.0389L166 111.307H137.982C137.994 111.536 138 111.767 138 112Z"
class="fill-surface-1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M136.098 112.955C136.098 118.502 131.129 124 125 124C118.871 124 113.902 118.502 113.902 112.955C113.902 112.775 113.908 111.596 113.918 111.419H93L101.161 91.5755C101.513 90.6338 102.489 90 103.587 90H146.413C147.511 90 148.487 90.6338 148.839 91.5755L157 111.419H136.082C136.092 111.596 136.098 112.775 136.098 112.955Z"
fill="#27292E"
class="fill-surface-3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.25 111.512V138C85.25 138.966 86.0335 139.75 87 139.75H163C163.966 139.75 164.75 138.966 164.75 138V111.512L155.255 83.4393C155.015 82.7285 154.348 82.25 153.598 82.25H96.4023C95.6519 82.25 94.985 82.7285 94.7446 83.4393L85.25 111.512Z"
stroke-width="2.5"
class="stroke-surface-4"
/>
<path
d="M98 111C101.937 111 106.185 111 110.745 111C112.621 111 112.621 112.319 112.621 113C112.621 119.627 118.117 125 124.897 125C131.677 125 137.173 119.627 137.173 113C137.173 112.319 137.173 111 139.05 111H164M90.5737 111H93H90.5737Z"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="stroke-surface-4"
/>
<path
d="M150.1 58.3027L139 70.7559M124.1 54V70.7559V54ZM98 58.3027L109.1 70.7559L98 58.3027Z"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="stroke-surface-4"
/>
</svg>
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-2xl font-semibold text-contrast">No versions created</div>
<div>Create your first project version.</div>
<br />
<ButtonStyled color="green">
<button @click="openModal"><PlusIcon /> Create version</button>
</ButtonStyled>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
LinkIcon,
MoreVerticalIcon,
PlusIcon,
ReportIcon,
ShareIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { reportVersion } from '~/utils/report-helpers.ts'
interface Props {
project: Labrinth.Projects.v2.Project
currentMember?: object
}
const { project, currentMember } = defineProps<Props>()
const versions = defineModel<Labrinth.Versions.v3.Version[]>('versions', { required: true })
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext()
const modal = ref<InstanceType<typeof CreateProjectVersionModal>>()
function openModal() {
modal.value?.show?.()
}
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const deleteVersionModal = ref<InstanceType<typeof ConfirmModal>>()
const selectedVersion = ref<string | null>(null)
const versionsWithDisplayUrl = computed(() =>
versions.value.map((v) => ({
...v,
displayUrlEnding: v.version_number,
})),
)
const emit = defineEmits(['onDownload'])
const baseDropdownId = useId()
function getPrimaryFile(version: Labrinth.Versions.v3.Version) {
return version.files.find((x) => x.primary) || version.files[0]
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
}
async function deleteVersion() {
const id = selectedVersion.value
if (!id) return
startLoading()
try {
await client.labrinth.versions_v3.deleteVersion(id)
addNotification({
title: 'Version deleted',
text: 'The version has been successfully deleted.',
type: 'success',
})
} catch (err: any) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
refreshVersions()
selectedVersion.value = null
stopLoading()
}
async function handleOpenEditVersionModal(version: Labrinth.Versions.v3.Version) {
selectedVersion.value = version.id
try {
const versionData = await client.labrinth.versions_v3.getVersion(version.id)
modal.value?.show({
project_id: project.id,
version_id: version.id,
name: versionData.name ?? '',
version_number: versionData.version_number ?? '',
changelog: versionData.changelog ?? '',
game_versions: versionData.game_versions ?? [],
version_type: versionData.version_type ?? 'release',
loaders: versionData.loaders ?? [],
dependencies: versionData.dependencies ?? [],
existing_files: versionData.files ?? [],
environment: versionData.environment,
})
} catch (err: any) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>

View File

@@ -1,163 +0,0 @@
<template>
<div class="normal-page__content flex flex-col gap-4">
<nuxt-link
:to="versionsListLink"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon />
{{
hasBackLink ? formatMessage(messages.backToVersions) : formatMessage(messages.allVersions)
}}
</nuxt-link>
<div class="flex gap-3">
<VersionChannelIndicator :channel="version.version_type" large />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<span class="text-sm font-semibold text-secondary"> {{ version.name }} </span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button><DownloadIcon /> Download</button>
</ButtonStyled>
<ButtonStyled>
<button><ShareIcon /> Share</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</div>
<div>
<h2 class="text-lg font-extrabold text-contrast">Files</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(file, index) in version.files"
:key="index"
class="flex gap-2 rounded-2xl bg-bg-raised p-4"
>
<div
:class="`flex h-9 w-9 items-center justify-center rounded-full ${file.primary ? 'bg-brand-highlight text-brand' : 'bg-button-bg text-secondary'}`"
>
<FileIcon />
</div>
<div class="flex flex-grow flex-col">
<span class="font-extrabold text-contrast">{{
file.primary ? 'Primary file' : 'Supplementary resource'
}}</span>
<span class="text-sm font-semibold text-secondary"
>{{ file.filename }} {{ formatBytes(file.size) }}</span
>
</div>
<div>
<ButtonStyled circular type="transparent">
<button>
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<h2 class="text-lg font-extrabold text-contrast">Dependencies</h2>
<h2 class="text-lg font-extrabold text-contrast">Changes</h2>
<div class="rounded-2xl bg-bg-raised px-6 py-4">
<div
class="markdown-body"
v-html="renderHighlightedString(version.changelog ?? 'No changelog provided')"
/>
</div>
</div>
</div>
<div class="normal-page__sidebar">
<div class="padding-lg h-[250px] rounded-2xl bg-bg-raised"></div>
</div>
</template>
<script setup lang="ts">
import {
ChevronLeftIcon,
DownloadIcon,
FileIcon,
MoreVerticalIcon,
ShareIcon,
} from '@modrinth/assets'
import { ButtonStyled, VersionChannelIndicator } from '@modrinth/ui'
import { formatBytes, renderHighlightedString } from '@modrinth/utils'
const router = useRouter()
const props = defineProps<{
project: Project
versions: Version[]
members: User[]
currentMember: User
dependencies: Dependency[]
resetProject: (opts?: { dedupe?: 'cancel' | 'defer' }) => Promise<void>
}>()
const version = computed(() => {
let version: Version | undefined
if (route.params.version === 'latest') {
let versionList = props.versions
if (route.query.loader) {
versionList = versionList.filter((x) => x.loaders.includes(route.query.loader))
}
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version))
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b))
} else {
version = props.versions.find(
(x) => x.id === route.params.version || x.displayUrlEnding === route.params.version,
)
}
if (!version) {
throw createError({
fatal: true,
statusCode: 404,
message: 'Version not found',
})
}
return version
})
// const data = useNuxtApp();
const route = useNativeRoute()
// const auth = await useAuth();
// const tags = useGeneratedState();
const versionsListLink = computed(() => {
if (router.options.history.state.back) {
if (router.options.history.state.back.includes('/versions')) {
return router.options.history.state.back
}
}
return `/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/versions`
})
const hasBackLink = computed(
() =>
router.options.history.state.back && router.options.history.state.back.endsWith('/versions'),
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToVersions: {
id: 'project.version.back-to-versions',
defaultMessage: 'Back to versions',
},
allVersions: {
id: 'project.version.all-versions',
defaultMessage: 'All versions',
},
})
</script>
<style lang="scss"></style>

View File

@@ -141,7 +141,7 @@
</ButtonStyled>
</div>
<div v-else class="input-group">
<ButtonStyled v-if="primaryFile" color="brand">
<ButtonStyled v-if="primaryFile && !currentMember" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
@@ -163,18 +163,6 @@
Report
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="currentMember"
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`"
>
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
</ButtonStyled>
<ButtonStyled>
<button
v-if="
@@ -187,12 +175,6 @@
Package as mod
</button>
</ButtonStyled>
<ButtonStyled>
<button v-if="currentMember" @click="$refs.modal_confirm.show()">
<TrashIcon aria-hidden="true" />
Delete
</button>
</ButtonStyled>
</div>
</div>
<div class="version-page__changelog universal-card">
@@ -1353,7 +1335,6 @@ export default defineNuxtComponent({
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1rem;
gap: var(--spacing-card-md);
h2,

View File

@@ -1,8 +0,0 @@
<template>
<div />
</template>
<script setup>
definePageMeta({
middleware: 'auth',
})
</script>

View File

@@ -1,34 +1,37 @@
<template>
<ConfirmModal
v-if="currentMember"
ref="deleteVersionModal"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<section class="experimental-styles-within overflow-visible">
<div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
class="card flex items-center gap-4"
>
<FileInput
:max-size="524288000"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="btn btn-primary"
aria-label="Upload a version"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="flex items-center gap-2">
<InfoIcon aria-hidden="true" /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<Admonition v-if="!hideVersionsAdmonition" type="info" class="mb-4">
Managing project versions has moved! You can now add and edit versions in the
<NuxtLink to="settings/versions" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => router.push('settings/versions')"
>
<SettingsIcon />
Edit versions
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideVersionsAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<ProjectPageVersions
v-if="versions.length"
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
@@ -113,24 +116,6 @@
},
shown: flags.developerMode,
},
{ divider: true, shown: currentMember },
{
id: 'edit',
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
shown: currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal.show()
},
shown: currentMember,
},
]"
aria-label="More options"
>
@@ -155,14 +140,6 @@
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit>
<EditIcon aria-hidden="true" />
Edit
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
@@ -175,6 +152,15 @@
</ButtonStyled>
</template>
</ProjectPageVersions>
<template v-else>
<p class="ml-2">
No versions in project. Visit
<NuxtLink to="settings/versions">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
upload your first version.
</p>
</template>
</section>
</template>
@@ -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
}
</script>

View File

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

View File

@@ -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<ManageVersionContextValue> = {
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(),
}),
}

View File

@@ -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<ManageVersionContextValue> = {
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(),
}),
}

View File

@@ -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<ManageVersionContextValue> = {
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(),
}),
}

View File

@@ -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<ManageVersionContextValue> = {
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<ManageVersionContextValue> = {
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),
}),
}

View File

@@ -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<ManageVersionContextValue> = {
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(),
}
},
}

View File

@@ -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<ManageVersionContextValue> = {
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<ManageVersionContextValue> = {
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),
}),
}

View File

@@ -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<ManageVersionContextValue> = {
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<ManageVersionContextValue> = {
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),
}),
}

View File

@@ -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,
]