1
0

Merge tag 'v0.10.16' into beta

This commit is contained in:
2025-11-01 14:14:52 +03:00
203 changed files with 6321 additions and 2161 deletions

View File

@@ -1,20 +1,23 @@
<template>
<div class="ad-parent relative mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<nuxt-link
:to="flags.enableMedalPromotion ? '/servers?plan&ref=medal' : '/servers'"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
<div class="wrapper relative mb-3 flex w-full justify-center rounded-2xl">
<AutoLink
:to="currentAd.link"
:aria-label="currentAd.description"
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
>
<img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-light.webp`"
alt="Host your next server with Modrinth Servers"
:src="currentAd.light"
aria-hidden="true"
:alt="currentAd.description"
class="light-image hidden rounded-[inherit]"
/>
<img
:src="`https://cdn-raw.modrinth.com/${flags.enableMedalPromotion ? 'medal-modrinth-servers' : 'modrinth-servers-placeholder'}-dark.webp`"
alt="Host your next server with Modrinth Servers"
:src="currentAd.dark"
aria-hidden="true"
:alt="currentAd.description"
class="dark-image rounded-[inherit]"
/>
</nuxt-link>
</AutoLink>
<div
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
>
@@ -23,6 +26,8 @@
</div>
</template>
<script setup>
import { AutoLink } from '@modrinth/ui'
const flags = useFeatureFlags()
useHead({
@@ -55,6 +60,25 @@ useHead({
],
})
const AD_PRESETS = {
medal: {
light: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp',
dark: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers?plan&ref=medal',
},
'modrinth-servers': {
light: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp',
dark: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers',
},
}
const currentAd = computed(() =>
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-servers'],
)
onMounted(() => {
window.tude = window.tude || { cmd: [] }
window.Raven = window.Raven || { cmd: [] }
@@ -137,10 +161,14 @@ iframe[id^='google_ads_iframe'] {
}
@media (max-width: 1024px) {
.ad-parent {
.wrapper {
display: none;
}
}
.wrapper > * {
box-shadow: var(--shadow-card);
}
</style>
<style lang="scss" scoped>

View File

@@ -8,6 +8,11 @@
{{ analytics.error.value }}
</div>
</div>
<div v-else-if="!isInitialized || analytics.loading.value" class="universal-card">
<h2>
<span class="label__title">Loading analytics...</span>
</h2>
</div>
<div v-else class="graphs">
<div class="graphs__vertical-bar">
<client-only>
@@ -419,6 +424,7 @@ const isUsingProjectColors = computed({
const startDate = ref(dayjs().startOf('day'))
const endDate = ref(dayjs().endOf('day'))
const timeResolution = ref(30)
const isInitialized = ref(false)
onBeforeMount(() => {
// Load cached data and range from localStorage - cache.
@@ -449,6 +455,8 @@ onMounted(() => {
startDate.value = ranges.startDate
endDate.value = ranges.endDate
timeResolution.value = selectedRange.value.timeResolution
isInitialized.value = true
})
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject)
@@ -482,6 +490,7 @@ const analytics = useFetchAllAnalytics(
startDate,
endDate,
timeResolution,
isInitialized,
)
const formattedCategorySubtitle = computed(() => {

View File

@@ -631,6 +631,7 @@ onMounted(() => {
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeybinds)
notifications.setNotificationLocation('right')
})

View File

@@ -13,14 +13,23 @@ import {
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
import {
ButtonStyled,
commonMessages,
injectNotificationManager,
OverflowMenu,
ProgressBar,
} from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
@@ -33,11 +42,13 @@ const props = withDefaults(
preview?: boolean
kyrosUrl?: string
jwt?: string
server?: ModrinthServer
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
server: undefined,
},
)
@@ -124,7 +135,48 @@ const messages = defineMessages({
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
downloadingBackup: {
id: 'servers.backups.item.downloading-backup',
defaultMessage: 'Downloading backup...',
},
downloading: {
id: 'servers.backups.item.downloading',
defaultMessage: 'Downloading',
},
})
const downloadingState = ref<{ progress: number; state: string } | undefined>(undefined)
const downloading = computed(() => downloadingState.value)
const handleDownload = async () => {
if (!props.server?.backups || downloading.value) {
return
}
downloadingState.value = { progress: 0, state: 'ongoing' }
try {
const download = props.server.backups.downloadBackup(props.backup.id, props.backup.name)
download.onProgress((p) => {
downloadingState.value = { progress: p.progress, state: 'ongoing' }
})
await download.promise
emit('download')
} catch (error) {
console.error('Failed to download backup:', error)
addNotification({
type: 'error',
title: 'Download failed',
text: error instanceof Error ? error.message : 'Failed to download backup',
})
} finally {
downloadingState.value = undefined
}
}
</script>
<template>
<div
@@ -192,6 +244,15 @@ const messages = defineMessages({
class="max-w-full"
/>
</div>
<div v-else-if="downloading" class="col-span-2 flex flex-col gap-3 text-blue">
{{ formatMessage(messages.downloadingBackup) }}
<ProgressBar
:progress="downloading.progress >= 0 ? downloading.progress : 0"
color="blue"
:waiting="downloading.progress <= 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
@@ -223,34 +284,32 @@ const messages = defineMessages({
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<ButtonStyled v-show="!downloading">
<button :disabled="!server?.backups" @click="handleDownload">
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'rename',
action: () => emit('rename'),
disabled: !!restoring || !!downloading,
},
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring,
disabled: !!restoring || !!downloading,
},
{ id: 'lock', action: () => emit('lock') },
{ id: 'lock', action: () => emit('lock'), disabled: !!restoring || !!downloading },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring,
disabled: !!restoring || !!downloading,
},
]"
>

View File

@@ -68,7 +68,11 @@
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!canTakeAction"
@click="handlePrimaryAction"
>
<div v-if="isTransitionState" class="grid place-content-center">
<LoadingIcon />
</div>
@@ -122,12 +126,15 @@ import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue'
import ServerInfoLabels from './ServerInfoLabels.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
interface PowerAction {
action: ServerPowerAction
@@ -142,6 +149,7 @@ const props = defineProps<{
serverName?: string
serverData: object
uptimeSeconds: number
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
@@ -163,7 +171,11 @@ const dontAskAgain = ref(false)
const startingDelay = ref(false)
const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
() =>
!props.isActioning &&
!startingDelay.value &&
!isTransitionState.value &&
!props.backupInProgress,
)
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>

View File

@@ -158,11 +158,20 @@ const currentPlanFromSubscription = computed<ServerPlan | undefined>(() => {
: undefined
})
const currentInterval = computed(() => {
const interval = subscription.value?.interval
if (interval === 'monthly' || interval === 'quarterly') {
return interval
}
return 'monthly'
})
async function initiatePayment(body: any): Promise<any> {
if (subscription.value) {
const transformedBody = {
interval: body.charge?.interval,
payment_method: body.id,
payment_method: body.type === 'confirmation_token' ? body.token : body.id,
product: body.charge?.product_id,
region: body.metadata?.server_region,
}
@@ -247,7 +256,7 @@ async function open(id?: string) {
subscription.value = null
}
purchaseModal.value?.show('quarterly')
purchaseModal.value?.show(currentInterval.value)
}
defineExpose({

View File

@@ -11,12 +11,35 @@ export class BackupsModule extends ServerModule {
}
async create(backupName: string): Promise<string> {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
method: 'POST',
body: { name: backupName },
})
await this.fetch() // Refresh this module
return response.id
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substring(7)}`
const tempBackup: Backup = {
id: tempId,
name: backupName,
created_at: new Date().toISOString(),
locked: false,
automated: false,
interrupted: false,
ongoing: true,
task: { create: { progress: 0, state: 'ongoing' } },
}
this.data.push(tempBackup)
try {
const response = await useServersFetch<{ id: string }>(`servers/${this.serverId}/backups`, {
method: 'POST',
body: { name: backupName },
})
const backup = this.data.find((b) => b.id === tempId)
if (backup) {
backup.id = response.id
}
return response.id
} catch (error) {
this.data = this.data.filter((b) => b.id !== tempId)
throw error
}
}
async rename(backupId: string, newName: string): Promise<void> {
@@ -24,35 +47,47 @@ export class BackupsModule extends ServerModule {
method: 'POST',
body: { name: newName },
})
await this.fetch() // Refresh this module
await this.fetch()
}
async delete(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}`, {
method: 'DELETE',
})
await this.fetch() // Refresh this module
await this.fetch()
}
async restore(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
method: 'POST',
})
await this.fetch() // Refresh this module
const backup = this.data.find((b) => b.id === backupId)
if (backup) {
if (!backup.task) backup.task = {}
backup.task.restore = { progress: 0, state: 'ongoing' }
}
try {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/restore`, {
method: 'POST',
})
} catch (error) {
if (backup?.task?.restore) {
delete backup.task.restore
}
throw error
}
}
async lock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/lock`, {
method: 'POST',
})
await this.fetch() // Refresh this module
await this.fetch()
}
async unlock(backupId: string): Promise<void> {
await useServersFetch(`servers/${this.serverId}/backups/${backupId}/unlock`, {
method: 'POST',
})
await this.fetch() // Refresh this module
await this.fetch()
}
async retry(backupId: string): Promise<void> {
@@ -71,4 +106,87 @@ export class BackupsModule extends ServerModule {
async getAutoBackup(): Promise<AutoBackupSettings> {
return await useServersFetch(`servers/${this.serverId}/autobackup`)
}
downloadBackup(
backupId: string,
backupName: string,
): {
promise: Promise<void>
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void
cancel: () => void
} {
const progressSubject = new EventTarget()
const abortController = new AbortController()
const downloadPromise = new Promise<void>((resolve, reject) => {
const auth = this.server.general?.node
if (!auth?.instance || !auth?.token) {
reject(new Error('Missing authentication credentials'))
return
}
const xhr = new XMLHttpRequest()
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = e.loaded / e.total
progressSubject.dispatchEvent(
new CustomEvent('progress', {
detail: { loaded: e.loaded, total: e.total, progress },
}),
)
} else {
// progress = -1 to indicate indeterminate size
progressSubject.dispatchEvent(
new CustomEvent('progress', {
detail: { loaded: e.loaded, total: 0, progress: -1 },
}),
)
}
})
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const blob = xhr.response
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${backupName}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
resolve()
} catch (error) {
reject(error)
}
} else {
reject(new Error(`Download failed with status ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Download failed'))
xhr.onabort = () => reject(new Error('Download cancelled'))
xhr.open(
'GET',
`https://${auth.instance}/modrinth/v0/backups/${backupId}/download?auth=${auth.token}`,
)
xhr.responseType = 'blob'
xhr.send()
abortController.signal.addEventListener('abort', () => xhr.abort())
})
return {
promise: downloadPromise,
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => {
progressSubject.addEventListener('progress', ((e: CustomEvent) => {
cb(e.detail)
}) as EventListener)
},
cancel: () => abortController.abort(),
}
}
}

View File

@@ -6,7 +6,7 @@ export interface ServersFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
contentType?: string
body?: Record<string, any>
version?: number
version?: number | 'internal'
override?: {
url?: string
token?: string
@@ -30,7 +30,7 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
}
const {
@@ -52,7 +52,14 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError('Service temporarily unavailable', 503, error, module)
throw new ModrinthServerError(
'Service temporarily unavailable',
503,
error,
module,
undefined,
undefined,
)
}
if (now - lastFailureTime.value > 30000) {
@@ -69,7 +76,14 @@ export async function useServersFetch<T>(
'[Modrinth Servers] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError('Configuration error: Missing PYRO_BASE_URL', 500, error, module)
throw new ModrinthServerError(
'Configuration error: Missing PYRO_BASE_URL',
500,
error,
module,
undefined,
undefined,
)
}
const versionString = `v${version}`
@@ -82,7 +96,9 @@ export async function useServersFetch<T>(
? `https://${newOverrideUrl}/${path.replace(/^\//, '')}`
: version === 0
? `${base}/modrinth/v${version}/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
: version === 'internal'
? `${base}/_internal/${path.replace(/^\//, '')}`
: `${base}/v${version}/${path.replace(/^\//, '')}`
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
@@ -177,6 +193,7 @@ export async function useServersFetch<T>(
fetchError,
module,
v1Error,
error.data,
)
}
@@ -198,6 +215,8 @@ export async function useServersFetch<T>(
undefined,
fetchError,
module,
undefined,
undefined,
)
}
}
@@ -210,7 +229,14 @@ export async function useServersFetch<T>(
statusCode,
lastError,
)
throw new ModrinthServerError('Maximum retry attempts reached', statusCode, pyroError, module)
throw new ModrinthServerError(
'Maximum retry attempts reached',
statusCode,
pyroError,
module,
undefined,
lastError.data,
)
}
const fetchError = new ModrinthServersFetchError(
@@ -218,5 +244,12 @@ export async function useServersFetch<T>(
undefined,
lastError || undefined,
)
throw new ModrinthServerError('Maximum retry attempts reached', undefined, fetchError, module)
throw new ModrinthServerError(
'Maximum retry attempts reached',
undefined,
fetchError,
module,
undefined,
undefined,
)
}

View File

@@ -82,6 +82,23 @@
</ButtonStyled>
</template>
</PagewideBanner>
<PagewideBanner v-if="showTinMismatchBanner" variant="error">
<template #title>
<span>{{ formatMessage(tinMismatchBannerMessages.title) }}</span>
</template>
<template #description>
<span>{{ formatMessage(tinMismatchBannerMessages.description) }}</span>
</template>
<template #actions>
<div class="flex w-fit flex-row">
<ButtonStyled color="red">
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
<MessageIcon /> {{ formatMessage(tinMismatchBannerMessages.action) }}
</nuxt-link>
</ButtonStyled>
</div>
</template>
</PagewideBanner>
<PagewideBanner v-if="showTaxComplianceBanner" variant="warning">
<template #title>
<span>{{ formatMessage(taxBannerMessages.title) }}</span>
@@ -444,6 +461,12 @@
link: '/admin/servers/notices',
shown: isAdmin(auth.user),
},
{
id: 'servers-nodes',
color: 'primary',
link: '/admin/servers/nodes',
shown: isAdmin(auth.user),
},
]"
>
<ModrinthIcon aria-hidden="true" />
@@ -463,6 +486,7 @@
<template #servers-notices>
<IssuesIcon aria-hidden="true" /> {{ formatMessage(messages.manageServerNotices) }}
</template>
<template #servers-nodes> <ServerIcon aria-hidden="true" /> Server Nodes </template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled type="transparent">
@@ -851,6 +875,7 @@ import {
LogInIcon,
LogOutIcon,
MastodonIcon,
MessageIcon,
ModrinthIcon,
MoonIcon,
OrganizationIcon,
@@ -918,7 +943,15 @@ const showTaxComplianceBanner = computed(() => {
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
const status = bal.form_completion_status ?? 'unknown'
const isComplete = status === 'complete'
return !!auth.value.user && thresholdMet && !isComplete
const isTinMismatch = status === 'tin-mismatch'
return !!auth.value.user && thresholdMet && !isComplete && !isTinMismatch
})
const showTinMismatchBanner = computed(() => {
const bal = payoutBalance.value
if (!bal) return false
const status = bal.form_completion_status ?? 'unknown'
return !!auth.value.user && status === 'tin-mismatch'
})
const taxBannerMessages = defineMessages({
@@ -929,7 +962,7 @@ const taxBannerMessages = defineMessages({
description: {
id: 'layout.banner.tax.description',
defaultMessage:
'Youve already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.',
"You've already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.",
},
action: {
id: 'layout.banner.tax.action',
@@ -937,6 +970,22 @@ const taxBannerMessages = defineMessages({
},
})
const tinMismatchBannerMessages = defineMessages({
title: {
id: 'layout.banner.tin-mismatch.title',
defaultMessage: 'Tax form failed',
},
description: {
id: 'layout.banner.tin-mismatch.description',
defaultMessage:
"Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form.",
},
action: {
id: 'layout.banner.tin-mismatch.action',
defaultMessage: 'Contact support',
},
})
const taxFormModalRef = ref(null)
function openTaxForm(e) {
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
@@ -1732,6 +1781,7 @@ const footerLinks = [
@media screen and (min-width: 354px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 674px) {
grid-template-columns: repeat(3, 1fr);
}
@@ -1926,10 +1976,12 @@ const footerLinks = [
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
@@ -1958,15 +2010,19 @@ const footerLinks = [
0% {
rotate: 0deg;
}
25% {
rotate: calc(1deg * (var(--_r-count) - 20));
}
50% {
rotate: 0deg;
}
75% {
rotate: calc(-1deg * (var(--_r-count) - 20));
}
100% {
rotate: 0deg;
}
@@ -1976,15 +2032,19 @@ const footerLinks = [
0% {
translate: 0;
}
25% {
translate: calc(2px * (var(--_r-count) - 20));
}
50% {
translate: 0;
}
75% {
translate: calc(-2px * (var(--_r-count) - 20));
}
100% {
translate: 0;
}
@@ -1994,15 +2054,19 @@ const footerLinks = [
0% {
transform: translateY(0);
}
25% {
transform: translateY(calc(2px * (var(--_r-count) - 20)));
}
50% {
transform: translateY(0);
}
75% {
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
}
100% {
transform: translateY(0);
}

View File

@@ -924,11 +924,20 @@
"message": "Complete tax form"
},
"layout.banner.tax.description": {
"message": "Youve already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted."
"message": "You've already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted."
},
"layout.banner.tax.title": {
"message": "Tax form required"
},
"layout.banner.tin-mismatch.action": {
"message": "Contact support"
},
"layout.banner.tin-mismatch.description": {
"message": "Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form."
},
"layout.banner.tin-mismatch.title": {
"message": "Tax form failed"
},
"layout.banner.verify-email.action": {
"message": "Re-send verification email"
},
@@ -1103,6 +1112,12 @@
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"profile.bio.fallback.creator": {
"message": "A Modrinth creator."
},
"profile.bio.fallback.user": {
"message": "A Modrinth user."
},
"profile.button.billing": {
"message": "Manage user billing"
},
@@ -1115,18 +1130,48 @@
"profile.button.manage-projects": {
"message": "Manage projects"
},
"profile.details.label.auth-providers": {
"message": "Auth providers"
},
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.has-password": {
"message": "Has password"
},
"profile.details.label.has-totp": {
"message": "Has TOTP"
},
"profile.details.label.payment-methods": {
"message": "Payment methods"
},
"profile.details.tooltip.email-not-verified": {
"message": "Email not verified"
},
"profile.details.tooltip.email-verified": {
"message": "Email verified"
},
"profile.error.not-found": {
"message": "User not found"
},
"profile.joined-at": {
"message": "Joined <date>{ago}</date>"
},
"profile.label.badges": {
"message": "Badges"
},
"profile.label.collection": {
"message": "Collection"
},
"profile.label.details": {
"message": "Details"
},
"profile.label.downloads": {
"message": "{count} {count, plural, one {download} other {downloads}}"
},
"profile.label.joined": {
"message": "Joined"
},
"profile.label.no": {
"message": "No"
},
"profile.label.no-collections": {
"message": "This user has no collections!"
},
@@ -1142,18 +1187,21 @@
"profile.label.organizations": {
"message": "Organizations"
},
"profile.label.projects": {
"message": "{count} {count, plural, one {project} other {projects}}"
},
"profile.label.saving": {
"message": "Saving..."
},
"profile.label.yes": {
"message": "Yes"
},
"profile.meta.description": {
"message": "Download {username}'s projects on Modrinth"
},
"profile.meta.description-with-bio": {
"message": "{bio} - Download {username}'s projects on Modrinth"
},
"profile.stats.downloads": {
"message": "{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}"
},
"profile.stats.projects": {
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
},
@@ -1886,6 +1934,12 @@
"servers.backups.item.creating-backup": {
"message": "Creating backup..."
},
"servers.backups.item.downloading": {
"message": "Downloading"
},
"servers.backups.item.downloading-backup": {
"message": "Downloading backup..."
},
"servers.backups.item.failed-to-create-backup": {
"message": "Failed to create backup"
},

View File

@@ -97,6 +97,41 @@
</div>
</div>
</NewModal>
<NewModal ref="creditModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Credit subscription</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="days" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Days to credit</span>
<span>Enter the number of days to add to the next due date.</span>
</label>
<input id="days" v-model.number="creditDays" type="number" min="1" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label for="sendEmail" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Send email to user</span>
<span>Notify the user about the credited days.</span>
</label>
<Toggle id="sendEmail" v-model="creditSendEmail" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="crediting" @click="applyCredit">
<CheckIcon aria-hidden="true" />
Apply credit
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="creditModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="page experimental-styles-within">
<div
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
@@ -140,6 +175,7 @@
</div>
</div>
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
<CopyCode :text="subscription.metadata.id" />
<ButtonStyled
v-if="
subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal'
@@ -153,7 +189,12 @@
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
</nuxt-link>
</ButtonStyled>
<CopyCode :text="subscription.metadata.id" />
<ButtonStyled>
<button @click="showCreditModal(subscription)">
<CurrencyIcon />
Credit
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-2">
@@ -292,6 +333,7 @@ import {
useRelativeTime,
} from '@modrinth/ui'
import { formatCategory, formatPrice } from '@modrinth/utils'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import dayjs from 'dayjs'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
@@ -370,6 +412,11 @@ const modifying = ref(false)
const modifyModal = ref()
const cancel = ref(false)
const crediting = ref(false)
const creditModal = ref()
const creditDays = ref(7)
const creditSendEmail = ref(true)
function showRefundModal(charge) {
selectedCharge.value = charge
refundType.value = 'full'
@@ -385,6 +432,44 @@ function showModifyModal(charge, subscription) {
modifyModal.value.show()
}
function showCreditModal(subscription) {
selectedSubscription.value = subscription
creditDays.value = 1
creditSendEmail.value = true
creditModal.value.show()
}
async function applyCredit() {
crediting.value = true
try {
const daysParsed = Math.max(1, Math.floor(Number(creditDays.value) || 1))
await useBaseFetch('billing/credit', {
method: 'POST',
body: JSON.stringify({
subscription_ids: [selectedSubscription.value.id],
days: daysParsed,
send_email: creditSendEmail.value,
message: DEFAULT_CREDIT_EMAIL_MESSAGE,
}),
internal: true,
})
addNotification({
title: 'Credit applied',
text: 'The subscription due date has been updated.',
type: 'success',
})
await refreshCharges()
creditModal.value.hide()
} catch (err) {
addNotification({
title: 'Error applying credit',
text: err.data?.description ?? String(err),
type: 'error',
})
}
crediting.value = false
}
async function refundCharge() {
refunding.value = true
try {

View File

@@ -0,0 +1,277 @@
<template>
<div class="page experimental-styles-within">
<div
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
>
<h1 class="m-0 text-2xl">Server nodes</h1>
<ButtonStyled color="brand">
<button @click="openBatchModal"><PlusIcon /> Batch credit</button>
</ButtonStyled>
</div>
<NewModal ref="batchModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Batch credit</span>
</template>
<div class="flex w-[720px] max-w-[90vw] flex-col gap-6">
<div class="flex flex-col gap-2">
<label class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Type </span>
<span>Select target to credit.</span>
</label>
<TeleportDropdownMenu
v-model="mode"
:options="modeOptions"
:display-name="(x) => x.name"
name="Type"
class="max-w-[8rem]"
/>
</div>
<div class="flex flex-col gap-2">
<label for="days" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Days to credit </span>
</label>
<input
id="days"
v-model.number="days"
class="w-32"
type="number"
min="1"
autocomplete="off"
/>
</div>
<div v-if="mode.id === 'nodes'" class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="node-input" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Node hostnames </span>
</label>
<div class="flex items-center gap-2">
<input
id="node-input"
v-model="nodeInput"
class="w-32"
type="text"
autocomplete="off"
/>
<ButtonStyled color="blue" color-fill="text">
<button class="shrink-0" @click="addNode">
<PlusIcon />
Add
</button>
</ButtonStyled>
</div>
<div v-if="selectedNodes.length" class="mt-1 flex flex-wrap gap-2">
<TagItem v-for="h in selectedNodes" :key="`node-${h}`" :action="() => removeNode(h)">
<XIcon />
{{ h }}
</TagItem>
</div>
</div>
</div>
<div v-else class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="region-select" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Region </span>
<span>This will credit all active servers in the region.</span>
</label>
<TeleportDropdownMenu
id="region-select"
v-model="selectedRegion"
:options="regions"
:display-name="(x) => x.display"
name="Region"
class="max-w-[24rem]"
/>
</div>
</div>
<div class="between flex items-center gap-4">
<label for="send-email-nodes" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Send email </span>
</label>
<Toggle id="send-email-nodes" v-model="sendEmail" />
</div>
<div v-if="sendEmail" class="flex flex-col gap-2">
<label for="message-region" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Customize Email </span>
<span>
Unless a particularly bad or out of the ordinary event happened, keep this to the
default
</span>
</label>
<div class="text-muted rounded-lg border border-divider bg-button-bg p-4">
<p>Hi {user.name},</p>
<div class="textarea-wrapper">
<textarea
id="message-region"
v-model="message"
rows="3"
class="w-full overflow-hidden"
/>
</div>
<p>
To make up for it, we've added {{ days }} day{{ pluralize(days) }} to your Modrinth
Servers subscription.
</p>
<p>
Your next charge was scheduled for {credit.previous_due} and will now be on
{credit.next_due}.
</p>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="applyDisabled" @click="apply">
<CheckIcon aria-hidden="true" />
Apply credits
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="batchModal?.hide?.()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
injectNotificationManager,
NewModal,
TagItem,
TeleportDropdownMenu,
Toggle,
} from '@modrinth/ui'
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import { computed, ref } from 'vue'
import { useBaseFetch } from '#imports'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()
const batchModal = ref<InstanceType<typeof NewModal>>()
const days = ref(1)
const sendEmail = ref(true)
const message = ref('')
const modeOptions = [
{ id: 'nodes', name: 'Nodes' },
{ id: 'region', name: 'Region' },
]
const mode = ref(modeOptions[0])
const nodeInput = ref('')
const selectedNodes = ref<string[]>([])
type RegionOpt = { key: string; display: string }
const regions = ref<RegionOpt[]>([])
const selectedRegion = ref<RegionOpt | null>(null)
const nodeHostnames = ref<string[]>([])
function openBatchModal() {
void ensureOverview()
message.value = DEFAULT_CREDIT_EMAIL_MESSAGE
batchModal.value?.show()
}
function addNode() {
const v = nodeInput.value.trim()
if (!v) return
if (!nodeHostnames.value.includes(v)) {
addNotification({
title: 'Unknown node',
text: "This hostname doesn't exist",
type: 'error',
})
return
}
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
nodeInput.value = ''
}
function removeNode(v: string) {
selectedNodes.value = selectedNodes.value.filter((x) => x !== v)
}
const applyDisabled = computed(() => {
if (days.value < 1) return true
if (mode.value.id === 'nodes') return selectedNodes.value.length === 0
return !selectedRegion.value
})
async function ensureOverview() {
if (regions.value.length || nodeHostnames.value.length) return
try {
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
regions.value = (data.regions || []).map((r: any) => ({
key: r.key,
display: `${r.display_name} (${r.key})`,
}))
nodeHostnames.value = data.node_hostnames || []
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0]
} catch (err) {
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
}
}
async function apply() {
try {
const body =
mode.value.id === 'nodes'
? {
nodes: selectedNodes.value.slice(),
days: Math.max(1, Math.floor(days.value)),
send_email: sendEmail.value,
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
}
: {
region: selectedRegion.value!.key,
days: Math.max(1, Math.floor(days.value)),
send_email: sendEmail.value,
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
}
await useBaseFetch('billing/credit', {
method: 'POST',
body: JSON.stringify(body),
internal: true,
})
addNotification({ title: 'Credits applied', type: 'success' })
batchModal.value?.hide()
selectedNodes.value = []
nodeInput.value = ''
message.value = ''
} catch (err: any) {
addNotification({
title: 'Error applying credits',
text: err?.data?.description ?? String(err),
type: 'error',
})
}
}
function pluralize(n: number): string {
return n === 1 ? '' : 's'
}
</script>
<style scoped>
.page {
padding: 1rem;
margin-left: auto;
margin-right: auto;
max-width: 56rem;
}
</style>

View File

@@ -18,12 +18,17 @@
<section class="auth-form">
<p>{{ formatMessage(postVerificationMessages.description) }}</p>
<NuxtLink v-if="auth.user" class="btn" link="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
<ButtonStyled v-if="auth.user">
<NuxtLink to="/settings/account">
<SettingsIcon /> {{ formatMessage(messages.accountSettings) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled v-else>
<NuxtLink to="/auth/sign-in">
{{ formatMessage(messages.signIn) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section>
</template>
@@ -40,24 +45,26 @@
</template>
</p>
<button
v-if="auth.user"
class="btn btn-primary continue-btn"
@click="handleResendEmailVerification"
>
{{ formatMessage(failedVerificationMessages.action) }} <RightArrowIcon />
</button>
<ButtonStyled v-if="auth.user" color="brand">
<button @click="handleResendEmailVerification">
{{ formatMessage(failedVerificationMessages.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<NuxtLink v-else to="/auth/sign-in" class="btn btn-primary continue-btn centered-btn">
{{ formatMessage(messages.signIn) }} <RightArrowIcon />
</NuxtLink>
<ButtonStyled v-else color="brand">
<NuxtLink to="/auth/sign-in">
{{ formatMessage(messages.signIn) }}
<RightArrowIcon />
</NuxtLink>
</ButtonStyled>
</section>
</template>
</div>
</template>
<script setup>
import { RightArrowIcon, SettingsIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()

View File

@@ -146,7 +146,7 @@
before proceeding.
</p>
<p v-if="blockedByTax" class="font-bold text-orange">
<p v-else-if="blockedByTax" class="font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
</p>

View File

@@ -4,7 +4,7 @@ import { articles as rawArticles } from '@modrinth/blog'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import type { User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import NewsletterButton from '~/components/ui/NewsletterButton.vue'
import ShareArticleButtons from '~/components/ui/ShareArticleButtons.vue'
@@ -74,6 +74,36 @@ useSeoMeta({
twitterCard: 'summary_large_image',
twitterImage: () => thumbnailPath.value,
})
onMounted(() => {
const videos = document.querySelectorAll('.markdown-body video')
if ('IntersectionObserver' in window) {
const videoObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const video = entry.target as HTMLVideoElement
if (entry.isIntersecting) {
video.play().catch(() => {})
} else {
video.pause()
}
})
},
{
threshold: 0.5,
},
)
videos.forEach((video) => {
videoObserver.observe(video)
})
} else {
videos.forEach((video) => {
;(video as HTMLVideoElement).setAttribute('autoplay', '')
})
}
})
</script>
<template>
@@ -181,14 +211,19 @@ useSeoMeta({
padding: 0;
}
ul,
ul > li:not(:last-child),
ol > li:not(:last-child) {
margin-bottom: 0.5rem;
}
ul,
ol {
p {
> li > p {
margin-top: 0;
margin-bottom: 0;
}
> li > p:not(:last-child) {
margin-bottom: 0.5rem;
}
}
@@ -220,20 +255,22 @@ useSeoMeta({
h2 {
font-size: 1.25rem;
margin-top: 1.5rem;
@media (min-width: 640px) {
font-size: 1.5rem;
}
}
h3 {
font-size: 1.125rem;
font-size: 1rem;
margin-top: 1.25rem;
@media (min-width: 640px) {
font-size: 1.25rem;
font-size: 1.125rem;
}
}
p {
margin-bottom: 1.25rem;
margin-bottom: 0.75rem;
font-size: 0.875rem;
@media (min-width: 640px) {
font-size: 1rem;
@@ -275,8 +312,34 @@ useSeoMeta({
}
}
hr {
border: none;
height: 1px;
background-color: var(--color-divider);
}
.video-wrapper {
display: inline-block;
max-width: 100%;
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--color-button-border);
@media (min-width: 640px) {
border-radius: var(--radius-lg);
}
video {
display: block;
max-width: 100%;
object-fit: cover;
height: auto;
}
}
> img,
> :has(img:first-child:last-child) {
> .video-wrapper,
> :has(img:first-child:last-child),
> :has(video:first-child:last-child) {
display: flex;
justify-content: center;
}

View File

@@ -151,6 +151,7 @@
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:backup-in-progress="backupInProgress"
@action="sendPowerAction"
/>
</div>
@@ -354,7 +355,7 @@
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
JSON.stringify(server, null, ' ')
JSON.stringify(server, null, ' ')
}}</pre>
</div>
</template>
@@ -759,9 +760,14 @@ const handleWebSocketMessage = (data: WSEvent) => {
curBackup.task = {}
}
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
const currentState = curBackup.task[data.task]?.state
const shouldUpdate = !(currentState === 'ongoing' && data.state === 'unchanged')
if (shouldUpdate) {
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
}
}
curBackup.ongoing = data.task === 'create' && data.state === 'ongoing'
@@ -1037,7 +1043,10 @@ const nodeUnavailableDetails = computed(() => [
},
{
label: 'Node',
value: server.general?.datacenter ?? 'Unknown',
value:
server.moduleErrors?.general?.error.responseData?.hostname ??
server.general?.datacenter ??
'Unknown',
type: 'inline' as const,
},
{
@@ -1277,6 +1286,7 @@ useHead({
opacity: 0;
transform: translateX(1rem);
}
100% {
opacity: 1;
transform: none;

View File

@@ -105,6 +105,7 @@
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:server="props.server"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@download="() => triggerDownloadAnimation()"

View File

@@ -16,7 +16,7 @@
<ButtonStyled>
<button @click="cancelRoleEdit">
<XIcon />
Cancel
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
@@ -25,9 +25,11 @@
@click="saveRoleEdit"
>
<template v-if="isSavingRole">
<SpinnerIcon class="animate-spin" /> Saving...
<SpinnerIcon class="animate-spin" /> {{ formatMessage(messages.savingLabel) }}
</template>
<template v-else>
<SaveIcon /> {{ formatMessage(commonMessages.saveChangesButton) }}
</template>
<template v-else> <SaveIcon /> Save changes </template>
</button>
</ButtonStyled>
</div>
@@ -36,10 +38,16 @@
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">Email</span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.emailLabel)
}}</span>
<div>
<span
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
v-tooltip="
user.email_verified
? formatMessage(messages.emailVerifiedTooltip)
: formatMessage(messages.emailNotVerifiedTooltip)
"
class="flex w-fit items-center gap-1"
>
<span>{{ user.email }}</span>
@@ -50,12 +58,16 @@
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Auth providers </span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.authProvidersLabel)
}}</span>
<span>{{ user.auth_providers.join(', ') }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Payment methods</span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.paymentMethodsLabel)
}}</span>
<span>
<template v-if="user.payout_data?.paypal_address">
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
@@ -70,16 +82,22 @@
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Has password </span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.hasPasswordLabel)
}}</span>
<span>
{{ user.has_password ? 'Yes' : 'No' }}
{{
user.has_password ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel)
}}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Has TOTP </span>
<span class="text-lg font-bold text-primary">{{
formatMessage(messages.hasTotpLabel)
}}</span>
<span>
{{ user.has_totp ? 'Yes' : 'No' }}
{{ user.has_totp ? formatMessage(messages.yesLabel) : formatMessage(messages.noLabel) }}
</span>
</div>
</div>
@@ -98,8 +116,8 @@
user.bio
? user.bio
: projects.length === 0
? 'A Modrinth user.'
: 'A Modrinth creator.'
? formatMessage(messages.bioFallbackUser)
: formatMessage(messages.bioFallbackCreator)
}}
</template>
<template #stats>
@@ -107,16 +125,22 @@
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<BoxIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(projects?.length || 0) }}
projects
{{
formatMessage(messages.profileProjectsLabel, {
count: formatCompactNumber(projects?.length || 0),
})
}}
</div>
<div
v-tooltip="sumDownloads.toLocaleString()"
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(sumDownloads) }}
downloads
{{
formatMessage(messages.profileDownloadsLabel, {
count: formatCompactNumber(sumDownloads),
})
}}
</div>
<div
v-tooltip="
@@ -128,7 +152,7 @@
class="flex items-center gap-2 font-semibold"
>
<CalendarIcon class="h-6 w-6 text-secondary" />
Joined
{{ formatMessage(messages.profileJoinedLabel) }}
{{ formatRelativeTime(user.created) }}
</div>
</template>
@@ -287,7 +311,7 @@
<h2 class="title">{{ collection.name }}</h2>
<div class="stats">
<LibraryIcon aria-hidden="true" />
Collection
{{ formatMessage(messages.collectionLabel) }}
</div>
</div>
</div>
@@ -298,25 +322,27 @@
<div class="stats">
<BoxIcon />
{{
`${$formatNumber(collection.projects?.length || 0, false)} project${(collection.projects?.length || 0) !== 1 ? 's' : ''}`
`${$formatNumber(collection.projects?.length || 0, false)} project${
(collection.projects?.length || 0) !== 1 ? 's' : ''
}`
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<GlobeIcon />
<span> Public </span>
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
<span> Unlisted </span>
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
<span> Private </span>
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
<span> Rejected </span>
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template>
</div>
</div>
@@ -449,25 +475,75 @@ const formatRelativeTime = useRelativeTime()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
profileProjectsStats: {
id: 'profile.stats.projects',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}',
profileProjectsLabel: {
id: 'profile.label.projects',
defaultMessage: '{count} {count, plural, one {project} other {projects}}',
},
profileDownloadsStats: {
id: 'profile.stats.downloads',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}',
profileDownloadsLabel: {
id: 'profile.label.downloads',
defaultMessage: '{count} {count, plural, one {download} other {downloads}}',
},
profileJoinedLabel: {
id: 'profile.label.joined',
defaultMessage: 'Joined',
},
savingLabel: {
id: 'profile.label.saving',
defaultMessage: 'Saving...',
},
emailLabel: {
id: 'profile.details.label.email',
defaultMessage: 'Email',
},
emailVerifiedTooltip: {
id: 'profile.details.tooltip.email-verified',
defaultMessage: 'Email verified',
},
emailNotVerifiedTooltip: {
id: 'profile.details.tooltip.email-not-verified',
defaultMessage: 'Email not verified',
},
authProvidersLabel: {
id: 'profile.details.label.auth-providers',
defaultMessage: 'Auth providers',
},
paymentMethodsLabel: {
id: 'profile.details.label.payment-methods',
defaultMessage: 'Payment methods',
},
hasPasswordLabel: {
id: 'profile.details.label.has-password',
defaultMessage: 'Has password',
},
hasTotpLabel: {
id: 'profile.details.label.has-totp',
defaultMessage: 'Has TOTP',
},
yesLabel: {
id: 'profile.label.yes',
defaultMessage: 'Yes',
},
noLabel: {
id: 'profile.label.no',
defaultMessage: 'No',
},
bioFallbackUser: {
id: 'profile.bio.fallback.user',
defaultMessage: 'A Modrinth user.',
},
bioFallbackCreator: {
id: 'profile.bio.fallback.creator',
defaultMessage: 'A Modrinth creator.',
},
collectionLabel: {
id: 'profile.label.collection',
defaultMessage: 'Collection',
},
profileProjectsFollowersStats: {
id: 'profile.stats.projects-followers',
defaultMessage:
'{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}',
},
profileJoinedAt: {
id: 'profile.joined-at',
defaultMessage: 'Joined <date>{ago}</date>',
},
profileUserId: {
id: 'profile.user-id',
defaultMessage: 'User ID: {id}',

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,5 +1,12 @@
{
"articles": [
{
"title": "More Ways to Withdraw",
"summary": "Coming soon: new withdraw options and a redesigned revenue dashboard",
"thumbnail": "https://modrinth.com/news/article/creator-withdrawals-overhaul/thumbnail.webp",
"date": "2025-10-27T23:30:00.000Z",
"link": "https://modrinth.com/news/article/creator-withdrawals-overhaul"
},
{
"title": "Standing By Our Values",
"summary": "Keeping LGBTQIA+ content visible despite demands from Russia.",

File diff suppressed because one or more lines are too long

View File

@@ -33,7 +33,6 @@
}
#modrinth-rail-1 {
border-radius: 1rem;
position: absolute;
left: 0;
top: 0;

View File

@@ -90,10 +90,10 @@ import StyledDoc from '../shared/StyledDoc.vue'
Purpose of Payment
</Text>
<Text class="m-0 text-sm leading-relaxed text-secondary">
This payout reflects revenue earned by the creator through their activity on the Modrinth
platform. Earnings are based on advertising revenue, subscriptions, and/or affiliate
commissions tied to the creator's published projects, in accordance with the Rewards Program
Terms.
This payout reflects the creator's earnings from their activity on the Modrinth platform.
Such earnings are based on advertising revenue derived from user engagement with the
creator's published projects and/or affiliate commissions in accordance with the Rewards
Program Terms.
</Text>
</Section>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
Modrinth account.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
sign in to your Modrinth account.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
{emailchanged.new_email}.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -70,9 +70,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-muted text-base">
If this wasn't you, please update your password and review your account security settings. If
you cannot do this, contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
you cannot do this, contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
A new personal access token, <b>{newpat.token_name}</b>, has been added to your account.
</Text>
<Text class="text-muted text-base">
If you did not create this token, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not create this token, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -14,9 +14,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-muted text-base">Hi {user.name},</Text>
<Text class="text-muted text-base"> Your password has been changed on your account. </Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
Modrinth account.</Text
>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -34,8 +34,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base">
If you have any questions about the creator rewards program, please contact support through
the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
or by replying to this email.
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
<Text class="text-base">Thank you for being a creator on Modrinth!</Text>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Heading, Text } from '@vue-email/components'
import StyledEmail from '../shared/StyledEmail.vue'
</script>
<template>
<StyledEmail title="Weve added time to your server">
<Heading as="h1" class="mb-2 text-2xl font-bold">Weve added time to your server</Heading>
<Text class="text-muted text-base">Hi {user.name},</Text>
<Text class="text-muted text-base">{credit.header_message}</Text>
<Text class="text-muted text-base">
To make up for it, we've added {credit.days_formatted} to your {credit.subscription.type}
subscription.
</Text>
<Text class="text-muted text-base">
Your next charge was scheduled for {credit.previous_due} and will now be on {credit.next_due}.
</Text>
<Text class="text-muted text-base">Thank you for supporting us,</Text>
<Text class="text-muted text-base">The Modrinth Team</Text>
</StyledEmail>
</template>

View File

@@ -32,7 +32,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-muted text-base">
Thank you for choosing Modrinth! If you have any questions or need help with your
subscription, reply to this email or visit our
subscription, visit our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>

View File

@@ -17,9 +17,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
need to submit the code generated by your authenticator app.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -18,9 +18,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
At your request, we've removed two-factor authentication from your Modrinth account.
</Text>
<Text class="text-muted text-base">
If you did not make this change, please contact us immediately by replying to this email or
<VLink href="https://support.modrinth.com" class="text-green underline"
>through our Support Portal</VLink
If you did not make this change, please contact us immediately through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>

View File

@@ -18,6 +18,7 @@ export default {
// Subscriptions
'subscription-tax-change': () => import('./account/SubscriptionTaxChange.vue'),
'subscription-credited': () => import('./account/SubscriptionCredited.vue'),
// Moderation
'report-submitted': () => import('./moderation/ReportSubmitted.vue'),

View File

@@ -68,8 +68,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
in error or is abusive, please contact support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>
or by replying to this email.
>.
</Text>
</StyledEmail>
</template>

View File

@@ -63,8 +63,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
this was sent in error or is abusive, please contact support
<VLink href="https://support.modrinth.com" class="text-green underline">
through the Support Portal</VLink
>
or by replying to this email.
>.
</Text>
</StyledEmail>
</template>

View File

@@ -45,8 +45,7 @@ import StyledEmail from '../shared/StyledEmail.vue'
</Button>
<Text class="text-base">
If you have questions or believe something isn't correct, you can reply to this email or reach
out via the
If you have questions or believe something isn't correct, you can reach out via the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>

View File

@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base">
If you believe this status was applied in error, you can reply in the moderation thread or
contact support through our
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
or by replying to this email.
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
<Text class="text-base">Thank you for publishing on Modrinth!</Text>

View File

@@ -53,8 +53,8 @@ import StyledEmail from '../shared/StyledEmail.vue'
<Text class="text-base">
If you did not initiate this transfer, please contact support immediately through the
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink>
or by replying to this email.
<VLink href="https://support.modrinth.com" class="text-green underline">Support Portal</VLink
>.
</Text>
</StyledEmail>
</template>

View File

@@ -307,6 +307,7 @@ export const useFetchAllAnalytics = (
startDate = ref(dayjs().subtract(30, 'days')),
endDate = ref(dayjs()),
timeResolution = ref(1440),
isInitialized = ref(false),
) => {
const downloadData = ref(null)
const viewData = ref(null)
@@ -388,8 +389,18 @@ export const useFetchAllAnalytics = (
}
watch(
[() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value],
[
() => startDate.value,
() => endDate.value,
() => timeResolution.value,
() => projects.value,
() => isInitialized.value,
],
async () => {
if (!isInitialized.value) {
return
}
const q = {
start_date: startDate.value.toISOString(),
end_date: endDate.value.toISOString(),
@@ -456,5 +467,6 @@ export const useFetchAllAnalytics = (
totalData,
loading,
error,
isInitialized,
}
}