Merge commit '7fa442fb28a2b9156690ff147206275163e7aec8' into beta

This commit is contained in:
2025-10-19 06:50:50 +03:00
1007 changed files with 143497 additions and 11362 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
class="iconified-button raised-button"
prompt="Replace"
:accept="acceptFileTypes"
:max-size="524288000"
:max-size="5242880"
should-always-reset
aria-label="Replace image"
@change="
@@ -197,7 +197,7 @@
</div>
<div v-if="currentMember" class="card header-buttons">
<FileInput
:max-size="524288000"
:max-size="5242880"
:accept="acceptFileTypes"
prompt="Upload an image"
aria-label="Upload an image"
@@ -295,11 +295,14 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { ConfirmModal, injectNotificationManager } from '@modrinth/ui'
import {
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
NewModal as Modal,
} from '@modrinth/ui'
import DropArea from '~/components/ui/DropArea.vue'
import FileInput from '~/components/ui/FileInput.vue'
import Modal from '~/components/ui/Modal.vue'
import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({
@@ -765,7 +768,6 @@ export default defineNuxtComponent({
}
.modal-gallery {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import {
AlignLeftIcon,
BookTextIcon,
ChartIcon,
GlobeIcon,
ImageIcon,
InfoIcon,
LinkIcon,
TagsIcon,
UsersIcon,
VersionIcon,
} from '@modrinth/assets'
import { commonMessages, commonProjectSettingsMessages } from '@modrinth/ui'
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
const { formatMessage } = useVIntl()
defineProps<{
currentMember: any
patchProject: any
patchIcon: any
resetProject: any
resetOrganization: any
resetMembers: any
}>()
const flags = useFeatureFlags()
const project = defineModel<Project>('project', { required: true })
const projectV3 = defineModel<ProjectV3Partial>('projectV3', { required: true })
const versions = defineModel<any>('versions')
const members = defineModel<any>('members')
const allMembers = defineModel<any>('allMembers')
const dependencies = defineModel<any>('dependencies')
const organization = defineModel<any>('organization')
</script>
<template>
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<aside class="universal-card">
<NavStack>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
:label="formatMessage(commonProjectSettingsMessages.general)"
>
<InfoIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="flags.newProjectGeneralSettings"
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings/general`"
:label="formatMessage(commonProjectSettingsMessages.general)"
:badge="formatMessage(commonMessages.newBadge)"
>
<InfoIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
v-if="
flags.newProjectEnvironmentSettings &&
projectV3.project_types.some((type) => ['mod', 'modpack'].includes(type))
"
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/environment`"
:label="formatMessage(commonProjectSettingsMessages.environment)"
:badge="formatMessage(commonMessages.newBadge)"
>
<GlobeIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/tags`"
:label="formatMessage(commonProjectSettingsMessages.tags)"
>
<TagsIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/description`"
:label="formatMessage(commonProjectSettingsMessages.description)"
>
<AlignLeftIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/license`"
:label="formatMessage(commonProjectSettingsMessages.license)"
>
<BookTextIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/links`"
:label="formatMessage(commonProjectSettingsMessages.links)"
>
<LinkIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/members`"
:label="formatMessage(commonProjectSettingsMessages.members)"
>
<UsersIcon aria-hidden="true" />
</NavStackItem>
<h3>{{ formatMessage(commonProjectSettingsMessages.view) }}</h3>
<NavStackItem
:link="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings/analytics`"
:label="formatMessage(commonProjectSettingsMessages.analytics)"
chevron
>
<ChartIcon aria-hidden="true" />
</NavStackItem>
<h3>{{ formatMessage(commonProjectSettingsMessages.upload) }}</h3>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/gallery`"
:label="formatMessage(commonProjectSettingsMessages.gallery)"
chevron
>
<ImageIcon aria-hidden="true" />
</NavStackItem>
<NavStackItem
:link="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
:label="formatMessage(commonProjectSettingsMessages.versions)"
chevron
>
<VersionIcon aria-hidden="true" />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="min-w-0">
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
</div>
</div>
</template>

View File

@@ -4,7 +4,7 @@
<div class="markdown-disclaimer">
<h2>Description</h2>
<span class="label__description">
You can type an extended description of your mod here.
You can type an extended description of your project here.
<span class="label__subdescription">
The description must clearly and honestly describe the purpose and function of the
project. See section 2.1 of the

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import {
Admonition,
commonProjectSettingsMessages,
injectNotificationManager,
injectProjectPageContext,
ProjectSettingsEnvSelector,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { injectApi } from '@modrinth/ui/src/providers/api.ts'
import { defineMessages, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const { currentMember, projectV2, projectV3, refreshProject } = injectProjectPageContext()
const { handleError } = injectNotificationManager()
const api = injectApi()
const saving = ref(false)
const supportsEnvironment = computed(() =>
projectV3.value.project_types.some((type) => ['mod', 'modpack'].includes(type)),
)
const needsToVerify = computed(
() =>
projectV3.value.side_types_migration_review_status === 'pending' &&
projectV3.value.environment?.length > 0 &&
projectV3.value.environment?.[0] !== 'unknown' &&
supportsEnvironment.value,
)
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
})
function getInitialEnv() {
return projectV3.value.environment?.length === 1 ? projectV3.value.environment[0] : undefined
}
const { saved, current, reset, save } = useSavable(
() => ({
environment: getInitialEnv(),
side_types_migration_review_status: projectV3.value.side_types_migration_review_status,
}),
({ environment, side_types_migration_review_status }) => {
saving.value = true
side_types_migration_review_status = 'reviewed'
api.projects
.editV3(projectV2.value.id, { environment, side_types_migration_review_status })
.then(() => refreshProject().then(reset))
.catch(handleError)
.finally(() => (saving.value = false))
},
)
// Set current to reviewed, which will trigger unsaved changes popup.
// It should not be possible to save without reviewing it.
const originalEnv = getInitialEnv()
if (originalEnv && originalEnv !== 'unknown') {
current.value.side_types_migration_review_status = 'reviewed'
}
const messages = defineMessages({
verifyButton: {
id: 'project.settings.environment.verification.verify-button',
defaultMessage: 'Verify',
},
verifyLabel: {
id: 'project.settings.environment.verification.verify-text',
defaultMessage: `Verify that this project's environment is set correctly.`,
},
wrongProjectTypeTitle: {
id: 'project.settings.environment.notice.wrong-project-type.title',
defaultMessage: `This project type does not support environment metadata`,
},
wrongProjectTypeDescription: {
id: 'project.settings.environment.notice.wrong-project-type.description',
defaultMessage: `Only mod or modpack projects can have environment metadata.`,
},
missingEnvTitle: {
id: 'project.settings.environment.notice.missing-env.title',
defaultMessage: `Please select an environment for your project`,
},
missingEnvDescription: {
id: 'project.settings.environment.notice.missing-env.description',
defaultMessage: `Your project is missing environment metadata, please select the appropriate option below.`,
},
multipleEnvironmentsTitle: {
id: 'project.settings.environment.notice.multiple-environments.title',
defaultMessage: 'Your project has multiple environments',
},
multipleEnvironmentsDescription: {
id: 'project.settings.environment.notice.multiple-environments.description',
defaultMessage:
"Different versions of your project have different environments selected, so you can't edit them globally at this time.",
},
reviewOptionsTitle: {
id: 'project.settings.environment.notice.review-options.title',
defaultMessage: 'Please review the options below',
},
reviewOptionsDescription: {
id: 'project.settings.environment.notice.review-options.description',
defaultMessage:
"We've just overhauled the Environments system on Modrinth and new options are now available. Please ensure the correct option is selected below and then click 'Verify' when you're done!",
},
})
</script>
<template>
<div>
<div class="card experimental-styles-within">
<h2 class="m-0 mb-2 block text-lg font-extrabold text-contrast">
{{ formatMessage(commonProjectSettingsMessages.environment) }}
</h2>
<Admonition
v-if="!supportsEnvironment"
type="critical"
:header="formatMessage(messages.wrongProjectTypeTitle)"
:body="formatMessage(messages.wrongProjectTypeDescription)"
class="mb-3"
/>
<template v-else>
<Admonition
v-if="!hasPermission"
type="critical"
:header="formatMessage(commonProjectSettingsMessages.noPermissionTitle)"
:body="formatMessage(commonProjectSettingsMessages.noPermissionDescription)"
class="mb-3"
/>
<Admonition
v-else-if="
!projectV3.environment ||
projectV3.environment.length === 0 ||
(projectV3.environment.length === 1 && projectV3.environment[0] === 'unknown')
"
type="critical"
:header="formatMessage(messages.missingEnvTitle)"
:body="formatMessage(messages.missingEnvDescription)"
class="mb-3"
/>
<Admonition
v-else-if="projectV3.environment.length > 1"
type="info"
:header="formatMessage(messages.multipleEnvironmentsTitle)"
:body="formatMessage(messages.multipleEnvironmentsDescription)"
class="mb-3"
/>
<Admonition
v-else-if="needsToVerify"
type="warning"
:header="formatMessage(messages.reviewOptionsTitle)"
:body="formatMessage(messages.reviewOptionsDescription)"
class="mb-3"
/>
<ProjectSettingsEnvSelector
v-model="current.environment"
:disabled="!hasPermission || projectV3?.environment?.length > 1"
/>
</template>
</div>
<UnsavedChangesPopup
v-if="supportsEnvironment && hasPermission && projectV3?.environment?.length <= 1"
:original="saved"
:modified="current"
:saving="saving"
:can-reset="!needsToVerify"
:text="needsToVerify ? messages.verifyLabel : undefined"
:save-label="needsToVerify ? messages.verifyButton : undefined"
:save-icon="needsToVerify ? CheckIcon : undefined"
@reset="reset"
@save="save"
/>
</div>
</template>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import {
IconSelect,
injectNotificationManager,
injectProjectPageContext,
SettingsLabel,
UnsavedChangesPopup,
useSavable,
} from '@modrinth/ui'
import { injectApi } from '@modrinth/ui/src/providers/api.ts'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
const { formatMessage } = useVIntl()
const { projectV2: project, refreshProject } = injectProjectPageContext()
const { handleError } = injectNotificationManager()
const api = injectApi()
const saving = ref(false)
const { saved, current, reset, save } = useSavable(
() => ({
title: project.value.title,
tagline: project.value.description,
url: project.value.slug,
icon: project.value.icon_url,
}),
({ title, tagline, url }) => {
const data: Record<string, string> = {
...(title !== undefined && { title }),
...(tagline !== undefined && { description: tagline }),
...(url !== undefined && { slug: url }),
}
if (data) {
saving.value = true
api.projects
.edit(project.value.id, { title, description: tagline, slug: url })
.then(() => refreshProject().then(reset))
.catch(handleError)
.finally(() => (saving.value = false))
}
},
)
const messages = defineMessages({
nameTitle: {
id: 'project.settings.general.name.title',
defaultMessage: 'Name',
},
nameDescription: {
id: 'project.settings.general.name.description',
defaultMessage:
"Avoid prefixes, suffixes, parentheticals, or added descriptions—just the project's actual name.",
},
taglineTitle: {
id: 'project.settings.general.tagline.title',
defaultMessage: 'Tagline',
},
taglineDescription: {
id: 'project.settings.general.tagline.description',
defaultMessage: 'Summarize your project in no more than one sentence.',
},
urlTitle: {
id: 'project.settings.general.url.title',
defaultMessage: 'URL',
},
})
const placeholders: { name: MessageDescriptor; tagline: MessageDescriptor }[] = [
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.1',
defaultMessage: 'e.g. Nether Overhaul 2',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.1',
defaultMessage: 'e.g. Overhauls game progression to revolve around the Nether.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.2',
defaultMessage: 'e.g. Construction Equipment',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.2',
defaultMessage: 'e.g. Adds wearable construction gear.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.3',
defaultMessage: 'e.g. Better than Caving',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.3',
defaultMessage: 'e.g. Adds realistic mineshaft-building mechanics.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.4',
defaultMessage: 'e.g. Enhanced Portals',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.4',
defaultMessage: 'e.g. Improves how Nether portals link to each other.',
},
}),
defineMessages({
name: {
id: 'project.settings.general.name.placeholder.5',
defaultMessage: 'e.g. Dangerous Mobs',
},
tagline: {
id: 'project.settings.general.tagline.placeholder.5',
defaultMessage:
'e.g. Adds powerful boss versions of the normal mobs to encounter in the night.',
},
}),
]
const placeholderIndex = useState<number>('project-settings-random-placeholder', () =>
Math.floor(Math.random() * (placeholders.length + 1)),
)
const placeholder = computed(() => placeholders[placeholderIndex.value] ?? placeholders[0])
</script>
<template>
<div>
<UnsavedChangesPopup
:original="saved"
:modified="current"
:saving="saving"
@reset="reset"
@save="save"
/>
<div class="base-card block">
<div class="group relative float-end ml-4">
<IconSelect v-model="current.icon" />
</div>
<div>
<SettingsLabel
id="project-name"
:title="messages.nameTitle"
:description="messages.nameDescription"
/>
<div class="flex">
<input
id="project-name"
v-model="current.title"
:placeholder="formatMessage(placeholder.name)"
autocomplete="off"
maxlength="50"
class="flex-grow"
type="text"
/>
</div>
</div>
<div class="mt-4">
<SettingsLabel
id="project-tagline"
:title="messages.taglineTitle"
:description="messages.taglineDescription"
/>
<input
id="project-tagline"
v-model="current.tagline"
:placeholder="formatMessage(placeholder.tagline)"
autocomplete="off"
maxlength="120"
class="w-full"
type="text"
/>
</div>
<div class="mt-4">
<SettingsLabel id="project-url" :title="messages.urlTitle" />
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<input
id="project-url"
v-model="current.url"
type="text"
maxlength="64"
autocomplete="off"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -67,7 +67,9 @@
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
https://modrinth.com/{{ $getProjectTypeForUrl(project.project_type, project.loaders) }}/
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
</div>
<input
id="project-slug"
@@ -96,6 +98,7 @@
</div>
<template
v-if="
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
@@ -258,15 +261,23 @@ import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager()
const flags = useFeatureFlags()
const props = defineProps({
project: {
type: Object,
required: true,
default: () => ({}),
},
projectV3: {
type: Object,
required: true,
default: () => ({}),
},
currentMember: {
type: Object,
required: true,

View File

@@ -91,7 +91,6 @@ const router = useRouter()
const props = defineProps<{
project: Project
versions: Version[]
featuredVersions: Version[]
members: User[]
currentMember: User
dependencies: Dependency[]

View File

@@ -1,3 +1,5 @@
<!-- eslint-disable vue/no-undef-components -->
<!-- TODO: Remove this^after converting to composition API. -->
<template>
<div v-if="version" class="version-page">
<ConfirmModal
@@ -74,14 +76,6 @@
<h2 :class="{ 'sr-only': isEditing }">
{{ version.name }}
</h2>
<div v-if="version.featured" class="featured">
<StarIcon aria-hidden="true" />
Featured
</div>
<div v-else-if="featuredVersions.find((x) => x.id === version.id)" class="featured">
<StarIcon aria-hidden="true" />
Auto-featured
</div>
</div>
<div v-if="fieldErrors && showKnownErrors" class="known-errors">
<ul>
@@ -121,11 +115,16 @@
Save
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="version.featured = !version.featured">
<ButtonStyled v-if="usesFeaturedVersions">
<button
v-tooltip="
`Featured versions are being phased out. If you're still using this for something in the API, seek an alternative soon.`
"
@click="version.featured = !version.featured"
>
<StarIcon aria-hidden="true" />
<template v-if="!version.featured"> Feature version</template>
<template v-else> Unfeature version</template>
<template v-if="!version.featured"> Feature version (deprecated)</template>
<template v-else> Unfeature version (deprecated)</template>
</button>
</ButtonStyled>
<ButtonStyled>
@@ -716,12 +715,6 @@ export default defineNuxtComponent({
return []
},
},
featuredVersions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
@@ -895,6 +888,8 @@ export default defineNuxtComponent({
.format('MMM D, YYYY')}. ${version.downloads} downloads.`,
)
const usesFeaturedVersions = computed(() => props.versions.some((v) => v.featured))
useSeoMeta({
title,
description,
@@ -906,6 +901,7 @@ export default defineNuxtComponent({
auth,
tags,
flags,
usesFeaturedVersions,
fileTypes: ref(fileTypes),
oldFileTypes: ref(oldFileTypes),
isCreating: ref(isCreating),
@@ -1307,20 +1303,14 @@ export default defineNuxtComponent({
this.shouldPreventActions = false
},
async resetProjectVersions() {
const [versions, featuredVersions, dependencies] = await Promise.all([
const [versions, dependencies] = await Promise.all([
useBaseFetch(`project/${this.version.project_id}/version`),
useBaseFetch(`project/${this.version.project_id}/version?featured=true`),
useBaseFetch(`project/${this.version.project_id}/dependencies`),
this.resetProject(),
])
const newCreatedVersions = this.$computeVersions(versions, this.members)
const featuredIds = featuredVersions.map((x) => x.id)
this.$emit('update:versions', newCreatedVersions)
this.$emit(
'update:featuredVersions',
newCreatedVersions.filter((version) => featuredIds.includes(version.id)),
)
this.$emit('update:dependencies', dependencies)
return newCreatedVersions

View File

@@ -195,13 +195,15 @@ import {
import {
ButtonStyled,
ConfirmModal,
DropArea,
FileInput,
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import DropArea from '~/components/ui/DropArea.vue'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
import { isPermission } from '~/utils/permissions.ts'
import { reportVersion } from '~/utils/report-helpers.ts'
const props = defineProps({
project: {

View File

@@ -71,8 +71,12 @@
</span>
<span>
Whether or not the subscription should be cancelled. Submitting this as "true" will
cancel the subscription, while submitting it as "false" will force another charge
attempt to be made.
cancel the subscription, while submitting it as "false" will
{{
selectedCharge.status === 'open'
? 'keep it as-is'
: 'force another charge attempt to be made'
}}.
</span>
</label>
<Toggle id="cancel" v-model="cancel" />
@@ -116,12 +120,16 @@
<div class="mb-4 grid grid-cols-[1fr_auto]">
<div>
<span class="flex items-center gap-2 font-semibold text-contrast">
<template v-if="subscription.product.metadata.type === 'midas'">
<!-- TODO(backend): provide proper metadata for midas (MR+) subscriptions -->
<template v-if="subscription.price_id === 'a6eRm92L'">
<ModrinthPlusIcon class="h-7 w-min" />
</template>
<template v-else-if="subscription.product.metadata.type === 'pyro'">
<template v-else-if="subscription.metadata?.type === 'pyro'">
<ModrinthServersIcon class="h-7 w-min" />
</template>
<template v-else-if="subscription.metadata?.type === 'medal'">
<span>Medal Trial Server</span>
</template>
<template v-else> Unknown product </template>
</span>
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
@@ -132,7 +140,11 @@
</div>
</div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
<ButtonStyled
v-if="
subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal'
"
>
<nuxt-link
:to="`/servers/manage/${subscription.metadata.id}`"
target="_blank"
@@ -152,7 +164,11 @@
>
<div
class="absolute bottom-0 left-0 top-0 w-1"
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
:class="
charge.type === 'refund'
? 'bg-purple'
: (chargeStatuses[charge.status]?.color ?? 'bg-blue')
"
/>
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
<div class="flex flex-col gap-2">
@@ -163,6 +179,7 @@
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
<template v-else-if="charge.status === 'processing'"> Processing </template>
<template v-else-if="charge.status === 'open'"> Upcoming </template>
<template v-else-if="charge.status === 'expiring'"> Expiring </template>
<template v-else> {{ charge.status }} </template>
</span>
@@ -236,8 +253,12 @@
Refund options
</button>
</ButtonStyled>
<ButtonStyled v-else-if="charge.status === 'failed'" color="red" color-fill="text">
<button @click="showModifyModal(subscription)">
<ButtonStyled
v-else-if="charge.status === 'failed' || charge.status === 'open'"
color="red"
color-fill="text"
>
<button @click="showModifyModal(charge, subscription)">
<CurrencyIcon />
Modify charge
</button>
@@ -274,7 +295,6 @@ import { formatCategory, formatPrice } from '@modrinth/utils'
import dayjs from 'dayjs'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
import { products } from '~/generated/state.json'
const { addNotification } = injectNotificationManager()
@@ -333,9 +353,6 @@ const subscriptionCharges = computed(() => {
.filter((charge) => charge.subscription_id === subscription.id)
.slice()
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
product: products.find((product) =>
product.prices.some((price) => price.id === subscription.price_id),
),
}
})
})
@@ -343,6 +360,7 @@ const subscriptionCharges = computed(() => {
const refunding = ref(false)
const refundModal = ref()
const selectedCharge = ref(null)
const selectedSubscription = ref(null)
const refundType = ref('full')
const refundTypes = ref(['full', 'partial', 'none'])
const refundAmount = ref(0)
@@ -360,8 +378,9 @@ function showRefundModal(charge) {
refundModal.value.show()
}
function showModifyModal(charge) {
function showModifyModal(charge, subscription) {
selectedCharge.value = charge
selectedSubscription.value = subscription
cancel.value = false
modifyModal.value.show()
}
@@ -369,13 +388,17 @@ function showModifyModal(charge) {
async function refundCharge() {
refunding.value = true
try {
const amountParsed = Math.max(0, Math.floor(Number(refundAmount.value) || 0))
const payload =
refundType.value === 'partial'
? { type: 'partial', amount: amountParsed, unprovision: unprovision.value }
: refundType.value === 'none'
? { type: 'none', unprovision: unprovision.value }
: { type: 'full', unprovision: unprovision.value }
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
method: 'POST',
body: JSON.stringify({
type: refundType.value,
amount: refundAmount.value,
unprovision: unprovision.value,
}),
body: JSON.stringify(payload),
internal: true,
})
await refreshCharges()
@@ -393,7 +416,7 @@ async function refundCharge() {
async function modifyCharge() {
modifying.value = true
try {
await useBaseFetch(`billing/subscription/${selectedCharge.value.id}`, {
await useBaseFetch(`billing/subscription/${selectedSubscription.value.id}`, {
method: 'PATCH',
body: JSON.stringify({
cancelled: cancel.value,
@@ -401,14 +424,14 @@ async function modifyCharge() {
internal: true,
})
addNotification({
title: 'Resubscription request submitted',
title: 'Modifications made',
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
await refreshCharges()
} catch (err) {
addNotification({
title: 'Error reattempting charge',
title: 'Error while sending request',
text: err.data?.description ?? err,
type: 'error',
})
@@ -432,6 +455,9 @@ const chargeStatuses = {
cancelled: {
color: 'bg-red',
},
expiring: {
color: 'bg-orange',
},
}
</script>
<style scoped>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { CopyIcon, LibraryIcon, PlayIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Card } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import docs from '~/templates/docs'
const allTemplates = Object.keys(docs).sort()
const query = ref('')
const filtered = computed(() =>
allTemplates.filter((t) => t.toLowerCase().includes(query.value.toLowerCase().trim())),
)
function openAll() {
let offset = 0
for (const id of filtered.value) {
openPreview(id, offset)
offset++
}
}
function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/doc/${id}`).catch(() => {})
}
function openPreview(id: string, offset = 0) {
const width = 800
const height = 1000
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
`/_internal/templates/doc/${id}`,
`doc-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
}
const counts = computed(() => ({
total: allTemplates.length,
shown: filtered.value.length,
}))
onMounted(() => {
document.getElementById('doc-search')?.focus()
})
</script>
<template>
<div class="normal-page no-sidebar">
<h1 class="mb-4 text-3xl font-extrabold text-heading">Document templates</h1>
<div class="normal-page__content">
<Card class="mb-6 flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<div class="relative">
<SearchIcon
class="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-secondary"
/>
<input
id="doc-search"
v-model="query"
type="text"
placeholder="Search templates..."
class="w-72 rounded-lg border border-divider bg-bg px-7 py-2 text-sm text-primary placeholder-secondary focus:border-green focus:outline-none"
/>
</div>
<ButtonStyled color="brand">
<button :disabled="filtered.length === 0" @click="openAll">
<LibraryIcon class="h-4 w-4" aria-hidden="true" />
Open all ({{ counts.shown }})
</button>
</ButtonStyled>
<span class="text-sm text-secondary">
Showing <span class="font-medium text-contrast">{{ counts.shown }}</span> of
<span class="font-medium text-contrast">{{ counts.total }}</span>
</span>
</div>
<div
v-if="filtered.length === 0"
class="rounded-lg border border-dashed border-divider px-6 py-10 text-center text-sm text-secondary"
>
No templates match your search.
</div>
<ul v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<li
v-for="id in filtered"
:key="id"
class="hover:border-green/70 group flex flex-col justify-between rounded-lg border border-divider bg-button-bg p-4 shadow-sm transition hover:shadow"
>
<div class="mb-3">
<div class="font-mono text-sm font-semibold tracking-tight text-contrast">
{{ id }}
</div>
<div class="mt-1 truncate text-xs text-secondary">
/_internal/templates/doc/{{ id }}
</div>
</div>
<div class="mt-auto flex gap-2">
<ButtonStyled color="brand" class="flex-1">
<button class="w-full justify-center" @click="openPreview(id)">
<PlayIcon class="h-4 w-4" aria-hidden="true" />
Preview
</button>
</ButtonStyled>
<ButtonStyled>
<button class="justify-center" title="Copy preview URL" @click="copy(id)">
<CopyIcon class="h-4 w-4" aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</li>
</ul>
</Card>
<p class="mt-2 text-xs text-secondary">
All templates come from
<code class="rounded bg-code-bg px-1 py-0.5 text-[11px] text-code-text"
>src/templates/docs/index.ts</code
>. Popouts render via
<code class="rounded bg-code-bg px-1 py-0.5 text-[11px] text-code-text"
>/_internal/templates/doc/[template]</code
>.
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { CopyIcon, LibraryIcon, PlayIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Card } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import emails from '~/templates/emails'
const allTemplates = Object.keys(emails).sort()
const query = ref('')
const filtered = computed(() =>
allTemplates.filter((t) => t.toLowerCase().includes(query.value.toLowerCase().trim())),
)
function openAll() {
let offset = 0
for (const id of filtered.value) {
openPreview(id, offset)
offset++
}
}
function copy(id: string) {
navigator.clipboard?.writeText(`/_internal/templates/email/${id}`).catch(() => {})
}
function openPreview(id: string, offset = 0) {
const width = 600
const height = 850
const left = window.screenX + (window.outerWidth - width) / 2 + ((offset * 28) % 320)
const top = window.screenY + (window.outerHeight - height) / 2 + ((offset * 28) % 320)
window.open(
`/_internal/templates/email/${id}`,
`email-${id}`,
`popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes,menubar=no,toolbar=no,location=no,status=no`,
)
}
const counts = computed(() => ({
total: allTemplates.length,
shown: filtered.value.length,
}))
onMounted(() => {
document.getElementById('email-search')?.focus()
})
</script>
<template>
<div class="normal-page no-sidebar">
<h1 class="mb-4 text-3xl font-extrabold text-heading">Email templates</h1>
<div class="normal-page__content">
<Card class="mb-6 flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<div class="relative">
<SearchIcon
class="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-secondary"
/>
<input
id="email-search"
v-model="query"
type="text"
placeholder="Search templates..."
class="w-72 rounded-lg border border-divider bg-bg px-7 py-2 text-sm text-primary placeholder-secondary focus:border-green focus:outline-none"
/>
</div>
<ButtonStyled color="brand">
<button :disabled="filtered.length === 0" @click="openAll">
<LibraryIcon class="h-4 w-4" aria-hidden="true" />
Open all ({{ counts.shown }})
</button>
</ButtonStyled>
<span class="text-sm text-secondary">
Showing <span class="font-medium text-contrast">{{ counts.shown }}</span> of
<span class="font-medium text-contrast">{{ counts.total }}</span>
</span>
</div>
<div
v-if="filtered.length === 0"
class="rounded-lg border border-dashed border-divider px-6 py-10 text-center text-sm text-secondary"
>
No templates match your search.
</div>
<ul v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<li
v-for="id in filtered"
:key="id"
class="hover:border-green/70 group flex flex-col justify-between rounded-lg border border-divider bg-button-bg p-4 shadow-sm transition hover:shadow"
>
<div class="mb-3">
<div class="font-mono text-sm font-semibold tracking-tight text-contrast">
{{ id }}
</div>
<div class="mt-1 truncate text-xs text-secondary">
/_internal/templates/email/{{ id }}
</div>
</div>
<div class="mt-auto flex gap-2">
<ButtonStyled color="brand" class="flex-1">
<button class="w-full justify-center" @click="openPreview(id)">
<PlayIcon class="h-4 w-4" aria-hidden="true" />
Preview
</button>
</ButtonStyled>
<ButtonStyled>
<button class="justify-center" title="Copy preview URL" @click="copy(id)">
<CopyIcon class="h-4 w-4" aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</li>
</ul>
</Card>
<p class="mt-2 text-xs text-secondary">
All templates come from
<code class="rounded bg-code-bg px-1 py-0.5 text-[11px] text-code-text"
>src/emails/index.ts</code
>. Popouts render via
<code class="rounded bg-code-bg px-1 py-0.5 text-[11px] text-code-text"
>/_internal/templates/email/[template]</code
>.
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,201 @@
<template>
<div class="normal-page no-sidebar">
<h1>File lookup</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div
class="border-highlight-gray hover:bg-button-hover relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed bg-button-bg p-8 transition-colors"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent
@dragenter.prevent
>
<p
class="mx-auto mb-0 flex items-center gap-2 text-center text-lg font-bold text-primary"
>
<UploadIcon /> Select file to lookup
</p>
<p class="mx-auto mt-0 text-center text-sm text-secondary">
Drag and drop or click here to browse
</p>
<input ref="fileInput" type="file" class="hidden" @change="handleFileSelect" />
</div>
<template v-if="selectedFile">
<div class="flex items-center gap-2 text-sm text-secondary">
<FileIcon class="h-4 w-4" />
<span>{{ selectedFile.name }} ({{ formatBytes(selectedFile.size) }})</span>
</div>
<div v-if="loadingHash" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="h-4 w-4 animate-spin" />
Calculating hashes...
</div>
<div v-if="loadingLookup" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="h-4 w-4 animate-spin" />
Looking up file on Modrinth...
</div>
<template v-if="fileHashes">
<h3 class="mb-0 text-lg font-extrabold text-contrast">File hashes:</h3>
<div class="flex flex-col gap-2">
<span class="text-xs text-secondary">SHA512:</span>
<CopyCode :text="fileHashes.sha512" />
<span class="mt-1 text-xs text-secondary">SHA256:</span>
<CopyCode :text="fileHashes.sha256" />
<span class="mt-1 text-xs text-secondary">SHA1:</span>
<CopyCode :text="fileHashes.sha1" />
</div>
</template>
</template>
<template v-if="lookupResult">
<h3 class="mb-0 text-lg font-extrabold text-contrast">Modrinth project:</h3>
<nuxt-link
class="flex w-fit items-center gap-2 text-lg font-semibold text-contrast hover:underline"
target="_blank"
:to="`/project/${lookupResult.projectId}`"
>
<Avatar :src="lookupResult.iconUrl" alt="" size="48px" />
{{ lookupResult.name }}
</nuxt-link>
<CopyCode :text="lookupResult.projectId" />
<h3 class="mb-0 text-lg font-extrabold text-contrast">Modrinth version:</h3>
<nuxt-link
class="text-blue hover:underline"
:to="`/project/${lookupResult.projectId}/version/${lookupResult.versionId}`"
target="_blank"
>
Version {{ lookupResult.versionNumber }}
</nuxt-link>
<CopyCode :text="lookupResult.versionId" />
</template>
<Admonition v-if="lookupError" type="critical" header="Lookup failed">
{{ lookupError }}
</Admonition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
import { Admonition, Avatar, CopyCode, injectNotificationManager } from '@modrinth/ui'
import { formatBytes, type Project, type Version } from '@modrinth/utils'
const { addNotification } = injectNotificationManager()
const fileInput = ref<HTMLInputElement>()
const selectedFile = ref<File | null>(null)
const fileHashes = ref<{
sha512: string
sha256: string
sha1: string
} | null>(null)
const loadingHash = ref(false)
const loadingLookup = ref(false)
const lookupResult = ref<{
projectId: string
versionId: string
name: string
versionNumber: string
iconUrl?: string | undefined
}>()
const lookupError = ref<string>('')
function triggerFileInput() {
fileInput.value?.click()
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
processFile(target.files[0])
}
}
function handleDrop(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
processFile(event.dataTransfer.files[0])
}
}
async function processFile(file: File) {
selectedFile.value = file
fileHashes.value = null
lookupResult.value = undefined
lookupError.value = ''
await calculateHashesAndLookup(file)
}
function formatHashBuffer(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
async function calculateHashesAndLookup(file: File): Promise<void> {
loadingHash.value = true
loadingLookup.value = true
try {
const buffer = await file.arrayBuffer()
const [sha512, sha256, sha1] = await Promise.all([
crypto.subtle.digest('SHA-512', buffer).then(formatHashBuffer),
crypto.subtle.digest('SHA-256', buffer).then(formatHashBuffer),
crypto.subtle.digest('SHA-1', buffer).then(formatHashBuffer),
])
fileHashes.value = { sha512, sha256, sha1 }
await lookupFile(sha512)
} catch (error) {
console.error('Error calculating hashes:', error)
addNotification({
title: 'Hash calculation failed',
text: 'Failed to calculate file hashes.',
type: 'error',
})
} finally {
loadingHash.value = false
loadingLookup.value = false
}
}
async function lookupFile(hash: string): Promise<void> {
if (!hash) {
return
}
try {
const version = (await useBaseFetch(`version_file/${hash}?algorithm=sha512`, {
method: 'GET',
})) as Version
if (version) {
const project = (await useBaseFetch(`project/${version.project_id}`, {
method: 'GET',
})) as Project
lookupResult.value = {
projectId: project.id,
versionId: version.id,
versionNumber: version.version_number,
name: project.title,
iconUrl: project.icon_url,
}
}
} catch (error: any) {
if (error.status === 404) {
lookupError.value = `File not found on Modrinth across projects you have access to.`
} else {
lookupError.value = error.data?.description || 'Failed to lookup file.'
}
}
}
</script>

File diff suppressed because one or more lines are too long

View File

@@ -82,10 +82,12 @@
<script setup>
import { CheckIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import { useAuth } from '@/composables/auth.js'
import { useScopes } from '@/composables/auth/scopes.ts'
import { useBaseFetch } from '@/composables/fetch.js'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()

View File

@@ -141,8 +141,10 @@ import {
SSOSteamIcon,
} from '@modrinth/assets'
import { commonMessages, injectNotificationManager } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl, getLauncherRedirectUrl } from '@/composables/auth.js'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -273,15 +275,14 @@ async function finishSignIn(token) {
token = auth.value.token
}
const usesLocalhostRedirectionScheme =
['4', '6'].includes(route.query.ipver) && Number(route.query.port) < 65536
const redirectUrl = `${getLauncherRedirectUrl(route)}/?code=${token}`
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === '4' ? '127.0.0.1' : '[::1]'}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
if (redirectUrl.startsWith('https://launcher-files.modrinth.com/')) {
await navigateTo(redirectUrl, {
external: true,
})
} else {
// When redirecting to localhost, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
@@ -289,10 +290,6 @@ async function finishSignIn(token) {
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl
} else {
await navigateTo(redirectUrl, {
external: true,
})
}
return

View File

@@ -146,8 +146,10 @@ import {
UserIcon,
} from '@modrinth/assets'
import { Checkbox, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import HCaptcha from '@/components/ui/HCaptcha.vue'
import { getAuthUrl } from '@/composables/auth.js'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()

View File

@@ -50,6 +50,9 @@
<script setup>
import { RightArrowIcon, WavingRinthbot } from '@modrinth/assets'
import { Checkbox, commonMessages } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import { normalizeChildren } from '@/utils/vue-children.ts'
const route = useRoute()
@@ -68,7 +71,7 @@ const messages = defineMessages({
welcomeDescription: {
id: 'auth.welcome.description',
defaultMessage:
'Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods.',
'Youre now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with amazing mods.',
},
welcomeLongTitle: {
id: 'auth.welcome.long-title',

View File

@@ -407,9 +407,11 @@ import {
useRelativeTime,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { IntlFormatted } from '@vintl/vintl/components'
import UpToDate from 'assets/images/illustrations/up_to_date.svg'
// import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { getProjectTypeMessage } from '@/utils/i18n-project-type'
import { normalizeChildren } from '@/utils/vue-children.ts'
import NavRow from '~/components/ui/NavRow.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'

View File

@@ -108,7 +108,7 @@ import {
} from '@modrinth/assets'
import { Avatar, Button, commonMessages } from '@modrinth/ui'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
const { formatMessage } = useVIntl()
const formatCompactNumber = useCompactNumber()

View File

@@ -52,7 +52,7 @@
import { PlusIcon, UsersIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import { useAuth } from '~/composables/auth.js'
const createOrgModal = ref(null)

View File

@@ -282,9 +282,24 @@
<ProjectStatusBadge v-if="project.status" :status="project.status" />
</div>
<div>
<div class="flex !flex-row items-center !justify-end gap-2">
<ButtonStyled
v-if="projectsWithMigrationWarning.includes(project.id)"
circular
color="orange"
>
<nuxt-link
v-tooltip="'Please review environment metadata'"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings/environment`"
>
<TriangleAlertIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
v-tooltip="formatMessage(commonMessages.settingsLabel)"
:to="`/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
@@ -310,6 +325,7 @@ import {
SortAscIcon,
SortDescIcon,
TrashIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import {
@@ -324,8 +340,8 @@ import {
import { formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import Modal from '~/components/ui/Modal.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import { getProjectTypeForUrl } from '~/helpers/projects.js'
useHead({ title: 'Projects - Modrinth' })
@@ -344,6 +360,7 @@ const { formatMessage } = useVIntl()
const user = await useUser()
const projects = ref([])
const projectsWithMigrationWarning = ref([])
const selectedProjects = ref([])
const sortBy = ref('Name')
const descending = ref(false)
@@ -437,6 +454,15 @@ async function bulkEditLinks() {
await initUserProjects()
if (user.value?.projects) {
projects.value = updateSort(user.value.projects, 'Name', false)
user.value?.projectsV3?.forEach((project) => {
if (
project.side_types_migration_review_status === 'pending' &&
(project.project_types.includes('mod') || project.project_types.includes('modpack')) &&
project.environment?.length === 1
) {
projectsWithMigrationWarning.value.push(project.id)
}
})
}
</script>
<style lang="scss" scoped>

View File

@@ -68,23 +68,27 @@
</div>
</div>
<div class="input-group mt-4">
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
<ButtonStyled color="brand">
<nuxt-link
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
class="iconified-button brand-button"
v-if="!(userBalance.available < minWithdraw || blockedByTax)"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon />
View transfer history
</NuxtLink>
<button v-else class="disabled"><TransferIcon /> Withdraw</button>
</ButtonStyled>
<ButtonStyled>
<NuxtLink to="/dashboard/revenue/transfers">
<HistoryIcon />
View transfer history
</NuxtLink>
</ButtonStyled>
</div>
<p v-if="blockedByTax" class="text-sm font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax
form.
</p>
<p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
@@ -101,10 +105,12 @@
email
{{ auth.user.payout_data.paypal_address }}
</p>
<button class="btn mt-4" @click="handleRemoveAuthProvider('paypal')">
<XIcon />
Disconnect account
</button>
<ButtonStyled>
<button class="mt-4" @click="handleRemoveAuthProvider('paypal')">
<XIcon />
Disconnect account
</button>
</ButtonStyled>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
@@ -117,8 +123,7 @@
<p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
@@ -127,15 +132,16 @@
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4"
name="search"
placeholder="@example"
type="search"
/>
<button class="btn btn-secondary" @click="updateVenmo">
<SaveIcon />
Save information
</button>
<ButtonStyled color="brand">
<button class="mt-4" @click="updateVenmo">
<SaveIcon />
Save information
</button>
</ButtonStyled>
</section>
</div>
</template>
@@ -149,20 +155,36 @@ import {
UnknownIcon,
XIcon,
} from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { formatDate } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { removeAuthProvider } from '~/composables/auth.js'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.js'
const { addNotification, handleError } = injectNotificationManager()
const auth = await useAuth()
const minWithdraw = ref(0.01)
const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
)
const { data: userBalance } = await useAsyncData(`payout/balance`, async () => {
const response = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
return {
...response,
available: parseFloat(response.available),
withdrawn_lifetime: parseFloat(response.withdrawn_lifetime),
withdrawn_ytd: parseFloat(response.withdrawn_ytd),
pending: parseFloat(response.pending),
dates: Object.fromEntries(
Object.entries(response.dates).map(([date, value]) => [date, parseFloat(value)]),
),
}
})
const blockedByTax = computed(() => {
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, 'month').endOf('month').add(60, 'days')
@@ -228,14 +250,6 @@ strong {
font-weight: 500;
}
.disabled-cursor-wrapper {
cursor: not-allowed;
}
.disabled-link {
pointer-events: none;
}
.grid-display {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}

View File

@@ -1,4 +1,10 @@
<template>
<CreatorTaxFormModal
ref="taxFormModalRef"
close-button-text="Continue"
@success="onTaxFormSuccess"
@cancelled="onTaxFormCancelled"
/>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
@@ -135,6 +141,15 @@
</template>
</div>
<p v-if="willTriggerTaxForm" class="font-bold text-orange">
This withdrawal will exceed $600 for the year. You will be prompted to complete a tax form
before proceeding.
</p>
<p v-if="blockedByTax" class="font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
</p>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
@@ -175,7 +190,8 @@
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees)
(fees > 0 && !agreedFees) ||
blockedByTax
"
class="iconified-button brand-button"
@click="withdraw"
@@ -202,6 +218,7 @@ import { all } from 'iso-3166-1'
import { Multiselect } from 'vue-multiselect'
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
@@ -222,7 +239,19 @@ const country = ref(
const [{ data: userBalance }, { data: payoutMethods, refresh: refreshPayoutMethods }] =
await Promise.all([
useAsyncData(`payout/balance`, () => useBaseFetch(`payout/balance`, { apiVersion: 3 })),
useAsyncData(`payout/balance`, async () => {
const response = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
return {
...response,
available: parseFloat(response.available),
withdrawn_lifetime: parseFloat(response.withdrawn_lifetime),
withdrawn_ytd: parseFloat(response.withdrawn_ytd),
pending: parseFloat(response.pending),
dates: Object.fromEntries(
Object.entries(response.dates).map(([date, value]) => [date, parseFloat(value)]),
),
}
}),
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
),
@@ -323,6 +352,23 @@ const agreedTransfer = ref(false)
const agreedFees = ref(false)
const agreedTerms = ref(false)
const flags = useFeatureFlags()
const blockedByTax = computed(() => {
if (flags.value.testTaxForm) return true
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
const willTriggerTaxForm = computed(() => {
if (flags.value.testTaxForm) return true
const status = userBalance.value?.form_completion_status ?? 'unknown'
const currentWithdrawn = userBalance.value?.withdrawn_ytd ?? 0
const wouldExceedThreshold = currentWithdrawn + parsedAmount.value >= 600
return wouldExceedThreshold && status !== 'complete' && !blockedByTax.value
})
watch(country, async () => {
await refreshPayoutMethods()
if (payoutMethods.value && payoutMethods.value[0]) {
@@ -342,7 +388,10 @@ watch(selectedMethod, () => {
agreedTerms.value = false
})
async function withdraw() {
const taxFormModalRef = ref(null)
const taxFormCancelled = ref(false)
async function performWithdrawal() {
startLoading()
try {
const auth = await useAuth()
@@ -375,6 +424,49 @@ async function withdraw() {
}
stopLoading()
}
async function withdraw() {
if (willTriggerTaxForm.value) {
taxFormCancelled.value = false
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
await taxFormModalRef.value.startTaxForm(new MouseEvent('click'))
}
return
}
await performWithdrawal()
}
async function onTaxFormSuccess() {
// Skip balance check if testTaxForm flag is enabled
if (flags.value.testTaxForm) {
await performWithdrawal()
return
}
// Refresh user balance to get updated form completion status
const updatedBalance = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
userBalance.value = updatedBalance
if (updatedBalance?.form_completion_status === 'complete') {
await performWithdrawal()
} else {
addNotification({
title: 'Tax form incomplete',
text: 'You must complete a tax form for this withdrawal.',
type: 'error',
})
}
}
function onTaxFormCancelled() {
taxFormCancelled.value = true
addNotification({
title: 'Withdrawal canceled',
text: 'You must complete a tax form for this withdrawal.',
type: 'error',
})
}
</script>
<style lang="scss" scoped>

View File

@@ -3,40 +3,43 @@
<div class="landing-hero">
<ModrinthIcon class="modrinth-icon text-brand" />
<h1 class="main-header">
The place for Minecraft
<div class="animate-strong">
<span>
<strong
v-for="projectType in tags.projectTypes"
:key="projectType.id"
class="main-header-strong"
>
{{ projectType.display }}s <br />
</strong>
<strong class="main-header-strong">servers <br /></strong>
<strong class="main-header-strong">mods</strong>
</span>
</div>
<IntlFormatted :message-id="messages.thePlaceForMinecraft">
<template #~content>
<div class="animate-strong">
<span>
<strong
v-for="[key, message] in Object.entries(contentTypeMessages)"
:key="`landing-content-type-${key}`"
class="main-header-strong"
>
{{ formatMessage(message) }} <br />
</strong>
<strong class="main-header-strong">
{{ formatMessage(contentTypeMessages.mods) }}
</strong>
</span>
</div>
</template>
</IntlFormatted>
</h1>
<h2>
Discover, play, and share Minecraft content through our open-source platform built for the
community.
{{ formatMessage(messages.discoverHeading) }}
</h2>
<div class="button-group">
<ButtonStyled color="brand" size="large">
<nuxt-link to="/mods">
<CompassIcon aria-hidden="true" />
Discover mods
{{ formatMessage(messages.discoverMods) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" type="outlined">
<nuxt-link v-if="!auth.user" to="/auth/sign-up" rel="noopener nofollow">
<LogInIcon aria-hidden="true" />
Sign up
{{ formatMessage(commonMessages.signUpButton) }}
</nuxt-link>
<nuxt-link v-else to="/dashboard/projects">
<DashboardIcon aria-hidden="true" />
Go to dashboard
{{ formatMessage(messages.goToDashboard) }}
</nuxt-link>
</ButtonStyled>
</div>
@@ -65,44 +68,46 @@
</div>
</div>
<div v-else class="relative z-[10] w-full text-center text-xl font-bold text-contrast">
Failed to load random projects :(
{{ formatMessage(messages.failedToLoadRandomProjects) }}
</div>
<div class="projects-transition" />
<div class="users-section">
<div class="section-header">
<div class="section-label green">For Players</div>
<h2 class="section-tagline">Discover over 50,000 creations</h2>
<div class="section-label green">{{ formatMessage(messages.forPlayersLabel) }}</div>
<h2 class="section-tagline">
{{ formatMessage(messages.discoverCreationsTagline, { count: formattedProjectCount }) }}
</h2>
<p class="section-description">
From magical biomes to cursed dungeons, you can be sure to find content to bring your
gameplay to the next level.
{{ formatMessage(messages.playersDescription) }}
</p>
</div>
<div class="feature-blob">
<div class="blob-text">
<h3>Find what you want, quickly and easily</h3>
<h3>{{ formatMessage(messages.findWhatYouWantHeading) }}</h3>
<p>
Modrinth's lightning-fast search and powerful filters let you find what you want as
you type.
{{ formatMessage(messages.findWhatYouWantDescription) }}
</p>
</div>
<div class="blob-demonstration gradient-border bigger">
<div class="demo-search">
<div class="search-controls">
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<label class="hidden" for="search">{{
formatMessage(messages.searchLabel)
}}</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="searchQuery"
type="search"
name="search"
:placeholder="`Search...`"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
@input="updateSearchProjects"
/>
</div>
<div class="sort-by">
<span class="label">Sort by</span>
<span class="label">{{ formatMessage(messages.sortByLabel) }}</span>
<Multiselect
v-model="sortType"
placeholder="Select one"
@@ -145,12 +150,12 @@
</div>
<div class="feature-blob reverse">
<div class="blob-text">
<h3>Follow projects you love</h3>
<p>Get notified every time your favorite projects update and stay in the loop</p>
<h3>{{ formatMessage(messages.followProjectsHeading) }}</h3>
<p>{{ formatMessage(messages.followProjectsDescription) }}</p>
</div>
<div class="blob-demonstration gradient-border">
<div class="notifs-demo">
<h3>Notifications</h3>
<h3>{{ formatMessage(messages.notificationsHeading) }}</h3>
<div class="notifications">
<div
v-for="(notification, index) in notifications"
@@ -168,24 +173,24 @@
:to="`${notification.project_type}/${notification.slug}`"
class="notif-header"
>
{{ notification.title }} has been updated!
{{ formatMessage(messages.hasBeenUpdated, { title: notification.title }) }}
</nuxt-link>
<p class="notif-desc">
Version {{ ['1.1.2', '1.0.3', '15.1'][index] }} has been released for
{{
$capitalizeString(
notification.display_categories[
notification.display_categories.length - 1
],
)
formatMessage(messages.versionReleased, {
version: ['1.1.2', '1.0.3', '15.1'][index],
gameVersion: notification.versions[notification.versions.length - 1],
})
}}
{{ notification.versions[notification.versions.length - 1] }}
</p>
<div class="date">
<CalendarIcon aria-hidden="true" />
<span>
Received
{{ formatRelativeTime(notification.date_modified) }}
{{
formatMessage(messages.receivedTime, {
time: formatRelativeTime(notification.date_modified),
})
}}
</span>
</div>
</div>
@@ -196,12 +201,15 @@
</div>
<div class="feature-blob">
<div class="blob-text">
<h3>Play with your favorite launcher</h3>
<h3>{{ formatMessage(messages.playWithLauncherHeading) }}</h3>
<p>
Modrinth's open-source API lets launchers add deep integration with Modrinth. You can
use Modrinth through
<nuxt-link class="title-link" to="/app">our own app</nuxt-link>
and some of the most popular launchers like ATLauncher, MultiMC, and Prism Launcher.
<IntlFormatted :message-id="messages.playWithLauncherDescription">
<template #link="{ children }">
<nuxt-link class="title-link" to="/app">
<component :is="() => children" />
</nuxt-link>
</template>
</IntlFormatted>
</p>
</div>
<div class="blob-demonstration gradient-border">
@@ -209,13 +217,13 @@
<img
v-if="$theme.active === 'light'"
src="https://cdn.modrinth.com/landing-new/launcher-light.webp"
alt="launcher graphic"
:alt="formatMessage(messages.launcherGraphicAlt)"
class="minecraft-screen"
/>
<img
v-else
src="https://cdn.modrinth.com/landing-new/launcher.webp"
alt="launcher graphic"
:alt="formatMessage(messages.launcherGraphicAlt)"
class="minecraft-screen"
/>
<div class="launcher-graphics">
@@ -223,15 +231,15 @@
rel="noopener"
href="https://prismlauncher.org/"
class="graphic gradient-border"
title="Prism Launcher"
aria-label="Prism Launcher"
:title="formatMessage(messages.prismLauncherLabel)"
:aria-label="formatMessage(messages.prismLauncherLabel)"
>
<PrismLauncherLogo aria-hidden="true" />
</a>
<nuxt-link
to="/app"
class="graphic gradient-border text-brand"
aria-label="Modrinth App"
:aria-label="formatMessage(messages.modrinthAppLabel)"
>
<ModrinthIcon aria-hidden="true" />
</nuxt-link>
@@ -239,8 +247,8 @@
rel="noopener"
href="https://atlauncher.com/"
class="graphic gradient-border"
title="ATLauncher"
aria-label="ATLauncher"
:title="formatMessage(messages.atlauncherLabel)"
:aria-label="formatMessage(messages.atlauncherLabel)"
>
<ATLauncherLogo aria-hidden="true" />
</a>
@@ -252,10 +260,10 @@
</div>
<div class="creator-section">
<div class="section-header">
<div class="section-label blue">For Creators</div>
<h2 class="section-tagline">Share your content with the world</h2>
<div class="section-label blue">{{ formatMessage(messages.forCreatorsLabel) }}</div>
<h2 class="section-tagline">{{ formatMessage(messages.shareContentTagline) }}</h2>
<p class="section-description">
Give an online home to your creations and reach a massive audience of dedicated players
{{ formatMessage(messages.creatorsDescription) }}
</p>
</div>
<div class="features">
@@ -281,10 +289,9 @@
</defs>
</svg>
</div>
<h3>Discovery</h3>
<h3>{{ formatMessage(creatorFeatureMessages.discoveryTitle) }}</h3>
<p>
Get your project discovered by thousands of users through search, our home page, Discord
server, and more ways to come in the future!
{{ formatMessage(creatorFeatureMessages.discoveryDescription) }}
</p>
</div>
<div class="feature gradient-border">
@@ -309,8 +316,8 @@
</defs>
</svg>
</div>
<h3>Team Management</h3>
<p>Invite your teammates and manage roles and permissions with ease</p>
<h3>{{ formatMessage(creatorFeatureMessages.teamManagementTitle) }}</h3>
<p>{{ formatMessage(creatorFeatureMessages.teamManagementDescription) }}</p>
</div>
<div class="feature gradient-border">
<div class="icon gradient-border">
@@ -334,8 +341,8 @@
</defs>
</svg>
</div>
<h3>Monetization</h3>
<p>Get paid ad revenue from your project pages and withdraw your funds at any time</p>
<h3>{{ formatMessage(creatorFeatureMessages.monetizationTitle) }}</h3>
<p>{{ formatMessage(creatorFeatureMessages.monetizationDescription) }}</p>
</div>
<div class="feature gradient-border">
<div class="icon gradient-border">
@@ -359,10 +366,9 @@
</defs>
</svg>
</div>
<h3>Diverse Ecosystem</h3>
<h3>{{ formatMessage(creatorFeatureMessages.diverseEcosystemTitle) }}</h3>
<p>
Integrate with your build tools through Minotaur for automatic uploads right when you
release a new version
{{ formatMessage(creatorFeatureMessages.diverseEcosystemDescription) }}
</p>
</div>
<div class="feature gradient-border">
@@ -387,8 +393,8 @@
</defs>
</svg>
</div>
<h3>Data and Statistics</h3>
<p>Get detailed reports on page views, download counts, and revenue</p>
<h3>{{ formatMessage(creatorFeatureMessages.dataStatisticsTitle) }}</h3>
<p>{{ formatMessage(creatorFeatureMessages.dataStatisticsDescription) }}</p>
</div>
<div class="feature gradient-border">
<div class="icon gradient-border">
@@ -412,9 +418,9 @@
</defs>
</svg>
</div>
<h3>Constantly Evolving</h3>
<h3>{{ formatMessage(creatorFeatureMessages.constantlyEvolvingTitle) }}</h3>
<p>
Get the best modding experience possible with constant updates from the Modrinth team
{{ formatMessage(creatorFeatureMessages.constantlyEvolvingDescription) }}
</p>
</div>
</div>
@@ -433,7 +439,9 @@ import {
ModrinthIcon,
SearchIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
import { Avatar, ButtonStyled, commonMessages, useRelativeTime } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { ref } from 'vue'
import { Multiselect } from 'vue-multiselect'
@@ -445,11 +453,16 @@ import { homePageNotifs, homePageProjects, homePageSearch } from '~/generated/st
const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl()
const searchQuery = ref('leave')
const sortType = ref('relevance')
const PROJECT_COUNT = 75000
const formatNumber = new Intl.NumberFormat().format
const formattedProjectCount = computed(() => formatNumber(PROJECT_COUNT))
const auth = await useAuth()
const tags = useTags()
const newProjects = homePageProjects?.slice(0, 40)
const val = Math.ceil(newProjects?.length / 3)
@@ -473,6 +486,213 @@ async function updateSearchProjects() {
searchProjects.value = res?.hits ?? []
}
const messages = defineMessages({
thePlaceForMinecraft: {
id: 'landing.heading.the-place-for-minecraft',
defaultMessage: 'The place for Minecraft {content}',
},
discoverHeading: {
id: 'landing.subheading',
defaultMessage:
'Discover, play, and share Minecraft content through our open-source platform built for the community.',
},
discoverMods: {
id: 'landing.button.discover-mods',
defaultMessage: 'Discover mods',
},
goToDashboard: {
id: 'landing.button.go-to-dashboard',
defaultMessage: 'Go to dashboard',
},
failedToLoadRandomProjects: {
id: 'landing.error.failedToLoadRandomProjects',
defaultMessage: 'Failed to load random projects :(',
},
forPlayersLabel: {
id: 'landing.section.for-players.label',
defaultMessage: 'For Players',
},
forCreatorsLabel: {
id: 'landing.section.for-creators.label',
defaultMessage: 'For Creators',
},
discoverCreationsTagline: {
id: 'landing.section.for-players.tagline',
defaultMessage: 'Discover over {count} creations',
},
shareContentTagline: {
id: 'landing.section.for-creators.tagline',
defaultMessage: 'Share your content with the world',
},
playersDescription: {
id: 'landing.section.for-players.description',
defaultMessage:
'From magical biomes to cursed dungeons, you can be sure to find content to bring your gameplay to the next level.',
},
creatorsDescription: {
id: 'landing.section.for-creators.description',
defaultMessage:
'Give an online home to your creations and reach a massive audience of dedicated players.',
},
findWhatYouWantHeading: {
id: 'landing.feature.search.heading',
defaultMessage: 'Find what you want, quickly and easily',
},
findWhatYouWantDescription: {
id: 'landing.feature.search.description',
defaultMessage:
"Modrinth's lightning-fast search and powerful filters let you find what you want as you type.",
},
followProjectsHeading: {
id: 'landing.feature.follow.heading',
defaultMessage: 'Follow projects you love',
},
followProjectsDescription: {
id: 'landing.feature.follow.description',
defaultMessage: 'Get notified every time your favorite projects update and stay in the loop.',
},
playWithLauncherHeading: {
id: 'landing.feature.launcher.heading',
defaultMessage: 'Play with your favorite launcher',
},
playWithLauncherDescription: {
id: 'landing.feature.launcher.description',
defaultMessage:
"Modrinth's open-source API lets launchers add deep integration with Modrinth. You can use Modrinth through <link>our own app</link> and some of the most popular launchers like ATLauncher, MultiMC, and Prism Launcher.",
},
searchPlaceholder: {
id: 'landing.search.placeholder',
defaultMessage: 'Search...',
},
searchLabel: {
id: 'landing.search.label',
defaultMessage: 'Search',
},
sortByLabel: {
id: 'landing.search.sort-by.label',
defaultMessage: 'Sort by',
},
notificationsHeading: {
id: 'landing.notifications.heading',
defaultMessage: 'Notifications',
},
hasBeenUpdated: {
id: 'landing.notifications.has-been-updated',
defaultMessage: '{title} has been updated!',
},
versionReleased: {
id: 'landing.notifications.version-released',
defaultMessage: 'Version {version} has been released for {gameVersion}',
},
receivedTime: {
id: 'landing.notifications.received-time',
defaultMessage: 'Received {time}',
},
launcherGraphicAlt: {
id: 'landing.launcher.graphic-alt',
defaultMessage:
'A simplified representation of a Minecraft window, with the Mojang Studios logo in Modrinth green.',
},
prismLauncherLabel: {
id: 'landing.launcher.prism-launcher-label',
defaultMessage: 'Prism Launcher',
},
modrinthAppLabel: {
id: 'landing.launcher.modrinth-app-label',
defaultMessage: 'Modrinth App',
},
atlauncherLabel: {
id: 'landing.launcher.atlauncher-label',
defaultMessage: 'ATLauncher',
},
})
const contentTypeMessages = defineMessages({
mods: {
id: 'landing.heading.the-place-for-minecraft.mods',
defaultMessage: 'mods',
},
resourcePacks: {
id: 'landing.heading.the-place-for-minecraft.resource-packs',
defaultMessage: 'resource packs',
},
dataPacks: {
id: 'landing.heading.the-place-for-minecraft.data-packs',
defaultMessage: 'data packs',
},
shaders: {
id: 'landing.heading.the-place-for-minecraft.shaders',
defaultMessage: 'shaders',
},
modpacks: {
id: 'landing.heading.the-place-for-minecraft.modpacks',
defaultMessage: 'modpacks',
},
plugins: {
id: 'landing.heading.the-place-for-minecraft.plugins',
defaultMessage: 'plugins',
},
servers: {
id: 'landing.heading.the-place-for-minecraft.servers',
defaultMessage: 'servers',
},
})
const creatorFeatureMessages = defineMessages({
discoveryTitle: {
id: 'landing.creator.feature.discovery.title',
defaultMessage: 'Discovery',
},
discoveryDescription: {
id: 'landing.creator.feature.discovery.description',
defaultMessage:
'Get your project discovered by thousands of users through search, our home page, Discord server, and more ways to come in the future!',
},
teamManagementTitle: {
id: 'landing.creator.feature.team-management.title',
defaultMessage: 'Team Management',
},
teamManagementDescription: {
id: 'landing.creator.feature.team-management.description',
defaultMessage: 'Invite your teammates and manage roles and permissions with ease',
},
monetizationTitle: {
id: 'landing.creator.feature.monetization.title',
defaultMessage: 'Monetization',
},
monetizationDescription: {
id: 'landing.creator.feature.monetization.description',
defaultMessage:
'Get paid ad revenue from your project pages and withdraw your funds at any time',
},
diverseEcosystemTitle: {
id: 'landing.creator.feature.diverse-ecosystem.title',
defaultMessage: 'Diverse Ecosystem',
},
diverseEcosystemDescription: {
id: 'landing.creator.feature.diverse-ecosystem.description',
defaultMessage:
'Integrate with your build tools through Minotaur for automatic uploads right when you release a new version',
},
dataStatisticsTitle: {
id: 'landing.creator.feature.data-statistics.title',
defaultMessage: 'Data and Statistics',
},
dataStatisticsDescription: {
id: 'landing.creator.feature.data-statistics.description',
defaultMessage: 'Get detailed reports on page views, download counts, and revenue',
},
constantlyEvolvingTitle: {
id: 'landing.creator.feature.constantly-evolving.title',
defaultMessage: 'Constantly Evolving',
},
constantlyEvolvingDescription: {
id: 'landing.creator.feature.constantly-evolving.description',
defaultMessage:
'Get the best modding experience possible with constant updates from the Modrinth team',
},
})
</script>
<style lang="scss" scoped>
@@ -764,10 +984,14 @@ async function updateSearchProjects() {
display: flex;
gap: 0.75rem;
align-items: center;
min-width: 12.25rem;
.label {
white-space: nowrap;
}
.selector {
max-width: 8rem;
min-width: 8rem;
white-space: nowrap;
}
@media screen and (max-width: 500px) {
@@ -1065,6 +1289,7 @@ async function updateSearchProjects() {
font-weight: 600;
line-height: 100%;
margin: 0 0 0.25rem;
width: 100%;
}
.main-header-strong {
@@ -1076,6 +1301,7 @@ async function updateSearchProjects() {
-webkit-text-fill-color: transparent;
-moz-text-fill-color: transparent;
color: transparent;
white-space: nowrap;
}
.animate-strong {
@@ -1089,7 +1315,7 @@ async function updateSearchProjects() {
> span {
position: absolute;
top: 0;
animation: slide 12s infinite;
animation: slide 14s infinite;
@media (prefers-reduced-motion) {
animation-play-state: paused !important;
@@ -1098,40 +1324,36 @@ async function updateSearchProjects() {
@keyframes slide {
0%,
10% {
12.5% {
top: 0;
}
13%,
23% {
14.3%,
26.8% {
top: -1.2em;
}
26%,
36% {
28.6%,
41.1% {
top: -2.4em;
}
39%,
49% {
42.9%,
55.4% {
top: -3.6em;
}
52%,
62% {
57.2%,
69.7% {
top: -4.8em;
}
65%,
75% {
71.5%,
84% {
top: -6em;
}
78%,
88% {
85.8%,
98.3% {
top: -7.2em;
}
99.99997%,
99.99998% {
100% {
top: -8.4em;
}
99.99999% {
top: 0;
}
}
}

View File

@@ -181,7 +181,8 @@ useSeoMeta({
padding: 0;
}
ul > li:not(:last-child) {
ul,
ol > li:not(:last-child) {
margin-bottom: 0.5rem;
}
@@ -192,6 +193,10 @@ useSeoMeta({
}
}
[data-contrast-text] {
color: var(--color-contrast);
}
ul,
ol {
strong {
@@ -203,7 +208,7 @@ useSeoMeta({
h1,
h2,
h3 {
margin-bottom: 0.25rem;
margin-bottom: 0.5rem;
}
h1 {

View File

@@ -18,7 +18,9 @@ const articles = ref(
title: article.title,
summary: article.summary,
date: article.date,
unlisted: article.unlisted,
}))
.filter((a) => !a.unlisted)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
)

View File

@@ -283,8 +283,7 @@ import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modri
import { formatNumber } from '@modrinth/utils'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
// import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'
import NavTabs from '~/components/ui/NavTabs.vue'

View File

@@ -323,7 +323,7 @@ import {
import { formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import OrganizationProjectTransferModal from '~/components/ui/OrganizationProjectTransferModal.vue'
import { injectOrganizationContext } from '~/providers/organization-context.ts'

View File

@@ -89,6 +89,8 @@ import { HeartIcon, ModrinthPlusIcon, SettingsIcon, SparklesIcon, StarIcon } fro
import { injectNotificationManager, PurchaseModal } from '@modrinth/ui'
import { calculateSavings, formatPrice, getCurrency } from '@modrinth/utils'
import { useBaseFetch } from '@/composables/fetch.js'
import { isPermission } from '@/utils/permissions.ts'
import { products } from '~/generated/state.json'
const { addNotification } = injectNotificationManager()

View File

@@ -292,6 +292,7 @@ import {
} from '@modrinth/ui'
import type { Project, Report, User, Version } from '@modrinth/utils'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useImageUpload } from '~/composables/image-upload.ts'

View File

@@ -20,7 +20,14 @@
class="flex flex-col gap-4 text-primary"
>
<span class="flex items-center gap-2">
<Avatar :src="server.general.image" size="48px" />
<Avatar
:src="
server.general.is_medal
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
: server.general.image
"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="bold font-extrabold text-contrast">
{{ server.general.name }}
@@ -321,6 +328,7 @@ import {
Button,
ButtonStyled,
Checkbox,
commonProjectTypeCategoryMessages,
DropdownSelect,
NewProjectCard,
Pagination,
@@ -628,12 +636,15 @@ function setClosestMaxResults() {
const selectableProjectTypes = computed(() => {
return [
{ label: 'Mods', href: `/mods` },
{ label: 'Resource Packs', href: `/resourcepacks` },
{ label: 'Data Packs', href: `/datapacks` },
{ label: 'Shaders', href: `/shaders` },
{ label: 'Modpacks', href: `/modpacks` },
{ label: 'Plugins', href: `/plugins` },
{ label: formatMessage(commonProjectTypeCategoryMessages.mod), href: `/mods` },
{
label: formatMessage(commonProjectTypeCategoryMessages.resourcepack),
href: `/resourcepacks`,
},
{ label: formatMessage(commonProjectTypeCategoryMessages.datapack), href: `/datapacks` },
{ label: formatMessage(commonProjectTypeCategoryMessages.shader), href: `/shaders` },
{ label: formatMessage(commonProjectTypeCategoryMessages.modpack), href: `/modpacks` },
{ label: formatMessage(commonProjectTypeCategoryMessages.plugin), href: `/plugins` },
]
})

View File

@@ -36,7 +36,7 @@
<div
class="relative h-fit w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Beta Release
{{ formatMessage(commonMessages.betaRelease) }}
</div>
<h1 class="relative m-0 max-w-3xl text-3xl font-bold !leading-[110%] md:text-6xl">
Host your next server with Modrinth Servers
@@ -392,7 +392,7 @@
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<h1 class="m-0 text-lg font-bold">Frequently Asked Questions</h1>
<div class="details-hide flex flex-col gap-1">
<details pyro-hash="cpus" class="group" :open="$route.hash === '#cpus'">
<details nav-hash="cpus" class="group" :open="$route.hash === '#cpus'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -404,7 +404,7 @@
GHz, paired with DDR5 memory.
</p>
</details>
<details pyro-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
<details nav-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -420,7 +420,7 @@
</p>
</details>
<details pyro-hash="ddos" class="group" :open="$route.hash === '#ddos'">
<details nav-hash="ddos" class="group" :open="$route.hash === '#ddos'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -433,7 +433,7 @@
</p>
</details>
<details pyro-hash="region" class="group" :open="$route.hash === '#region'">
<details nav-hash="region" class="group" :open="$route.hash === '#region'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -441,13 +441,13 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 ml-6 leading-[160%]">
We have servers available in North America and Europe at the moment that you can
choose upon purchase. More regions to come in the future! If you'd like to switch
your region, please contact support.
We have servers available in North America, Europe, and Southeast Asia at the moment
that you can choose upon purchase. More regions to come in the future! If you'd like
to switch your region, please contact support.
</p>
</details>
<details pyro-hash="storage" class="group" :open="$route.hash === '#storage'">
<details nav-hash="storage" class="group" :open="$route.hash === '#storage'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -460,7 +460,7 @@
</p>
</details>
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
<details nav-hash="performance" class="group" :open="$route.hash === '#performance'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -481,7 +481,7 @@
</p>
</details>
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
<details nav-hash="prices" class="group" :open="$route.hash === '#prices'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -493,7 +493,7 @@
</p>
</details>
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
<details nav-hash="versions" class="group" :open="$route.hash === '#versions'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
@@ -516,17 +516,18 @@
</section>
<section
id="plan"
pyro-hash="plan"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<div
nav-hash="plan"
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center"
>
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
There's a server for everyone
</h1>
<p class="m-0 flex items-center gap-1">
Available in North America and Europe for wide coverage.
Available in North America, Europe, and Southeast Asia for wide coverage.
</p>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
@@ -551,6 +552,8 @@
<span v-else></span>
</div>
<MedalPlanPromotion v-if="flags.enableMedalPromotion" />
<ul class="m-0 flex w-full grid-cols-3 flex-col gap-8 p-0 lg:grid">
<ServerPlanSelector
:capacity="capacityStatuses?.small?.available"
@@ -642,20 +645,28 @@ import {
TransferIcon,
VersionIcon,
} from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, ModrinthServersPurchaseModal } from '@modrinth/ui'
import {
ButtonStyled,
commonMessages,
injectNotificationManager,
ModrinthServersPurchaseModal,
} from '@modrinth/ui'
import { monthsInInterval } from '@modrinth/ui/src/utils/billing.ts'
import { formatPrice } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { useBaseFetch } from '@/composables/fetch.js'
import OptionGroup from '~/components/ui/OptionGroup.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue'
import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
import { products } from '~/generated/state.json'
const { addNotification } = injectNotificationManager()
const { locale } = useVIntl()
const { locale, formatMessage } = useVIntl()
const flags = useFeatureFlags()
const billingPeriods = ref(['monthly', 'quarterly'])
const billingPeriod = ref(billingPeriods.value.includes('quarterly') ? 'quarterly' : 'monthly')
@@ -849,8 +860,8 @@ const isAtCapacity = computed(
const scrollToFaq = () => {
if (route.hash) {
// where pyro-hash === route.hash
const faq = document.querySelector(`[pyro-hash="${route.hash.slice(1)}"]`)
// where nav-hash === route.hash
const faq = document.querySelector(`[nav-hash="${route.hash.slice(1)}"]`)
if (faq) {
faq.open = true
const top = faq.getBoundingClientRect().top
@@ -922,16 +933,22 @@ const selectProduct = async (product) => {
await nextTick()
if (product === 'custom') {
purchaseModal.value?.show(billingPeriod.value, undefined, selectedProjectId.value)
purchaseModal.value?.show(billingPeriod.value, null, selectedProjectId.value)
} else {
purchaseModal.value?.show(billingPeriod.value, selectedProduct.value, selectedProjectId.value)
}
}
const planQuery = () => {
if (route.query.plan) {
document.getElementById('plan').scrollIntoView()
selectProduct(route.query.plan)
const planQuery = async () => {
if ('plan' in route.query) {
await nextTick()
const planElement = document.querySelector(`[nav-hash="plan"]`)
if (planElement) {
planElement.scrollIntoView({ behavior: 'smooth' })
if (route.query.plan !== null) {
await selectProduct(route.query.plan)
}
}
}
}

View File

@@ -115,58 +115,63 @@
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
<div
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
>
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
</NuxtLink>
</div>
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
<h1
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
>
{{ serverData.name }}
</h1>
<div
v-if="isConnected"
data-pyro-server-action-buttons
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
v-if="!serverData.flows?.intro"
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
@action="sendPowerAction"
/>
</div>
</div>
<div
v-if="serverData.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon /> Configuring server...
</div>
<UiServersServerInfoLabels
v-else
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
<div>
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
</NuxtLink>
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
<ServerIcon
:image="
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
"
class="drop-shadow-lg sm:drop-shadow-none"
/>
<div
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
>
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
<h1
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-2xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
>
{{ serverData.name }}
</h1>
<div
v-if="isConnected"
data-pyro-server-action-buttons
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<PanelServerActionButton
v-if="!serverData.flows?.intro"
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
@action="sendPowerAction"
/>
</div>
</div>
<div
v-if="serverData.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon /> Configuring server...
</div>
<ServerInfoLabels
v-else
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
</div>
@@ -175,7 +180,7 @@
v-if="serverData?.status === 'installing'"
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<LazyUiServersPanelSpinner class="size-10 animate-spin" /> Setting up your server...
<PanelSpinner class="size-10 animate-spin" /> Setting up your server...
</div>
<div v-else>
<h2 class="my-4 text-xl font-extrabold">
@@ -196,7 +201,7 @@
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
<NavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
@@ -290,6 +295,10 @@
</div>
</div>
<div v-if="serverData.is_medal" class="mb-4">
<MedalServerCountdown :server-id="server.serverId" />
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
@@ -304,7 +313,7 @@
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
<PanelSpinner />
Hang on, we're reconnecting to your server.
</div>
@@ -313,13 +322,13 @@
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<ServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" />
<LazyUiServersInstallingTicker />
<PanelSpinner class="!h-3 !w-3" />
<InstallingTicker />
</div>
</div>
</div>
@@ -382,7 +391,14 @@ import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, type Reactive, ref } from 'vue'
import { reloadNuxtApp } from '#app'
import NavTabs from '~/components/ui/NavTabs.vue'
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
import ServerInfoLabels from '~/components/ui/servers/ServerInfoLabels.vue'
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'

View File

@@ -107,7 +107,6 @@
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@prepare="() => prepareDownload(backup.id)"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@@ -233,19 +232,6 @@ function triggerDownloadAnimation() {
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const prepareDownload = async (backupId: string) => {
try {
await props.server.backups?.prepare(backupId)
} catch (error) {
console.error('Failed to prepare download:', error)
addNotification({
type: 'error',
title: 'Failed to prepare backup for download',
text: error as string,
})
}
}
const lockBackup = async (backupId: string) => {
try {
await props.server.backups?.lock(backupId)

View File

@@ -1,5 +1,5 @@
<template>
<UiServersContentVersionEditModal
<ContentVersionEditModal
v-if="!invalidModal"
ref="versionEditModal"
:type="type"
@@ -59,7 +59,7 @@
/>
</div>
<ButtonStyled>
<UiServersTeleportOverflowMenu
<TeleportOverflowMenu
position="bottom"
direction="left"
:aria-label="`Filter ${type}s`"
@@ -77,8 +77,8 @@
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
<template #enabled> Only enabled </template>
<template #disabled> Only disabled </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
<ButtonStyled>
@@ -202,7 +202,7 @@
@click="showVersionModal(mod)"
>
<template v-if="mod.changing">
<UiServersIconsLoadingIcon class="animate-spin" />
<LoadingIcon class="animate-spin" />
</template>
<template v-else>
<EditIcon />
@@ -212,13 +212,13 @@
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<UiServersIconsLoadingIcon
<LoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<UiServersTeleportOverflowMenu
<TeleportOverflowMenu
:options="[
{
id: 'edit',
@@ -240,8 +240,8 @@
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<input
@@ -312,7 +312,7 @@
</div>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
<UiServersIconsLoaderIcon loader="Vanilla" class="size-24" />
<LoaderIcon loader="Vanilla" class="size-24" />
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
<p class="m-0">
Add content to your server by installing a modpack or choosing a different platform that
@@ -359,8 +359,12 @@ import { Avatar, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import type { Mod } from '@modrinth/utils'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ContentVersionEditModal from '~/components/ui/servers/ContentVersionEditModal.vue'
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
import LoadingIcon from '~/components/ui/servers/icons/LoadingIcon.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'

View File

@@ -1,31 +1,19 @@
<template>
<div data-pyro-file-manager-root class="contents">
<LazyUiServersFilesCreateItemModal
ref="createItemModal"
:type="newItemType"
@create="handleCreateNewItem"
/>
<FilesCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
<FilesUploadZipUrlModal ref="uploadZipModal" :server="server" />
<FilesUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
<LazyUiServersFilesRenameItemModal
ref="renameItemModal"
:item="selectedItem"
@rename="handleRenameItem"
/>
<FilesRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
<LazyUiServersFilesMoveItemModal
<FilesMoveItemModal
ref="moveItemModal"
:item="selectedItem"
:current-path="currentPath"
@move="handleMoveItem"
/>
<LazyUiServersFilesDeleteItemModal
ref="deleteItemModal"
:item="selectedItem"
@delete="handleDeleteItem"
/>
<FilesDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
<FilesUploadDragAndDrop
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
@@ -33,7 +21,7 @@
>
<div ref="mainContent" class="relative isolate flex w-full flex-col">
<div v-if="!isEditing" class="contents">
<UiServersFilesBrowseNavbar
<FilesBrowseNavbar
:breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery"
:current-filter="viewFilter"
@@ -46,11 +34,7 @@
@filter="handleFilter"
@update:search-query="searchQuery = $event"
/>
<UiServersFilesLabelBar
:sort-field="sortMethod"
:sort-desc="sortDesc"
@sort="handleSort"
/>
<FilesLabelBar :sort-field="sortMethod" :sort-desc="sortDesc" @sort="handleSort" />
<div
v-for="op in ops"
:key="`fs-op-${op.op}-${op.src}`"
@@ -172,7 +156,7 @@
@upload-complete="refreshList()"
/>
</div>
<UiServersFilesEditingNavbar
<FilesEditingNavbar
v-else
:file-name="editingFile?.name"
:is-image="isEditingImage"
@@ -211,10 +195,10 @@
class="ace_editor ace_hidpi ace-one-dark ace_dark rounded-b-lg"
@init="onInit"
/>
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
<FilesImageViewer v-else :image-blob="imagePreview" />
</div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFileVirtualList
<FileVirtualList
:items="filteredItems"
@extract="handleExtractItem"
@delete="showDeleteModal"
@@ -239,7 +223,7 @@
</div>
</div>
<LazyUiServersFileManagerError
<FileManagerError
v-else-if="loadError"
title="Unable to load files"
message="The folder may not exist."
@@ -259,7 +243,7 @@
</div>
</FilesUploadDragAndDrop>
<UiServersFilesContextMenu
<FilesContextMenu
ref="contextMenu"
:item="contextMenuInfo.item"
:x="contextMenuInfo.x"
@@ -289,10 +273,21 @@ import { formatBytes, ModrinthServersFetchError } from '@modrinth/utils'
import { useInfiniteScroll } from '@vueuse/core'
import { computed } from 'vue'
import FileManagerError from '~/components/ui/servers/FileManagerError.vue'
import FilesBrowseNavbar from '~/components/ui/servers/FilesBrowseNavbar.vue'
import FilesContextMenu from '~/components/ui/servers/FilesContextMenu.vue'
import FilesCreateItemModal from '~/components/ui/servers/FilesCreateItemModal.vue'
import FilesDeleteItemModal from '~/components/ui/servers/FilesDeleteItemModal.vue'
import FilesEditingNavbar from '~/components/ui/servers/FilesEditingNavbar.vue'
import FilesImageViewer from '~/components/ui/servers/FilesImageViewer.vue'
import FilesLabelBar from '~/components/ui/servers/FilesLabelBar.vue'
import FilesMoveItemModal from '~/components/ui/servers/FilesMoveItemModal.vue'
import FilesRenameItemModal from '~/components/ui/servers/FilesRenameItemModal.vue'
import FilesUploadConflictModal from '~/components/ui/servers/FilesUploadConflictModal.vue'
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
import FilesUploadZipUrlModal from '~/components/ui/servers/FilesUploadZipUrlModal.vue'
import FileVirtualList from '~/components/ui/servers/FileVirtualList.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
@@ -398,13 +393,13 @@ const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
if (currentPage.value === 1) {
return {
items: applyDefaultSort(data.items),
items: data.items,
total: data.total,
}
}
return {
items: [...(directoryData.value?.items || []), ...applyDefaultSort(data.items)],
items: [...(directoryData.value?.items || []), ...data.items],
total: data.total,
}
} catch (error) {
@@ -732,15 +727,6 @@ const handleCreateError = (error: any) => {
}
}
const applyDefaultSort = (items: DirectoryItem[]) => {
return items.sort((a: any, b: any) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
}
const handleSort = (field: string) => {
if (sortMethod.value === field) {
sortDesc.value = !sortDesc.value

View File

@@ -75,7 +75,7 @@
</div>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats
<ServerStats
:data="isConnected && !isWsAuthIncorrect ? stats : undefined"
:loading="!isConnected || isWsAuthIncorrect"
/>
@@ -87,17 +87,11 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<UiServersPanelServerStatus
v-if="isConnected && !isWsAuthIncorrect"
:state="serverPowerState"
/>
<PanelServerStatus v-if="isConnected && !isWsAuthIncorrect" :state="serverPowerState" />
</div>
</div>
<UiServersPanelTerminal
:full-screen="fullScreen"
:loading="!isConnected || isWsAuthIncorrect"
>
<PanelTerminal :full-screen="fullScreen" :loading="!isConnected || isWsAuthIncorrect">
<div class="relative w-full px-4 pt-4">
<ul
v-if="suggestions.length && isConnected && !isWsAuthIncorrect"
@@ -169,7 +163,7 @@
/>
</div>
</div>
</UiServersPanelTerminal>
</PanelTerminal>
</div>
</div>
@@ -191,6 +185,9 @@ import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
import ServerStats from '~/components/ui/servers/ServerStats.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
type ServerProps = {

View File

@@ -1,5 +1,5 @@
<template>
<UiServersServerSidebar
<ServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
@@ -20,6 +20,7 @@ import {
} from '@modrinth/assets'
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
import ServerSidebar from '~/components/ui/servers/ServerSidebar.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'

View File

@@ -61,7 +61,7 @@
</div>
</div>
<div class="card flex flex-col gap-4">
<div v-if="!data.is_medal" class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span>
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
@@ -88,7 +88,7 @@
>
<EditIcon class="h-8 w-8 text-contrast" />
</div>
<UiServersServerIcon :image="icon" />
<ServerIcon :image="icon" />
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
@@ -101,7 +101,7 @@
</div>
</div>
<div v-else />
<UiServersSaveBanner
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
@@ -116,6 +116,8 @@ import { EditIcon, TransferIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()

View File

@@ -251,7 +251,7 @@
</div>
</div>
</div>
<UiServersSaveBanner
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:is-updating="isUpdating"
@@ -282,6 +282,7 @@ import {
} from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()

View File

@@ -31,7 +31,7 @@
</div>
</div>
</div>
<UiServersSaveBanner
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
@@ -45,6 +45,7 @@
import { injectNotificationManager } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()

View File

@@ -77,7 +77,7 @@
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<UiServersTeleportDropdownMenu
<TeleportDropdownMenu
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
@@ -95,7 +95,10 @@
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
<div
v-else-if="typeof property === 'number' && index !== 'level-seed' && index !== 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
@@ -104,6 +107,18 @@
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div
v-else-if="index === 'level-seed' || index === 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
@@ -131,7 +146,7 @@
</p>
</div>
<UiServersSaveBanner
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
@@ -144,10 +159,11 @@
<script setup lang="ts">
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager, TeleportDropdownMenu } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
@@ -178,8 +194,14 @@ const { data: propsData, status } = await useAsyncData('ServerProperties', async
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
value = value.toLowerCase() === 'true'
} else if (!isNaN(value as any) && value !== '') {
value = Number(value)
} else {
const intLike = /^[-+]?\d+$/.test(value)
if (intLike) {
const n = Number(value)
if (Number.isSafeInteger(n)) {
value = n
}
}
}
properties[key.trim()] = value
@@ -195,6 +217,7 @@ watch(
propsData,
(newPropsData) => {
if (newPropsData) {
console.log(newPropsData)
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
}

View File

@@ -77,7 +77,7 @@
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<UiServersTeleportDropdownMenu
<TeleportDropdownMenu
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
@@ -90,7 +90,7 @@
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<UiServersTeleportDropdownMenu
<TeleportDropdownMenu
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
@@ -101,7 +101,7 @@
</div>
</div>
</div>
<UiServersSaveBanner
<SaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
@@ -113,8 +113,9 @@
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager, TeleportDropdownMenu } from '@modrinth/ui'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()

View File

@@ -3,6 +3,7 @@
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<ServersUpgradeModalWrapper ref="upgradeModal" />
<div
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
@@ -53,7 +54,7 @@
</div>
</div>
<LazyUiServersServerManageEmptyState
<ServerManageEmptyState
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
/>
@@ -93,12 +94,17 @@
v-if="filteredData.length > 0 || isPollingForNewServers"
class="m-0 flex flex-col gap-4 p-0"
>
<UiServersServerListing
v-for="server in filteredData"
<MedalServerListing
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openUpgradeModal(server.server_id)"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast">No servers found.</p>
@@ -111,10 +117,16 @@
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode } from '@modrinth/ui'
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
import dayjs from 'dayjs'
import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app'
import MedalServerListing from '~/components/ui/servers/marketing/MedalServerListing.vue'
import ServerListing from '~/components/ui/servers/ServerListing.vue'
import ServerManageEmptyState from '~/components/ui/servers/ServerManageEmptyState.vue'
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
definePageMeta({
@@ -138,15 +150,40 @@ const {
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>('ServerList', () =>
useServersFetch<ServerResponse>('servers'),
)
} = await useAsyncData<ServerResponse>('ServerList', async () => {
const serverResponse = await useServersFetch<ServerResponse>('servers')
let subscriptions: any[] | undefined
for (const server of serverResponse.servers) {
if (server.is_medal) {
// Inject end date into server object.
const serverID = server.server_id
if (!subscriptions) {
subscriptions = (await useBaseFetch(`billing/subscriptions`, {
internal: true,
})) as any[]
}
for (const subscription of subscriptions) {
if (subscription.metadata?.id === serverID) {
server.medal_expires = dayjs(subscription.created as string)
.add(5, 'days')
.toISOString()
}
}
}
}
return serverResponse
})
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response
})
const serverList = computed(() => {
const serverList = computed<Server[]>(() => {
if (!serverResponse.value) return []
return serverResponse.value.servers
})
@@ -168,7 +205,7 @@ function introToTop(array: Server[]): Server[] {
})
}
const filteredData = computed(() => {
const filteredData = computed<Server[]>(() => {
if (!searchInput.value.trim()) {
return introToTop(serverList.value)
}
@@ -208,4 +245,13 @@ onUnmounted(() => {
clearInterval(intervalId)
}
})
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
open: (id: string) => void | Promise<void>
}>
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
function openUpgradeModal(serverId: string) {
upgradeModal.value?.open(serverId)
}
</script>

View File

@@ -16,6 +16,7 @@
</NavStackItem>
<NavStackItem
v-if="isStaging"
:badge="`${formatMessage(commonMessages.beta)}`"
link="/settings/language"
:label="formatMessage(commonSettingsMessages.language)"
>

View File

@@ -432,7 +432,7 @@ import SteamIcon from 'assets/icons/auth/sso-steam.svg'
import QrcodeVue from 'qrcode.vue'
import Modal from '~/components/ui/Modal.vue'
import { removeAuthProvider } from '~/composables/auth.js'
import { getAuthUrl, removeAuthProvider } from '~/composables/auth.js'
useHead({
title: 'Account settings - Modrinth',

View File

@@ -1,4 +1,5 @@
<template>
<ServersUpgradeModalWrapper ref="upgradeModal" />
<section class="universal-card experimental-styles-within">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
@@ -31,15 +32,15 @@
<div class="flex flex-col gap-2">
<span class="font-bold">Benefits</span>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<CheckCircleIcon class="h-5 w-5 shrink-0 text-brand" />
<span> Ad-free browsing on modrinth.com and Modrinth App </span>
</div>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<CheckCircleIcon class="h-5 w-5 shrink-0 text-brand" />
<span>Modrinth+ badge on your profile</span>
</div>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<CheckCircleIcon class="h-5 w-5 shrink-0 text-brand" />
<span>Support Modrinth and creators directly</span>
</div>
</div>
@@ -51,18 +52,35 @@
{{
formatPrice(
vintl.locale,
midasSubscriptionPrice.prices.intervals[midasCharge.subscription_interval],
midasSubscriptionPrice.prices.intervals[midasSubscription.interval],
midasSubscriptionPrice.currency_code,
)
}}
/
{{ midasCharge.subscription_interval }}
{{ midasSubscription.interval }}
</template>
<template v-else>
{{ formatPrice(vintl.locale, price.prices.intervals.monthly, price.currency_code) }}
/ month
</template>
</span>
<!-- Next charge preview for Midas when interval is changing -->
<div
v-if="
midasCharge &&
midasCharge.status === 'open' &&
midasSubscription &&
midasSubscription.interval &&
midasCharge.subscription_interval !== midasSubscription.interval
"
class="-mt-1 flex items-baseline gap-2 text-sm text-secondary"
>
<span class="opacity-70">Next:</span>
<span class="font-semibold text-contrast">
{{ formatPrice(vintl.locale, midasCharge.amount, midasCharge.currency_code) }}
</span>
<span>/{{ midasCharge.subscription_interval.replace('ly', '') }}</span>
</div>
<template v-if="midasCharge">
<span
v-if="
@@ -88,12 +106,24 @@
<span v-else-if="midasCharge.status === 'cancelled'" class="text-sm text-secondary">
Expires {{ $dayjs(midasCharge.due).format('MMMM D, YYYY') }}
</span>
<span
v-if="
midasCharge.status === 'open' &&
midasSubscription &&
midasSubscription.interval &&
midasCharge.subscription_interval !== midasSubscription.interval
"
class="text-sm text-secondary"
>
Switches to {{ midasCharge.subscription_interval }} billing on
{{ $dayjs(midasCharge.due).format('MMMM D, YYYY') }}
</span>
</template>
<span v-else class="text-sm text-secondary">
Or
{{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }}
/ year (save
{{ formatPrice(vintl.locale, price.prices.intervals.yearly, price.currency_code) }} /
year (save
{{
calculateSavings(price.prices.intervals.monthly, price.prices.intervals.yearly)
}}%)!
@@ -168,8 +198,7 @@
@click="switchMidasInterval(oppositeInterval)"
>
<SpinnerIcon v-if="changingInterval" class="animate-spin" />
<TransferIcon v-else />
{{ changingInterval ? 'Switching' : 'Switch' }} to
<TransferIcon v-else /> {{ changingInterval ? 'Switching' : 'Switch' }} to
{{ oppositeInterval }}
</button>
</ButtonStyled>
@@ -205,11 +234,12 @@
>
<div class="flex flex-col justify-between gap-4">
<div class="flex flex-col gap-4">
<LazyUiServersModrinthServersIcon class="flex h-8 w-fit" />
<ModrinthServersIcon class="flex h-8 w-fit" />
<div class="flex flex-col gap-2">
<UiServersServerListing
<ServerListing
v-if="subscription.serverInfo"
v-bind="subscription.serverInfo"
:pending-change="getPendingChange(subscription)"
/>
<div v-else class="w-fit">
<p>
@@ -236,9 +266,8 @@
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<span>
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }}
Shared CPUs (Bursts up to
{{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} Shared CPUs (Bursts up
to {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
</span>
</div>
<div class="flex items-center gap-2">
@@ -288,16 +317,60 @@
</span>
<span>/{{ subscription.interval.replace('ly', '') }}</span>
</div>
<div
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status === 'open' &&
((getPyroCharge(subscription).price_id &&
getPyroCharge(subscription).price_id !== subscription.price_id) ||
(getPyroCharge(subscription).subscription_interval &&
getPyroCharge(subscription).subscription_interval !==
subscription.interval))
"
class="-mt-1 flex items-baseline gap-2 text-sm text-secondary"
>
<span class="opacity-70">Next:</span>
<span class="font-semibold text-contrast">
{{
formatPrice(
vintl.locale,
getPyroCharge(subscription).amount,
getPyroCharge(subscription).currency_code,
)
}}
</span>
<span>
/
{{
(
getPyroCharge(subscription).subscription_interval ||
subscription.interval
).replace('ly', '')
}}
</span>
</div>
<div v-if="getPyroCharge(subscription)" class="mb-4 flex flex-col items-end">
<span class="text-sm text-secondary">
Since
{{ $dayjs(subscription.created).format('MMMM D, YYYY') }}
Since {{ $dayjs(subscription.created).format('MMMM D, YYYY') }}
</span>
<span
v-if="getPyroCharge(subscription).status === 'open'"
class="text-sm text-secondary"
>
Renews
Renews {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
</span>
<span
v-if="
getPyroCharge(subscription).status === 'open' &&
getPyroCharge(subscription).subscription_interval &&
getPyroCharge(subscription).subscription_interval !==
subscription.interval
"
class="text-sm text-secondary"
>
Switches to
{{ getPyroCharge(subscription).subscription_interval }}
billing on
{{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
</span>
<span
@@ -311,8 +384,7 @@
v-else-if="getPyroCharge(subscription).status === 'cancelled'"
class="text-sm text-secondary"
>
Expires
{{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
Expires {{ $dayjs(getPyroCharge(subscription).due).format('MMMM D, YYYY') }}
</span>
<span
v-else-if="getPyroCharge(subscription).status === 'failed'"
@@ -407,37 +479,6 @@
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<PurchaseModal
ref="pyroPurchaseModal"
:product="upgradeProducts"
:country="country"
custom-server
:existing-subscription="currentSubscription"
:existing-plan="currentProduct"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) =>
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
internal: true,
method: `PATCH`,
body: body,
})
"
:renewal-date="currentSubRenewalDate"
:on-error="
(err) =>
addNotification({
title: 'An error occurred',
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
"
:fetch-capacity-statuses="fetchCapacityStatuses"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
/>
<AddPaymentMethodModal
ref="addPaymentMethodModal"
:publishable-key="config.public.stripePublishableKey"
@@ -588,10 +629,14 @@ import {
import { calculateSavings, formatPrice, getCurrency } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useBaseFetch } from '@/composables/fetch.js'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
import ServerListing from '~/components/ui/servers/ServerListing.vue'
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
import { products } from '~/generated/state.json'
const { addNotification } = injectNotificationManager()
const { addNotification, handleError } = injectNotificationManager()
definePageMeta({
middleware: 'auth',
})
@@ -903,6 +948,12 @@ const getPyroProduct = (subscription) => {
return productsData.value.find((p) => p.prices?.some((x) => x.id === subscription.price_id))
}
// Get product by a price ID (useful for pending next-charge changes)
const getProductFromPriceId = (priceId) => {
if (!priceId || !productsData.value) return null
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
}
const getPyroCharge = (subscription) => {
if (!subscription || !charges.value) return null
return charges.value.find(
@@ -931,76 +982,18 @@ const getProductPrice = (product, interval) => {
)
}
const modalCancel = ref(null)
const getPlanChangeVerb = (currentProduct, nextProduct) => {
const curRam = currentProduct?.metadata?.ram ?? 0
const nextRam = nextProduct?.metadata?.ram ?? 0
const pyroPurchaseModal = ref()
const currentSubscription = ref(null)
const currentProduct = ref(null)
const upgradeProducts = ref([])
upgradeProducts.value.metadata = { type: 'pyro' }
const currentSubRenewalDate = ref()
const showPyroUpgradeModal = async (subscription) => {
currentSubscription.value = subscription
currentSubRenewalDate.value = getPyroCharge(subscription).due
currentProduct.value = getPyroProduct(subscription)
upgradeProducts.value = products.filter(
(p) =>
p.metadata.type === 'pyro' &&
(!currentProduct.value || p.metadata.ram > currentProduct.value.metadata.ram),
)
upgradeProducts.value.metadata = { type: 'pyro' }
await nextTick()
if (!currentProduct.value) {
console.error('Could not find product for current subscription')
addNotification({
title: 'An error occurred',
text: 'Could not find product for current subscription',
type: 'error',
})
return
}
if (!pyroPurchaseModal.value) {
console.error('pyroPurchaseModal ref is undefined')
return
}
pyroPurchaseModal.value.show()
return nextRam < curRam ? 'downgrade' : 'upgrade'
}
async function fetchCapacityStatuses(serverId, product) {
if (product) {
try {
return {
custom: await useServersFetch(`servers/${serverId}/upgrade-stock`, {
method: 'POST',
body: {
cpu: product.metadata.cpu,
memory_mb: product.metadata.ram,
swap_mb: product.metadata.swap,
storage_mb: product.metadata.storage,
},
}),
}
} catch (error) {
console.error('Error checking server capacities:', error)
addNotification({
title: 'Error checking server capacities',
text: error,
type: 'error',
})
return {
custom: { available: 0 },
small: { available: 0 },
medium: { available: 0 },
large: { available: 0 },
}
}
}
const modalCancel = ref(null)
const upgradeModal = ref(null)
const showPyroUpgradeModal = (subscription) => {
upgradeModal.value?.open(subscription?.metadata?.id)
}
const resubscribePyro = async (subscriptionId, wasSuspended) => {
@@ -1093,6 +1086,7 @@ function showCancellationSurvey(subscription) {
window.Tally.openPopup(formId, popupOptions)
} else {
console.warn('Tally script not yet loaded')
cancelSubscription(subscription.id, true)
}
} catch (e) {
console.error('Error opening Tally popup:', e)
@@ -1107,4 +1101,50 @@ useHead({
},
],
})
const getPendingChange = (subscription) => {
const charge = getPyroCharge(subscription)
if (!charge || charge.status !== 'open') return null
const nextProduct = getProductFromPriceId(charge.price_id)
if (!nextProduct || charge.price_id === subscription.price_id) {
// Not a plan change, but interval could change
if (charge.subscription_interval && charge.subscription_interval !== subscription.interval) {
return {
planSize: getProductSize(getPyroProduct(subscription)),
cpu: getPyroProduct(subscription)?.metadata?.cpu / 2,
cpuBurst: getPyroProduct(subscription)?.metadata?.cpu,
ramGb: (getPyroProduct(subscription)?.metadata?.ram || 0) / 1024,
swapGb: (getPyroProduct(subscription)?.metadata?.swap || 0) / 1024 || undefined,
storageGb: (getPyroProduct(subscription)?.metadata?.storage || 0) / 1024 || undefined,
date: charge.due,
intervalChange: charge.subscription_interval,
verb: 'Switches',
}
}
return null
}
const curProduct = getPyroProduct(subscription)
const verb = getPlanChangeVerb(curProduct, nextProduct)
const cpu = nextProduct?.metadata?.cpu ?? 0
const ram = nextProduct?.metadata?.ram ?? 0
const swap = nextProduct?.metadata?.swap ?? 0
const storage = nextProduct?.metadata?.storage ?? 0
return {
planSize: getProductSize(nextProduct),
cpu: cpu / 2,
cpuBurst: cpu,
ramGb: ram / 1024,
swapGb: swap ? swap / 1024 : undefined,
storageGb: storage ? storage / 1024 : undefined,
date: charge.due,
intervalChange:
charge.subscription_interval && charge.subscription_interval !== subscription.interval
? charge.subscription_interval
: null,
verb,
}
}
</script>

View File

@@ -56,9 +56,9 @@
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'list'"
class="radio"
class="radio shrink-0"
/>
<RadioButtonIcon v-else class="radio" />
<RadioButtonIcon v-else class="radio shrink-0" />
Rows
</div>
</button>
@@ -82,9 +82,9 @@
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'grid'"
class="radio"
class="radio shrink-0"
/>
<RadioButtonIcon v-else class="radio" />
<RadioButtonIcon v-else class="radio shrink-0" />
Grid
</div>
</button>
@@ -106,9 +106,9 @@
<div class="label">
<RadioButtonCheckedIcon
v-if="cosmetics.searchDisplayMode[projectType.id] === 'gallery'"
class="radio"
class="radio shrink-0"
/>
<RadioButtonIcon v-else class="radio" />
<RadioButtonIcon v-else class="radio shrink-0" />
Gallery
</div>
</button>
@@ -207,7 +207,10 @@
import { CodeIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import { Button, injectNotificationManager, ThemeSelector } from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { normalizeChildren } from '@/utils/vue-children.ts'
import MessageBanner from '~/components/ui/MessageBanner.vue'
import type { DisplayLocation } from '~/plugins/cosmetics'
import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts'

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { IssuesIcon, RadioButtonCheckedIcon, RadioButtonIcon } from '@modrinth/assets'
import { commonSettingsMessages } from '@modrinth/ui'
import { Admonition, commonSettingsMessages } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import Fuse from 'fuse.js/dist/fuse.basic'
import { isModifierKeyDown } from '~/helpers/events.ts'
@@ -47,6 +48,11 @@ const messages = defineMessages({
id: 'settings.language.languages.language-label-error',
defaultMessage: '{label}. Error',
},
languageWarning: {
id: 'settings.language.warning',
defaultMessage:
'Changing the site language may cause some content to appear in English if a translation is not available. The site is not yet fully translated, so some content may remain in English for certain languages. We are still working on improving our localization system, so occasionally content may appear broken.',
},
})
const categoryNames = defineMessages({
@@ -286,10 +292,14 @@ function getItemLabel(locale: Locale) {
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(commonSettingsMessages.language) }}</h2>
<div class="card-description">
<Admonition type="warning">
{{ formatMessage(messages.languageWarning) }}
</Admonition>
<div class="card-description mt-4">
<IntlFormatted :message-id="messages.languagesDescription">
<template #crowdin-link="{ children }">
<a href="https://crowdin.com/project/modrinth">
<a href="https://translate.modrinth.com">
<component :is="() => children" />
</a>
</template>

View File

@@ -212,6 +212,7 @@ import {
injectNotificationManager,
useRelativeTime,
} from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import Modal from '~/components/ui/Modal.vue'
import {

View File

@@ -92,6 +92,7 @@
<script setup>
import { SaveIcon, TrashIcon, UndoIcon, UploadIcon, UserIcon, XIcon } from '@modrinth/assets'
import { Avatar, Button, commonMessages, FileInput, injectNotificationManager } from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()

View File

@@ -366,6 +366,7 @@ import {
OverflowMenu,
useRelativeTime,
} from '@modrinth/ui'
import { IntlFormatted } from '@vintl/vintl/components'
import TenMClubBadge from '~/assets/images/badges/10m-club.svg?component'
import AlphaTesterBadge from '~/assets/images/badges/alpha-tester.svg?component'
@@ -375,9 +376,8 @@ import ModBadge from '~/assets/images/badges/mod.svg?component'
import PlusBadge from '~/assets/images/badges/plus.svg?component'
import StaffBadge from '~/assets/images/badges/staff.svg?component'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
// import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
import ModalCreation from '~/components/ui/create/ProjectCreateModal.vue'
import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import { isStaff } from '~/helpers/users.js'