You've already forked AstralRinth
forked from didirus/AstralRinth
Merge tag 'v0.10.16' into beta
This commit is contained in:
@@ -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 {
|
||||
|
||||
277
apps/frontend/src/pages/admin/servers/nodes.vue
Normal file
277
apps/frontend/src/pages/admin/servers/nodes.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Reference in New Issue
Block a user