feat: linked server instances (#5221)
* ping queue with tests * mc ping server info + timeout * sqlx prepare * tombi fmt * tombi fmt * allow querying server ping data * fix shear * wip: resolve comments with pings * Switch to Redis for server pings * tombi fmt * fix compile error * clear cache on project ping, add server store link * Schema changes * Improve server messages for app pinging * synthetic server project version for search indexing * wip: clean up server ping, background tasks * fix migration to sync with main, propagate background task errors * wip: server modpack content query, components in search * wip: massive component query refactor * fix more defaults stuff * sqlx * fix serde deser flatten * fix search indexing not showing fields * remove leftover prompt * fix import * add diff detection for version dependencies without version_id/project_id * move servers tab to end * hide app nav tabs if only one tab * fix undefined property * on click link for server side bar info * show recommended & supported versions for vanilla * fix how install.js installs instance with modpack content title instead of server project title and dont fetch icon when installing to existing instance * use large play button instance * show update success instead of launching right into the game * add global installing server project state * add comment * small change: open discover to modpack * implement ping server projects for latency in app * add projectV3 to nag context for moderation package * fix play server project button when instance is launched * add ping to project header * wip: server verified plays * server verified plays compiling * queue up server plays in batches * report server plays improved in frontend * fixes to tracking server joins * fix: server project detection to do loose null check * fix server projects showing license * fix empty server info card * fix server projects links title * Fix backend impl for server player count analytics * fix: allow for links to be set to empty * hook up server recent plays * cargo sqlx prepare * add project sidebar stories * feat: update project sidebar server info card to new design * update server project header and project card * feat: add hide label for project cards * feat: add tags sidebar card * small fix to keep color consistent * fix: remove required content tab from server project page * many small fixes * handle locking server instance content * fix hiding modal after saving server compatibility version * copy content card item and table from content tab update branch * fix nav tabs active tag * fix switching between server instance vs regular instance persisted invalid state * fix a lot of the bugginess of navtabs when theres hidden/shown tabs between instances. match frontend nav tabs * hook up backend searchfor frontend in websiet * fix: server project card tags * hook up search v3 in app backend for app frontend * Don't return missing components in project query * Add game versions to server filters * move reporting server joins to backend * send account UUID along with server play analytics * update java server ping schema * feat: implement use server search for search sorting and filter facets * pnpm prepr * fix game version filter facet * fix: allow java and bedrock addresses to be deleted * feat: hook up languages * Default deserialize `ProjectSerial` * feat: show server project tags * small fix on languages multi select * also default java server content * fix: update compatibility modal not closing after successful upload * remove play button in website discovery for servers * reenable fence in app backend * update online/offline tag * add online status indicator pulsing * revert pulsing * disable link for custom modpack project and show tooltip * change modpack to modded type * update ip address entire button to be clickable * polish server info card styles * make offline tag red and properly hook up online tag * move server related settings into own tab * fix setting project compatibility resets unsaved changes * fix javaServerPatchaData wiping content field * updates to compatibility card, add download button and display supported versions better * fix unsaved changes popup for tags * remove console.log * fix incorrect project type in projects in dashboard * fix: savable.ts to reset currentValues to data() after save * upload server banner as gallery image with title == "__mc_server_banner__" and filter it from frontend gallery * fix error handling and helper text copy * ensure gallery banners are filtered in app backend gallery display * add grouped filters for search * add query params for server search * feat: deep linking to open server project page then open install to play * fix search in app frontend * fix: server project showing offline * fix: profile create error app backend Here's what was happening and the fix: Root cause: In create.rs:107, profile_create assumed the icon_path parameter was always a local filename relative to the caches directory. It did caches_dir().join(icon) which produced a path like ...\caches\https://staging-cdn.modrinth.com/... — the colons in https:// are illegal in Windows paths (OS error 123). The frontend's installServerProject and createVanillaInstance in install.js:290 both pass project.icon_url (a full URL) directly as the icon parameter. Fix: Modified profile_create to detect when the icon parameter is a URL (starts with http:// or https://). When it is, it downloads the icon via fetch(), extracts the filename from the URL path, and passes the downloaded bytes and filename to set_icon() which hashes and caches it properly. The existing local-file path continues to work as before. * pass undefined instead of unknown for modpack content modal * fix: wrong way to determine offline status * delete required content page placeholder * fix: redirect running function instead of passing function * add in wiki page * fix diffs which have unknown project/filename * pnpm prepr * feat: add handling for "stop" instance state for server project card and page play button * fix updating modpack shouldn't launch right into game * small fix on external icon * fix refresh search causing infinite rerender i.e. maximum call stack size exceeded watch(route) → watch(() => [route.query.i, route.query.ai, route.path]) (line 102): The deep watch on the entire Vue Router route object was the most likely cause of the stack overflow. Vue Router's route object contains matched records with component definitions and other deeply nested structures. Deep-watching it triggers recursive traversal on every route change (including those from router.replace() inside refreshSearch()). Now it only watches the specific properties that updateInstanceContext() actually needs. ref → shallowRef for serverHits and serverPings (line 189-190): The v3 search results can be deeply nested objects (minecraft_java_server.ping.data, content, etc.). Using shallowRef prevents Vue from creating deep reactive proxies on these objects, which is consistent with how results already uses shallowRef on line 295. Re-entrance guard + try/catch on refreshSearch() (line 310): The watcher calls refreshSearch() without awaiting, so state changes during the async execution could trigger the watcher again, causing concurrent calls. The guard prevents overlapping calls, and the try/catch ensures loading.value = false is always reached (fixing the infinite loading). * don't require auth token for logging server play * fetch latest server player count from redis instead of search doc * remove components. in search facet * Category and search sort fixes * add logging for refreshSearch in browse.vue * fix: use windows.history.replace instead of router.replace due to vue production bug and remove logs * fix: server refresh search reactivity * fix: type errors * conquer the type errors in Browse.vue * update search input background * fix tags location * slight change to color * feat: add linked to modpack project for regular modpack instances * feat: installation tab updates * fix: copy ip missing hover effect * feat: implement category and countries negative filters * fix servers tab label in profile page * implement add server to instance * feat: implement allow editing server instances * update installation settings to handle vanilla server instance case * hide servers tab when installing content to instance * add sorting for user installed content to be top of list in content * update categories filters from one group filter card to separate filters cards * add active scale * fix offline server showing online * update language display * update tooltip * hide navtabs if theres only one tab * fix: modpack content name truncate in project card * feat: add server projects to moderation queue * update redirect middleware no longer needs projectV3 * update comment * fix: server tags labels * feat: add the mf icons finally * Revert "update redirect middleware no longer needs projectV3" This reverts commit 1289cb52869185abe1481dfb6b0c00c0233bf59e. * fix open in browser * revert any handling for handling base linked modpack content for content tab * update instance online players to be client ping * fix showing modpack/loader version for server instance in installation settings * server projects are not marked as modpacks * skip license check for server projects * feat: add the concept of linked worlds for server instances and keep in sync with server project * fix: router.push doesn't add history state, use nagivateTo instead * fix: get server modpack content wrong link * update some categories to default collapse * small fixes * optional languages & bedrock * move creator below tags * sort linked worlds to be first * add red orange and green ping variants * bring back content tab * add download button in required content in app * fix: server info card loading * fix: brief flash of normal project before server project stuff loads in * misc fixes * invalidate project v3 * fix unused imports * Quick pass for moderation related changes (#5429) * filter certain nags out from server projects. * move add-links nag to links.ts * first few server related nags * moderation checklist groundwork * Prevent undefined stage from appearing on servers. * add projectV3 to shouldShow callback * Filter buttons by server project type * fix, revert private use msg, adjust server & link nags * starting tags + servers msg * fix no projectV3 * fix: router.push doesn't add history state, use nagivateTo instead * Tags nag works with servers now * support servers' v3 exclusive links * reupload, and status messages + nag tweaks. * fixes * Update tags.vue warning for server projects. * don't suggest adding a bedrock IP * Tweak phrasing on servers alert msg --------- Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com> Co-authored-by: tdgao <mr.trumgao@gmail.com> Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com> * only show unique tags in project card * add projectV3 to cache purge * fix type: add projectV3 to cache purge * update caching behaviour for installing * max 3 plays per user * accept date_modified and date_created for sorting * add locking environment filter for server instance and update copy * custom pack button only shows when needed (#5444) * expose server pinging route to frontend * feat: add server field validation with pinging on unfocus * improve pinging logs * try another pinging crate * small fixes * prefill published project id for updating published project * fix running app bar for mac * cargo sqlx prepare * fix app login avatar * pnpm prepr * fix download menu for mac * FIX CI * fix lint errors * cargo fmt * fix toml * fix more lint * add server copy * more lint * fix any types * also ping unlisted and private servers * fix lint * remove option for showTypeSelector * fix cannot read user from undefined * pnpm prepr * update pinging to make it better * update copy * fix login cache issue * add project select default icon * fix: minecraft_java_server not redirecting * pnpm prepr * fix required content card in project page for custom modpack * fix app project cards custom modpacks * update pre-collapsed for app frontend * don't send server projects to discord webhook * add lock icon to linked world managed by server project * pnpm prepr * make automod msgs on server projects private * fix pagination for server projects tab * fix recent plays copy * fix sync linked world with server project * pnpm prepr * add 0.11.0 changelog * update date --------- Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com> Co-authored-by: aecsocket <aecsocket@tutanota.com> Co-authored-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
}
|
||||
@@ -99,7 +99,7 @@
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
|
||||
// Same styling as h3
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.17rem;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -381,7 +381,7 @@
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,7 +1113,7 @@ svg.inline-svg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-12);
|
||||
padding: var(--gap-16) var(--gap-24);
|
||||
padding: var(--gap-16);
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-18);
|
||||
|
||||
@@ -380,18 +380,18 @@ a {
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
|
||||
color: var(--color-text-dark);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
input {
|
||||
|
||||
@@ -114,28 +114,19 @@ iframe[id^='google_ads_iframe'] {
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui::before {
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui::before,
|
||||
#qc-cmp2-ui::after {
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='primary'] {
|
||||
#qc-cmp2-ui button[mode='primary'],
|
||||
#qc-cmp2-ui button[mode='secondary'] {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='secondary'] {
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='link'] {
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<nav
|
||||
v-if="filteredLinks.length > 1"
|
||||
ref="scrollContainer"
|
||||
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
:class="{ 'card-shadow': mode === 'navigation' }"
|
||||
@@ -299,7 +300,14 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(() => props.links, updateActiveTab, { deep: true })
|
||||
watch(
|
||||
() => props.links,
|
||||
async () => {
|
||||
await nextTick()
|
||||
updateActiveTab()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="space-y-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="!noHeader" class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast">
|
||||
Minecraft versions <span class="text-red">*</span>
|
||||
</span>
|
||||
@@ -45,6 +45,7 @@
|
||||
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
|
||||
modelValue.includes(version) ? '!text-contrast' : '',
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@click="() => handleToggleVersion(version)"
|
||||
@blur="
|
||||
() => {
|
||||
@@ -76,6 +77,8 @@ type GameVersion = Labrinth.Tags.v2.GameVersion
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
noHeader?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -158,7 +161,7 @@ function groupVersions(gameVersions: GameVersion[]) {
|
||||
|
||||
const groups: Record<string, string[]> = {}
|
||||
|
||||
let currentGroupKey
|
||||
let currentGroupKey: string
|
||||
|
||||
gameVersions.forEach((gameVersion) => {
|
||||
if (gameVersion.version_type === 'release') {
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="min-w-md flex max-w-md flex-col gap-3">
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
projectType === 'server'
|
||||
? formatMessage(messages.serverProjectTitle)
|
||||
: formatMessage(messages.title)
|
||||
"
|
||||
:max-width="'550px'"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<CreateLimitAlert v-model="hasHitLimit" type="project" />
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="project-type">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.typeLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="project-type"
|
||||
v-model="projectType"
|
||||
name="project-type"
|
||||
:options="projectTypeOptions"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="name">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
@@ -19,32 +42,46 @@
|
||||
@update:model-value="updatedName()"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="slug">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper">
|
||||
<label for="slug" class="flex flex-col gap-2.5">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.urlLabel) }}
|
||||
</span>
|
||||
<div class="text-input-wrapper !w-full">
|
||||
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
|
||||
<StyledInput
|
||||
id="slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
class="w-full"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:disabled="hasHitLimit"
|
||||
@update:model-value="manualSlug = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.visibilityLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="owner">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.ownerLabel) }}
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="owner"
|
||||
v-model="owner"
|
||||
name="owner"
|
||||
:options="[userOption, ...ownerOptions]"
|
||||
searchable
|
||||
:disabled="hasHitLimit"
|
||||
show-icon-in-selected
|
||||
/>
|
||||
<span>{{ formatMessage(messages.ownerDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.visibilityLabel) }}
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
|
||||
</label>
|
||||
<Chips
|
||||
id="visibility"
|
||||
@@ -54,14 +91,13 @@
|
||||
:capitalize="false"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="additional-information" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.summaryLabel) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="additional-information"
|
||||
@@ -71,8 +107,9 @@
|
||||
:placeholder="formatMessage(messages.summaryPlaceholder)"
|
||||
:disabled="hasHitLimit"
|
||||
/>
|
||||
<span>{{ formatMessage(messages.summaryDescription) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<ButtonStyled class="w-24">
|
||||
<button @click="cancel">
|
||||
<XIcon aria-hidden="true" />
|
||||
@@ -80,7 +117,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" class="w-32">
|
||||
<button :disabled="hasHitLimit" @click="createProject">
|
||||
<button v-tooltip="missingFieldsTooltip" :disabled="disableCreate" @click="createProject">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createProject) }}
|
||||
</button>
|
||||
@@ -90,30 +127,76 @@
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, defineAsyncComponent, h } from 'vue'
|
||||
|
||||
import CreateLimitAlert from './CreateLimitAlert.vue'
|
||||
|
||||
type ProjectTypes = 'server' | 'project'
|
||||
interface VisibilityOption {
|
||||
actual: Labrinth.Projects.v2.ProjectStatus
|
||||
display: string
|
||||
}
|
||||
interface ShowOptions {
|
||||
type?: 'server' | 'project'
|
||||
}
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
const auth = (await useAuth()) as Ref<{
|
||||
user: { id: string; username: string; avatar_url: string } | null
|
||||
}>
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'create.project.title',
|
||||
defaultMessage: 'Creating a project',
|
||||
},
|
||||
serverProjectTitle: {
|
||||
id: 'create.project.server-project-title',
|
||||
defaultMessage: 'Creating a server project',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'create.project.type-label',
|
||||
defaultMessage: 'Type',
|
||||
},
|
||||
typeProject: {
|
||||
id: 'create.project.type-project',
|
||||
defaultMessage: 'Project',
|
||||
},
|
||||
typeServer: {
|
||||
id: 'create.project.type-server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
ownerLabel: {
|
||||
id: 'create.project.owner-label',
|
||||
defaultMessage: 'Owner',
|
||||
},
|
||||
ownerDescription: {
|
||||
id: 'create.project.owner-description',
|
||||
defaultMessage: `Set the project owner as yourself or an organization you're a member of.`,
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'create.project.name-label',
|
||||
defaultMessage: 'Name',
|
||||
@@ -146,6 +229,10 @@ const messages = defineMessages({
|
||||
id: 'create.project.create-project',
|
||||
defaultMessage: 'Create project',
|
||||
},
|
||||
createServerProject: {
|
||||
id: 'create.project.create-server-project',
|
||||
defaultMessage: 'Create server',
|
||||
},
|
||||
visibilityPublic: {
|
||||
id: 'create.project.visibility-public',
|
||||
defaultMessage: 'Public',
|
||||
@@ -158,24 +245,38 @@ const messages = defineMessages({
|
||||
id: 'create.project.visibility-private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
organizationId: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
missingFieldsTooltip: {
|
||||
id: 'create.project.missing-fields-tooltip',
|
||||
defaultMessage: 'Missing fields: {fields}',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
const props = defineProps<{
|
||||
organizationId?: string | null
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const hasHitLimit = ref(false)
|
||||
|
||||
const name = ref('')
|
||||
const slug = ref('')
|
||||
const description = ref('')
|
||||
const manualSlug = ref(false)
|
||||
const visibilities = ref([
|
||||
const projectType = ref<ProjectTypes>('project')
|
||||
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
|
||||
{
|
||||
value: 'project',
|
||||
label: formatMessage(messages.typeProject),
|
||||
},
|
||||
{
|
||||
value: 'server',
|
||||
label: formatMessage(messages.typeServer),
|
||||
},
|
||||
])
|
||||
const ownerOptions = ref<ComboboxOption<string>[]>([])
|
||||
const owner = ref<string | undefined>('self')
|
||||
const organizations = ref<Labrinth.Projects.v3.Organization[]>([])
|
||||
const visibilities = ref<VisibilityOption[]>([
|
||||
{
|
||||
actual: 'approved',
|
||||
display: formatMessage(messages.visibilityPublic),
|
||||
@@ -189,10 +290,84 @@ const visibilities = ref([
|
||||
display: formatMessage(messages.visibilityPrivate),
|
||||
},
|
||||
])
|
||||
const visibility = ref(visibilities.value[0])
|
||||
const visibility = ref<VisibilityOption>(visibilities.value[0])
|
||||
|
||||
const disableCreate = computed(() => {
|
||||
if (hasHitLimit.value) return true
|
||||
if (!name.value.trim() || !slug.value.trim()) return true
|
||||
if (description.value.trim().length < 3) return true
|
||||
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
|
||||
return true
|
||||
return false
|
||||
})
|
||||
|
||||
const missingFieldsTooltip = computed(() => {
|
||||
const missingFields = []
|
||||
if (!name.value.trim()) missingFields.push(formatMessage(messages.nameLabel))
|
||||
if (!slug.value.trim()) missingFields.push(formatMessage(messages.urlLabel))
|
||||
if (description.value.trim().length < 3) missingFields.push(formatMessage(messages.summaryLabel))
|
||||
if (owner.value !== 'self' && !organizations.value.find((org) => org.id === owner.value))
|
||||
missingFields.push(formatMessage(messages.ownerLabel))
|
||||
|
||||
if (missingFields.length === 0) return ''
|
||||
return formatMessage(messages.missingFieldsTooltip, {
|
||||
fields: missingFields.join(', '),
|
||||
})
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
modal.value.hide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const userOption = computed(() => ({
|
||||
value: 'self',
|
||||
label: auth.value.user?.username || 'Unknown user',
|
||||
icon: auth.value.user?.avatar_url
|
||||
? defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: auth.value.user?.avatar_url,
|
||||
alt: 'User Avatar',
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
async function fetchOrganizations() {
|
||||
if (!auth.value.user?.id) return
|
||||
|
||||
try {
|
||||
const orgs = (await useBaseFetch(`user/${auth.value.user.id}/organizations`, {
|
||||
apiVersion: 3,
|
||||
})) as Labrinth.Projects.v3.Organization[]
|
||||
|
||||
organizations.value = orgs || []
|
||||
|
||||
ownerOptions.value = organizations.value.map((org) => ({
|
||||
value: org.id,
|
||||
label: org.name,
|
||||
icon: org.icon_url
|
||||
? defineAsyncComponent(() =>
|
||||
Promise.resolve({
|
||||
setup: () => () =>
|
||||
h('img', {
|
||||
src: org.icon_url,
|
||||
alt: `${org.name} Icon`,
|
||||
class: 'h-5 w-5 rounded',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
if (props.organizationId) owner.value = props.organizationId
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch organizations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
@@ -200,9 +375,7 @@ async function createProject() {
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const projectData = {
|
||||
const projectData: Labrinth.Projects.v2.CreateProjectBase = {
|
||||
title: name.value.trim(),
|
||||
project_type: 'mod',
|
||||
slug: slug.value,
|
||||
@@ -212,8 +385,8 @@ async function createProject() {
|
||||
initial_versions: [],
|
||||
team_members: [
|
||||
{
|
||||
user_id: auth.value.user.id,
|
||||
name: auth.value.user.username,
|
||||
user_id: auth.value.user?.id,
|
||||
name: auth.value.user?.username,
|
||||
role: 'Owner',
|
||||
},
|
||||
],
|
||||
@@ -224,45 +397,70 @@ async function createProject() {
|
||||
is_draft: true,
|
||||
}
|
||||
|
||||
if (props.organizationId) {
|
||||
projectData.organization_id = props.organizationId
|
||||
}
|
||||
|
||||
formData.append('data', JSON.stringify(projectData))
|
||||
|
||||
try {
|
||||
await useBaseFetch('project', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Disposition': formData,
|
||||
},
|
||||
})
|
||||
let createdProjectId: string | undefined
|
||||
|
||||
modal.value.hide()
|
||||
if (projectType.value === 'server') {
|
||||
const result = await labrinth.projects_v3.createServerProject({
|
||||
base: {
|
||||
name: projectData.title,
|
||||
slug: projectData.slug,
|
||||
summary: projectData.description,
|
||||
description: '',
|
||||
requested_status: projectData.requested_status,
|
||||
organization_id: owner.value !== 'self' ? owner.value : undefined,
|
||||
},
|
||||
minecraft_server: {
|
||||
// empty component
|
||||
},
|
||||
minecraft_java_server: {
|
||||
address: '',
|
||||
port: 25565,
|
||||
},
|
||||
minecraft_bedrock_server: {
|
||||
address: '',
|
||||
port: 19132,
|
||||
},
|
||||
})
|
||||
createdProjectId = result.id
|
||||
} else {
|
||||
const result = (await useBaseFetch('project', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'Content-Disposition': formData as unknown as string,
|
||||
},
|
||||
})) as Labrinth.Projects.v3.Project
|
||||
createdProjectId = result.id
|
||||
console.log(createdProjectId)
|
||||
}
|
||||
|
||||
modal.value?.hide()
|
||||
await router.push(`/project/${slug.value}/settings`)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err,
|
||||
text: error.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function show(event) {
|
||||
async function show(event?: MouseEvent, options?: ShowOptions) {
|
||||
name.value = ''
|
||||
slug.value = ''
|
||||
description.value = ''
|
||||
manualSlug.value = false
|
||||
modal.value.show(event)
|
||||
owner.value = 'self'
|
||||
projectType.value = options?.type ?? 'project'
|
||||
await fetchOrganizations()
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
|
||||
function updatedName() {
|
||||
if (!manualSlug.value) {
|
||||
slug.value = name.value
|
||||
|
||||
@@ -100,6 +100,7 @@ interface Tags {
|
||||
|
||||
interface Props {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
projectV3: Labrinth.Projects.v3.Project
|
||||
versions?: Labrinth.Versions.v2.Version[]
|
||||
currentMember?: Labrinth.Projects.v3.TeamMember | null
|
||||
collapsed?: boolean
|
||||
@@ -172,6 +173,7 @@ const emit = defineEmits<{
|
||||
|
||||
const nagContext = computed<NagContext>(() => ({
|
||||
project: props.project,
|
||||
projectV3: props.projectV3,
|
||||
versions: props.versions,
|
||||
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
|
||||
currentRoute: props.routeName,
|
||||
|
||||
@@ -1654,7 +1654,7 @@ function shouldShowStage(stage: Stage): boolean {
|
||||
|
||||
function shouldShowAction(action: Action): boolean {
|
||||
if (typeof action.shouldShow === 'function') {
|
||||
return action.shouldShow(projectV2.value)
|
||||
return action.shouldShow(projectV2.value, projectV3.value)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -1663,7 +1663,7 @@ function shouldShowAction(action: Action): boolean {
|
||||
function getVisibleDropdownOptions(action: DropdownAction) {
|
||||
return action.options.filter((option) => {
|
||||
if (typeof option.shouldShow === 'function') {
|
||||
return option.shouldShow(projectV2.value)
|
||||
return option.shouldShow(projectV2.value, projectV3.value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -1672,7 +1672,7 @@ function getVisibleDropdownOptions(action: DropdownAction) {
|
||||
function getVisibleMultiSelectOptions(action: MultiSelectChipsAction) {
|
||||
return action.options.filter((option) => {
|
||||
if (typeof option.shouldShow === 'function') {
|
||||
return option.shouldShow(projectV2.value)
|
||||
return option.shouldShow(projectV2.value, projectV3.value)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-xl font-semibold text-contrast">Server compatibility</div>
|
||||
<div v-if="!content" class="text-sm text-secondary">
|
||||
Select whether your server is vanilla or modded and which versions it supports. The
|
||||
Modrinth App uses this when a player joins.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="content.kind === 'vanilla'" class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
|
||||
>
|
||||
<BoxIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
</div>
|
||||
|
||||
Vanilla server
|
||||
</div>
|
||||
<div
|
||||
v-else-if="content.kind === 'modpack' && !usingCustomMrpack"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
|
||||
>
|
||||
<PackageIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
</div>
|
||||
Published modpack
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
|
||||
>
|
||||
<PackagePlusIcon class="h-4 w-4 shrink-0 text-secondary" />
|
||||
</div>
|
||||
Custom modpack
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled v-if="content" type="outlined">
|
||||
<button class="!border-[1px]" @click="handleSwitchCompatibility">
|
||||
<ArrowLeftRightIcon />
|
||||
Switch type
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button @click="handleSetCompatibility">
|
||||
<ComponentIcon />
|
||||
Set compatibility
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="content"
|
||||
class="flex justify-between gap-4 rounded-2xl border border-solid border-surface-5 p-4"
|
||||
>
|
||||
<!-- kind = vanilla -->
|
||||
<div
|
||||
v-if="content?.kind === 'vanilla'"
|
||||
class="flex flex-col items-start justify-between gap-2.5"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Recommended version</div>
|
||||
<div class="text-2xl font-semibold text-contrast">
|
||||
{{ content.recommended_game_version ?? '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Supported versions</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<TagItem
|
||||
v-for="v in formatVersionsForDisplay(
|
||||
content.supported_game_versions,
|
||||
tags.gameVersions,
|
||||
)"
|
||||
:key="v"
|
||||
>
|
||||
{{ v }}
|
||||
</TagItem>
|
||||
<div v-if="!content.supported_game_versions.length">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- kind = modpack -->
|
||||
<div
|
||||
v-if="content?.kind === 'modpack' && modpackProject"
|
||||
class="flex w-full max-w-[500px] flex-col items-start justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Required modpack</div>
|
||||
<div class="w-fullitems-center flex gap-3 rounded-2xl bg-surface-1 p-3">
|
||||
<Avatar
|
||||
v-if="!usingCustomMrpack"
|
||||
:src="modpackProject.icon_url"
|
||||
size="56px"
|
||||
:tint-by="modpackProject.name"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-solid border-surface-5 bg-surface-3"
|
||||
>
|
||||
<PackagePlusIcon class="h-10 w-10 shrink-0 text-secondary" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-center gap-1">
|
||||
<div class="font-semibold text-contrast">
|
||||
<NuxtLink
|
||||
v-if="!usingCustomMrpack"
|
||||
:to="`/modpack/${modpackProject.slug}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ modpackProject.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>{{ modpackFileName }}</span>
|
||||
</div>
|
||||
<div class="flex h-6 items-center gap-1.5 text-secondary">
|
||||
<NuxtLink
|
||||
v-if="modpackOrg?.name"
|
||||
:to="`/organization/${modpackOrg.slug}`"
|
||||
class="flex items-center gap-1 hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
v-if="modpackOrg?.icon_url"
|
||||
:src="modpackOrg.icon_url"
|
||||
size="24px"
|
||||
class="rounded-2xl"
|
||||
/>
|
||||
{{ modpackOrg.name }}
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="modpackOrg?.name && modpackVersion"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
<NuxtLink
|
||||
v-if="modpackVersion && !usingCustomMrpack"
|
||||
:to="`/modpack/${modpackProject?.slug}/version/${modpackVersion.id}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
v{{ modpackVersion.version_number }}
|
||||
</NuxtLink>
|
||||
<div v-else-if="modpackVersion" class="flex items-center gap-1.5">
|
||||
<div>v{{ modpackVersion.version_number }}</div>
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
|
||||
<a
|
||||
v-if="modpackVersion.files?.length"
|
||||
:href="
|
||||
modpackVersion.files.find((f) => f.primary)?.url ??
|
||||
modpackVersion.files[0]?.url
|
||||
"
|
||||
class="flex items-center gap-0.5 hover:underline"
|
||||
>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="modpackVersion" class="flex flex-col gap-2">
|
||||
<div class="font-medium text-secondary">Required version</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<TagItem v-for="gv in modpackVersion.game_versions" :key="gv">
|
||||
{{ gv }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-for="loader in modpackVersion.mrpack_loaders"
|
||||
:key="loader"
|
||||
:style="`--_color: var(--color-platform-${loader})`"
|
||||
>
|
||||
<component
|
||||
:is="getLoaderIcon(loader)"
|
||||
v-if="getLoaderIcon(loader)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<FormattedTag :tag="loader" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="content">
|
||||
<button class="!w-full !max-w-[160px]" @click="handleUpdateContent">
|
||||
<RefreshCwIcon />
|
||||
Update
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<ServerCompatibilityModal ref="serverCompatibilityModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BoxIcon,
|
||||
ComponentIcon,
|
||||
DownloadIcon,
|
||||
getLoaderIcon,
|
||||
PackageIcon,
|
||||
PackagePlusIcon,
|
||||
RefreshCwIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
FormattedTag,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
TagItem,
|
||||
} from '@modrinth/ui'
|
||||
import { formatVersionsForDisplay } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
|
||||
import ServerCompatibilityModal from './ServerCompatibilityModal/ServerCompatibilityModal.vue'
|
||||
|
||||
const serverCompatibilityModal = useTemplateRef<InstanceType<typeof ServerCompatibilityModal>>(
|
||||
'serverCompatibilityModal',
|
||||
)
|
||||
|
||||
const { projectV3 } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const content = computed(() => {
|
||||
if (!projectV3.value) return null
|
||||
|
||||
const content = projectV3.value.minecraft_java_server?.content
|
||||
if (!content) return null
|
||||
|
||||
if (content?.kind === 'vanilla' && !content.recommended_game_version) {
|
||||
return null
|
||||
}
|
||||
|
||||
return content
|
||||
})
|
||||
|
||||
const modpackVersionId = computed(() => {
|
||||
if (content.value?.kind === 'modpack') return content.value.version_id
|
||||
return null
|
||||
})
|
||||
|
||||
const { data: modpackVersion } = useQuery({
|
||||
queryKey: computed(() => ['versions', 'detail', modpackVersionId.value]),
|
||||
queryFn: () => labrinth.versions_v3.getVersion(modpackVersionId.value!),
|
||||
enabled: computed(() => !!modpackVersionId.value),
|
||||
})
|
||||
|
||||
const modpackProjectId = computed(() => modpackVersion.value?.project_id ?? null)
|
||||
|
||||
const { data: modpackProject } = useQuery({
|
||||
queryKey: computed(() => ['project', 'v3', modpackProjectId.value]),
|
||||
queryFn: () => labrinth.projects_v3.get(modpackProjectId.value!),
|
||||
enabled: computed(() => !!modpackProjectId.value),
|
||||
})
|
||||
|
||||
const { data: modpackOrg } = useQuery({
|
||||
queryKey: computed(() => ['project', 'org', modpackProjectId.value]),
|
||||
queryFn: () => labrinth.projects_v3.getOrganization(modpackProjectId.value!),
|
||||
enabled: computed(() => !!modpackProjectId.value && !!modpackProject.value?.organization),
|
||||
})
|
||||
|
||||
const usingCustomMrpack = computed(() => modpackVersion.value?.project_id === projectV3.value?.id)
|
||||
|
||||
const modpackFileName = computed(() => {
|
||||
if (!modpackVersion.value?.files?.length) return null
|
||||
const primary = modpackVersion.value.files.find((f) => f.primary)
|
||||
return (primary ?? modpackVersion.value.files[0]).filename
|
||||
})
|
||||
|
||||
function handleSetCompatibility() {
|
||||
serverCompatibilityModal.value?.show()
|
||||
}
|
||||
|
||||
function handleSwitchCompatibility() {
|
||||
serverCompatibilityModal.value?.show({ isSwitchingCompatibilityType: true })
|
||||
}
|
||||
|
||||
function handleUpdateContent() {
|
||||
if (!content.value?.kind) return
|
||||
|
||||
switch (content.value.kind) {
|
||||
case 'vanilla':
|
||||
serverCompatibilityModal.value?.show({ updateContentKind: 'vanilla' })
|
||||
break
|
||||
case 'modpack':
|
||||
if (usingCustomMrpack.value) {
|
||||
serverCompatibilityModal.value?.show({ updateContentKind: 'custom-modpack' })
|
||||
} else {
|
||||
serverCompatibilityModal.value?.show({ updateContentKind: 'published-modpack' })
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<Admonition v-if="isSwitchingCompatibilityType" type="critical" header="Data loss warning">
|
||||
Changing the compatibility type will reset your previous compatibility settings and redistribute
|
||||
the new settings to users in the Modrinth App.
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition } from '@modrinth/ui'
|
||||
|
||||
import { injectServerCompatibilityContext } from '../../../../providers/manage-server-compatibility-modal'
|
||||
|
||||
const { isSwitchingCompatibilityType } = injectServerCompatibilityContext()
|
||||
</script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<MultiStageModal
|
||||
ref="modal"
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
:fade="ctx.isSwitchingCompatibilityType.value ? 'danger' : 'standard'"
|
||||
@hide="handleHide"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectProjectPageContext, MultiStageModal } from '@modrinth/ui'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import {
|
||||
type CompatibilityType,
|
||||
createServerCompatibilityContext,
|
||||
provideServerCompatibilityContext,
|
||||
} from '../../../../providers/manage-server-compatibility-modal'
|
||||
|
||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
||||
|
||||
const { projectV3 } = injectProjectPageContext()
|
||||
const ctx = createServerCompatibilityContext(modal)
|
||||
provideServerCompatibilityContext(ctx)
|
||||
|
||||
interface ShowModalOptions {
|
||||
stageId?: string | null
|
||||
updateContentKind?: CompatibilityType
|
||||
isSwitchingCompatibilityType?: boolean
|
||||
}
|
||||
|
||||
async function show(options?: ShowModalOptions) {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
|
||||
if (options?.updateContentKind) {
|
||||
ctx.compatibilityType.value = options.updateContentKind
|
||||
ctx.isEditingExistingCompatibility.value = true
|
||||
|
||||
// Prefill existing values for vanilla
|
||||
if (options.updateContentKind === 'vanilla' && content && content.kind === 'vanilla') {
|
||||
ctx.supportedGameVersions.value = content.supported_game_versions ?? []
|
||||
ctx.recommendedGameVersion.value = content.recommended_game_version ?? null
|
||||
}
|
||||
|
||||
if (options.updateContentKind === 'published-modpack') {
|
||||
ctx.selectedProjectId.value = content?.kind === 'modpack' ? content.project_id || '' : ''
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
modal.value?.setStage(1)
|
||||
} else {
|
||||
modal.value?.setStage(options?.stageId ?? 0)
|
||||
}
|
||||
|
||||
if (options?.isSwitchingCompatibilityType) {
|
||||
ctx.isSwitchingCompatibilityType.value = true
|
||||
}
|
||||
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
ctx.resetContext()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
handleHide()
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="font-semibold text-contrast">Select compatibility type</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<button
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex !w-full items-center gap-4 rounded-3xl bg-surface-4 p-3 text-left transition-all hover:brightness-125"
|
||||
@click="selectType(option.value)"
|
||||
>
|
||||
<div
|
||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5"
|
||||
>
|
||||
<component :is="option.icon" class="h-9 w-9 text-secondary" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-bold text-contrast">{{ option.label }}</span>
|
||||
<span class="text-sm text-secondary">{{ option.description }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Servers with custom modpacks should not be uploaded as separate modpack projects.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BoxIcon, PackageIcon, PackagePlusIcon } from '@modrinth/assets'
|
||||
|
||||
import {
|
||||
type CompatibilityType,
|
||||
injectServerCompatibilityContext,
|
||||
} from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const ctx = injectServerCompatibilityContext()
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: 'vanilla' as CompatibilityType,
|
||||
label: 'Vanilla server',
|
||||
description: 'A vanilla server with no mods.',
|
||||
icon: BoxIcon,
|
||||
},
|
||||
{
|
||||
value: 'published-modpack' as CompatibilityType,
|
||||
label: 'Published modpack',
|
||||
description: 'A modded server using a published modpack.',
|
||||
icon: PackageIcon,
|
||||
},
|
||||
{
|
||||
value: 'custom-modpack' as CompatibilityType,
|
||||
label: 'Custom modpack',
|
||||
description: 'A modded server using a custom modpack.',
|
||||
icon: PackagePlusIcon,
|
||||
},
|
||||
]
|
||||
|
||||
function selectType(type: CompatibilityType) {
|
||||
ctx.compatibilityType.value = type
|
||||
ctx.modal.value?.nextStage()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Select modpack</div>
|
||||
|
||||
<div class="flex flex-col gap-6 rounded-2xl border border-solid border-surface-5 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast">Project</label>
|
||||
<ProjectCombobox
|
||||
v-model="selectedProjectId"
|
||||
:project-types="['modpack']"
|
||||
:exclude-project-ids="[currentProjectId]"
|
||||
placeholder="Select modpack"
|
||||
search-placeholder="Search by name or paste ID..."
|
||||
loading-message="Loading..."
|
||||
no-results-message="No results found"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedProjectId" class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast">Version</label>
|
||||
<Combobox
|
||||
v-model="selectedVersionId"
|
||||
placeholder="Select version"
|
||||
:options="versionOptions"
|
||||
:searchable="true"
|
||||
search-placeholder="Search versions..."
|
||||
:no-options-message="versionsLoading ? 'Loading...' : 'No results found'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedVersion" class="flex flex-col gap-4 rounded-2xl bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-secondary">Game version</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem v-for="gv in selectedVersion.game_versions" :key="gv">
|
||||
{{ gv }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedVersion.mrpack_loaders?.length"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="text-secondary">Platform</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<TagItem
|
||||
v-for="loader in selectedVersion.mrpack_loaders"
|
||||
:key="loader"
|
||||
:style="`--_color: var(--color-platform-${loader})`"
|
||||
>
|
||||
<component :is="getLoaderIcon(loader)" v-if="getLoaderIcon(loader)" class="h-4 w-4" />
|
||||
<FormattedTag :tag="loader" enforce-type="loader" />
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getLoaderIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Combobox,
|
||||
FormattedTag,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
ProjectCombobox,
|
||||
TagItem,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const { projectV3 } = injectProjectPageContext()
|
||||
|
||||
const currentProjectId = computed(() => projectV3.value?.id)
|
||||
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
interface VersionInfo {
|
||||
id: string
|
||||
version_number: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const versionsLoading = ref(false)
|
||||
const projectVersions = ref<VersionInfo[]>([])
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
projectVersions.value.map((v) => ({
|
||||
label: v.version_number,
|
||||
value: v.id,
|
||||
})),
|
||||
)
|
||||
|
||||
const { data: selectedVersion } = useQuery({
|
||||
queryKey: computed(() => ['versions', 'detail', selectedVersionId.value]),
|
||||
queryFn: () => labrinth.versions_v3.getVersion(selectedVersionId.value),
|
||||
enabled: computed(() => !!selectedVersionId.value),
|
||||
})
|
||||
|
||||
watch(
|
||||
() => selectedProjectId.value,
|
||||
async (newProjectId) => {
|
||||
selectedVersionId.value = ''
|
||||
projectVersions.value = []
|
||||
|
||||
if (!newProjectId) return
|
||||
|
||||
versionsLoading.value = true
|
||||
try {
|
||||
const versions = await labrinth.versions_v3.getProjectVersions(newProjectId)
|
||||
projectVersions.value = versions.map((v) => ({
|
||||
id: v.id,
|
||||
version_number: v.version_number,
|
||||
name: v.name,
|
||||
}))
|
||||
} catch (error: unknown) {
|
||||
const err = error as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: 'Failed to load versions',
|
||||
text: err.data?.description || String(error),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
versionsLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<label>
|
||||
<span class="label__title">Supported MC versions</span>
|
||||
<McVersionPicker v-model="supportedGameVersions" no-header :game-versions="gameVersions" />
|
||||
</label>
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title">Recommended MC version</span>
|
||||
<Combobox
|
||||
v-model="recommendedGameVersion"
|
||||
v-tooltip="
|
||||
!recommendedOptions.length
|
||||
? 'Set supported versions before selecting the recommended version'
|
||||
: undefined
|
||||
"
|
||||
:options="recommendedOptions"
|
||||
searchable
|
||||
:display-name="(val: string) => val"
|
||||
placeholder="Select version"
|
||||
:disabled="!recommendedOptions.length"
|
||||
/>
|
||||
<div class="mt-2 text-secondary">
|
||||
Players joining the server from the Modrinth App will connect using this version.
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Combobox } from '@modrinth/ui'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import McVersionPicker from '~/components/ui/create-project-version/components/McVersionPicker.vue'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const { supportedGameVersions, recommendedGameVersion } = injectServerCompatibilityContext()
|
||||
|
||||
const generatedState = useGeneratedState()
|
||||
const gameVersions = generatedState.value.gameVersions
|
||||
|
||||
const recommendedOptions = computed(() =>
|
||||
gameVersions
|
||||
.filter((v) => v.version_type === 'release')
|
||||
.filter((v) => supportedGameVersions.value.includes(v.version))
|
||||
.map((v) => ({ label: v.version, value: v.version })),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => supportedGameVersions.value,
|
||||
(supported) => {
|
||||
if (recommendedGameVersion.value && !supported.includes(recommendedGameVersion.value)) {
|
||||
recommendedGameVersion.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<DataLossWarningBanner />
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<div class="font-semibold text-contrast">Upload custom modpack</div>
|
||||
|
||||
<DropzoneFileInput
|
||||
v-if="!ctx.customModpackFile.value"
|
||||
primary-prompt="Drag and drop your .mrpack file"
|
||||
secondary-prompt="Or click to browse"
|
||||
accept=".mrpack"
|
||||
size="medium"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="ctx.customModpackFile.value"
|
||||
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">
|
||||
<FileIcon />
|
||||
<span
|
||||
v-tooltip="ctx.customModpackFile.value.name"
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap font-medium"
|
||||
>
|
||||
{{ ctx.customModpackFile.value.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ButtonStyled size="standard" :circular="true">
|
||||
<button
|
||||
v-tooltip="'Replace file'"
|
||||
aria-label="Replace file"
|
||||
class="!shadow-none"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<ArrowLeftRightIcon aria-hidden="true" />
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
@change="handleFileInputChange"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Checkbox v-model="ctx.hasLicensePermission.value">
|
||||
<span class="max-w-[90%] text-left text-primary">
|
||||
Do you have the appropriate licenses to redistribute all content in this Modpack?
|
||||
<NuxtLink
|
||||
to="https://support.modrinth.com/en/articles/8797527-obtaining-modpack-permissions"
|
||||
external
|
||||
target="_blank"
|
||||
class="font-medium text-blue underline"
|
||||
>
|
||||
Learn more
|
||||
</NuxtLink>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeftRightIcon, FileIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, DropzoneFileInput } from '@modrinth/ui'
|
||||
|
||||
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
|
||||
|
||||
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
|
||||
|
||||
const ctx = injectServerCompatibilityContext()
|
||||
|
||||
const fileInput = useTemplateRef<HTMLInputElement>('fileInput')
|
||||
|
||||
function handleFileUpload(files: File[]) {
|
||||
if (files.length > 0) {
|
||||
ctx.customModpackFile.value = files[0]
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInputChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file) {
|
||||
ctx.customModpackFile.value = file
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectGeneralSettings: false,
|
||||
newProjectEnvironmentSettings: true,
|
||||
hideRussiaCensorshipBanner: false,
|
||||
serverDiscovery: false,
|
||||
serverDiscovery: true,
|
||||
disablePrettyProjectUrlRedirects: false,
|
||||
hidePreviewBanner: false,
|
||||
i18nDebug: false,
|
||||
|
||||
@@ -92,6 +92,11 @@ export const useGeneratedState = () =>
|
||||
id: 'modpack',
|
||||
display: 'modpack',
|
||||
},
|
||||
{
|
||||
actual: 'server',
|
||||
id: 'server',
|
||||
display: 'server',
|
||||
},
|
||||
],
|
||||
loaderData: {
|
||||
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
|
||||
|
||||
@@ -3,6 +3,8 @@ export const getProjectTypeForUrl = (type, categories) => {
|
||||
}
|
||||
|
||||
export const getProjectTypeForUrlShorthand = (type, categories, overrideTags) => {
|
||||
if (type === 'minecraft_java_server') return 'server'
|
||||
|
||||
const tags = overrideTags ?? useGeneratedState().value
|
||||
|
||||
if (type === 'mod') {
|
||||
|
||||
@@ -398,6 +398,10 @@
|
||||
id: 'new-project',
|
||||
action: (event) => $refs.modal_creation.show(event),
|
||||
},
|
||||
{
|
||||
id: 'new-server-project',
|
||||
action: (event) => $refs.modal_creation.show(event, { type: 'server' }),
|
||||
},
|
||||
{
|
||||
id: 'new-collection',
|
||||
action: (event) => $refs.modal_collection_creation.show(event),
|
||||
@@ -410,10 +414,13 @@
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
{{ formatMessage(messages.publish) }}
|
||||
<template #new-project>
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newProject) }}
|
||||
</template>
|
||||
<template #new-server-project>
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newServerProject) }}
|
||||
</template>
|
||||
<!-- <template #import-project> <BoxImportIcon /> Import project </template>-->
|
||||
<template #new-collection>
|
||||
<CollectionIcon aria-hidden="true" /> {{ formatMessage(messages.newCollection) }}
|
||||
@@ -835,6 +842,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.create-new',
|
||||
defaultMessage: 'Create new...',
|
||||
},
|
||||
publish: {
|
||||
id: 'layout.action.publish',
|
||||
defaultMessage: 'Publish',
|
||||
},
|
||||
reviewProjects: {
|
||||
id: 'layout.action.review-projects',
|
||||
defaultMessage: 'Project review',
|
||||
@@ -867,6 +878,10 @@ const messages = defineMessages({
|
||||
id: 'layout.action.new-project',
|
||||
defaultMessage: 'New project',
|
||||
},
|
||||
newServerProject: {
|
||||
id: 'layout.action.new-server-project',
|
||||
defaultMessage: 'New server',
|
||||
},
|
||||
newCollection: {
|
||||
id: 'layout.action.new-collection',
|
||||
defaultMessage: 'New collection',
|
||||
|
||||
@@ -497,12 +497,27 @@
|
||||
"create.project.create-project": {
|
||||
"message": "Create project"
|
||||
},
|
||||
"create.project.create-server-project": {
|
||||
"message": "Create server"
|
||||
},
|
||||
"create.project.missing-fields-tooltip": {
|
||||
"message": "Missing fields: {fields}"
|
||||
},
|
||||
"create.project.name-label": {
|
||||
"message": "Name"
|
||||
},
|
||||
"create.project.name-placeholder": {
|
||||
"message": "Enter project name..."
|
||||
},
|
||||
"create.project.owner-description": {
|
||||
"message": "Set the project owner as yourself or an organization you're a member of."
|
||||
},
|
||||
"create.project.owner-label": {
|
||||
"message": "Owner"
|
||||
},
|
||||
"create.project.server-project-title": {
|
||||
"message": "Creating a server project"
|
||||
},
|
||||
"create.project.summary-description": {
|
||||
"message": "A sentence or two that describes your project."
|
||||
},
|
||||
@@ -515,6 +530,15 @@
|
||||
"create.project.title": {
|
||||
"message": "Creating a project"
|
||||
},
|
||||
"create.project.type-label": {
|
||||
"message": "Type"
|
||||
},
|
||||
"create.project.type-project": {
|
||||
"message": "Project"
|
||||
},
|
||||
"create.project.type-server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"create.project.url-label": {
|
||||
"message": "URL"
|
||||
},
|
||||
@@ -1433,6 +1457,12 @@
|
||||
"layout.action.new-project": {
|
||||
"message": "New project"
|
||||
},
|
||||
"layout.action.new-server-project": {
|
||||
"message": "New server"
|
||||
},
|
||||
"layout.action.publish": {
|
||||
"message": "Publish"
|
||||
},
|
||||
"layout.action.reports": {
|
||||
"message": "Review reports"
|
||||
},
|
||||
@@ -2132,6 +2162,12 @@
|
||||
"project-type.resourcepack.singular": {
|
||||
"message": "Resource Pack"
|
||||
},
|
||||
"project-type.server.plural": {
|
||||
"message": "Servers"
|
||||
},
|
||||
"project-type.server.singular": {
|
||||
"message": "Server"
|
||||
},
|
||||
"project-type.shader.plural": {
|
||||
"message": "Shaders"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,17 @@ import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useServerModrinthClient } from '~/server/utils/api-client'
|
||||
|
||||
// All valid project type URL segments
|
||||
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
|
||||
const PROJECT_TYPES = [
|
||||
'project',
|
||||
'mod',
|
||||
'plugin',
|
||||
'datapack',
|
||||
'shader',
|
||||
'resourcepack',
|
||||
'modpack',
|
||||
'server',
|
||||
'minecraft_java_server',
|
||||
]
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only handle project routes
|
||||
@@ -23,6 +33,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Fetch v2 project for redirect check AND cache it for the page
|
||||
// Using fetchQuery ensures the page's useQuery gets this cached result
|
||||
const project = await queryClient.fetchQuery(projectQueryOptions.v2(projectId, client))
|
||||
const projectV3 = await queryClient.fetchQuery(projectQueryOptions.v3(projectId, client))
|
||||
|
||||
// Let page handle 404
|
||||
if (!project) return
|
||||
@@ -35,12 +46,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
queryClient.setQueryData(['project', 'v2', project.id], project)
|
||||
}
|
||||
|
||||
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
|
||||
// Determine the correct URL type
|
||||
const correctType = getProjectTypeForUrlShorthand(
|
||||
project.project_type,
|
||||
project.loaders,
|
||||
tags.value,
|
||||
)
|
||||
const correctType = getProjectTypeForUrlShorthand(projectType, project.loaders, tags.value)
|
||||
|
||||
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
|
||||
const pathParts = to.path.split('/')
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"
|
||||
/>
|
||||
</NewModal>
|
||||
<OpenInAppModal ref="openInAppModal" />
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
@@ -432,7 +433,15 @@
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
<ProjectHeader :project="project" :member="!!currentMember">
|
||||
<component
|
||||
:is="isServerProject ? ServerProjectHeader : ProjectHeader"
|
||||
v-if="projectV3Loaded"
|
||||
v-bind="
|
||||
isServerProject
|
||||
? { project, projectV3, member: !!currentMember }
|
||||
: { project, member: !!currentMember }
|
||||
"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
|
||||
<nuxt-link
|
||||
@@ -446,6 +455,7 @@
|
||||
|
||||
<div class="hidden sm:contents">
|
||||
<ButtonStyled
|
||||
v-if="!isServerProject"
|
||||
size="large"
|
||||
:color="
|
||||
(auth.user && currentMember) || route.name === 'type-id-version-version'
|
||||
@@ -458,8 +468,6 @@
|
||||
v-tooltip="
|
||||
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
|
||||
"
|
||||
@mouseenter="loadVersions"
|
||||
@focus="loadVersions"
|
||||
@click="(event) => downloadModal.show(event)"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
@@ -468,10 +476,29 @@
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
size="large"
|
||||
:color="
|
||||
(auth.user && currentMember) || route.name === 'type-id-version-version'
|
||||
? `standard`
|
||||
: `brand`
|
||||
"
|
||||
:circular="!!auth.user && !!currentMember"
|
||||
>
|
||||
<button
|
||||
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
|
||||
@click="handlePlayServerProject"
|
||||
>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ auth.user && currentMember ? '' : 'Play' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="contents sm:hidden">
|
||||
<ButtonStyled
|
||||
v-if="!isServerProject"
|
||||
size="large"
|
||||
circular
|
||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||
@@ -479,13 +506,21 @@
|
||||
<button
|
||||
:aria-label="formatMessage(commonMessages.downloadButton)"
|
||||
class="flex sm:hidden"
|
||||
@mouseenter="loadVersions"
|
||||
@focus="loadVersions"
|
||||
@click="(event) => downloadModal.show(event)"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else
|
||||
size="large"
|
||||
circular
|
||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||
>
|
||||
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
|
||||
<PlayIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
|
||||
@@ -737,7 +772,7 @@
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ProjectHeader>
|
||||
</component>
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
:project="project"
|
||||
@@ -796,7 +831,19 @@
|
||||
</div>
|
||||
|
||||
<div class="normal-page__sidebar">
|
||||
<ProjectSidebarServerInfo
|
||||
v-if="isServerProject && serverDataLoaded"
|
||||
:project-v3="projectV3"
|
||||
:tags="tags"
|
||||
:required-content="serverRequiredContent"
|
||||
:recommended-version="serverRecommendedVersion"
|
||||
:supported-versions="serverSupportedVersions"
|
||||
:loaders="serverModpackLoaders"
|
||||
:status-online="projectV3?.minecraft_java_server?.ping?.data != null"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<ProjectSidebarCompatibility
|
||||
v-if="projectV3Loaded && !isServerProject"
|
||||
:project="project"
|
||||
:tags="tags"
|
||||
:v3-metadata="projectV3"
|
||||
@@ -805,9 +852,14 @@
|
||||
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
|
||||
<ProjectSidebarLinks
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
:link-target="$external()"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<ProjectSidebarTags
|
||||
:project="project"
|
||||
class="card flex-card experimental-styles-within"
|
||||
/>
|
||||
<ProjectSidebarCreators
|
||||
:organization="organization"
|
||||
:members="members"
|
||||
@@ -827,7 +879,7 @@
|
||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||
|
||||
<div class="details-list">
|
||||
<div class="details-list__item">
|
||||
<div v-if="projectV3Loaded && !isServerProject" class="details-list__item">
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(messages.licensedLabel) }}
|
||||
@@ -953,6 +1005,7 @@ import {
|
||||
ListIcon,
|
||||
ModrinthIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
@@ -976,6 +1029,7 @@ import {
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
OpenInAppModal,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
ProjectBackgroundGradient,
|
||||
@@ -985,8 +1039,11 @@ import {
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarDetails,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarServerInfo,
|
||||
ProjectSidebarTags,
|
||||
provideProjectPageContext,
|
||||
ScrollablePanel,
|
||||
ServerProjectHeader,
|
||||
ServersPromo,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
@@ -1041,6 +1098,7 @@ const debug = useDebugLogger('DownloadModal')
|
||||
|
||||
const settingsModal = ref()
|
||||
const downloadModal = ref()
|
||||
const openInAppModal = ref()
|
||||
const overTheTopDownloadAnimation = ref()
|
||||
|
||||
const userSelectedGameVersion = ref(null)
|
||||
@@ -1051,6 +1109,9 @@ const gameVersionFilterInput = ref()
|
||||
|
||||
const versionFilter = ref('')
|
||||
|
||||
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
||||
|
||||
const baseId = useId()
|
||||
@@ -1121,6 +1182,21 @@ const showVersionsCheckbox = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const serverProject = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug || project.value.id,
|
||||
numPlayers: projectV3.value?.minecraft_java_server?.ping?.data?.players_online,
|
||||
icon: project.value.icon_url,
|
||||
statusOnline: !!projectV3.value?.minecraft_java_server?.ping?.data,
|
||||
region: projectV3.value?.minecraft_server?.country,
|
||||
}))
|
||||
|
||||
function handlePlayServerProject() {
|
||||
openInAppModal.value?.show({
|
||||
serverProject: serverProject.value,
|
||||
})
|
||||
}
|
||||
|
||||
function installWithApp() {
|
||||
setTimeout(() => {
|
||||
getModrinthAppAccordion.value.open()
|
||||
@@ -1536,13 +1612,104 @@ const project = computed(() => {
|
||||
const projectId = computed(() => projectRaw.value?.id)
|
||||
|
||||
// V3 Project
|
||||
const { data: projectV3, error: _projectV3Error } = useQuery({
|
||||
const {
|
||||
data: projectV3,
|
||||
error: _projectV3Error,
|
||||
isPending: projectV3Pending,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['project', 'v3', projectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!projectId.value),
|
||||
})
|
||||
|
||||
// Server sidebar: modpack version + project for required content
|
||||
const serverModpackVersionId = computed(() => {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
return content?.kind === 'modpack' ? content.version_id : null
|
||||
})
|
||||
|
||||
const { data: serverModpackVersion, isPending: serverModpackVersionPending } = useQuery({
|
||||
queryKey: computed(() => ['sidebar-modpack-version', serverModpackVersionId.value]),
|
||||
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!serverModpackVersionId.value),
|
||||
})
|
||||
|
||||
const serverModpackProjectId = computed(() => serverModpackVersion.value?.project_id ?? null)
|
||||
|
||||
const { data: serverModpackProject, isPending: serverModpackProjectPending } = useQuery({
|
||||
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
|
||||
staleTime: STALE_TIME,
|
||||
enabled: computed(() => !!serverModpackProjectId.value),
|
||||
})
|
||||
|
||||
const serverDataLoaded = computed(() => {
|
||||
if (!projectV3.value) return false
|
||||
if (serverModpackVersionId.value && serverModpackVersionPending.value) return false
|
||||
if (serverModpackProjectId.value && serverModpackProjectPending.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const serverRequiredContent = computed(() => {
|
||||
if (!serverModpackProject.value) return null
|
||||
const primaryFile =
|
||||
serverModpackVersion.value?.files?.find((f) => f.primary) ??
|
||||
serverModpackVersion.value?.files?.[0]
|
||||
return {
|
||||
name: serverModpackProject.value.name,
|
||||
versionNumber: serverModpackVersion.value?.version_number ?? '',
|
||||
icon: serverModpackProject.value.icon_url,
|
||||
onclickName:
|
||||
serverModpackProject.value.id !== projectId.value
|
||||
? () => navigateTo(`/modpack/${serverModpackProject.value.slug}`)
|
||||
: undefined,
|
||||
onclickVersion:
|
||||
serverModpackProject.value.id !== projectId.value
|
||||
? () =>
|
||||
navigateTo(
|
||||
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
|
||||
)
|
||||
: undefined,
|
||||
onclickDownload: primaryFile?.url
|
||||
? () => navigateTo(primaryFile.url, { external: true })
|
||||
: undefined,
|
||||
showCustomModpackTooltip: serverModpackProject.value.id === projectId.value,
|
||||
}
|
||||
})
|
||||
|
||||
const serverRecommendedVersion = computed(() => {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
if (!content) return null
|
||||
|
||||
if (content.kind === 'modpack') {
|
||||
return serverModpackVersion.value?.game_versions?.[0] ?? null
|
||||
}
|
||||
|
||||
if (content.kind === 'vanilla') {
|
||||
return content.recommended_game_version ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const serverSupportedVersions = computed(() => {
|
||||
const content = projectV3.value?.minecraft_java_server?.content
|
||||
if (!content) return []
|
||||
|
||||
if (content.kind === 'vanilla') {
|
||||
return content.supported_game_versions?.filter((v) => !!v) ?? []
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const serverModpackLoaders = computed(() => {
|
||||
if (!serverModpackVersion.value) return []
|
||||
return serverModpackVersion.value.mrpack_loaders ?? []
|
||||
})
|
||||
|
||||
// Members
|
||||
const { data: allMembersRaw, error: _membersError } = useQuery({
|
||||
queryKey: computed(() => ['project', projectId.value, 'members']),
|
||||
@@ -1664,8 +1831,10 @@ async function updateProjectRoute() {
|
||||
|
||||
async function invalidateProject() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', routeProjectId.value] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', routeProjectId.value] })
|
||||
if (routeProjectId.value !== projectId.value) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v2', projectId.value] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', 'v3', projectId.value] })
|
||||
}
|
||||
// Prefix match — invalidates members, versions, dependencies, organization
|
||||
await queryClient.invalidateQueries({ queryKey: ['project', projectId.value] })
|
||||
@@ -1711,7 +1880,6 @@ const patchProjectMutation = useMutation({
|
||||
text: err.data ? err.data.description : err.message,
|
||||
type: 'error',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
@@ -1764,6 +1932,57 @@ const patchStatusMutation = useMutation({
|
||||
},
|
||||
})
|
||||
|
||||
// Mutation for patching V3 project data
|
||||
const patchProjectV3Mutation = useMutation({
|
||||
mutationFn: async ({ projectId, data }) => {
|
||||
await client.labrinth.projects_v3.edit(projectId, data)
|
||||
return data
|
||||
},
|
||||
|
||||
onMutate: async ({ projectId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['project', 'v3', projectId] })
|
||||
|
||||
const previousProject = queryClient.getQueryData(['project', 'v3', projectId])
|
||||
|
||||
queryClient.setQueryData(['project', 'v3', projectId], (old) => {
|
||||
if (!old) return old
|
||||
const merged = { ...old }
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
merged[key] &&
|
||||
typeof merged[key] === 'object' &&
|
||||
!Array.isArray(merged[key])
|
||||
) {
|
||||
merged[key] = { ...merged[key], ...value }
|
||||
} else {
|
||||
merged[key] = value
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
|
||||
return { previousProject, projectId }
|
||||
},
|
||||
|
||||
onError: (err, _variables, context) => {
|
||||
if (context?.previousProject) {
|
||||
queryClient.setQueryData(['project', 'v3', context.projectId], context.previousProject)
|
||||
}
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data ? err.data.description : err.message,
|
||||
type: 'error',
|
||||
})
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
await invalidateProject()
|
||||
},
|
||||
})
|
||||
|
||||
// Mutation for patching project icon
|
||||
const patchIconMutation = useMutation({
|
||||
mutationFn: async ({ projectId, icon }) => {
|
||||
@@ -1790,7 +2009,6 @@ const patchIconMutation = useMutation({
|
||||
text: err.data ? err.data.description : err.message,
|
||||
type: 'error',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
},
|
||||
|
||||
onSettled: async () => {
|
||||
@@ -2147,7 +2365,30 @@ async function patchProject(resData, quiet = false) {
|
||||
text: formatMessage(messages.projectUpdatedMessage),
|
||||
type: 'success',
|
||||
})
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
resolve(true)
|
||||
},
|
||||
onError: () => resolve(false),
|
||||
onSettled: () => stopLoading(),
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function patchProjectV3(resData, quiet = false) {
|
||||
startLoading()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
patchProjectV3Mutation.mutate(
|
||||
{ projectId: project.value.id, data: resData },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
if (!quiet) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.projectUpdated),
|
||||
text: formatMessage(messages.projectUpdatedMessage),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
resolve(true)
|
||||
},
|
||||
@@ -2279,7 +2520,8 @@ async function deleteVersion(id) {
|
||||
}
|
||||
|
||||
const navLinks = computed(() => {
|
||||
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||
const routeType = route.params.type || project.value.project_type
|
||||
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -2294,13 +2536,19 @@ const navLinks = computed(() => {
|
||||
{
|
||||
label: formatMessage(messages.changelogTab),
|
||||
href: `${projectUrl}/changelog`,
|
||||
shown: hasVersions.value,
|
||||
shown:
|
||||
hasVersions.value &&
|
||||
projectV3Loaded.value &&
|
||||
projectV3.value?.minecraft_server === undefined,
|
||||
onHover: loadVersions,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.versionsTab),
|
||||
href: `${projectUrl}/versions`,
|
||||
shown: hasVersions.value || !!currentMember.value,
|
||||
shown:
|
||||
(hasVersions.value || !!currentMember.value) &&
|
||||
projectV3Loaded.value &&
|
||||
projectV3.value?.minecraft_server === undefined,
|
||||
subpages: [`${projectUrl}/version/`],
|
||||
onHover: loadVersions,
|
||||
},
|
||||
@@ -2335,6 +2583,7 @@ provideProjectPageContext({
|
||||
|
||||
// Mutation functions
|
||||
patchProject,
|
||||
patchProjectV3,
|
||||
patchIcon,
|
||||
setProcessing,
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
:src="
|
||||
previewImage
|
||||
? previewImage
|
||||
: project.gallery?.[editIndex]?.url
|
||||
? project.gallery[editIndex].url
|
||||
: filteredGallery[editIndex]?.url
|
||||
? filteredGallery[editIndex].url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
alt="gallery-preview"
|
||||
@@ -175,14 +175,14 @@
|
||||
<ContractIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="(project?.gallery?.length ?? 0) > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="previous circle-button"
|
||||
@click="previousImage()"
|
||||
>
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="(project?.gallery?.length ?? 0) > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="next circle-button"
|
||||
@click="nextImage()"
|
||||
>
|
||||
@@ -222,7 +222,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
<div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
|
||||
<div v-if="currentMember && filteredGallery.length" class="card header-buttons">
|
||||
<FileInput
|
||||
:max-size="5242880"
|
||||
:accept="acceptFileTypes"
|
||||
@@ -243,8 +243,8 @@
|
||||
@change="handleFiles"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="project?.gallery?.length" class="items">
|
||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||
<div v-if="filteredGallery.length" class="items">
|
||||
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
|
||||
<a class="gallery-thumbnail" @click="expandImage(item as GalleryItem, index)">
|
||||
<img
|
||||
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
|
||||
@@ -414,8 +414,13 @@ const previewImage = ref<string | null>(null)
|
||||
const shouldPreventActions = ref(false)
|
||||
|
||||
// Constant for accepted file types
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
|
||||
const filteredGallery = computed(
|
||||
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
|
||||
// Keyboard navigation for expanded image modal
|
||||
useEventListener(document, 'keydown', (e) => {
|
||||
if (expandedGalleryItem.value) {
|
||||
@@ -435,18 +440,18 @@ useEventListener(document, 'keydown', (e) => {
|
||||
// Navigation functions
|
||||
function nextImage() {
|
||||
expandedGalleryIndex.value++
|
||||
if (expandedGalleryIndex.value >= project.value.gallery!.length) {
|
||||
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
|
||||
expandedGalleryIndex.value = 0
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
expandedGalleryIndex.value--
|
||||
if (expandedGalleryIndex.value < 0) {
|
||||
expandedGalleryIndex.value = project.value.gallery!.length - 1
|
||||
expandedGalleryIndex.value = filteredGallery.value.length - 1
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
|
||||
}
|
||||
|
||||
function expandImage(item: GalleryItem, index: number) {
|
||||
@@ -509,7 +514,7 @@ async function editGalleryItem() {
|
||||
shouldPreventActions.value = true
|
||||
startLoading()
|
||||
|
||||
const imageUrl = project.value!.gallery![editIndex.value].url
|
||||
const imageUrl = filteredGallery.value[editIndex.value].url
|
||||
const success = await contextEditGalleryItem(
|
||||
imageUrl,
|
||||
editTitle.value || undefined,
|
||||
@@ -529,7 +534,7 @@ async function editGalleryItem() {
|
||||
async function deleteGalleryImage() {
|
||||
startLoading()
|
||||
|
||||
const imageUrl = project.value!.gallery![deleteIndex.value].url!
|
||||
const imageUrl = filteredGallery.value[deleteIndex.value].url!
|
||||
await contextDeleteGalleryItem(imageUrl)
|
||||
|
||||
stopLoading()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ImageIcon,
|
||||
InfoIcon,
|
||||
LinkIcon,
|
||||
ServerIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
@@ -36,6 +37,8 @@ const {
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const navItems = computed(() => {
|
||||
const base = `${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||
|
||||
@@ -57,26 +60,36 @@ const navItems = computed(() => {
|
||||
icon: InfoIcon,
|
||||
}
|
||||
: null,
|
||||
isServerProject.value && {
|
||||
link: `/${base}/settings/server`,
|
||||
label: formatMessage(commonProjectSettingsMessages.server),
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
link: `/${base}/settings/tags`,
|
||||
label: formatMessage(commonProjectSettingsMessages.tags),
|
||||
icon: TagsIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/description`,
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/versions`,
|
||||
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||
icon: VersionIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/license`,
|
||||
label: formatMessage(commonProjectSettingsMessages.license),
|
||||
icon: BookTextIcon,
|
||||
},
|
||||
isServerProject.value && {
|
||||
link: `/${base}/settings/description`,
|
||||
label: formatMessage(commonProjectSettingsMessages.description),
|
||||
icon: AlignLeftIcon,
|
||||
},
|
||||
{
|
||||
link: `/${base}/settings/gallery`,
|
||||
label: formatMessage(commonProjectSettingsMessages.gallery),
|
||||
@@ -92,13 +105,13 @@ const navItems = computed(() => {
|
||||
label: formatMessage(commonProjectSettingsMessages.members),
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/analytics`,
|
||||
label: formatMessage(commonProjectSettingsMessages.analytics),
|
||||
icon: ChartIcon,
|
||||
},
|
||||
{ type: 'heading', label: 'moderation', shown: showEnvironment },
|
||||
{
|
||||
!isServerProject.value && { type: 'heading', label: 'moderation', shown: showEnvironment },
|
||||
!isServerProject.value && {
|
||||
link: `/${base}/settings/environment`,
|
||||
label: formatMessage(commonProjectSettingsMessages.environment),
|
||||
icon: GlobeIcon,
|
||||
@@ -125,14 +138,16 @@ watch(route, () => {
|
||||
<div class="mb-8 flex w-full flex-col gap-4">
|
||||
<ModerationProjectNags
|
||||
v-if="
|
||||
(currentMember && project.status === 'draft') ||
|
||||
tags.rejectedStatuses.includes(project.status)
|
||||
projectV3 &&
|
||||
((currentMember && project.status === 'draft') ||
|
||||
tags.rejectedStatuses.includes(project.status))
|
||||
"
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
:project-v3="projectV3"
|
||||
:versions="versions ?? undefined"
|
||||
:current-member="currentMember"
|
||||
:collapsed="collapsedChecklist"
|
||||
:route-name="route.name"
|
||||
:route-name="route.name as string"
|
||||
:tags="tags"
|
||||
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||
@set-processing="setProcessing"
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
:src="
|
||||
previewImage
|
||||
? previewImage
|
||||
: project.gallery[editIndex] && project.gallery[editIndex].url
|
||||
? project.gallery[editIndex].url
|
||||
: filteredGallery[editIndex] && filteredGallery[editIndex].url
|
||||
? filteredGallery[editIndex].url
|
||||
: 'https://cdn.modrinth.com/placeholder-banner.svg'
|
||||
"
|
||||
alt="gallery-preview"
|
||||
@@ -175,14 +175,14 @@
|
||||
<ContractIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="project.gallery.length > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="previous circle-button"
|
||||
@click="previousImage()"
|
||||
>
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
v-if="project.gallery.length > 1"
|
||||
v-if="filteredGallery.length > 1"
|
||||
class="next circle-button"
|
||||
@click="nextImage()"
|
||||
>
|
||||
@@ -215,7 +215,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="items">
|
||||
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||
<div v-for="(item, index) in filteredGallery" :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'"
|
||||
@@ -340,22 +340,27 @@ const editFile = ref(null)
|
||||
const previewImage = ref(null)
|
||||
const shouldPreventActions = ref(false)
|
||||
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
|
||||
|
||||
const filteredGallery = computed(
|
||||
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
|
||||
)
|
||||
|
||||
const nextImage = () => {
|
||||
expandedGalleryIndex.value++
|
||||
if (expandedGalleryIndex.value >= project.value.gallery.length) {
|
||||
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
|
||||
expandedGalleryIndex.value = 0
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
||||
}
|
||||
|
||||
const previousImage = () => {
|
||||
expandedGalleryIndex.value--
|
||||
if (expandedGalleryIndex.value < 0) {
|
||||
expandedGalleryIndex.value = project.value.gallery.length - 1
|
||||
expandedGalleryIndex.value = filteredGallery.value.length - 1
|
||||
}
|
||||
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
|
||||
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
|
||||
}
|
||||
|
||||
const expandImage = (item, index) => {
|
||||
@@ -414,7 +419,7 @@ const editGalleryItem = async () => {
|
||||
shouldPreventActions.value = true
|
||||
|
||||
const success = await editGalleryItemMutation(
|
||||
project.value.gallery[editIndex.value].url,
|
||||
filteredGallery.value[editIndex.value].url,
|
||||
editTitle.value || undefined,
|
||||
editDescription.value || undefined,
|
||||
editFeatured.value,
|
||||
@@ -429,7 +434,7 @@ const editGalleryItem = async () => {
|
||||
}
|
||||
|
||||
const deleteGalleryImage = async () => {
|
||||
await deleteGalleryItemMutation(project.value.gallery[deleteIndex.value].url)
|
||||
await deleteGalleryItemMutation(filteredGallery.value[deleteIndex.value].url)
|
||||
}
|
||||
|
||||
const handleKeydown = (e) => {
|
||||
|
||||
@@ -10,154 +10,236 @@
|
||||
@proceed="deleteProject"
|
||||
/>
|
||||
<section class="universal-card">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Project information</span>
|
||||
</h3>
|
||||
</div>
|
||||
<label for="project-icon">
|
||||
<span class="label__title">Icon</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
<Avatar
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
|
||||
:alt="project.title"
|
||||
size="md"
|
||||
class="project__icon"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
id="project-icon"
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="choose-image iconified-button"
|
||||
prompt="Upload icon"
|
||||
aria-label="Upload icon"
|
||||
:disabled="!hasPermission"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedIcon && (previewImage || project.icon_url)"
|
||||
class="iconified-button"
|
||||
:disabled="!hasPermission"
|
||||
@click="markIconForDeletion"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove icon
|
||||
</button>
|
||||
<div class="flex max-w-[600px] flex-col gap-6">
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Project information</span>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="project-name">
|
||||
<span class="label__title">Name</span>
|
||||
</label>
|
||||
<StyledInput id="project-name" v-model="name" :maxlength="2048" :disabled="!hasPermission" />
|
||||
|
||||
<label for="project-slug">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper">
|
||||
<div class="text-input-wrapper__before">
|
||||
<span class="hidden sm:inline">https://modrinth.com</span>/{{
|
||||
$getProjectTypeForUrl(project.project_type, project.loaders)
|
||||
}}/
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label for="project-summary">
|
||||
<span class="label__title">Summary</span>
|
||||
</label>
|
||||
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
|
||||
<TriangleAlertIcon class="my-auto" />
|
||||
{{ summaryWarning }}
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-summary"
|
||||
v-model="summary"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="summary-input"
|
||||
/>
|
||||
<template
|
||||
v-if="
|
||||
!flags.newProjectEnvironmentSettings &&
|
||||
project.versions?.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
project.project_type !== 'datapack'
|
||||
"
|
||||
>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-client">
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
<div>
|
||||
<label for="project-name">
|
||||
<span class="label__title">Name</span>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-env-client"
|
||||
v-model="clientSide"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="sideTypes"
|
||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
<StyledInput
|
||||
id="project-name"
|
||||
v-model="name"
|
||||
:maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-server">
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-env-server"
|
||||
v-model="serverSide"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="sideTypes"
|
||||
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-visibility">
|
||||
<span class="label__title">Visibility</span>
|
||||
<div class="label__description">
|
||||
Public and archived projects are visible in search. Unlisted projects are published, but
|
||||
not visible in search or on user profiles. Private projects are only accessible by
|
||||
members of the project.
|
||||
|
||||
<p>If approved by the moderators:</p>
|
||||
<ul class="visibility-info">
|
||||
<div>
|
||||
<label for="project-slug">
|
||||
<span class="label__title">URL</span>
|
||||
</label>
|
||||
<div class="text-input-wrapper !w-full">
|
||||
<div class="text-input-wrapper__before">
|
||||
<span class="hidden sm:inline">https://modrinth.com</span>/{{
|
||||
$getProjectTypeForUrl(project.project_type, project.loaders)
|
||||
}}/
|
||||
</div>
|
||||
<StyledInput
|
||||
id="project-slug"
|
||||
v-model="slug"
|
||||
:maxlength="64"
|
||||
autocomplete="off"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="project-summary">
|
||||
<span class="label__title">Summary</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
id="project-summary"
|
||||
v-model="summary"
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:disabled="!hasPermission"
|
||||
resize="vertical"
|
||||
/>
|
||||
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
|
||||
<TriangleAlertIcon class="my-auto" />
|
||||
{{ summaryWarning }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="project-icon">
|
||||
<span class="label__title"
|
||||
>Icon <span class="font-normal text-secondary">(optional)</span></span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="input-group">
|
||||
<Avatar
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
|
||||
:alt="project.title"
|
||||
size="md"
|
||||
class="project__icon"
|
||||
/>
|
||||
<div class="input-stack">
|
||||
<FileInput
|
||||
id="project-icon"
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="choose-image iconified-button"
|
||||
prompt="Upload icon"
|
||||
aria-label="Upload icon"
|
||||
:disabled="!hasPermission"
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedIcon && (previewImage || project.icon_url)"
|
||||
class="iconified-button"
|
||||
:disabled="!hasPermission"
|
||||
@click="markIconForDeletion"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove icon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server Project Settings -->
|
||||
<template v-if="isServerProject">
|
||||
<!-- Banner -->
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title"
|
||||
>Banner <span class="font-normal text-secondary">(optional)</span></span
|
||||
>
|
||||
</label>
|
||||
<div class="mt-2">
|
||||
<label
|
||||
class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-dashed border-surface-5 transition-colors"
|
||||
:class="
|
||||
!deletedBanner && (bannerPreview || bannerGalleryImage?.url)
|
||||
? 'border-none'
|
||||
: 'aspect-[468/60] border-2 bg-surface-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
|
||||
class="relative h-full w-full overflow-hidden rounded-2xl"
|
||||
>
|
||||
<img
|
||||
:src="bannerPreview || bannerGalleryImage?.url"
|
||||
alt="Banner preview"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<ImageIcon v-else aria-hidden="true" class="h-8 w-8 text-secondary" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="hidden"
|
||||
:disabled="!hasPermission"
|
||||
@change="
|
||||
(e) => {
|
||||
const input = e.target
|
||||
if (input.files?.length) {
|
||||
if (fileIsValid(input.files[0], { maxSize: 524288, alertOnInvalid: true }))
|
||||
showBannerPreview(Array.from(input.files))
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<FileInput
|
||||
:max-size="524288"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="iconified-button"
|
||||
prompt="Upload banner"
|
||||
:disabled="!hasPermission"
|
||||
@change="showBannerPreview"
|
||||
>
|
||||
<UploadIcon aria-hidden="true" />
|
||||
</FileInput>
|
||||
<button
|
||||
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
|
||||
class="iconified-button"
|
||||
:disabled="!hasPermission"
|
||||
@click="markBannerForDeletion"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Remove banner
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 text-secondary">Gif, 468×60px recommended.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-if="
|
||||
!isServerProject &&
|
||||
!flags.newProjectEnvironmentSettings &&
|
||||
project.versions?.length !== 0 &&
|
||||
project.project_type !== 'resourcepack' &&
|
||||
project.project_type !== 'plugin' &&
|
||||
project.project_type !== 'shader' &&
|
||||
project.project_type !== 'datapack'
|
||||
"
|
||||
>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-client">
|
||||
<span class="label__title">Client-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
client side. Just because a mod works in Singleplayer doesn't mean it has actual
|
||||
client-side functionality.
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="clientSide"
|
||||
:options="sideTypeOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="project-env-server">
|
||||
<span class="label__title">Server-side</span>
|
||||
<span class="label__description">
|
||||
Select based on if the
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
|
||||
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
|
||||
server.
|
||||
</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="serverSide"
|
||||
:options="sideTypeOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label>
|
||||
<span class="label__title">Visibility</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<Combobox
|
||||
v-model="visibility"
|
||||
:options="visibilityOptions"
|
||||
placeholder="Select one"
|
||||
:disabled="!hasPermission"
|
||||
:max-height="500"
|
||||
/>
|
||||
<div>If approved by the moderators:</div>
|
||||
<ul class="visibility-info m-0">
|
||||
<li>
|
||||
<CheckIcon
|
||||
v-if="visibility === 'approved' || visibility === 'archived'"
|
||||
@@ -187,31 +269,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-visibility"
|
||||
v-model="visibility"
|
||||
class="small-multiselect"
|
||||
placeholder="Select one"
|
||||
:options="tags.approvedStatuses"
|
||||
:custom-label="(value) => formatProjectStatus(value)"
|
||||
:searchable="false"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:allow-empty="false"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -235,14 +293,21 @@
|
||||
Delete project
|
||||
</button>
|
||||
</section>
|
||||
<UnsavedChangesPopup
|
||||
:original="original"
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
ImageIcon,
|
||||
IssuesIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
TriangleAlertIcon,
|
||||
UploadIcon,
|
||||
@@ -251,13 +316,15 @@ import {
|
||||
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
|
||||
import {
|
||||
Avatar,
|
||||
Combobox,
|
||||
ConfirmModal,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
|
||||
|
||||
import FileInput from '~/components/ui/FileInput.vue'
|
||||
import { useFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
@@ -265,11 +332,13 @@ import { useFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
currentMember,
|
||||
patchProject,
|
||||
patchIcon,
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
@@ -290,6 +359,15 @@ const visibility = ref(
|
||||
: project.value.requested_status,
|
||||
)
|
||||
|
||||
// Server project specific refs
|
||||
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const bannerPreview = ref(null)
|
||||
const deletedBanner = ref(false)
|
||||
const bannerFile = ref(null)
|
||||
const bannerGalleryImage = computed(() =>
|
||||
project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME),
|
||||
)
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
||||
@@ -311,9 +389,37 @@ const summaryWarning = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const sideTypes = ['required', 'optional', 'unsupported']
|
||||
const sideTypeOptions = [
|
||||
{ value: 'required', label: 'Required' },
|
||||
{ value: 'optional', label: 'Optional' },
|
||||
{ value: 'unsupported', label: 'Unsupported' },
|
||||
]
|
||||
|
||||
const patchData = computed(() => {
|
||||
const visibilityOptions = computed(() =>
|
||||
tags.value.approvedStatuses.map((status) => {
|
||||
const subLabel = () => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'Visible via URL, on your profile, and in search.'
|
||||
case 'archived':
|
||||
return 'Visible via URL, on your profile, and in search, but marked as archived.'
|
||||
case 'unlisted':
|
||||
return 'Visible via URL only. Not shown on your profile or in search.'
|
||||
case 'private':
|
||||
return 'Not publicly visible. Only accessible to project members.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: status,
|
||||
label: formatProjectStatus(status),
|
||||
subLabel: subLabel(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const basePatchData = computed(() => {
|
||||
const data = {}
|
||||
|
||||
if (name.value !== project.value.title) {
|
||||
@@ -342,9 +448,52 @@ const patchData = computed(() => {
|
||||
return data
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
|
||||
})
|
||||
const saving = ref(false)
|
||||
|
||||
const original = computed(() => ({
|
||||
name: project.value.title,
|
||||
slug: project.value.slug,
|
||||
summary: project.value.description,
|
||||
clientSide: project.value.client_side,
|
||||
serverSide: project.value.server_side,
|
||||
visibility: tags.value.approvedStatuses.includes(project.value.status)
|
||||
? project.value.status
|
||||
: project.value.requested_status,
|
||||
icon: null,
|
||||
deletedIcon: false,
|
||||
bannerFile: null,
|
||||
deletedBanner: false,
|
||||
}))
|
||||
|
||||
const modified = computed(() => ({
|
||||
name: name.value,
|
||||
slug: slug.value,
|
||||
summary: summary.value,
|
||||
clientSide: clientSide.value,
|
||||
serverSide: serverSide.value,
|
||||
visibility: visibility.value,
|
||||
icon: icon.value,
|
||||
deletedIcon: deletedIcon.value,
|
||||
bannerFile: bannerFile.value,
|
||||
deletedBanner: deletedBanner.value,
|
||||
}))
|
||||
|
||||
function resetChanges() {
|
||||
name.value = project.value.title
|
||||
slug.value = project.value.slug
|
||||
summary.value = project.value.description
|
||||
clientSide.value = project.value.client_side
|
||||
serverSide.value = project.value.server_side
|
||||
visibility.value = tags.value.approvedStatuses.includes(project.value.status)
|
||||
? project.value.status
|
||||
: project.value.requested_status
|
||||
icon.value = null
|
||||
previewImage.value = null
|
||||
deletedIcon.value = false
|
||||
bannerFile.value = null
|
||||
bannerPreview.value = null
|
||||
deletedBanner.value = false
|
||||
}
|
||||
|
||||
const hasModifiedVisibility = () => {
|
||||
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
|
||||
@@ -354,17 +503,33 @@ const hasModifiedVisibility = () => {
|
||||
return originalVisibility !== visibility.value
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (hasChanges.value) {
|
||||
await patchProject(patchData.value)
|
||||
}
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const hasV2Changes = Object.keys(basePatchData.value).length > 0
|
||||
|
||||
if (deletedIcon.value) {
|
||||
await deleteIcon()
|
||||
deletedIcon.value = false
|
||||
} else if (icon.value) {
|
||||
await patchIcon(icon.value)
|
||||
icon.value = null
|
||||
if (hasV2Changes) {
|
||||
await patchProject(basePatchData.value)
|
||||
}
|
||||
|
||||
if (deletedIcon.value) {
|
||||
await deleteIcon()
|
||||
deletedIcon.value = false
|
||||
} else if (icon.value) {
|
||||
await patchIcon(icon.value)
|
||||
icon.value = null
|
||||
}
|
||||
|
||||
if (deletedBanner.value) {
|
||||
await deleteBanner()
|
||||
deletedBanner.value = false
|
||||
} else if (bannerFile.value) {
|
||||
await uploadBanner()
|
||||
bannerFile.value = null
|
||||
bannerPreview.value = null
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +543,79 @@ const showPreviewImage = (files) => {
|
||||
}
|
||||
}
|
||||
|
||||
const showBannerPreview = (files) => {
|
||||
const file = files[0]
|
||||
if (file) {
|
||||
bannerFile.value = file
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
bannerPreview.value = e.target.result
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
deletedBanner.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const markBannerForDeletion = () => {
|
||||
bannerPreview.value = null
|
||||
bannerFile.value = null
|
||||
deletedBanner.value = true
|
||||
}
|
||||
|
||||
const uploadBanner = async () => {
|
||||
if (!bannerFile.value) return
|
||||
|
||||
try {
|
||||
// First, delete existing banner image if there is one
|
||||
const existingBanner = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
|
||||
if (existingBanner) {
|
||||
await labrinth.projects_v2.deleteGalleryImage(project.value.id, existingBanner.url)
|
||||
}
|
||||
|
||||
// Upload new banner as gallery image with special title
|
||||
const ext = bannerFile.value.type.split('/').pop() ?? 'png'
|
||||
await labrinth.projects_v2.createGalleryImage(project.value.id, bannerFile.value, {
|
||||
ext,
|
||||
featured: false,
|
||||
title: MC_SERVER_BANNER_NAME,
|
||||
})
|
||||
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Banner updated',
|
||||
text: 'Your project banner has been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to update banner',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBanner = async () => {
|
||||
try {
|
||||
const bannerImage = project.value.gallery?.find((img) => img.title === MC_SERVER_BANNER_NAME)
|
||||
if (bannerImage) {
|
||||
await labrinth.projects_v2.deleteGalleryImage(project.value.id, bannerImage.url)
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Banner removed',
|
||||
text: 'Your project banner has been removed.',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to remove banner',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteProject = async () => {
|
||||
await useBaseFetch(`project/${project.value.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -409,10 +647,14 @@ const deleteIcon = async () => {
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.visibility-info {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-xs);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
@@ -435,15 +677,6 @@ svg {
|
||||
}
|
||||
}
|
||||
|
||||
.summary-input {
|
||||
min-height: 8rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.small-multiselect {
|
||||
max-width: 15rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,84 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<!-- Server Project Links -->
|
||||
<section v-if="isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-website" title="Your server's website.">
|
||||
<span class="label__title">Website</span>
|
||||
<span class="label__description">Your server's official website.</span>
|
||||
</label>
|
||||
<input
|
||||
id="server-website"
|
||||
v-model="siteUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-store" title="Your server's store page.">
|
||||
<span class="label__title">Store</span>
|
||||
<span class="label__description">A link to your server's store or shop.</span>
|
||||
</label>
|
||||
<input
|
||||
id="server-store"
|
||||
v-model="storeUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
id="server-wiki"
|
||||
title="A page containing information, documentation, and help for the server."
|
||||
>
|
||||
<span class="label__title">Wiki page</span>
|
||||
<span class="label__description"
|
||||
>A page containing information, documentation, and help for the server.</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="server-wiki"
|
||||
v-model="serverWikiUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label id="server-discord" title="An invitation link to your Discord server.">
|
||||
<span class="label__title">Discord</span>
|
||||
<span class="label__description">An invitation link to your Discord server.</span>
|
||||
</label>
|
||||
<input
|
||||
id="server-discord"
|
||||
v-model="serverDiscordUrl"
|
||||
type="url"
|
||||
placeholder="Enter a valid URL"
|
||||
maxlength="2048"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasServerChanges"
|
||||
@click="saveServerChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Standard Project Links -->
|
||||
<section v-if="!isServerProject" class="universal-card">
|
||||
<h2>External links</h2>
|
||||
<div class="adjacent-input">
|
||||
<label
|
||||
@@ -174,17 +252,51 @@
|
||||
<script setup>
|
||||
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
|
||||
import { DropdownSelect, injectProjectPageContext, StyledInput } from '@modrinth/ui'
|
||||
import {
|
||||
DropdownSelect,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const tags = useGeneratedState()
|
||||
|
||||
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
|
||||
const {
|
||||
projectV2: project,
|
||||
projectV3,
|
||||
currentMember,
|
||||
patchProject,
|
||||
invalidate,
|
||||
} = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const issuesUrl = ref(project.value.issues_url)
|
||||
const sourceUrl = ref(project.value.source_url)
|
||||
const wikiUrl = ref(project.value.wiki_url)
|
||||
const discordUrl = ref(project.value.discord_url)
|
||||
|
||||
// Server project links
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const siteUrl = ref(projectV3.value?.link_urls?.site?.url ?? '')
|
||||
const storeUrl = ref(projectV3.value?.link_urls?.store?.url ?? '')
|
||||
const serverWikiUrl = ref(projectV3.value?.link_urls?.wiki?.url ?? '')
|
||||
const serverDiscordUrl = ref(projectV3.value?.link_urls?.discord?.url ?? '')
|
||||
|
||||
watch(
|
||||
projectV3,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
siteUrl.value = newVal.link_urls?.site?.url ?? ''
|
||||
storeUrl.value = newVal.link_urls?.store?.url ?? ''
|
||||
serverWikiUrl.value = newVal.link_urls?.wiki?.url ?? ''
|
||||
serverDiscordUrl.value = newVal.link_urls?.discord?.url ?? ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const isIssuesUrlCommon = computed(() => {
|
||||
if (!issuesUrl.value || issuesUrl.value.trim().length === 0) return true
|
||||
return isCommonUrl(issuesUrl.value, commonLinkDomains.issues)
|
||||
@@ -281,6 +393,56 @@ const hasChanges = computed(() => {
|
||||
return Object.keys(patchData.value).length > 0
|
||||
})
|
||||
|
||||
// Server project links
|
||||
const serverPatchData = computed(() => {
|
||||
const data = {}
|
||||
const originalSite = projectV3.value?.link_urls?.site?.url ?? ''
|
||||
const originalStore = projectV3.value?.link_urls?.store?.url ?? ''
|
||||
const originalWiki = projectV3.value?.link_urls?.wiki?.url ?? ''
|
||||
const originalDiscord = projectV3.value?.link_urls?.discord?.url ?? ''
|
||||
|
||||
if (checkDifference(siteUrl.value, originalSite)) {
|
||||
data.site = siteUrl.value === '' ? null : siteUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(storeUrl.value, originalStore)) {
|
||||
data.store = storeUrl.value === '' ? null : storeUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(serverWikiUrl.value, originalWiki)) {
|
||||
data.wiki = serverWikiUrl.value === '' ? null : serverWikiUrl.value?.trim()
|
||||
}
|
||||
if (checkDifference(serverDiscordUrl.value, originalDiscord)) {
|
||||
data.discord = serverDiscordUrl.value === '' ? null : serverDiscordUrl.value?.trim()
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
const hasServerChanges = computed(() => {
|
||||
return Object.keys(serverPatchData.value).length > 0
|
||||
})
|
||||
|
||||
async function saveServerChanges() {
|
||||
const linkUpdates = serverPatchData.value
|
||||
if (Object.keys(linkUpdates).length === 0) return
|
||||
|
||||
try {
|
||||
await labrinth.projects_v3.edit(project.value.id, {
|
||||
link_urls: linkUpdates,
|
||||
})
|
||||
await invalidate()
|
||||
addNotification({
|
||||
title: 'Links updated',
|
||||
text: 'Your server links have been updated.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
title: 'Failed to update links',
|
||||
text: err.data?.description ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (patchData.value && (await patchProject(patchData.value))) {
|
||||
donationLinks.value = JSON.parse(JSON.stringify(project.value.donation_urls))
|
||||
@@ -321,13 +483,7 @@ function updateDonationLinks() {
|
||||
}
|
||||
|
||||
function checkDifference(newLink, existingLink) {
|
||||
if (newLink === '' && existingLink !== null) {
|
||||
return true
|
||||
}
|
||||
if (!newLink && !existingLink) {
|
||||
return false
|
||||
}
|
||||
return newLink !== existingLink
|
||||
return newLink != existingLink
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
496
apps/frontend/src/pages/[type]/[id]/settings/server.vue
Normal file
496
apps/frontend/src/pages/[type]/[id]/settings/server.vue
Normal file
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-2xl font-semibold text-contrast">Server details</div>
|
||||
|
||||
<!-- Country -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="server-country">
|
||||
<span class="label__title">Country</span>
|
||||
</label>
|
||||
<Combobox
|
||||
id="server-country"
|
||||
v-model="country"
|
||||
:options="countryOptions"
|
||||
searchable
|
||||
placeholder="Select country"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="server-language">
|
||||
<span class="label__title"
|
||||
>Languages <span class="font-normal text-secondary">(optional)</span></span
|
||||
>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="server-language"
|
||||
v-model="languages"
|
||||
:options="languageOptions.map((l) => l.value)"
|
||||
:custom-label="(code) => languageOptions.find((l) => l.value === code)?.label ?? code"
|
||||
:multiple="true"
|
||||
:searchable="true"
|
||||
:show-labels="false"
|
||||
:close-on-select="false"
|
||||
placeholder="Select languages"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Java Address -->
|
||||
<div class="max-w-[600px]">
|
||||
<div class="flex items-center justify-between">
|
||||
<label for="java-address">
|
||||
<span class="label__title !m-0 !text-contrast">Java address</span>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex items-center gap-2"
|
||||
@focusout="
|
||||
() => {
|
||||
if (!lastPingAddressChanged && javaPingResult) return
|
||||
pingJavaServer()
|
||||
}
|
||||
"
|
||||
>
|
||||
<StyledInput
|
||||
id="java-address"
|
||||
v-model="javaAddress"
|
||||
placeholder="Enter address"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="flex-grow"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<StyledInput
|
||||
v-model="javaPort"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="w-24"
|
||||
input-class="text-center"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="javaAddress"
|
||||
class="mt-2 flex gap-1.5"
|
||||
:class="{
|
||||
'items-center': javaPingResult?.online,
|
||||
'items-start': javaPingResult && !javaPingResult.online,
|
||||
}"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-if="(javaAddress && javaPingResult) || javaPingLoading"
|
||||
circular
|
||||
type="transparent"
|
||||
size="small"
|
||||
color="oranges"
|
||||
>
|
||||
<button
|
||||
v-tooltip="'Refresh ping'"
|
||||
:disabled="javaPingLoading"
|
||||
@click="pingJavaServer"
|
||||
>
|
||||
<SpinnerIcon v-if="javaPingLoading" class="animate-spin" />
|
||||
<RefreshCwIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-if="javaPingResult !== null && !javaPingLoading && javaPingResult.online"
|
||||
class="mt-0.5 flex items-center gap-1.5 text-green"
|
||||
>
|
||||
Server is online!
|
||||
<template v-if="javaPingResult.latency">
|
||||
Latency: {{ javaPingResult.latency }}ms
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="javaPingResult !== null && !javaPingLoading" class="mt-0.5 text-orange">
|
||||
We couldn’t ping this server. It may be blocked by your host so try refreshing a few
|
||||
times. If it still doesn’t respond please
|
||||
<a
|
||||
class="inline underline"
|
||||
href="https://support.modrinth.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
contact support</a
|
||||
>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bedrock Address -->
|
||||
<div class="max-w-[600px]">
|
||||
<label for="bedrock-address">
|
||||
<span class="label__title !text-contrast"
|
||||
>Bedrock address
|
||||
<span class="font-normal text-secondary">(optional)</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<StyledInput
|
||||
id="bedrock-address"
|
||||
v-model="bedrockAddress"
|
||||
placeholder="Enter address"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="flex-grow"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<StyledInput
|
||||
v-model="bedrockPort"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="w-24"
|
||||
input-class="text-center"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompatibilityCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<UnsavedChangesPopup
|
||||
:original="original"
|
||||
:modified="modified"
|
||||
:saving="saving"
|
||||
@reset="resetChanges"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RefreshCwIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
StyledInput,
|
||||
UnsavedChangesPopup,
|
||||
} from '@modrinth/ui'
|
||||
import { Multiselect } from 'vue-multiselect'
|
||||
|
||||
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
|
||||
|
||||
const PING_TIMEOUT_MS = 5000
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
|
||||
|
||||
const javaAddress = ref('')
|
||||
const javaPort = ref(25565)
|
||||
const bedrockAddress = ref('')
|
||||
const bedrockPort = ref(19132)
|
||||
const country = ref('')
|
||||
const languages = ref([])
|
||||
|
||||
const javaPingLoading = ref(false)
|
||||
const javaPingResult = ref(null)
|
||||
|
||||
const lastPingedAddress = ref({ address: '', port: null })
|
||||
|
||||
const lastPingAddressChanged = computed(() => {
|
||||
return (
|
||||
javaAddress.value.trim() !== lastPingedAddress.value.address ||
|
||||
javaPort.value !== lastPingedAddress.value.port
|
||||
)
|
||||
})
|
||||
|
||||
let pingDebounceTimer = null
|
||||
|
||||
watch([javaAddress, javaPort], () => {
|
||||
clearTimeout(pingDebounceTimer)
|
||||
pingDebounceTimer = setTimeout(() => {
|
||||
pingJavaServer()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
const EDIT_DETAILS = 1 << 2
|
||||
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
|
||||
})
|
||||
|
||||
async function pingJavaServer() {
|
||||
const address = javaAddress.value?.trim()
|
||||
if (!address) {
|
||||
javaPingResult.value = null
|
||||
return
|
||||
}
|
||||
|
||||
javaPingLoading.value = true
|
||||
javaPingResult.value = null
|
||||
|
||||
const port = javaPort.value || 25565
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
client.labrinth.server_ping_internal.pingMinecraftJava({
|
||||
address,
|
||||
port,
|
||||
}),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Ping timed out')), PING_TIMEOUT_MS),
|
||||
),
|
||||
])
|
||||
javaPingResult.value = { online: true, latency: null }
|
||||
} catch {
|
||||
javaPingResult.value = { online: false, latency: null }
|
||||
} finally {
|
||||
javaPingLoading.value = false
|
||||
lastPingedAddress.value = { address, port }
|
||||
}
|
||||
}
|
||||
|
||||
function initFromProjectV3(v3) {
|
||||
if (!v3) return
|
||||
javaAddress.value = v3.minecraft_java_server?.address ?? ''
|
||||
javaPort.value = v3.minecraft_java_server?.port ?? 25565
|
||||
bedrockAddress.value = v3.minecraft_bedrock_server?.address ?? ''
|
||||
bedrockPort.value = v3.minecraft_bedrock_server?.port ?? 19132
|
||||
country.value = v3.minecraft_server?.country ?? ''
|
||||
languages.value = v3.minecraft_server?.languages ?? []
|
||||
|
||||
pingJavaServer()
|
||||
}
|
||||
|
||||
// initialize projectV3 values once
|
||||
if (projectV3.value) {
|
||||
initFromProjectV3(projectV3.value)
|
||||
} else {
|
||||
const stop = watch(
|
||||
() => projectV3.value,
|
||||
(v3) => {
|
||||
if (!v3) return
|
||||
initFromProjectV3(v3)
|
||||
stop()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const countryOptions = [
|
||||
{ value: 'US', label: 'United States' },
|
||||
{ value: 'CA', label: 'Canada' },
|
||||
{ value: 'GB', label: 'United Kingdom' },
|
||||
{ value: 'DE', label: 'Germany' },
|
||||
{ value: 'FR', label: 'France' },
|
||||
{ value: 'NL', label: 'Netherlands' },
|
||||
{ value: 'FI', label: 'Finland' },
|
||||
{ value: 'SE', label: 'Sweden' },
|
||||
{ value: 'NO', label: 'Norway' },
|
||||
{ value: 'DK', label: 'Denmark' },
|
||||
{ value: 'PL', label: 'Poland' },
|
||||
{ value: 'CZ', label: 'Czech Republic' },
|
||||
{ value: 'RO', label: 'Romania' },
|
||||
{ value: 'CH', label: 'Switzerland' },
|
||||
{ value: 'AT', label: 'Austria' },
|
||||
{ value: 'BE', label: 'Belgium' },
|
||||
{ value: 'IE', label: 'Ireland' },
|
||||
{ value: 'ES', label: 'Spain' },
|
||||
{ value: 'IT', label: 'Italy' },
|
||||
{ value: 'PT', label: 'Portugal' },
|
||||
{ value: 'RU', label: 'Russia' },
|
||||
{ value: 'UA', label: 'Ukraine' },
|
||||
{ value: 'LT', label: 'Lithuania' },
|
||||
{ value: 'LV', label: 'Latvia' },
|
||||
{ value: 'EE', label: 'Estonia' },
|
||||
{ value: 'BG', label: 'Bulgaria' },
|
||||
{ value: 'HR', label: 'Croatia' },
|
||||
{ value: 'HU', label: 'Hungary' },
|
||||
{ value: 'SK', label: 'Slovakia' },
|
||||
{ value: 'RS', label: 'Serbia' },
|
||||
{ value: 'GR', label: 'Greece' },
|
||||
{ value: 'TR', label: 'Turkey' },
|
||||
{ value: 'IL', label: 'Israel' },
|
||||
{ value: 'AE', label: 'United Arab Emirates' },
|
||||
{ value: 'SA', label: 'Saudi Arabia' },
|
||||
{ value: 'IN', label: 'India' },
|
||||
{ value: 'SG', label: 'Singapore' },
|
||||
{ value: 'JP', label: 'Japan' },
|
||||
{ value: 'KR', label: 'South Korea' },
|
||||
{ value: 'CN', label: 'China' },
|
||||
{ value: 'HK', label: 'Hong Kong' },
|
||||
{ value: 'TW', label: 'Taiwan' },
|
||||
{ value: 'AU', label: 'Australia' },
|
||||
{ value: 'NZ', label: 'New Zealand' },
|
||||
{ value: 'BR', label: 'Brazil' },
|
||||
{ value: 'AR', label: 'Argentina' },
|
||||
{ value: 'CL', label: 'Chile' },
|
||||
{ value: 'CO', label: 'Colombia' },
|
||||
{ value: 'MX', label: 'Mexico' },
|
||||
{ value: 'ZA', label: 'South Africa' },
|
||||
{ value: 'NG', label: 'Nigeria' },
|
||||
{ value: 'KE', label: 'Kenya' },
|
||||
{ value: 'EG', label: 'Egypt' },
|
||||
{ value: 'MY', label: 'Malaysia' },
|
||||
{ value: 'TH', label: 'Thailand' },
|
||||
{ value: 'VN', label: 'Vietnam' },
|
||||
{ value: 'PH', label: 'Philippines' },
|
||||
{ value: 'ID', label: 'Indonesia' },
|
||||
{ value: 'PK', label: 'Pakistan' },
|
||||
{ value: 'BD', label: 'Bangladesh' },
|
||||
]
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'es', label: 'Spanish' },
|
||||
{ value: 'pt', label: 'Portuguese' },
|
||||
{ value: 'fr', label: 'French' },
|
||||
{ value: 'de', label: 'German' },
|
||||
{ value: 'it', label: 'Italian' },
|
||||
{ value: 'nl', label: 'Dutch' },
|
||||
{ value: 'ru', label: 'Russian' },
|
||||
{ value: 'uk', label: 'Ukrainian' },
|
||||
{ value: 'pl', label: 'Polish' },
|
||||
{ value: 'cs', label: 'Czech' },
|
||||
{ value: 'sk', label: 'Slovak' },
|
||||
{ value: 'hu', label: 'Hungarian' },
|
||||
{ value: 'ro', label: 'Romanian' },
|
||||
{ value: 'bg', label: 'Bulgarian' },
|
||||
{ value: 'hr', label: 'Croatian' },
|
||||
{ value: 'sr', label: 'Serbian' },
|
||||
{ value: 'el', label: 'Greek' },
|
||||
{ value: 'tr', label: 'Turkish' },
|
||||
{ value: 'ar', label: 'Arabic' },
|
||||
{ value: 'he', label: 'Hebrew' },
|
||||
{ value: 'hi', label: 'Hindi' },
|
||||
{ value: 'bn', label: 'Bengali' },
|
||||
{ value: 'ur', label: 'Urdu' },
|
||||
{ value: 'zh', label: 'Chinese' },
|
||||
{ value: 'ja', label: 'Japanese' },
|
||||
{ value: 'ko', label: 'Korean' },
|
||||
{ value: 'th', label: 'Thai' },
|
||||
{ value: 'vi', label: 'Vietnamese' },
|
||||
{ value: 'id', label: 'Indonesian' },
|
||||
{ value: 'ms', label: 'Malay' },
|
||||
{ value: 'tl', label: 'Filipino' },
|
||||
{ value: 'sv', label: 'Swedish' },
|
||||
{ value: 'no', label: 'Norwegian' },
|
||||
{ value: 'da', label: 'Danish' },
|
||||
{ value: 'fi', label: 'Finnish' },
|
||||
{ value: 'lt', label: 'Lithuanian' },
|
||||
{ value: 'lv', label: 'Latvian' },
|
||||
{ value: 'et', label: 'Estonian' },
|
||||
]
|
||||
|
||||
const javaServerPatchData = computed(() => {
|
||||
const addressChanged =
|
||||
javaAddress.value.trim() !== (projectV3.value?.minecraft_java_server?.address ?? '') ||
|
||||
javaPort.value !== (projectV3.value?.minecraft_java_server?.port ?? 25565)
|
||||
if (addressChanged) {
|
||||
return {
|
||||
address: javaAddress.value.trim(),
|
||||
port: javaPort.value,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const bedrockServerPatchData = computed(() => {
|
||||
const origBedrock = projectV3.value?.minecraft_bedrock_server
|
||||
if (
|
||||
bedrockAddress.value !== (origBedrock?.address ?? '') ||
|
||||
bedrockPort.value !== (origBedrock?.port ?? 19132)
|
||||
) {
|
||||
return {
|
||||
address: bedrockAddress.value.trim(),
|
||||
port: bedrockPort.value,
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const serverPatchData = computed(() => {
|
||||
const origServer = projectV3.value?.minecraft_server
|
||||
const countryChanged = country.value && country.value !== origServer?.country
|
||||
const languagesChanged =
|
||||
JSON.stringify([...languages.value].sort()) !==
|
||||
JSON.stringify([...(origServer?.languages ?? [])].sort())
|
||||
|
||||
if (countryChanged || languagesChanged) {
|
||||
return {
|
||||
...origServer,
|
||||
...(countryChanged ? { country: country.value } : {}),
|
||||
...(languagesChanged ? { languages: languages.value } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
const v3PatchData = computed(() => {
|
||||
const data = {}
|
||||
if (Object.keys(serverPatchData.value).length > 0) {
|
||||
data.minecraft_server = serverPatchData.value
|
||||
}
|
||||
if (Object.keys(javaServerPatchData.value).length > 0) {
|
||||
data.minecraft_java_server = javaServerPatchData.value
|
||||
}
|
||||
if (Object.keys(bedrockServerPatchData.value).length > 0) {
|
||||
data.minecraft_bedrock_server = bedrockServerPatchData.value
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const original = computed(() => ({
|
||||
javaAddress: projectV3.value?.minecraft_java_server?.address ?? '',
|
||||
javaPort: projectV3.value?.minecraft_java_server?.port ?? 25565,
|
||||
bedrockAddress: projectV3.value?.minecraft_bedrock_server?.address ?? '',
|
||||
bedrockPort: projectV3.value?.minecraft_bedrock_server?.port ?? 19132,
|
||||
country: projectV3.value?.minecraft_server?.country ?? '',
|
||||
languages: projectV3.value?.minecraft_server?.languages ?? [],
|
||||
}))
|
||||
|
||||
const modified = computed(() => ({
|
||||
javaAddress: javaAddress.value,
|
||||
javaPort: javaPort.value,
|
||||
bedrockAddress: bedrockAddress.value,
|
||||
bedrockPort: bedrockPort.value,
|
||||
country: country.value,
|
||||
languages: languages.value,
|
||||
}))
|
||||
|
||||
function resetChanges() {
|
||||
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
|
||||
javaPort.value = projectV3.value?.minecraft_java_server?.port ?? 25565
|
||||
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''
|
||||
bedrockPort.value = projectV3.value?.minecraft_bedrock_server?.port ?? 19132
|
||||
country.value = projectV3.value?.minecraft_server?.country ?? ''
|
||||
languages.value = projectV3.value?.minecraft_server?.languages ?? []
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (javaAddress.value.trim() && !javaPingResult.value?.online) {
|
||||
addNotification({
|
||||
title: 'Cannot save',
|
||||
text: 'The Java server must be reachable before saving. Please ensure the ping succeeds.',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const hasV3Changes = Object.keys(v3PatchData.value).length > 0
|
||||
if (hasV3Changes) {
|
||||
await patchProjectV3(v3PatchData.value)
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -31,7 +31,10 @@
|
||||
that apply.
|
||||
</p>
|
||||
|
||||
<p v-if="project.versions.length === 0" class="known-errors">
|
||||
<p
|
||||
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
|
||||
class="known-errors"
|
||||
>
|
||||
Please upload a version first in order to select tags!
|
||||
</p>
|
||||
<template v-else>
|
||||
@@ -156,24 +159,28 @@ interface Category {
|
||||
const tags = useGeneratedState()
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const { projectV2: project, patchProject } = injectProjectPageContext()
|
||||
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
|
||||
|
||||
const formatCategoryName = (categoryName: string) => {
|
||||
return formatCategory(formatMessage, categoryName)
|
||||
}
|
||||
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
|
||||
const matchesProjectType = (x: Category) =>
|
||||
x.project_type === project.value.actualProjectType ||
|
||||
(x.project_type === 'minecraft_java_server' && isServerProject.value)
|
||||
|
||||
const { saved, current, saving, reset, save } = useSavable(
|
||||
() => ({
|
||||
selectedTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
|
||||
(x: Category) =>
|
||||
x.project_type === project.value.actualProjectType &&
|
||||
matchesProjectType(x) &&
|
||||
(project.value.categories.includes(x.name) ||
|
||||
project.value.additional_categories.includes(x.name)),
|
||||
) as Category[],
|
||||
featuredTags: sortedCategories(tags.value, formatCategoryName, locale.value).filter(
|
||||
(x: Category) =>
|
||||
x.project_type === project.value.actualProjectType &&
|
||||
project.value.categories.includes(x.name),
|
||||
(x: Category) => matchesProjectType(x) && project.value.categories.includes(x.name),
|
||||
) as Category[],
|
||||
}),
|
||||
async () => {
|
||||
@@ -217,7 +224,7 @@ const { saved, current, saving, reset, save } = useSavable(
|
||||
const categoryLists = computed(() => {
|
||||
const lists: Record<string, Category[]> = {}
|
||||
sortedCategories(tags.value, formatCategoryName, locale.value).forEach((x: Category) => {
|
||||
if (x.project_type === project.value.actualProjectType) {
|
||||
if (matchesProjectType(x)) {
|
||||
const header = x.header
|
||||
if (!lists[header]) {
|
||||
lists[header] = []
|
||||
@@ -230,7 +237,13 @@ const categoryLists = computed(() => {
|
||||
|
||||
const tooManyTagsWarning = computed(() => {
|
||||
const tagCount = current.value.selectedTags.length
|
||||
if (tagCount > 8) {
|
||||
if (projectV3?.value?.minecraft_server != null) {
|
||||
if (tagCount > 18) {
|
||||
return `You've selected ${tagCount} tags. Please reduce to 18 or fewer to keep your server focused and easier to discover.`
|
||||
} else if (tagCount > 12) {
|
||||
return `You've selected ${tagCount} tags. Consider reducing to 12 or fewer to keep your server focused and easier to discover.`
|
||||
}
|
||||
} else if (tagCount > 8) {
|
||||
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
</ProjectPageVersions>
|
||||
|
||||
<template v-if="!versions?.length">
|
||||
<div class="grid place-content-center py-10">
|
||||
<div class="grid place-items-center py-10">
|
||||
<svg
|
||||
width="250"
|
||||
height="200"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
link: '/dashboard/affiliate-links',
|
||||
label: formatMessage(commonMessages.affiliateLinksButton),
|
||||
icon: AffiliateIcon,
|
||||
shown: isAffiliate,
|
||||
shown: !!isAffiliate,
|
||||
},
|
||||
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
|
||||
]"
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Projects</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show($event)">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.createAProjectButton) }}
|
||||
</button>
|
||||
@@ -454,6 +454,14 @@ async function bulkEditLinks() {
|
||||
await initUserProjects()
|
||||
if (user.value?.projects) {
|
||||
projects.value = updateSort(user.value.projects, 'Name', false)
|
||||
|
||||
// minecraft_java_server type determined from component on projectV3
|
||||
projects.value = projects.value.map((project) => {
|
||||
const projectV3 = user.value?.projectsV3?.find((p) => p.id === project.id)
|
||||
if (projectV3?.minecraft_server != null)
|
||||
return { ...project, project_type: 'minecraft_java_server' }
|
||||
return project
|
||||
})
|
||||
user.value?.projectsV3?.forEach((project) => {
|
||||
if (
|
||||
project.side_types_migration_review_status === 'pending' &&
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useSearch,
|
||||
useServerSearch,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
|
||||
@@ -84,6 +85,10 @@ const currentType = computed(() =>
|
||||
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
|
||||
)
|
||||
|
||||
watch(currentType, (newType) => {
|
||||
console.log('currentType changed:', newType)
|
||||
})
|
||||
|
||||
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
|
||||
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
|
||||
|
||||
@@ -336,6 +341,28 @@ async function serverInstall(project: InstallableSearchResult) {
|
||||
}
|
||||
|
||||
const noLoad = ref(false)
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags, query, maxResults, currentPage })
|
||||
|
||||
const effectiveSortType = computed({
|
||||
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
|
||||
set: (v: SortType) => {
|
||||
if (currentType.value === 'server') serverCurrentSortType.value = v
|
||||
else currentSortType.value = v
|
||||
},
|
||||
})
|
||||
const effectiveSortTypes = computed(() =>
|
||||
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
|
||||
)
|
||||
|
||||
const {
|
||||
data: rawResults,
|
||||
refresh: refreshSearch,
|
||||
@@ -343,15 +370,30 @@ const {
|
||||
} = useLazyFetch(
|
||||
() => {
|
||||
const config = useRuntimeConfig()
|
||||
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
|
||||
if (currentType.value === 'server') {
|
||||
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
|
||||
return `${base}search${serverRequestParams.value}`
|
||||
}
|
||||
|
||||
return `${base}search${requestParams.value}`
|
||||
},
|
||||
{
|
||||
watch: false,
|
||||
transform: (hits) => {
|
||||
transform: (
|
||||
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
|
||||
): Labrinth.Search.v2.SearchResults => {
|
||||
noLoad.value = false
|
||||
return hits as Labrinth.Search.v2.SearchResults
|
||||
if ('hits_per_page' in hits) {
|
||||
return {
|
||||
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
|
||||
total_hits: hits.total_hits,
|
||||
limit: hits.hits_per_page,
|
||||
offset: (hits.page - 1) * hits.hits_per_page,
|
||||
}
|
||||
}
|
||||
return hits
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -395,7 +437,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...createPageParams(),
|
||||
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query: params })
|
||||
@@ -406,6 +448,12 @@ watch([currentFilters], () => {
|
||||
updateSearchResults(1, false)
|
||||
})
|
||||
|
||||
watch([serverCurrentFilters, serverCurrentSortType], () => {
|
||||
if (currentType.value === 'server') {
|
||||
updateSearchResults(1, false)
|
||||
}
|
||||
})
|
||||
|
||||
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
@@ -444,6 +492,32 @@ useSeoMeta({
|
||||
ogTitle,
|
||||
ogDescription: description,
|
||||
})
|
||||
|
||||
const serverHits = computed(
|
||||
() =>
|
||||
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
|
||||
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
|
||||
)
|
||||
|
||||
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
|
||||
const content = hit.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack') {
|
||||
const { project_name, project_icon, project_id } = content
|
||||
if (!project_name) return undefined
|
||||
return {
|
||||
name: project_name,
|
||||
icon: project_icon,
|
||||
onclick:
|
||||
project_id !== hit.project_id
|
||||
? () => {
|
||||
navigateTo(`/project/${project_id}`)
|
||||
}
|
||||
: undefined,
|
||||
showCustomModpackTooltip: project_id === hit.project_id,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
|
||||
@@ -547,41 +621,72 @@ useSeoMeta({
|
||||
@update:model-value="updateSearchResults()"
|
||||
/>
|
||||
</div>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filter"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByServer) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByServer) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
|
||||
</SearchSidebarFilter>
|
||||
<template v-if="currentType === 'server'">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
v-model:toggled-groups="serverToggledGroups"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filterType"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="
|
||||
![
|
||||
'server_category_minecraft_server_meta',
|
||||
'server_category_minecraft_server_community',
|
||||
'server_game_version',
|
||||
].includes(filterType.id)
|
||||
"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in filters.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="currentFilters"
|
||||
v-model:toggled-groups="toggledGroups"
|
||||
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
|
||||
:provided-filters="serverFilters"
|
||||
:filter-type="filter"
|
||||
:class="
|
||||
filtersMenuOpen
|
||||
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised'
|
||||
"
|
||||
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
|
||||
content-class="mb-4 mx-3"
|
||||
inner-panel-class="p-1"
|
||||
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
|
||||
</template>
|
||||
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #locked-game_version>
|
||||
{{ formatMessage(messages.gameVersionProvidedByServer) }}
|
||||
</template>
|
||||
<template #locked-mod_loader>
|
||||
{{ formatMessage(messages.modLoaderProvidedByServer) }}
|
||||
</template>
|
||||
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="normal-page__content">
|
||||
@@ -601,10 +706,10 @@ useSeoMeta({
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="currentSortType"
|
||||
v-model="effectiveSortType"
|
||||
class="!w-auto flex-grow md:flex-grow-0"
|
||||
name="Sort by"
|
||||
:options="[...sortTypes]"
|
||||
:options="effectiveSortTypes"
|
||||
:display-name="(option?: SortType) => option?.display"
|
||||
@change="updateSearchResults()"
|
||||
>
|
||||
@@ -650,6 +755,14 @@ useSeoMeta({
|
||||
/>
|
||||
</div>
|
||||
<SearchFilterControl
|
||||
v-if="currentType === 'server'"
|
||||
v-model:selected-filters="serverCurrentFilters"
|
||||
:filters="serverFilterTypes"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="currentFilters"
|
||||
:filters="filters.filter((f) => f.display !== 'none')"
|
||||
:provided-filters="serverFilters"
|
||||
@@ -657,7 +770,14 @@ useSeoMeta({
|
||||
:provided-message="messages.providedByServer"
|
||||
/>
|
||||
<LogoAnimated v-if="searchLoading && !noLoad" />
|
||||
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
|
||||
<div
|
||||
v-else-if="
|
||||
currentType === 'server'
|
||||
? serverHits.length === 0
|
||||
: results && results.hits && results.hits.length === 0
|
||||
"
|
||||
class="no-results"
|
||||
>
|
||||
<p>No results found for your query!</p>
|
||||
</div>
|
||||
<div v-else class="search-results-container">
|
||||
@@ -667,90 +787,117 @@ useSeoMeta({
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="result in results?.hits"
|
||||
:key="result.project_id"
|
||||
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="deprioritizedTags"
|
||||
:exclude-loaders="excludeLoaders"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(currentType)
|
||||
? {
|
||||
clientSide: result.client_side,
|
||||
serverSide: result.server_side,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
@mouseenter="handleProjectMouseEnter(result)"
|
||||
@mouseleave="handleProjectHoverEnd"
|
||||
>
|
||||
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons">
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<HeartIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<BookmarkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button>
|
||||
<MoreVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="currentType === 'server'">
|
||||
<ProjectCard
|
||||
v-for="project in serverHits"
|
||||
:key="`server-card-${project.project_id}`"
|
||||
:title="project.name"
|
||||
:icon-url="project.icon_url || undefined"
|
||||
:summary="project.summary"
|
||||
:tags="project.categories"
|
||||
:link="`/server/${project.slug}`"
|
||||
:server-online-players="
|
||||
project.minecraft_java_server?.ping?.data?.players_online ?? 0
|
||||
"
|
||||
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||
:server-region-code="project.minecraft_server?.country"
|
||||
:server-status-online="!!project.minecraft_java_server?.ping?.data"
|
||||
:server-modpack-content="getServerModpackContent(project)"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProjectCard
|
||||
v-for="result in results?.hits"
|
||||
:key="result.project_id"
|
||||
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{ name: result.author, link: `/user/${result.author}` }"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="deprioritizedTags"
|
||||
:exclude-loaders="excludeLoaders"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(currentType)
|
||||
? {
|
||||
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
|
||||
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="
|
||||
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
|
||||
"
|
||||
@mouseenter="handleProjectMouseEnter(result)"
|
||||
@mouseleave="handleProjectHoverEnd"
|
||||
>
|
||||
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
|
||||
<template v-if="flags.showDiscoverProjectButtons">
|
||||
<ButtonStyled color="brand">
|
||||
<button>
|
||||
<DownloadIcon />
|
||||
Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<HeartIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button>
|
||||
<BookmarkIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button>
|
||||
<MoreVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="server">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon />
|
||||
Installed
|
||||
</button>
|
||||
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
|
||||
Installing...
|
||||
</button>
|
||||
<button v-else @click="serverInstall(result as InstallableSearchResult)">
|
||||
<DownloadIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="server">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-if="
|
||||
(result as InstallableSearchResult).installed ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find(
|
||||
(x: InstallableMod) => x.project_id === result.project_id,
|
||||
)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
>
|
||||
<CheckIcon />
|
||||
Installed
|
||||
</button>
|
||||
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
|
||||
Installing...
|
||||
</button>
|
||||
<button v-else @click="serverInstall(result as InstallableSearchResult)">
|
||||
<DownloadIcon />
|
||||
Install
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
</ProjectCardList>
|
||||
</div>
|
||||
<div class="pagination-after">
|
||||
|
||||
@@ -221,6 +221,7 @@ const filterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Data Packs', label: 'Data Packs' },
|
||||
{ value: 'Plugins', label: 'Plugins' },
|
||||
{ value: 'Shaders', label: 'Shaders' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
]
|
||||
|
||||
const currentSortType = ref('Oldest')
|
||||
@@ -282,15 +283,17 @@ const typeFiltered = computed(() => {
|
||||
'Data Packs': 'datapack',
|
||||
Plugins: 'plugin',
|
||||
Shaders: 'shader',
|
||||
Servers: 'minecraft_java_server',
|
||||
}
|
||||
|
||||
const projectType = filterMap[currentFilterType.value]
|
||||
if (!projectType) return baseFiltered.value
|
||||
|
||||
return baseFiltered.value.filter(
|
||||
(queueItem) =>
|
||||
queueItem.project.project_types.length > 0 &&
|
||||
queueItem.project.project_types[0] === projectType,
|
||||
(queueItem.project.project_types.length > 0 &&
|
||||
queueItem.project.project_types[0] === projectType) ||
|
||||
(projectType === 'minecraft_java_server' &&
|
||||
queueItem.project.project_types.includes('minecraft_java_server')),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ const onDeleteOrganization = useClientTry(async () => {
|
||||
multiline
|
||||
:maxlength="256"
|
||||
:disabled="!hasPermission"
|
||||
wrapper-class="summary-input"
|
||||
resize="vertical"
|
||||
/>
|
||||
</div>
|
||||
<div class="universal-card">
|
||||
@@ -245,10 +245,3 @@ const onDeleteOrganization = useClientTry(async () => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.summary-input {
|
||||
min-height: 8rem;
|
||||
max-width: 24rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
<div class="header__row">
|
||||
<h2 class="header__title text-2xl">Projects</h2>
|
||||
<div class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_creation.show($event)">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(commonMessages.createAProjectButton) }}
|
||||
</button>
|
||||
|
||||
@@ -48,6 +48,7 @@ export default defineNuxtPlugin({
|
||||
modpack: 'list',
|
||||
shader: 'gallery',
|
||||
datapack: 'list',
|
||||
server: 'list',
|
||||
user: 'list',
|
||||
collection: 'list',
|
||||
},
|
||||
|
||||
426
apps/frontend/src/providers/manage-server-compatibility-modal.ts
Normal file
426
apps/frontend/src/providers/manage-server-compatibility-modal.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
|
||||
import { ArrowLeftRightIcon, LeftArrowIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
createContext,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
type MultiStageModal,
|
||||
type StageConfigInput,
|
||||
} from '@modrinth/ui'
|
||||
import JSZip from 'jszip'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { markRaw, toRaw } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import SelectCompatibilityType from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectCompatibilityType.vue'
|
||||
import SelectPublishedModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue'
|
||||
import SelectVanillaVersions from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectVanillaVersions.vue'
|
||||
import UploadCustomModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/UploadCustomModpack.vue'
|
||||
|
||||
export type CompatibilityType = 'vanilla' | 'published-modpack' | 'custom-modpack'
|
||||
|
||||
export interface ServerCompatibilityContextValue {
|
||||
// Stage management
|
||||
stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[]
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
|
||||
isSubmitting: Ref<boolean>
|
||||
isUploading: Ref<boolean>
|
||||
uploadProgress: Ref<UploadProgress>
|
||||
|
||||
// State
|
||||
compatibilityType: Ref<CompatibilityType | null>
|
||||
selectedProjectId: Ref<string>
|
||||
selectedVersionId: Ref<string>
|
||||
supportedGameVersions: Ref<string[]>
|
||||
recommendedGameVersion: Ref<string | null>
|
||||
customModpackFile: Ref<File | null>
|
||||
hasLicensePermission: Ref<boolean>
|
||||
isEditingExistingCompatibility: Ref<boolean>
|
||||
isSwitchingCompatibilityType: Ref<boolean>
|
||||
|
||||
// Actions
|
||||
resetContext: () => void
|
||||
handleSave: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectServerCompatibilityContext, provideServerCompatibilityContext] =
|
||||
createContext<ServerCompatibilityContextValue>('ServerCompatibilityModal')
|
||||
|
||||
export function createServerCompatibilityContext(
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
|
||||
): ServerCompatibilityContextValue {
|
||||
const { projectV3, patchProjectV3 } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadProgress = ref<UploadProgress>({ loaded: 0, total: 0, progress: 0 })
|
||||
const compatibilityType = ref<CompatibilityType | null>(null)
|
||||
const selectedProjectId = ref('')
|
||||
const selectedVersionId = ref('')
|
||||
const supportedGameVersions = ref<string[]>([])
|
||||
const recommendedGameVersion = ref<string | null>(null)
|
||||
const customModpackFile = ref<File | null>(null)
|
||||
const hasLicensePermission = ref(false)
|
||||
const isEditingExistingCompatibility = ref(false)
|
||||
const isSwitchingCompatibilityType = ref(false)
|
||||
|
||||
async function uploadCustomModpackFile(file: File): Promise<Labrinth.Versions.v3.Version> {
|
||||
const rawFile = toRaw(file)
|
||||
|
||||
// Default to filename if we can't parse the mrpack
|
||||
let versionName = rawFile.name.replace(/\.(zip|mrpack)$/i, '')
|
||||
let versionNumber = versionName
|
||||
const loaders: string[] = []
|
||||
let gameVersions: string[] = []
|
||||
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(rawFile)
|
||||
const indexFile = zip.file('modrinth.index.json')
|
||||
|
||||
if (indexFile) {
|
||||
const indexContent = await indexFile.async('text')
|
||||
const metadata = JSON.parse(indexContent) as {
|
||||
name?: string
|
||||
versionId?: string
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
if (metadata.name) {
|
||||
versionName = metadata.name
|
||||
}
|
||||
if (metadata.versionId) {
|
||||
versionNumber = metadata.versionId
|
||||
}
|
||||
if (metadata.dependencies) {
|
||||
if ('forge' in metadata.dependencies) loaders.push('forge')
|
||||
if ('neoforge' in metadata.dependencies) loaders.push('neoforge')
|
||||
if ('fabric-loader' in metadata.dependencies) loaders.push('fabric')
|
||||
if ('quilt-loader' in metadata.dependencies) loaders.push('quilt')
|
||||
if (metadata.dependencies.minecraft) {
|
||||
gameVersions = [metadata.dependencies.minecraft]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn('Could not parse modrinth.index.json from mrpack')
|
||||
}
|
||||
|
||||
const draftVersion: Labrinth.Versions.v3.DraftVersion = {
|
||||
project_id: projectV3.value.id,
|
||||
name: versionName,
|
||||
version_number: versionNumber,
|
||||
version_type: 'release',
|
||||
loaders,
|
||||
game_versions: gameVersions,
|
||||
featured: false,
|
||||
status: 'listed',
|
||||
changelog: '',
|
||||
dependencies: [],
|
||||
environment: 'client_and_server',
|
||||
}
|
||||
|
||||
const files: Labrinth.Versions.v3.DraftVersionFile[] = [{ file: rawFile, fileType: undefined }]
|
||||
|
||||
const uploadHandle = labrinth.versions_v3.createVersion(draftVersion, files, 'modpack')
|
||||
|
||||
uploadHandle.onProgress((progress) => {
|
||||
uploadProgress.value = progress
|
||||
})
|
||||
|
||||
return await uploadHandle.promise
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSubmitting.value = true
|
||||
setTimeout(() => {
|
||||
if (compatibilityType.value === 'custom-modpack' && isSubmitting.value === true) {
|
||||
isUploading.value = true
|
||||
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
|
||||
}
|
||||
}, 1500)
|
||||
try {
|
||||
switch (compatibilityType.value) {
|
||||
case 'vanilla':
|
||||
await patchProjectV3({
|
||||
minecraft_java_server: {
|
||||
content: {
|
||||
kind: 'vanilla',
|
||||
supported_game_versions: supportedGameVersions.value,
|
||||
recommended_game_version: recommendedGameVersion.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
case 'published-modpack':
|
||||
await patchProjectV3({
|
||||
minecraft_java_server: {
|
||||
content: {
|
||||
kind: 'modpack',
|
||||
version_id: selectedVersionId.value,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
case 'custom-modpack': {
|
||||
if (!customModpackFile.value) break
|
||||
|
||||
// Upload the modpack file as a new version
|
||||
const uploadedVersion: Labrinth.Versions.v3.Version = await uploadCustomModpackFile(
|
||||
customModpackFile.value,
|
||||
)
|
||||
|
||||
// Patch the project to point to the newly uploaded version
|
||||
try {
|
||||
await patchProjectV3({
|
||||
minecraft_java_server: {
|
||||
content: {
|
||||
kind: 'modpack',
|
||||
version_id: uploadedVersion.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
// If patch fails, clean up the uploaded version
|
||||
try {
|
||||
await labrinth.versions_v3.deleteVersion(uploadedVersion.id)
|
||||
} catch {
|
||||
console.error('Failed to clean up uploaded version after patch failure')
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
isUploading.value = false
|
||||
isSubmitting.value = false
|
||||
await nextTick()
|
||||
modal.value?.hide()
|
||||
} catch (err) {
|
||||
isUploading.value = false
|
||||
isSubmitting.value = false
|
||||
|
||||
const error = err as { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: 'Failed to save server compatibility',
|
||||
text: error.data?.description || String(err),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetContext() {
|
||||
compatibilityType.value = null
|
||||
selectedProjectId.value = ''
|
||||
selectedVersionId.value = ''
|
||||
supportedGameVersions.value = []
|
||||
recommendedGameVersion.value = null
|
||||
customModpackFile.value = null
|
||||
hasLicensePermission.value = false
|
||||
isEditingExistingCompatibility.value = false
|
||||
isSwitchingCompatibilityType.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
stageConfigs,
|
||||
modal,
|
||||
isSubmitting,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
compatibilityType,
|
||||
selectedProjectId,
|
||||
selectedVersionId,
|
||||
supportedGameVersions,
|
||||
recommendedGameVersion,
|
||||
customModpackFile,
|
||||
hasLicensePermission,
|
||||
isEditingExistingCompatibility,
|
||||
isSwitchingCompatibilityType,
|
||||
resetContext,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
|
||||
const selectCompatibilityTypeStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'select-compatibility-type',
|
||||
stageContent: markRaw(SelectCompatibilityType),
|
||||
title: 'Compatibility type',
|
||||
cannotNavigateForward: (ctx) => !ctx.compatibilityType.value,
|
||||
leftButtonConfig: null,
|
||||
rightButtonConfig: null,
|
||||
}
|
||||
|
||||
const selectVanillaVersionsStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'select-vanilla-versions',
|
||||
stageContent: markRaw(SelectVanillaVersions),
|
||||
title: 'Vanilla versions',
|
||||
skip: (ctx) => ctx.compatibilityType.value !== 'vanilla' && !!ctx.compatibilityType.value,
|
||||
leftButtonConfig: (ctx) =>
|
||||
ctx.isEditingExistingCompatibility.value
|
||||
? {
|
||||
label: 'Cancel',
|
||||
icon: XIcon,
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}
|
||||
: {
|
||||
label: 'Back',
|
||||
icon: LeftArrowIcon,
|
||||
onClick: () => ctx.modal.value?.prevStage(),
|
||||
},
|
||||
rightButtonConfig: (ctx) =>
|
||||
ctx.isSwitchingCompatibilityType.value
|
||||
? {
|
||||
label: 'Change type',
|
||||
icon: ArrowLeftRightIcon,
|
||||
iconPosition: 'before' as const,
|
||||
color: 'red' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
ctx.supportedGameVersions.value.length === 0 ||
|
||||
!ctx.recommendedGameVersion.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
}
|
||||
: {
|
||||
label: ctx.isSubmitting.value
|
||||
? ctx.isEditingExistingCompatibility.value
|
||||
? 'Updating…'
|
||||
: 'Saving…'
|
||||
: ctx.isEditingExistingCompatibility.value
|
||||
? 'Save changes'
|
||||
: 'Save',
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'green' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
ctx.supportedGameVersions.value.length === 0 ||
|
||||
!ctx.recommendedGameVersion.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
},
|
||||
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
|
||||
}
|
||||
|
||||
const selectPublishedModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'select-published-modpack',
|
||||
stageContent: markRaw(SelectPublishedModpack),
|
||||
title: 'Select modpack',
|
||||
skip: (ctx) => ctx.compatibilityType.value !== 'published-modpack',
|
||||
cannotNavigateForward: (ctx) => !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
|
||||
leftButtonConfig: (ctx) =>
|
||||
ctx.isEditingExistingCompatibility.value
|
||||
? {
|
||||
label: 'Cancel',
|
||||
icon: XIcon,
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}
|
||||
: {
|
||||
label: 'Back',
|
||||
icon: LeftArrowIcon,
|
||||
onClick: () => ctx.modal.value?.prevStage(),
|
||||
},
|
||||
rightButtonConfig: (ctx) =>
|
||||
ctx.isSwitchingCompatibilityType.value
|
||||
? {
|
||||
label: 'Change type',
|
||||
icon: ArrowLeftRightIcon,
|
||||
iconPosition: 'before' as const,
|
||||
color: 'red' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
}
|
||||
: {
|
||||
label: ctx.isSubmitting.value
|
||||
? ctx.isEditingExistingCompatibility.value
|
||||
? 'Updating…'
|
||||
: 'Saving…'
|
||||
: ctx.isEditingExistingCompatibility.value
|
||||
? 'Save changes'
|
||||
: 'Save',
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'green' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
},
|
||||
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
|
||||
}
|
||||
|
||||
function getUploadLabel(ctx: ServerCompatibilityContextValue): string {
|
||||
if (ctx.isUploading.value) {
|
||||
if (ctx.uploadProgress.value.progress >= 1) {
|
||||
return 'Saving…'
|
||||
}
|
||||
return `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
|
||||
}
|
||||
if (ctx.isSubmitting.value) {
|
||||
return ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…'
|
||||
}
|
||||
return ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save'
|
||||
}
|
||||
|
||||
const uploadCustomModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
|
||||
id: 'upload-custom-modpack',
|
||||
stageContent: markRaw(UploadCustomModpack),
|
||||
title: 'Custom modpack',
|
||||
skip: (ctx) => ctx.compatibilityType.value !== 'custom-modpack',
|
||||
disableClose: (ctx) => ctx.isUploading.value,
|
||||
leftButtonConfig: (ctx) =>
|
||||
ctx.isEditingExistingCompatibility.value
|
||||
? {
|
||||
label: 'Cancel',
|
||||
icon: XIcon,
|
||||
disabled: ctx.isUploading.value,
|
||||
onClick: () => ctx.modal.value?.hide(),
|
||||
}
|
||||
: {
|
||||
label: 'Back',
|
||||
icon: LeftArrowIcon,
|
||||
disabled: ctx.isUploading.value,
|
||||
onClick: () => ctx.modal.value?.prevStage(),
|
||||
},
|
||||
rightButtonConfig: (ctx) =>
|
||||
ctx.isSwitchingCompatibilityType.value
|
||||
? {
|
||||
label: ctx.isUploading.value
|
||||
? `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
|
||||
: 'Change type',
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'red' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
!ctx.customModpackFile.value ||
|
||||
!ctx.hasLicensePermission.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
buttonClass: 'tabular-nums',
|
||||
}
|
||||
: {
|
||||
label: getUploadLabel(ctx),
|
||||
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
|
||||
iconPosition: 'before' as const,
|
||||
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
|
||||
color: 'green' as const,
|
||||
disabled:
|
||||
ctx.isSubmitting.value ||
|
||||
!ctx.customModpackFile.value ||
|
||||
!ctx.hasLicensePermission.value,
|
||||
onClick: () => ctx.handleSave(),
|
||||
buttonClass: 'tabular-nums',
|
||||
},
|
||||
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
|
||||
}
|
||||
|
||||
const stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[] = [
|
||||
selectCompatibilityTypeStage,
|
||||
selectVanillaVersionsStage,
|
||||
selectPublishedModpackStage,
|
||||
uploadCustomModpackStage,
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -41,6 +41,14 @@ const projectTypeMessages = defineMessages({
|
||||
id: 'project-type.resourcepack.plural',
|
||||
defaultMessage: 'Resource Packs',
|
||||
},
|
||||
server: {
|
||||
id: 'project-type.server.singular',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
servers: {
|
||||
id: 'project-type.server.plural',
|
||||
defaultMessage: 'Servers',
|
||||
},
|
||||
shader: {
|
||||
id: 'project-type.shader.singular',
|
||||
defaultMessage: 'Shader',
|
||||
|
||||
Reference in New Issue
Block a user