You've already forked AstralRinth
forked from didirus/AstralRinth
Merge tag 'v0.10.16' into beta
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
ChangeSkinIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
RestoreIcon,
|
||||
RightArrowIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -65,7 +67,7 @@ import { debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { list } from '@/helpers/profile.js'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
@@ -668,7 +670,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
|
||||
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
|
||||
:to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</NavButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NavButton
|
||||
@@ -677,29 +679,39 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</template>
|
||||
<ButtonStyled v-if="credentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'sign-out',
|
||||
action: () => logOut(),
|
||||
color: 'danger',
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
>
|
||||
<Avatar
|
||||
:src="credentials.user.avatar_url"
|
||||
:alt="credentials.user.username"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<NavButton v-else v-tooltip.right="'Sign in'" :to="() => signIn()">
|
||||
<LogInIcon />
|
||||
<template #label>Sign in</template>
|
||||
<OverflowMenu
|
||||
v-if="credentials"
|
||||
v-tooltip.right="`Modrinth account`"
|
||||
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
|
||||
},
|
||||
{
|
||||
id: 'sign-out',
|
||||
action: () => logOut(),
|
||||
color: 'danger',
|
||||
},
|
||||
]"
|
||||
placement="right-end"
|
||||
>
|
||||
<Avatar :src="credentials.user.avatar_url" alt="" size="32px" circle />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Signed in as
|
||||
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
|
||||
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
|
||||
{{ credentials.user.username }}
|
||||
</span>
|
||||
</span>
|
||||
<ExternalIcon />
|
||||
</template>
|
||||
<template #sign-out> <LogOutIcon /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
<NavButton v-else v-tooltip.right="'Sign in to a Modrinth account'" :to="() => signIn()">
|
||||
<LogInIcon class="text-brand" />
|
||||
</NavButton>
|
||||
</div>
|
||||
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
|
||||
@@ -837,20 +849,26 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
|
||||
>
|
||||
<div id="sidebar-teleport-target" class="sidebar-teleport-content"></div>
|
||||
<div class="sidebar-default-content" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<h3 class="text-lg m-0">Playing as</h3>
|
||||
<div
|
||||
class="p-4 pr-1 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid"
|
||||
>
|
||||
<h3 class="text-base text-primary font-medium m-0">Playing as</h3>
|
||||
<suspense>
|
||||
<AccountsCard ref="accounts" mode="small" />
|
||||
</suspense>
|
||||
</div>
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<suspense>
|
||||
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
|
||||
<FriendsList
|
||||
:credentials="credentials"
|
||||
:sign-in="() => signIn()"
|
||||
:refresh-credentials="fetchCredentials"
|
||||
/>
|
||||
</suspense>
|
||||
</div>
|
||||
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center">
|
||||
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3>
|
||||
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full">
|
||||
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
|
||||
<h3 class="text-base mb-4 text-primary font-medium m-0 text-left w-full">News</h3>
|
||||
<div class="space-y-4 flex flex-col items-center w-full">
|
||||
<NewsArticleCard
|
||||
v-for="(item, index) in news"
|
||||
:key="`news-${index}`"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
@@ -40,22 +40,27 @@ defineExpose({
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
const menuWidth = contextMenu.value.clientWidth
|
||||
const menuHeight = contextMenu.value.clientHeight
|
||||
|
||||
if (menuWidth + event.pageX >= window.innerWidth) {
|
||||
left.value = event.pageX - menuWidth + 2 + 'px'
|
||||
} else {
|
||||
left.value = event.pageX - 2 + 'px'
|
||||
}
|
||||
|
||||
if (menuHeight + event.pageY >= window.innerHeight) {
|
||||
top.value = event.pageY - menuHeight + 2 + 'px'
|
||||
} else {
|
||||
top.value = event.pageY - 2 + 'px'
|
||||
}
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
MailIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { MailIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_user_many } from '@/helpers/cache'
|
||||
import { friend_listener } from '@/helpers/events'
|
||||
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
|
||||
import {
|
||||
add_friend,
|
||||
friends,
|
||||
type FriendWithUserData,
|
||||
remove_friend,
|
||||
transformFriends,
|
||||
} from '@/helpers/friends.ts'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: unknown | null
|
||||
credentials: ModrinthCredentials | null
|
||||
signIn: () => void
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
|
||||
const search = ref('')
|
||||
const manageFriendsModal = ref()
|
||||
const friendInvitesModal = ref()
|
||||
|
||||
const username = ref('')
|
||||
@@ -47,61 +47,64 @@ async function addFriendFromModal() {
|
||||
await loadFriends()
|
||||
}
|
||||
|
||||
const friendOptions = ref()
|
||||
async function handleFriendOptions(args) {
|
||||
switch (args.option) {
|
||||
case 'remove-friend':
|
||||
await removeFriend(args.item)
|
||||
break
|
||||
async function addFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await add_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function addFriend(friend: Friend) {
|
||||
await add_friend(
|
||||
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
|
||||
).catch(handleError)
|
||||
await loadFriends()
|
||||
async function removeFriend(friend: FriendWithUserData) {
|
||||
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
|
||||
if (id) {
|
||||
await remove_friend(id).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFriend(friend: Friend) {
|
||||
await remove_friend(
|
||||
friend.id === userCredentials.value.user_id ? friend.friend_id : friend.id,
|
||||
).catch(handleError)
|
||||
await loadFriends()
|
||||
}
|
||||
const userFriends = ref<FriendWithUserData[]>([])
|
||||
const sortedFriends = computed<FriendWithUserData[]>(() =>
|
||||
userFriends.value.slice().sort((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
return 0 // Both are null, equal in sorting
|
||||
}
|
||||
if (a.last_updated === null) {
|
||||
return 1 // `a` is null, move it after `b`
|
||||
}
|
||||
if (b.last_updated === null) {
|
||||
return -1 // `b` is null, move it after `a`
|
||||
}
|
||||
// Both are non-null, sort by date
|
||||
return b.last_updated.diff(a.last_updated)
|
||||
}),
|
||||
)
|
||||
const filteredFriends = computed<FriendWithUserData[]>(() =>
|
||||
sortedFriends.value.filter((x) =>
|
||||
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
|
||||
),
|
||||
)
|
||||
|
||||
type Friend = {
|
||||
id: string
|
||||
friend_id: string | null
|
||||
status: string | null
|
||||
last_updated: Dayjs | null
|
||||
created: Dayjs
|
||||
username: string
|
||||
accepted: boolean
|
||||
online: boolean
|
||||
avatar: string
|
||||
}
|
||||
|
||||
const userFriends = ref<Friend[]>([])
|
||||
const acceptedFriends = computed(() =>
|
||||
userFriends.value
|
||||
.filter((x) => x.accepted)
|
||||
.toSorted((a, b) => {
|
||||
if (a.last_updated === null && b.last_updated === null) {
|
||||
return 0 // Both are null, equal in sorting
|
||||
}
|
||||
if (a.last_updated === null) {
|
||||
return 1 // `a` is null, move it after `b`
|
||||
}
|
||||
if (b.last_updated === null) {
|
||||
return -1 // `b` is null, move it after `a`
|
||||
}
|
||||
// Both are non-null, sort by date
|
||||
return b.last_updated.diff(a.last_updated)
|
||||
}),
|
||||
const activeFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
|
||||
)
|
||||
const onlineFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
|
||||
)
|
||||
const offlineFriends = computed<FriendWithUserData[]>(() =>
|
||||
filteredFriends.value.filter((x) => !x.online && x.accepted),
|
||||
)
|
||||
const pendingFriends = computed(() =>
|
||||
userFriends.value.filter((x) => !x.accepted).toSorted((a, b) => b.created.diff(a.created)),
|
||||
filteredFriends.value
|
||||
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
|
||||
.slice()
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
const incomingRequests = computed(() =>
|
||||
userFriends.value
|
||||
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
|
||||
.slice()
|
||||
.sort((a, b) => b.created.diff(a.created)),
|
||||
)
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -110,34 +113,7 @@ async function loadFriends(timeout = false) {
|
||||
|
||||
try {
|
||||
const friendsList = await friends()
|
||||
|
||||
if (friendsList.length === 0) {
|
||||
userFriends.value = []
|
||||
} else {
|
||||
const friendStatuses = await friend_statuses()
|
||||
const users = await get_user_many(
|
||||
friendsList.map((x) => (x.id === userCredentials.value.user_id ? x.friend_id : x.id)),
|
||||
)
|
||||
|
||||
userFriends.value = friendsList.map((friend) => {
|
||||
const user = users.find((x) => x.id === friend.id || x.id === friend.friend_id)
|
||||
const status = friendStatuses.find(
|
||||
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
|
||||
)
|
||||
return {
|
||||
id: friend.id,
|
||||
friend_id: friend.friend_id,
|
||||
status: status?.profile_name,
|
||||
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
|
||||
created: dayjs(friend.created),
|
||||
avatar: user?.avatar_url,
|
||||
username: user?.username,
|
||||
online: !!status,
|
||||
accepted: friend.accepted,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
userFriends.value = await transformFriends(friendsList, userCredentials.value)
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
console.error('Error loading friends', e)
|
||||
@@ -152,6 +128,7 @@ watch(
|
||||
() => {
|
||||
if (userCredentials.value === undefined) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
} else if (userCredentials.value === null) {
|
||||
userFriends.value = []
|
||||
loading.value = false
|
||||
@@ -166,49 +143,87 @@ const unlisten = await friend_listener(() => loadFriends())
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
addFriend: {
|
||||
id: 'friends.action.add-friend',
|
||||
defaultMessage: 'Add a friend',
|
||||
},
|
||||
addingAFriend: {
|
||||
id: 'friends.add-friend.title',
|
||||
defaultMessage: 'Adding a friend',
|
||||
},
|
||||
usernameTitle: {
|
||||
id: 'friends.add-friend.username.title',
|
||||
defaultMessage: "What's your friend's Modrinth username?",
|
||||
},
|
||||
usernameDescription: {
|
||||
id: 'friends.add-friend.username.description',
|
||||
defaultMessage: 'It may be different from their Minecraft username!',
|
||||
},
|
||||
usernamePlaceholder: {
|
||||
id: 'friends.add-friend.username.placeholder',
|
||||
defaultMessage: 'Enter Modrinth username...',
|
||||
},
|
||||
sendFriendRequest: {
|
||||
id: 'friends.add-friend.submit',
|
||||
defaultMessage: 'Send friend request',
|
||||
},
|
||||
viewFriendRequests: {
|
||||
id: 'friends.action.view-friend-requests',
|
||||
defaultMessage: '{count} friend {count, plural, one {request} other {requests}}',
|
||||
},
|
||||
searchFriends: {
|
||||
id: 'friends.search-friends-placeholder',
|
||||
defaultMessage: 'Search friends...',
|
||||
},
|
||||
friends: {
|
||||
id: 'friends.heading',
|
||||
defaultMessage: 'Friends',
|
||||
},
|
||||
pending: {
|
||||
id: 'friends.heading.pending',
|
||||
defaultMessage: 'Pending',
|
||||
},
|
||||
active: {
|
||||
id: 'friends.heading.active',
|
||||
defaultMessage: 'Active',
|
||||
},
|
||||
online: {
|
||||
id: 'friends.heading.online',
|
||||
defaultMessage: 'Online',
|
||||
},
|
||||
offline: {
|
||||
id: 'friends.heading.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
noFriendsMatch: {
|
||||
id: 'friends.no-friends-match',
|
||||
defaultMessage: `No friends matching ''{query}''`,
|
||||
},
|
||||
signInToAddFriends: {
|
||||
id: 'friends.sign-in-to-add-friends',
|
||||
defaultMessage:
|
||||
"<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!",
|
||||
},
|
||||
addFriendsToShare: {
|
||||
id: 'friends.add-friends-to-share',
|
||||
defaultMessage: "<link>Add friends</link> to see what they're playing!",
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="manageFriendsModal" header="Manage friends">
|
||||
<p v-if="acceptedFriends.length === 0">Add friends to share what you're playing!</p>
|
||||
<div v-else class="flex flex-col gap-4 min-w-[20rem]">
|
||||
<input v-model="search" type="text" placeholder="Search friends..." class="w-full" />
|
||||
<div
|
||||
v-for="friend in acceptedFriends.filter(
|
||||
(x) => !search || x.username.toLowerCase().includes(search),
|
||||
)"
|
||||
:key="friend.username"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<span
|
||||
v-if="friend.online"
|
||||
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div>{{ friend.username }}</div>
|
||||
<div class="ml-auto">
|
||||
<ButtonStyled>
|
||||
<button @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="friendInvitesModal" header="View friend requests">
|
||||
<p v-if="pendingFriends.length === 0">You have no pending friend requests :C</p>
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div v-for="friend in pendingFriends" :key="friend.username" class="flex gap-2">
|
||||
<p v-if="incomingRequests.length === 0">You have no pending friend requests :C</p>
|
||||
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
|
||||
<div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
|
||||
<div>
|
||||
<p class="m-0">
|
||||
<template v-if="friend.id === userCredentials.user_id">
|
||||
<span class="font-bold">{{ friend.username }}</span> sent you a friend request
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
<span class="text-contrast">{{ friend.username }}</span> sent you a friend request
|
||||
</template>
|
||||
<template v-else>
|
||||
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
||||
@@ -219,7 +234,7 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="friend.id === userCredentials.user_id">
|
||||
<template v-if="friend.id === userCredentials?.user_id">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="addFriend(friend)">
|
||||
<UserPlusIcon />
|
||||
@@ -246,78 +261,89 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="addFriendModal" header="Add a friend">
|
||||
<div class="mb-4">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Username</h2>
|
||||
<p class="m-0 mt-1 leading-tight">You can add friends with their Modrinth username.</p>
|
||||
<input
|
||||
v-model="username"
|
||||
class="mt-2 w-full"
|
||||
type="text"
|
||||
placeholder="Enter username..."
|
||||
@keyup.enter="addFriendFromModal"
|
||||
/>
|
||||
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
|
||||
<div class="min-w-[30rem]">
|
||||
<h2 class="m-0 text-base font-medium text-primary">
|
||||
{{ formatMessage(messages.usernameTitle) }}
|
||||
</h2>
|
||||
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
|
||||
{{ formatMessage(messages.usernameDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<div class="iconified-input flex-1">
|
||||
<UserIcon aria-hidden="true" />
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.usernamePlaceholder)"
|
||||
@keyup.enter="addFriendFromModal"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="username.length === 0" @click="addFriendFromModal">
|
||||
<SendIcon />
|
||||
{{ formatMessage(messages.sendFriendRequest) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="username.length === 0" @click="addFriendFromModal">
|
||||
<UserPlusIcon />
|
||||
Add friend
|
||||
</ModalWrapper>
|
||||
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 ml-2 mr-1">
|
||||
<template v-if="sortedFriends.length > 0">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.addFriend)"
|
||||
:aria-label="formatMessage(messages.addFriend)"
|
||||
@click="addFriendModal.show"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="iconified-input flex-1">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="friends-search-bar flex w-full"
|
||||
:placeholder="formatMessage(messages.searchFriends)"
|
||||
@keyup.esc="search = ''"
|
||||
/>
|
||||
<button
|
||||
v-if="search"
|
||||
v-tooltip="formatMessage(commonMessages.clearButton)"
|
||||
class="r-btn flex items-center justify-center bg-transparent button-animation p-2 cursor-pointer appearance-none border-none"
|
||||
@click="search = ''"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<h3 v-else class="ml-2 w-full text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
class="relative"
|
||||
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
|
||||
@click="friendInvitesModal.show"
|
||||
>
|
||||
<MailIcon />
|
||||
<span
|
||||
v-if="incomingRequests.length > 0"
|
||||
aria-hidden="true"
|
||||
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
|
||||
>
|
||||
{{ incomingRequests.length }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ModalWrapper>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg m-0">Friends</h3>
|
||||
<ButtonStyled v-if="userCredentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'add-friend',
|
||||
action: () => addFriendModal.show(),
|
||||
},
|
||||
{
|
||||
id: 'manage-friends',
|
||||
action: () => manageFriendsModal.show(),
|
||||
shown: acceptedFriends.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'view-requests',
|
||||
action: () => friendInvitesModal.show(),
|
||||
shown: pendingFriends.length > 0,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #add-friend>
|
||||
<UserPlusIcon aria-hidden="true" />
|
||||
Add friend
|
||||
</template>
|
||||
<template #manage-friends>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
Manage friends
|
||||
<div
|
||||
v-if="acceptedFriends.length > 0"
|
||||
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
|
||||
>
|
||||
{{ acceptedFriends.length }}
|
||||
</div>
|
||||
</template>
|
||||
<template #view-requests>
|
||||
<MailIcon aria-hidden="true" />
|
||||
View friend requests
|
||||
<div
|
||||
v-if="pendingFriends.length > 0"
|
||||
class="bg-button-bg w-6 h-6 rounded-full flex items-center justify-center"
|
||||
>
|
||||
{{ pendingFriends.length }}
|
||||
</div>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 v-if="loading" class="ml-4 mr-1 text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.friends) }}
|
||||
</h3>
|
||||
<template v-if="loading">
|
||||
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
|
||||
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse ml-4 mr-1">
|
||||
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
|
||||
@@ -325,50 +351,77 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="acceptedFriends.length === 0">
|
||||
<div class="text-sm">
|
||||
<template v-else-if="sortedFriends.length === 0">
|
||||
<div class="text-sm ml-4 mr-1">
|
||||
<div v-if="!userCredentials">
|
||||
<span class="text-link cursor-pointer" @click="signIn">Sign in</span> to add friends!
|
||||
<IntlFormatted :message-id="messages.signInToAddFriends">
|
||||
<template #link="{ children }">
|
||||
<span class="font-semibold text-brand cursor-pointer" @click="signIn">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span class="text-link cursor-pointer" @click="addFriendModal.show()">Add friends</span>
|
||||
to share what you're playing!
|
||||
<IntlFormatted :message-id="messages.addFriendsToShare">
|
||||
<template #link="{ children }">
|
||||
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
||||
<template #remove-friend> <TrashIcon /> Remove friend </template>
|
||||
</ContextMenu>
|
||||
<div
|
||||
v-for="friend in acceptedFriends.slice(0, 5)"
|
||||
:key="friend.username"
|
||||
class="flex gap-2 items-center"
|
||||
:class="{ grayscale: !friend.online }"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) =>
|
||||
friendOptions.showMenu(event, friend, [
|
||||
{
|
||||
name: 'remove-friend',
|
||||
color: 'danger',
|
||||
},
|
||||
])
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
|
||||
<span
|
||||
v-if="friend.online"
|
||||
class="bottom-1 right-0 absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-md m-0 font-medium" :class="{ 'text-secondary': !friend.online }">
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FriendsSection
|
||||
v-if="activeFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="activeFriends"
|
||||
:heading="formatMessage(messages.active)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="onlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="onlineFriends"
|
||||
:heading="formatMessage(messages.online)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="offlineFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
:open-by-default="activeFriends.length + onlineFriends.length < 3"
|
||||
:friends="offlineFriends"
|
||||
:heading="formatMessage(messages.offline)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<FriendsSection
|
||||
v-if="pendingFriends.length > 0"
|
||||
:is-searching="!!search"
|
||||
open-by-default
|
||||
:friends="pendingFriends"
|
||||
:heading="formatMessage(messages.pending)"
|
||||
:remove-friend="removeFriend"
|
||||
/>
|
||||
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
|
||||
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.friends-search-bar {
|
||||
background: none;
|
||||
border: 2px solid var(--color-button-bg) !important;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.friends-search-bar::placeholder {
|
||||
@apply text-sm font-normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
185
apps/app-frontend/src/components/ui/friends/FriendsSection.vue
Normal file
185
apps/app-frontend/src/components/ui/friends/FriendsSection.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
|
||||
import { Accordion, Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { FriendWithUserData } from '@/helpers/friends.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
friends: FriendWithUserData[]
|
||||
heading: string
|
||||
removeFriend: (friend: FriendWithUserData) => Promise<void>
|
||||
isSearching?: boolean
|
||||
openByDefault?: boolean
|
||||
}>(),
|
||||
{
|
||||
isSearching: false,
|
||||
openByDefault: false,
|
||||
},
|
||||
)
|
||||
|
||||
function createContextMenuOptions(friend: FriendWithUserData) {
|
||||
if (friend.accepted) {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'remove-friend',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
name: 'view-profile',
|
||||
},
|
||||
{
|
||||
name: 'cancel-request',
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function openProfile(username: string) {
|
||||
openUrl('https://modrinth.com/user/' + username)
|
||||
}
|
||||
|
||||
const friendOptions = useTemplateRef('friendOptions')
|
||||
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
|
||||
switch (args.option) {
|
||||
case 'remove-friend':
|
||||
case 'cancel-request':
|
||||
await props.removeFriend(args.item)
|
||||
break
|
||||
case 'view-profile':
|
||||
openProfile(args.item.username)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
removeFriend: {
|
||||
id: 'friends.friend.remove-friend',
|
||||
defaultMessage: 'Remove friend',
|
||||
},
|
||||
heading: {
|
||||
id: 'friends.section.heading',
|
||||
defaultMessage: '{title} - {count}',
|
||||
},
|
||||
friendRequestSent: {
|
||||
id: 'friends.friend.request-sent',
|
||||
defaultMessage: 'Friend request sent',
|
||||
},
|
||||
cancelRequest: {
|
||||
id: 'friends.friend.cancel-request',
|
||||
defaultMessage: 'Cancel request',
|
||||
},
|
||||
viewProfile: {
|
||||
id: 'friends.friend.view-profile',
|
||||
defaultMessage: 'View profile',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
|
||||
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
|
||||
</ContextMenu>
|
||||
<Accordion
|
||||
:open-by-default="openByDefault"
|
||||
:force-open="isSearching"
|
||||
:button-class="
|
||||
'pl-4 pr-3 flex w-full items-center bg-transparent border-0 p-0' +
|
||||
(isSearching
|
||||
? ''
|
||||
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.heading, { title: heading, count: friends.length }) }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="pt-3 flex flex-col gap-1">
|
||||
<div
|
||||
v-for="friend in friends"
|
||||
:key="friend.username"
|
||||
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full ml-4 mr-1"
|
||||
@contextmenu.prevent.stop="
|
||||
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
|
||||
"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="friend.avatar"
|
||||
:class="{ grayscale: !friend.online && friend.accepted }"
|
||||
class="w-12 h-12 rounded-full"
|
||||
size="32px"
|
||||
circle
|
||||
/>
|
||||
<span
|
||||
v-if="friend.online"
|
||||
aria-hidden="true"
|
||||
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-sm m-0"
|
||||
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ friend.username }}
|
||||
</span>
|
||||
<span v-if="!friend.accepted" class="m-0 text-xs">
|
||||
{{ formatMessage(messages.friendRequestSent) }}
|
||||
</span>
|
||||
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="friend.accepted" circular type="transparent">
|
||||
<OverflowMenu
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:options="[
|
||||
{
|
||||
id: 'view-profile',
|
||||
action: () => openProfile(friend.username),
|
||||
},
|
||||
{
|
||||
id: 'remove-friend',
|
||||
action: () => removeFriend(friend),
|
||||
color: 'red',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #view-profile>
|
||||
<UserIcon />
|
||||
{{ formatMessage(messages.viewProfile) }}
|
||||
</template>
|
||||
<template #remove-friend>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeFriend) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else type="transparent" circular>
|
||||
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Accordion>
|
||||
</template>
|
||||
@@ -130,6 +130,7 @@ import {
|
||||
remove_custom_skin,
|
||||
type Skin,
|
||||
type SkinModel,
|
||||
type SkinTextureUrl,
|
||||
unequip_skin,
|
||||
} from '@/helpers/skins.ts'
|
||||
|
||||
@@ -142,7 +143,7 @@ const currentSkin = ref<Skin | null>(null)
|
||||
const shouldRestoreModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const uploadedTextureUrl = ref<string | null>(null)
|
||||
const uploadedTextureUrl = ref<SkinTextureUrl | null>(null)
|
||||
const previewSkin = ref<string>('')
|
||||
|
||||
const variant = ref<SkinModel>('CLASSIC')
|
||||
@@ -188,7 +189,7 @@ function getSortedCapeExcluding(excludeId: string): Cape | undefined {
|
||||
|
||||
async function loadPreviewSkin() {
|
||||
if (uploadedTextureUrl.value) {
|
||||
previewSkin.value = uploadedTextureUrl.value
|
||||
previewSkin.value = uploadedTextureUrl.value.normalized
|
||||
} else if (currentSkin.value) {
|
||||
try {
|
||||
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
|
||||
@@ -253,11 +254,11 @@ async function show(e: MouseEvent, skin?: Skin) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
async function showNew(e: MouseEvent, skinTextureUrl: SkinTextureUrl) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = await determineModelType(skinTextureUrl)
|
||||
variant.value = await determineModelType(skinTextureUrl.original)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
@@ -267,7 +268,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
async function restoreWithNewTexture(skinTextureUrl: string) {
|
||||
async function restoreWithNewTexture(skinTextureUrl: SkinTextureUrl) {
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
await loadPreviewSkin()
|
||||
|
||||
@@ -361,7 +362,7 @@ async function save() {
|
||||
let textureUrl: string
|
||||
|
||||
if (uploadedTextureUrl.value) {
|
||||
textureUrl = uploadedTextureUrl.value
|
||||
textureUrl = uploadedTextureUrl.value.original
|
||||
} else {
|
||||
textureUrl = currentSkin.value!.texture
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function friends() {
|
||||
return await invoke('plugin:friends|friends')
|
||||
}
|
||||
|
||||
export async function friend_statuses() {
|
||||
return await invoke('plugin:friends|friend_statuses')
|
||||
}
|
||||
|
||||
export async function add_friend(userId) {
|
||||
return await invoke('plugin:friends|add_friend', { userId })
|
||||
}
|
||||
|
||||
export async function remove_friend(userId) {
|
||||
return await invoke('plugin:friends|remove_friend', { userId })
|
||||
}
|
||||
79
apps/app-frontend/src/helpers/friends.ts
Normal file
79
apps/app-frontend/src/helpers/friends.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { User } from '@modrinth/utils'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { get_user_many } from '@/helpers/cache'
|
||||
import type { ModrinthCredentials } from '@/helpers/mr_auth'
|
||||
|
||||
export type UserStatus = {
|
||||
user_id: string
|
||||
profile_name: string | null
|
||||
last_update: string
|
||||
}
|
||||
|
||||
export type UserFriend = {
|
||||
id: string
|
||||
friend_id: string
|
||||
accepted: boolean
|
||||
created: string
|
||||
}
|
||||
|
||||
export async function friends(): Promise<UserFriend[]> {
|
||||
return await invoke('plugin:friends|friends')
|
||||
}
|
||||
|
||||
export async function friend_statuses(): Promise<UserStatus[]> {
|
||||
return await invoke('plugin:friends|friend_statuses')
|
||||
}
|
||||
|
||||
export async function add_friend(userId: string): Promise<void> {
|
||||
return await invoke('plugin:friends|add_friend', { userId })
|
||||
}
|
||||
|
||||
export async function remove_friend(userId: string): Promise<void> {
|
||||
return await invoke('plugin:friends|remove_friend', { userId })
|
||||
}
|
||||
|
||||
export type FriendWithUserData = {
|
||||
id: string
|
||||
friend_id: string | null
|
||||
status: string | null
|
||||
last_updated: Dayjs | null
|
||||
created: Dayjs
|
||||
username: string
|
||||
accepted: boolean
|
||||
online: boolean
|
||||
avatar: string
|
||||
}
|
||||
export async function transformFriends(
|
||||
friends: UserFriend[],
|
||||
credentials: ModrinthCredentials | null,
|
||||
): Promise<FriendWithUserData[]> {
|
||||
if (friends.length === 0 || !credentials) {
|
||||
return []
|
||||
}
|
||||
|
||||
const friendStatuses = await friend_statuses()
|
||||
const users = await get_user_many(
|
||||
friends.map((x) => (x.id === credentials.user_id ? x.friend_id : x.id)),
|
||||
)
|
||||
|
||||
return friends.map((friend) => {
|
||||
const user = users.find((x: User) => x.id === friend.id || x.id === friend.friend_id)
|
||||
const status = friendStatuses.find(
|
||||
(x) => x.user_id === friend.id || x.user_id === friend.friend_id,
|
||||
)
|
||||
return {
|
||||
id: friend.id,
|
||||
friend_id: friend.friend_id,
|
||||
status: status?.profile_name ?? null,
|
||||
last_updated: status && status.last_update ? dayjs(status.last_update) : null,
|
||||
created: dayjs(friend.created),
|
||||
avatar: user?.avatar_url ?? '',
|
||||
username: user?.username ?? '',
|
||||
online: !!status,
|
||||
accepted: friend.accepted,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,18 +5,25 @@
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function login() {
|
||||
export type ModrinthCredentials = {
|
||||
session: string
|
||||
expires: string
|
||||
user_id: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export async function login(): Promise<ModrinthCredentials> {
|
||||
return await invoke('plugin:mr-auth|modrinth_login')
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
export async function logout(): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|logout')
|
||||
}
|
||||
|
||||
export async function get() {
|
||||
export async function get(): Promise<ModrinthCredentials | null> {
|
||||
return await invoke('plugin:mr-auth|get')
|
||||
}
|
||||
|
||||
export async function cancelLogin() {
|
||||
export async function cancelLogin(): Promise<void> {
|
||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export type AppSettings = {
|
||||
skipped_update: string | null
|
||||
pending_update_toast_for_version: string | null
|
||||
auto_download_updates: boolean | null
|
||||
|
||||
version: number
|
||||
}
|
||||
|
||||
// Get full settings object
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface Skin {
|
||||
is_equipped: boolean
|
||||
}
|
||||
|
||||
export interface SkinTextureUrl {
|
||||
original: string
|
||||
normalized: string
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
|
||||
|
||||
export const DEFAULT_MODELS: Record<string, SkinModel> = {
|
||||
|
||||
@@ -65,6 +65,69 @@
|
||||
"app.update.reload-to-update": {
|
||||
"message": "Reload to install update"
|
||||
},
|
||||
"friends.action.add-friend": {
|
||||
"message": "Add a friend"
|
||||
},
|
||||
"friends.action.view-friend-requests": {
|
||||
"message": "{count} friend {count, plural, one {request} other {requests}}"
|
||||
},
|
||||
"friends.add-friend.submit": {
|
||||
"message": "Send friend request"
|
||||
},
|
||||
"friends.add-friend.title": {
|
||||
"message": "Adding a friend"
|
||||
},
|
||||
"friends.add-friend.username.description": {
|
||||
"message": "It may be different from their Minecraft username!"
|
||||
},
|
||||
"friends.add-friend.username.placeholder": {
|
||||
"message": "Enter Modrinth username..."
|
||||
},
|
||||
"friends.add-friend.username.title": {
|
||||
"message": "What's your friend's Modrinth username?"
|
||||
},
|
||||
"friends.add-friends-to-share": {
|
||||
"message": "<link>Add friends</link> to see what they're playing!"
|
||||
},
|
||||
"friends.friend.cancel-request": {
|
||||
"message": "Cancel request"
|
||||
},
|
||||
"friends.friend.remove-friend": {
|
||||
"message": "Remove friend"
|
||||
},
|
||||
"friends.friend.request-sent": {
|
||||
"message": "Friend request sent"
|
||||
},
|
||||
"friends.friend.view-profile": {
|
||||
"message": "View profile"
|
||||
},
|
||||
"friends.heading": {
|
||||
"message": "Friends"
|
||||
},
|
||||
"friends.heading.active": {
|
||||
"message": "Active"
|
||||
},
|
||||
"friends.heading.offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"friends.heading.online": {
|
||||
"message": "Online"
|
||||
},
|
||||
"friends.heading.pending": {
|
||||
"message": "Pending"
|
||||
},
|
||||
"friends.no-friends-match": {
|
||||
"message": "No friends matching ''{query}''"
|
||||
},
|
||||
"friends.search-friends-placeholder": {
|
||||
"message": "Search friends..."
|
||||
},
|
||||
"friends.section.heading": {
|
||||
"message": "{title} - {count}"
|
||||
},
|
||||
"friends.sign-in-to-add-friends": {
|
||||
"message": "<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!"
|
||||
},
|
||||
"instance.add-server.add-and-play": {
|
||||
"message": "Add and play"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ import { get_default_user, login as login_flow, users } from '@/helpers/auth'
|
||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { get as getSettings } from '@/helpers/settings.ts'
|
||||
import type { Cape, Skin } from '@/helpers/skins.ts'
|
||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||
import {
|
||||
equip_skin,
|
||||
filterDefaultSkins,
|
||||
@@ -245,16 +245,18 @@ function openUploadSkinModal(e: MouseEvent) {
|
||||
|
||||
function onSkinFileUploaded(buffer: ArrayBuffer) {
|
||||
const fakeEvent = new MouseEvent('click')
|
||||
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
|
||||
(skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
},
|
||||
)
|
||||
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
|
||||
normalize_skin_texture(originalSkinTexUrl).then((skinTextureNormalized: Uint8Array) => {
|
||||
const skinTexUrl: SkinTextureUrl = {
|
||||
original: originalSkinTexUrl,
|
||||
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
|
||||
}
|
||||
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
|
||||
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
|
||||
} else {
|
||||
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onUploadCanceled() {
|
||||
|
||||
@@ -139,9 +139,9 @@
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||
<template #open_folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
|
||||
@@ -92,7 +92,7 @@ import {
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, Card } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
// import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
@@ -151,17 +151,26 @@ const expandImage = (item, index) => {
|
||||
|
||||
function keyListener(e) {
|
||||
if (expandedGalleryItem.value) {
|
||||
e.preventDefault()
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hideImage()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
previousImage()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
nextImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keypress', keyListener)
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', keyListener)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', keyListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -148,9 +148,15 @@ fn main() {
|
||||
} else {
|
||||
"app-window-state.json".to_string()
|
||||
},
|
||||
)
|
||||
// Use *only* POSITION and SIZE state flags, because saving VISIBLE causes the `visible: false` to not take effect
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::POSITION
|
||||
| tauri_plugin_window_state::StateFlags::SIZE
|
||||
| tauri_plugin_window_state::StateFlags::MAXIMIZED,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
)
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.89.0 AS build
|
||||
FROM rust:1.90.0 AS build
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
@@ -14,7 +14,7 @@ RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
mkdir /daedalus \
|
||||
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
|
||||
LABEL org.opencontainers.image.title=daedalus
|
||||
|
||||
@@ -2762,10 +2762,7 @@
|
||||
},
|
||||
{
|
||||
"_comment": "Replace glfw from 3.3.1 with version from 3.3.2 to prevent stack smashing",
|
||||
"match": [
|
||||
"org.lwjgl:lwjgl-glfw-natives-linux:3.3.1",
|
||||
"org.lwjgl:lwjgl-glfw:3.3.1:natives-linux"
|
||||
],
|
||||
"match": ["org.lwjgl:lwjgl-glfw-natives-linux:3.3.1"],
|
||||
"override": {
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
@@ -2776,5 +2773,115 @@
|
||||
},
|
||||
"name": "org.lwjgl:lwjgl-glfw-natives-linux:3.3.2-lwjgl.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"_comment": "Use newer JNA on macOS to prevent crashes due to faulty assertion",
|
||||
"match": [
|
||||
"net.java.dev.jna:jna:5.6.0",
|
||||
"net.java.dev.jna:jna:5.8.0",
|
||||
"net.java.dev.jna:jna:5.9.0",
|
||||
"net.java.dev.jna:jna:5.10.0",
|
||||
"net.java.dev.jna:jna:5.12.1"
|
||||
],
|
||||
"override": {
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow"
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalLibraries": [
|
||||
{
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"sha1": "1200e7ebeedbe0d10062093f32925a912020e747",
|
||||
"size": 1879325,
|
||||
"url": "https://libraries.minecraft.net/net/java/dev/jna/jna/5.13.0/jna-5.13.0.jar"
|
||||
}
|
||||
},
|
||||
"name": "net.java.dev.jna:jna:5.13.0",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_comment": "Use newer JNA on macOS to prevent crashes due to faulty assertion",
|
||||
"match": [
|
||||
"net.java.dev.jna:jna-platform:5.6.0",
|
||||
"net.java.dev.jna:jna-platform:5.8.0",
|
||||
"net.java.dev.jna:jna-platform:5.9.0",
|
||||
"net.java.dev.jna:jna-platform:5.10.0",
|
||||
"net.java.dev.jna:jna-platform:5.12.1"
|
||||
],
|
||||
"override": {
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow"
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "disallow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalLibraries": [
|
||||
{
|
||||
"downloads": {
|
||||
"artifact": {
|
||||
"sha1": "88e9a306715e9379f3122415ef4ae759a352640d",
|
||||
"size": 1363209,
|
||||
"url": "https://libraries.minecraft.net/net/java/dev/jna/jna-platform/5.13.0/jna-platform-5.13.0.jar"
|
||||
}
|
||||
},
|
||||
"name": "net.java.dev.jna:jna-platform:5.13.0",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "allow",
|
||||
"os": {
|
||||
"name": "osx-arm64"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct LibraryPatch {
|
||||
}
|
||||
|
||||
fn fetch_library_patches() -> Result<Vec<LibraryPatch>, Error> {
|
||||
// The file below is a copy of https://github.com/PrismLauncher/meta/blob/main/meta/common/mojang-library-patches.json.
|
||||
// That file belongs to a repository licensed under the Microsoft Public License (Ms-PL)
|
||||
let patches = include_bytes!("../library-patches.json");
|
||||
Ok(serde_json::from_slice(patches)?)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Guide for contributing to Modrinth's backend
|
||||
|
||||
This project is part of our [monorepo](https://github.com/modrinth/code). You can find it in the `apps/labrinth` directory. The instructions below assume that you have switched your working directory to the `apps/labrinth` subdirectory.
|
||||
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432, a MeiliSearch instance on port 7700, and a [Mailpit](https://mailpit.axllent.org/) SMTP server on port 1025, with a web UI to inspect sent emails on port 8025. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), cmake, and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432, a MeiliSearch instance on port 7700, and a [Mailpit](https://mailpit.axllent.org/) SMTP server on port 1025, with a web UI to inspect sent emails on port 8025. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000.
|
||||
|
||||
To get a basic configuration, copy the `.env.local` file to `.env`. Now, you'll have to install the sqlx CLI, which can be done with cargo:
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
::backdrop,
|
||||
:root[data-theme='light'],
|
||||
[data-theme='light'] ::backdrop {
|
||||
--sl-font-system:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
|
||||
--sl-color-white: var(--color-contrast); /* “white” */
|
||||
--sl-color-gray-1: var(--color-base);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -631,6 +631,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeybinds)
|
||||
notifications.setNotificationLocation('right')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
'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.',
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -924,11 +924,20 @@
|
||||
"message": "Complete tax form"
|
||||
},
|
||||
"layout.banner.tax.description": {
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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}',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
@@ -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
@@ -33,7 +33,6 @@
|
||||
}
|
||||
|
||||
#modrinth-rail-1 {
|
||||
border-radius: 1rem;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="We’ve added time to your server">
|
||||
<Heading as="h1" class="mb-2 text-2xl font-bold">We’ve 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=default
|
||||
CLICKHOUSE_DATABASE=staging_ariadne
|
||||
|
||||
MAXMIND_ACCOUNT_ID=none
|
||||
MAXMIND_LICENSE_KEY=none
|
||||
|
||||
FLAME_ANVIL_URL=none
|
||||
@@ -141,4 +142,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||
ANROK_API_KEY=none
|
||||
ANROK_API_URL=none
|
||||
|
||||
GOTENBERG_URL=http://labrinth-gotenberg:13000
|
||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
@@ -115,6 +115,7 @@ CLICKHOUSE_USER=default
|
||||
CLICKHOUSE_PASSWORD=default
|
||||
CLICKHOUSE_DATABASE=staging_ariadne
|
||||
|
||||
MAXMIND_ACCOUNT_ID=none
|
||||
MAXMIND_LICENSE_KEY=none
|
||||
|
||||
FLAME_ANVIL_URL=none
|
||||
@@ -142,4 +143,7 @@ COMPLIANCE_PAYOUT_THRESHOLD=disabled
|
||||
ANROK_API_KEY=none
|
||||
ANROK_API_URL=none
|
||||
|
||||
GOTENBERG_URL=http://localhost:13000
|
||||
GOTENBERG_CALLBACK_BASE=http://host.docker.internal:8000/_internal/gotenberg
|
||||
|
||||
ARCHON_URL=none
|
||||
|
||||
@@ -113,6 +113,10 @@ migrations/20250725230041_reports-closed-status-index.sql
|
||||
migrations/20250727184120_user-newsletter-subscription-column.sql
|
||||
migrations/20250804221014_users-redeemals.sql
|
||||
migrations/20250805001654_product-prices-public.sql
|
||||
migrations/20250823233518_user-compliance.sql
|
||||
migrations/20250902133943_notification-extension.sql
|
||||
migrations/20250914190749_affiliate_codes.sql
|
||||
migrations/20250927120742_user_limits.sql
|
||||
|
||||
# Prettier reformats some of the PostgreSQL-specific COPY syntax here,
|
||||
# which is very likely to break things
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n INNER JOIN users_subscriptions us ON us.id = charges.subscription_id\n WHERE\n charges.charge_type = $1 AND\n (\n (charges.status = 'cancelled' AND charges.due < NOW()) OR\n (charges.status = 'expiring' AND charges.due < NOW()) OR\n (charges.status = 'failed' AND charges.last_attempt < NOW() - INTERVAL '2 days')\n )\n AND us.status = 'provisioned'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e36e0ac1e2edb73533961a18e913f0b8e4f420a76e511571bb2eed9355771e54"
|
||||
"hash": "372c03a6daf0045f615faa9a6205558cd0ea1d9dba5948e8fa2496ed99de8fea"
|
||||
}
|
||||
19
apps/labrinth/.sqlx/query-3d05de766a0987028d465a3305938164e4d79734c17c07111f8923f2faa517bf.json
generated
Normal file
19
apps/labrinth/.sqlx/query-3d05de766a0987028d465a3305938164e4d79734c17c07111f8923f2faa517bf.json
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users_subscriptions_credits\n (subscription_id, user_id, creditor_id, days, previous_due, next_due)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::bigint[], $4::int[], $5::timestamptz[], $6::timestamptz[])\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8Array",
|
||||
"Int8Array",
|
||||
"Int8Array",
|
||||
"Int4Array",
|
||||
"TimestamptzArray",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3d05de766a0987028d465a3305938164e4d79734c17c07111f8923f2faa517bf"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n\t\t\tWHERE\n\t\t\t status = 'succeeded'\n\t\t\t AND tax_platform_id IS NULL\n AND payment_platform_id IS NOT NULL\n\t\t\tORDER BY due ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n OFFSET $1\n\t\t\tLIMIT $2\n\t\t\t",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,11 +97,20 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
@@ -124,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "050e755134f6d1f09de805ae2cd0f7ca8f6efb96be9f070c43db7fd2049af2d2"
|
||||
"hash": "4a0e5c7ebd4565b95fb99983484cec76952f1505a75eb1a006b3ad9b8aa91a51"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n INNER JOIN users_subscriptions us ON us.id = charges.subscription_id\n WHERE\n charges.charge_type = $1 AND\n (\n (charges.status = 'cancelled' AND charges.due < NOW()) OR\n (charges.status = 'expiring' AND charges.due < NOW()) OR\n (charges.status = 'failed' AND charges.last_attempt < NOW() - INTERVAL '2 days')\n )\n AND us.status = 'provisioned'\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9f0c73fabe99d9891faaebdd3518b362437dcdcef9cd9a68b950fba61218bb4d"
|
||||
"hash": "4ed57832b7c02e1f4c683e256455c76e645cde49f95b0e5bfecd3d3d2330ed5c"
|
||||
}
|
||||
34
apps/labrinth/.sqlx/query-5a972c49ccacf8735ec36d691f1c34b86c33703f984c869346b0c0be1c4a4883.json
generated
Normal file
34
apps/labrinth/.sqlx/query-5a972c49ccacf8735ec36d691f1c34b86c33703f984c869346b0c0be1c4a4883.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss, tax_transaction_version, tax_platform_accounting_time)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n tax_amount = EXCLUDED.tax_amount,\n tax_platform_id = EXCLUDED.tax_platform_id,\n tax_last_updated = EXCLUDED.tax_last_updated,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type,\n\t\t\t\t\ttax_drift_loss = EXCLUDED.tax_drift_loss,\n\t\t\t\t\ttax_transaction_version = EXCLUDED.tax_transaction_version,\n\t\t\t\t\ttax_platform_accounting_time = EXCLUDED.tax_platform_accounting_time\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Int8",
|
||||
"Int4",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5a972c49ccacf8735ec36d691f1c34b86c33703f984c869346b0c0be1c4a4883"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n WHERE parent_charge_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7973e569e784f416c1b4f1e6f3b099dca9c0d9c84e55951a730d8c214580e0d6"
|
||||
"hash": "64233913683d187ee6c449eb106bd1a27929e05b497aaea93e9e8f318770c64c"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-6443da83032ef5d6cb907f97fb37ae62351eeeb2ae3b8148cf8a8fd0deb2795a.json
generated
Normal file
15
apps/labrinth/.sqlx/query-6443da83032ef5d6cb907f97fb37ae62351eeeb2ae3b8148cf8a8fd0deb2795a.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE affiliate_codes\n SET created_by = $1\n WHERE created_by = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6443da83032ef5d6cb907f97fb37ae62351eeeb2ae3b8148cf8a8fd0deb2795a"
|
||||
}
|
||||
27
apps/labrinth/.sqlx/query-68968755bd5eacce9009d1d873d08ef679e6638e57e4711c2c215f32e691d856.json
generated
Normal file
27
apps/labrinth/.sqlx/query-68968755bd5eacce9009d1d873d08ef679e6638e57e4711c2c215f32e691d856.json
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO users_subscriptions_credits\n (subscription_id, user_id, creditor_id, days, previous_due, next_due)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int4",
|
||||
"Timestamptz",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "68968755bd5eacce9009d1d873d08ef679e6638e57e4711c2c215f32e691d856"
|
||||
}
|
||||
143
apps/labrinth/.sqlx/query-70236e8be98967070160f703ed0242239eb5a4c6bef3748dac57fa339260c9c1.json
generated
Normal file
143
apps/labrinth/.sqlx/query-70236e8be98967070160f703ed0242239eb5a4c6bef3748dac57fa339260c9c1.json
generated
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n\t\t\tWHERE\n\t\t\t status = 'succeeded'\n\t\t\t AND tax_platform_id IS NULL\n AND payment_platform_id IS NOT NULL\n\t\t\tORDER BY due ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n OFFSET $1\n\t\t\tLIMIT $2\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "due",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "charge_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "subscription_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "70236e8be98967070160f703ed0242239eb5a4c6bef3748dac57fa339260c9c1"
|
||||
}
|
||||
14
apps/labrinth/.sqlx/query-713034d4968b290a0096e41b9da044f6760683cb89ff39255a177bb025e7638e.json
generated
Normal file
14
apps/labrinth/.sqlx/query-713034d4968b290a0096e41b9da044f6760683cb89ff39255a177bb025e7638e.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM payouts_values_notifications\n WHERE user_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "713034d4968b290a0096e41b9da044f6760683cb89ff39255a177bb025e7638e"
|
||||
}
|
||||
142
apps/labrinth/.sqlx/query-7d8de27065490edc560b0c81061295ca82f44546527e1a31c03e5bb7a07c1e63.json
generated
Normal file
142
apps/labrinth/.sqlx/query-7d8de27065490edc560b0c81061295ca82f44546527e1a31c03e5bb7a07c1e63.json
generated
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n\t\t\tINNER JOIN users u ON u.id = charges.user_id\n\t\t\tWHERE\n\t\t\t status = 'open'\n\t\t\t AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'\n\t\t\t AND u.email IS NOT NULL\n\t\t\t AND due - INTERVAL '7 days' > NOW()\n AND due - INTERVAL '30 days' < NOW() -- Due between 7 and 30 days from now\n\t\t\tORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "due",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "charge_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "subscription_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "7d8de27065490edc560b0c81061295ca82f44546527e1a31c03e5bb7a07c1e63"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE id = $1",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n WHERE\n\t\t\t subscription_id = $1\n\t\t\t AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')\n\t\t\tORDER BY due ASC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4e8e9f9cb42f90cc17702386fdb78385608f19dae9439cb6a860503600127b04"
|
||||
"hash": "91866517bf34fb8bf31a7a49832b18fca60c293ad349eaec07b573d22a28301c"
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "due",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "charge_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "subscription_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9a35729acbba06eafaa205922e4987e082a000ec1b397957650e1332191613ca"
|
||||
}
|
||||
58
apps/labrinth/.sqlx/query-aafa63e08f9d556dcd55c4687f0323aa502ce9feb3f439a614bb7759dcb03b23.json
generated
Normal file
58
apps/labrinth/.sqlx/query-aafa63e08f9d556dcd55c4687f0323aa502ce9feb3f439a614bb7759dcb03b23.json
generated
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n WHERE us.metadata->>'type' = 'pyro' AND us.metadata->>'id' = ANY($1::text[])\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "interval",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "metadata",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "aafa63e08f9d556dcd55c4687f0323aa502ce9feb3f439a614bb7759dcb03b23"
|
||||
}
|
||||
15
apps/labrinth/.sqlx/query-b97afaa6cab8e042ab0117e64b43a8ba3a2c2be461ff9a6309d7e36c3148aeea.json
generated
Normal file
15
apps/labrinth/.sqlx/query-b97afaa6cab8e042ab0117e64b43a8ba3a2c2be461ff9a6309d7e36c3148aeea.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE payouts_values\n SET user_id = $1\n WHERE user_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b97afaa6cab8e042ab0117e64b43a8ba3a2c2be461ff9a6309d7e36c3148aeea"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n tax_amount = EXCLUDED.tax_amount,\n tax_platform_id = EXCLUDED.tax_platform_id,\n tax_last_updated = EXCLUDED.tax_last_updated,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type,\n\t\t\t\t\ttax_drift_loss = EXCLUDED.tax_drift_loss\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c0c70ebc3d59a5ab6a4c81e987df178a7828a45ed3134d3336cb59572f40beab"
|
||||
}
|
||||
14
apps/labrinth/.sqlx/query-ca9b41de4618bcf8ff4f6086f658add5d93855f599a48eeb5f1811f14e7fe610.json
generated
Normal file
14
apps/labrinth/.sqlx/query-ca9b41de4618bcf8ff4f6086f658add5d93855f599a48eeb5f1811f14e7fe610.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM affiliate_codes\n WHERE affiliate = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ca9b41de4618bcf8ff4f6086f658add5d93855f599a48eeb5f1811f14e7fe610"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'open' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n status = 'failed' AND due < NOW() - INTERVAL '30 days'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e2e58113bc3a3db6ffc75b5c5e10acd16403aa0679ef53330f2ce3e8a45f7b9f"
|
||||
"hash": "caf16ed13389398c1ee3456c0e2534310be545293b2693f1c747425295b367a8"
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n \n\t\t\tINNER JOIN users u ON u.id = charges.user_id\n\t\t\tWHERE\n\t\t\t status = 'open'\n\t\t\t AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'\n\t\t\t AND u.email IS NOT NULL\n\t\t\t AND due - INTERVAL '7 days' > NOW()\n AND due - INTERVAL '14 days' < NOW() -- Due between 7 and 14 days from now\n\t\t\tORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC\n\t\t\tFOR NO KEY UPDATE SKIP LOCKED\n\t\t\tLIMIT $1\n\t\t\t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "price_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "currency_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "status",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "due",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "last_attempt",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "charge_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "subscription_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "tax_amount",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "tax_platform_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "subscription_interval?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "payment_platform",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "payment_platform_id?",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "parent_charge_id?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "net?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "tax_last_updated?",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cd18ae8abe81a159a134923957f4cd6d0ba3b1bcbc89df349cf7b5b1897603b8"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\"\n FROM charges\n WHERE parent_charge_id = $1",
|
||||
"query": "\n SELECT\n charges.id, charges.user_id, charges.price_id, charges.amount, charges.currency_code, charges.status, charges.due, charges.last_attempt,\n charges.charge_type, charges.subscription_id, charges.tax_amount, charges.tax_platform_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n charges.subscription_interval AS \"subscription_interval?\",\n charges.payment_platform,\n charges.payment_platform_id AS \"payment_platform_id?\",\n charges.parent_charge_id AS \"parent_charge_id?\",\n charges.net AS \"net?\",\n\t\t\t\tcharges.tax_last_updated AS \"tax_last_updated?\",\n\t\t\t\tcharges.tax_drift_loss AS \"tax_drift_loss?\",\n charges.tax_transaction_version AS \"tax_transaction_version?\",\n charges.tax_platform_accounting_time AS \"tax_platform_accounting_time?\"\n FROM charges\n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -97,6 +97,16 @@
|
||||
"ordinal": 18,
|
||||
"name": "tax_drift_loss?",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "tax_transaction_version?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "tax_platform_accounting_time?",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -123,8 +133,10 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "51c542076b4b3811eb12f051294f55827a27f51e65e668525b8b545f570c0bda"
|
||||
"hash": "f0618e69765ba605b1db7f25a233cbf6f13bddc80a9719cbd401395db718b2f2"
|
||||
}
|
||||
@@ -42,7 +42,6 @@ deadpool-redis.workspace = true
|
||||
dotenvy = { workspace = true }
|
||||
either = { workspace = true }
|
||||
eyre = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
@@ -69,8 +68,8 @@ image = { workspace = true, features = [
|
||||
itertools = { workspace = true }
|
||||
json-patch = { workspace = true }
|
||||
lettre = { workspace = true }
|
||||
maxminddb = { workspace = true }
|
||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||
modrinth-maxmind = { workspace = true }
|
||||
murmur2 = { workspace = true }
|
||||
paste = { workspace = true }
|
||||
path-util = { workspace = true }
|
||||
@@ -91,6 +90,7 @@ rust_decimal = { workspace = true, features = [
|
||||
] }
|
||||
rust_iso3166 = { workspace = true }
|
||||
rust-s3 = { workspace = true }
|
||||
rustls.workspace = true
|
||||
rusty-money = { workspace = true }
|
||||
sentry = { workspace = true }
|
||||
sentry-actix = { workspace = true }
|
||||
@@ -108,9 +108,8 @@ sqlx = { workspace = true, features = [
|
||||
"postgres",
|
||||
"runtime-tokio",
|
||||
"rust_decimal",
|
||||
"tls-rustls-ring",
|
||||
"tls-rustls-aws-lc-rs",
|
||||
] }
|
||||
tar = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "sync"] }
|
||||
tokio-stream = { workspace = true }
|
||||
@@ -121,6 +120,9 @@ tracing-ecs = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
utoipa = { workspace = true }
|
||||
utoipa-actix-web = { workspace = true }
|
||||
utoipa-swagger-ui = { workspace = true }
|
||||
uuid = { workspace = true, features = ["fast-rng", "serde", "v4"] }
|
||||
validator = { workspace = true, features = ["derive"] }
|
||||
webp = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.89.0 AS build
|
||||
FROM rust:1.90.0 AS build
|
||||
|
||||
WORKDIR /usr/src/labrinth
|
||||
COPY . .
|
||||
@@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/usr/src/labrinth/target \
|
||||
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
|
||||
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/modrinth/code
|
||||
LABEL org.opencontainers.image.title=labrinth
|
||||
|
||||
@@ -3,24 +3,22 @@
|
||||
|
||||
-- Inserts 5 dummy users for testing, with slight differences
|
||||
-- 'Friend' and 'enemy' function like 'user', but we can use them to simulate 'other' users that may or may not be able to access certain things
|
||||
-- IDs 1-5, 1-5
|
||||
INSERT INTO users (id, username, email, role) VALUES (1, 'Admin', 'admin@modrinth.com', 'admin');
|
||||
INSERT INTO users (id, username, email, role)
|
||||
VALUES (2, 'Moderator', 'moderator@modrinth.com', 'moderator');
|
||||
INSERT INTO users (id, username, email, role) VALUES (3, 'User', 'user@modrinth.com', 'developer');
|
||||
INSERT INTO users (id, username, email, role)
|
||||
VALUES (4, 'Friend', 'friend@modrinth.com', 'developer');
|
||||
INSERT INTO users (id, username, email, role)
|
||||
VALUES (5, 'Enemy', 'enemy@modrinth.com', 'developer');
|
||||
VALUES ({{user_id::ADMIN}}, 'Admin', 'admin@modrinth.com', 'admin'),
|
||||
({{user_id::MODERATOR}}, 'Moderator', 'moderator@modrinth.com', 'moderator'),
|
||||
({{user_id::USER}}, 'User', 'user@modrinth.com', 'developer'),
|
||||
({{user_id::FRIEND}}, 'Friend', 'friend@modrinth.com', 'developer'),
|
||||
({{user_id::ENEMY}}, 'Enemy', 'enemy@modrinth.com', 'developer');
|
||||
|
||||
-- Full PATs for each user, with different scopes
|
||||
-- These are not legal PATs, as they contain all scopes- they mimic permissions of a logged in user
|
||||
-- IDs: 50-54, o p q r s
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (50, 1, 'admin-pat', 'mrp_patadmin', $1, '2030-08-18 15:48:58.435729+00');
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (51, 2, 'moderator-pat', 'mrp_patmoderator', $1, '2030-08-18 15:48:58.435729+00');
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, 3, 'user-pat', 'mrp_patuser', $1, '2030-08-18 15:48:58.435729+00');
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', $1, '2030-08-18 15:48:58.435729+00');
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00');
|
||||
INSERT INTO pats (id, user_id, name, access_token, scopes, expires)
|
||||
VALUES (50, {{user_id::ADMIN}}, 'admin-pat', '{{pat::ADMIN}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
|
||||
(51, {{user_id::MODERATOR}}, 'moderator-pat', '{{pat::MODERATOR}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
|
||||
(52, {{user_id::USER}}, 'user-pat', '{{pat::USER}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
|
||||
(53, {{user_id::FRIEND}}, 'friend-pat', '{{pat::FRIEND}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00'),
|
||||
(54, {{user_id::ENEMY}}, 'enemy-pat', '{{pat::ENEMY}}', {{all_scopes}}, '2030-08-18 15:48:58.435729+00');
|
||||
|
||||
INSERT INTO loaders (id, loader) VALUES (5, 'fabric');
|
||||
INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (5, 1);
|
||||
@@ -119,7 +117,7 @@ VALUES (
|
||||
1,
|
||||
'oauth_client_alpha',
|
||||
NULL,
|
||||
$1,
|
||||
{{all_scopes}},
|
||||
'4dbff86cc2ca1bae1e16468a05cb9881c97f1753bce3619034898faa1aabe429955a1bf8ec483d7421fe3c1646613a59ed5441fb0f321389f77f48a879c7b1f1',
|
||||
3
|
||||
);
|
||||
@@ -1104,5 +1104,7 @@ COPY public.users (id, github_id, username, email, avatar_url, bio, created, rol
|
||||
103587649610509 \N Default admin user admin@modrinth.invalid https://avatars.githubusercontent.com/u/106493074 $ chmod 777 labrinth 2020-07-18 16:03:00.000000+00 admin 0 0.00000000000000000000 \N \N \N \N \N $argon2i$v=19$m=4096,t=3,p=1$c2FsdEl0V2l0aFNhbHQ$xTGvQNICqetaNA0Wu1GwFmYhQjAreRcjBz6ornhaFXA t \N \N \N \N \N \N https://avatars.githubusercontent.com/u/106493074 t
|
||||
\.
|
||||
|
||||
INSERT INTO sessions (id, session, user_id, created, last_login, expires, refresh_expires, city, country, ip, os, platform, user_agent)
|
||||
VALUES (93083445641246, 'mra_admin', 103587649610509, '2025-10-20 14:58:53.128901+00', '2025-10-20 14:58:53.128901+00', '2025-11-03 14:58:53.128901+00', '2025-12-19 14:58:53.128901+00', '', '', '127.0.0.1', 'Linux', 'Chrome', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36');
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE charges ADD COLUMN tax_transaction_version INTEGER;
|
||||
ALTER TABLE charges ADD COLUMN tax_platform_accounting_time TIMESTAMP WITH TIME ZONE;
|
||||
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE users_subscriptions_credits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
subscription_id BIGINT NOT NULL REFERENCES users_subscriptions (id),
|
||||
user_id BIGINT NOT NULL REFERENCES users (id),
|
||||
creditor_id BIGINT NOT NULL REFERENCES users (id),
|
||||
days INTEGER NOT NULL,
|
||||
previous_due TIMESTAMPTZ NOT NULL,
|
||||
next_due TIMESTAMPTZ NOT NULL,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO notifications_types
|
||||
(name, delivery_priority, expose_in_user_preferences, expose_in_site_notifications)
|
||||
VALUES ('subscription_credited', 1, FALSE, FALSE);
|
||||
|
||||
INSERT INTO users_notifications_preferences (user_id, channel, notification_type, enabled)
|
||||
VALUES (NULL, 'email', 'subscription_credited', TRUE);
|
||||
|
||||
INSERT INTO notifications_templates
|
||||
(channel, notification_type, subject_line, body_fetch_url, plaintext_fallback)
|
||||
VALUES
|
||||
(
|
||||
'email',
|
||||
'subscription_credited',
|
||||
'We’ve added time to your server',
|
||||
'https://modrinth.com/_internal/templates/email/subscription-credited',
|
||||
CONCAT(
|
||||
'Hi {user.name},',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'{credit.header_message}',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'To make up for it, we''ve added {credit.days_formatted} to your {credit.subscription.type} subscription.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Your next charge was scheduled for {credit.previous_due} and will now be on {credit.next_due}.',
|
||||
CHR(10),
|
||||
CHR(10),
|
||||
'Thank you for supporting us,',
|
||||
CHR(10),
|
||||
'The Modrinth Team'
|
||||
)
|
||||
);
|
||||
@@ -27,6 +27,7 @@ pub async fn init_client_with_database(
|
||||
.with_url(dotenvy::var("CLICKHOUSE_URL").unwrap())
|
||||
.with_user(dotenvy::var("CLICKHOUSE_USER").unwrap())
|
||||
.with_password(dotenvy::var("CLICKHOUSE_PASSWORD").unwrap())
|
||||
.with_validation(false)
|
||||
};
|
||||
|
||||
client
|
||||
|
||||
@@ -5,5 +5,6 @@ pub use models::DBImage;
|
||||
pub use models::DBProject;
|
||||
pub use models::DBVersion;
|
||||
pub use postgres_database::{
|
||||
ReadOnlyPgPool, check_for_migrations, connect_all, register_and_set_metrics,
|
||||
MIGRATOR, ReadOnlyPgPool, check_for_migrations, connect_all,
|
||||
register_and_set_metrics,
|
||||
};
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct DBCharge {
|
||||
pub tax_amount: i64,
|
||||
pub tax_platform_id: Option<String>,
|
||||
pub tax_last_updated: Option<DateTime<Utc>>,
|
||||
pub tax_transaction_version: Option<i32>,
|
||||
pub tax_platform_accounting_time: Option<DateTime<Utc>>,
|
||||
|
||||
// Net is always in USD
|
||||
pub net: Option<i64>,
|
||||
@@ -56,6 +58,8 @@ struct ChargeQueryResult {
|
||||
tax_last_updated: Option<DateTime<Utc>>,
|
||||
net: Option<i64>,
|
||||
tax_drift_loss: Option<i64>,
|
||||
tax_transaction_version: Option<i32>,
|
||||
tax_platform_accounting_time: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl TryFrom<ChargeQueryResult> for DBCharge {
|
||||
@@ -84,6 +88,8 @@ impl TryFrom<ChargeQueryResult> for DBCharge {
|
||||
net: r.net,
|
||||
tax_last_updated: r.tax_last_updated,
|
||||
tax_drift_loss: r.tax_drift_loss,
|
||||
tax_transaction_version: r.tax_transaction_version,
|
||||
tax_platform_accounting_time: r.tax_platform_accounting_time,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -103,7 +109,9 @@ macro_rules! select_charges_with_predicate {
|
||||
charges.parent_charge_id AS "parent_charge_id?",
|
||||
charges.net AS "net?",
|
||||
charges.tax_last_updated AS "tax_last_updated?",
|
||||
charges.tax_drift_loss AS "tax_drift_loss?"
|
||||
charges.tax_drift_loss AS "tax_drift_loss?",
|
||||
charges.tax_transaction_version AS "tax_transaction_version?",
|
||||
charges.tax_platform_accounting_time AS "tax_platform_accounting_time?"
|
||||
FROM charges
|
||||
"#
|
||||
+ $predicate,
|
||||
@@ -119,8 +127,8 @@ impl DBCharge {
|
||||
) -> Result<DBChargeId, DatabaseError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||
INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net, tax_amount, tax_platform_id, tax_last_updated, tax_drift_loss, tax_transaction_version, tax_platform_accounting_time)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE
|
||||
SET status = EXCLUDED.status,
|
||||
@@ -139,7 +147,9 @@ impl DBCharge {
|
||||
amount = EXCLUDED.amount,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
charge_type = EXCLUDED.charge_type,
|
||||
tax_drift_loss = EXCLUDED.tax_drift_loss
|
||||
tax_drift_loss = EXCLUDED.tax_drift_loss,
|
||||
tax_transaction_version = EXCLUDED.tax_transaction_version,
|
||||
tax_platform_accounting_time = EXCLUDED.tax_platform_accounting_time
|
||||
"#,
|
||||
self.id.0,
|
||||
self.user_id.0,
|
||||
@@ -160,6 +170,8 @@ impl DBCharge {
|
||||
self.tax_platform_id.as_deref(),
|
||||
self.tax_last_updated,
|
||||
self.tax_drift_loss,
|
||||
self.tax_transaction_version,
|
||||
self.tax_platform_accounting_time,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
@@ -221,7 +233,10 @@ impl DBCharge {
|
||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||
let user_subscription_id = user_subscription_id.0;
|
||||
let res = select_charges_with_predicate!(
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')",
|
||||
"WHERE
|
||||
subscription_id = $1
|
||||
AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')
|
||||
ORDER BY due ASC LIMIT 1",
|
||||
user_subscription_id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
@@ -323,7 +338,7 @@ impl DBCharge {
|
||||
AND COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) < NOW() - INTERVAL '1 day'
|
||||
AND u.email IS NOT NULL
|
||||
AND due - INTERVAL '7 days' > NOW()
|
||||
AND due - INTERVAL '14 days' < NOW() -- Due between 7 and 14 days from now
|
||||
AND due - INTERVAL '30 days' < NOW() -- Due between 7 and 30 days from now
|
||||
ORDER BY COALESCE(tax_last_updated, '-infinity' :: TIMESTAMPTZ) ASC
|
||||
FOR NO KEY UPDATE SKIP LOCKED
|
||||
LIMIT $1
|
||||
|
||||
@@ -35,6 +35,7 @@ pub mod user_subscription_item;
|
||||
pub mod users_compliance;
|
||||
pub mod users_notifications_preferences_item;
|
||||
pub mod users_redeemals;
|
||||
pub mod users_subscriptions_credits;
|
||||
pub mod version_item;
|
||||
|
||||
pub use affiliate_code_item::DBAffiliateCode;
|
||||
|
||||
@@ -753,6 +753,46 @@ impl DBUser {
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE affiliate_codes
|
||||
SET created_by = $1
|
||||
WHERE created_by = $2",
|
||||
deleted_user as DBUserId,
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM affiliate_codes
|
||||
WHERE affiliate = $1",
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE payouts_values
|
||||
SET user_id = $1
|
||||
WHERE user_id = $2",
|
||||
deleted_user as DBUserId,
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM payouts_values_notifications
|
||||
WHERE user_id = $1",
|
||||
id as DBUserId,
|
||||
)
|
||||
.execute(&mut **transaction)
|
||||
.await?;
|
||||
|
||||
let open_subscriptions =
|
||||
DBUserSubscription::get_all_user(id, &mut **transaction)
|
||||
.await?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user