You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '7fa442fb28a2b9156690ff147206275163e7aec8' into beta
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
|
||||
160
apps/frontend/src/pages/[type]/[id]/settings.vue
Normal file
160
apps/frontend/src/pages/[type]/[id]/settings.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
176
apps/frontend/src/pages/[type]/[id]/settings/environment.vue
Normal file
176
apps/frontend/src/pages/[type]/[id]/settings/environment.vue
Normal 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>
|
||||
192
apps/frontend/src/pages/[type]/[id]/settings/general.vue
Normal file
192
apps/frontend/src/pages/[type]/[id]/settings/general.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -91,7 +91,6 @@ const router = useRouter()
|
||||
const props = defineProps<{
|
||||
project: Project
|
||||
versions: Version[]
|
||||
featuredVersions: Version[]
|
||||
members: User[]
|
||||
currentMember: User
|
||||
dependencies: Dependency[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
131
apps/frontend/src/pages/admin/docs.vue
Normal file
131
apps/frontend/src/pages/admin/docs.vue
Normal 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>
|
||||
131
apps/frontend/src/pages/admin/emails.vue
Normal file
131
apps/frontend/src/pages/admin/emails.vue
Normal 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>
|
||||
201
apps/frontend/src/pages/admin/file_lookup.vue
Normal file
201
apps/frontend/src/pages/admin/file_lookup.vue
Normal 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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
'You’re now part of the awesome community of creators & explorers already building, downloading, and staying up-to-date with awazing mods.',
|
||||
'You’re 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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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` },
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
v-if="isStaging"
|
||||
:badge="`${formatMessage(commonMessages.beta)}`"
|
||||
link="/settings/language"
|
||||
:label="formatMessage(commonSettingsMessages.language)"
|
||||
>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -212,6 +212,7 @@ import {
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user