Migrate to Turborepo (#1251)

This commit is contained in:
Evan Song
2024-07-04 21:46:29 -07:00
committed by GitHub
parent 6fa1acc461
commit 0f2ddb452c
811 changed files with 5623 additions and 7832 deletions

View File

@@ -0,0 +1,24 @@
<template>
<div>
<ChartDisplay :projects="projects ?? undefined" :personal="true" />
</div>
</template>
<script setup>
import ChartDisplay from '~/components/ui/charts/ChartDisplay.vue'
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Analytics - Modrinth',
})
const auth = await useAuth()
const id = auth.value?.user?.id
const { data: projects } = await useAsyncData(`user/${id}/projects`, () =>
useBaseFetch(`user/${id}/projects`)
)
</script>

View File

@@ -0,0 +1,235 @@
<template>
<div class="universal-card">
<CollectionCreateModal ref="modal_creation" />
<h2>{{ formatMessage(commonMessages.collectionsLabel) }}</h2>
<div class="search-row">
<div class="iconified-input">
<label for="search-input" hidden>{{ formatMessage(messages.searchInputLabel) }}</label>
<SearchIcon />
<input id="search-input" v-model="filterQuery" type="text" />
<Button v-if="filterQuery" class="r-btn" @click="() => (filterQuery = '')">
<XIcon />
</Button>
</div>
<Button color="primary" @click="$refs.modal_creation.show()">
<PlusIcon /> {{ formatMessage(messages.createNewButton) }}
</Button>
</div>
<div class="collections-grid">
<nuxt-link
v-if="'followed projects'.includes(filterQuery)"
:to="`/collection/following`"
class="universal-card recessed collection"
>
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
<div class="details">
<span class="title">{{ formatMessage(commonMessages.followedProjectsLabel) }}</span>
<span class="description">
{{ formatMessage(messages.followingCollectionDescription) }}
</span>
<div class="stat-bar">
<div class="stats">
<BoxIcon />
{{
formatMessage(messages.projectsCountLabel, {
count: formatCompactNumber(user.follows.length),
})
}}
</div>
<div class="stats">
<LockIcon /> <span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</div>
</div>
</div>
</nuxt-link>
<nuxt-link
v-for="collection in orderedCollections"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="universal-card recessed collection"
>
<Avatar :src="collection.icon_url" class="icon" />
<div class="details">
<span class="title">{{ collection.name }}</span>
<span class="description">
{{ collection.description }}
</span>
<div class="stat-bar">
<div class="stats">
<BoxIcon />
{{
formatMessage(messages.projectsCountLabel, {
count: formatCompactNumber(collection.projects?.length || 0),
})
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<WorldIcon />
<span> {{ formatMessage(commonMessages.publicLabel) }} </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
<span> {{ formatMessage(commonMessages.unlistedLabel) }} </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
<span> {{ formatMessage(commonMessages.privateLabel) }} </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
<span> {{ formatMessage(commonMessages.rejectedLabel) }} </span>
</template>
</div>
</div>
</div>
</nuxt-link>
</div>
</div>
</template>
<script setup>
import { BoxIcon, SearchIcon, XIcon, PlusIcon, LinkIcon, LockIcon } from '@modrinth/assets'
import { Avatar, Button } from '@modrinth/ui'
import WorldIcon from '~/assets/images/utils/world.svg?component'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
const { formatMessage } = useVIntl()
const formatCompactNumber = useCompactNumber()
const messages = defineMessages({
createNewButton: {
id: 'dashboard.collections.button.create-new',
defaultMessage: 'Create new',
},
collectionsLongTitle: {
id: 'dashboard.collections.long-title',
defaultMessage: 'Your collections',
},
followingCollectionDescription: {
id: 'collection.description.following',
defaultMessage: "Auto-generated collection of all the projects you're following.",
},
projectsCountLabel: {
id: 'dashboard.collections.label.projects-count',
defaultMessage: '{count, plural, one {{count} project} other {{count} projects}}',
},
searchInputLabel: {
id: 'dashboard.collections.label.search-input',
defaultMessage: 'Search your collections',
},
})
definePageMeta({
middleware: 'auth',
})
useHead({
title: () => `${formatMessage(messages.collectionsLongTitle)} - Modrinth`,
})
const user = await useUser()
const auth = await useAuth()
if (process.client) {
await initUserFollows()
}
const filterQuery = ref('')
const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/collections`, () =>
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 })
)
const orderedCollections = computed(() => {
if (!collections.value) return []
return collections.value
.sort((a, b) => {
const aUpdated = new Date(a.updated)
const bUpdated = new Date(b.updated)
return bUpdated - aUpdated
})
.filter((collection) => {
if (!filterQuery.value) return true
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
})
})
</script>
<style lang="scss">
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@media screen and (max-width: 800px) {
grid-template-columns: repeat(1, 1fr);
}
gap: var(--gap-md);
.collection {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
}
}
}
.search-row {
margin-bottom: var(--gap-lg);
display: flex;
align-items: center;
gap: var(--gap-lg) var(--gap-sm);
flex-wrap: wrap;
justify-content: center;
.iconified-input {
flex-grow: 1;
input {
height: 2rem;
}
}
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="dashboard-overview">
<section class="universal-card dashboard-header">
<Avatar :src="auth.user.avatar_url" size="md" circle :alt="auth.user.username" />
<div class="username">
<h1>
{{ auth.user.username }}
</h1>
<NuxtLink class="goto-link" :to="`/user/${auth.user.username}`">
Visit your profile
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
</div>
</section>
<div class="dashboard-notifications">
<section class="universal-card">
<div class="header__row">
<h2 class="header__title">Notifications</h2>
<nuxt-link
v-if="notifications.length > 0"
class="goto-link"
to="/dashboard/notifications"
>
See all <ChevronRightIcon />
</nuxt-link>
</div>
<template v-if="notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
compact
@update:notifications="() => refresh()"
/>
<nuxt-link
v-if="extraNotifs > 0"
class="goto-link view-more-notifs"
to="/dashboard/notifications"
>
View {{ extraNotifs }} more notification{{ extraNotifs === 1 ? '' : 's' }}
<ChevronRightIcon />
</nuxt-link>
</template>
<div v-else class="universal-body">
<p>You have no unread notifications.</p>
<nuxt-link class="iconified-button" to="/dashboard/notifications/history">
<HistoryIcon /> View notification history
</nuxt-link>
</div>
</section>
</div>
<div class="dashboard-analytics">
<section class="universal-card">
<h2>Analytics</h2>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Total downloads</div>
<div class="value">
{{ $formatNumber(projects.reduce((agg, x) => agg + x.downloads, 0)) }}
</div>
<span
>from
{{ downloadsProjectCount }}
project{{ downloadsProjectCount === 1 ? '' : 's' }}</span
>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
<!-- <ChevronRightIcon-->
<!-- class="featured-header-chevron"-->
<!-- aria-hidden="true"-->
<!-- /></NuxtLink>-->
</div>
<div class="grid-display__item">
<div class="label">Total followers</div>
<div class="value">
{{ $formatNumber(projects.reduce((agg, x) => agg + x.followers, 0)) }}
</div>
<span>
<span
>from {{ followersProjectCount }} project{{
followersProjectCount === 1 ? '' : 's'
}}</span
></span
>
</div>
<div class="grid-display__item">
<div class="label">Current balance</div>
<div class="value">
{{ $formatMoney(auth.user.payout_data.balance, true) }}
</div>
<NuxtLink
v-if="auth.user.payout_data.balance > 0"
class="goto-link"
to="/dashboard/revenue"
>
Withdraw earnings
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?component'
import HistoryIcon from '~/assets/images/utils/history.svg?component'
import Avatar from '~/components/ui/Avatar.vue'
import NotificationItem from '~/components/ui/NotificationItem.vue'
import { fetchExtraNotificationData, groupNotifications } from '~/helpers/notifications.js'
useHead({
title: 'Dashboard - Modrinth',
})
const auth = await useAuth()
const [{ data: projects }] = await Promise.all([
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
useBaseFetch(`user/${auth.value.user.id}/projects`)
),
])
const downloadsProjectCount = computed(
() => projects.value.filter((project) => project.downloads > 0).length
)
const followersProjectCount = computed(
() => projects.value.filter((project) => project.followers > 0).length
)
const { data, refresh } = await useAsyncData(async () => {
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
const filteredNotifications = notifications.filter((notif) => !notif.read)
const slice = filteredNotifications.slice(0, 30) // send first 30 notifs to be grouped before trimming to 3
return fetchExtraNotificationData(slice).then((notifications) => {
notifications = groupNotifications(notifications).slice(0, 3)
return { notifications, extraNotifs: filteredNotifications.length - slice.length }
})
})
const notifications = computed(() => {
if (data.value === null) {
return []
}
return data.value.notifications
})
const extraNotifs = computed(() => (data.value ? data.value.extraNotifs : 0))
</script>
<style lang="scss">
.dashboard-overview {
display: grid;
grid-template:
'header header'
'notifications analytics' / 1fr auto;
gap: var(--spacing-card-md);
> .universal-card {
margin: 0;
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
}
}
.dashboard-notifications {
grid-area: notifications;
//display: flex;
//flex-direction: column;
//gap: var(--spacing-card-md);
a.view-more-notifs {
display: flex;
width: fit-content;
margin-left: auto;
}
}
.dashboard-analytics {
grid-area: analytics;
}
.dashboard-header {
display: flex;
gap: var(--spacing-card-bg);
grid-area: header;
.username {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
justify-content: center;
word-break: break-word;
h1 {
margin: 0;
}
}
@media screen and (max-width: 650px) {
.avatar {
width: 4rem;
height: 4rem;
}
.username {
h1 {
font-size: var(--font-size-xl);
}
}
}
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="history"
current-title="History"
:link-stack="[{ href: `/dashboard/notifications`, label: 'Notifications' }]"
/>
<div class="header__row">
<div class="header__title">
<h2 v-if="history">Notification history</h2>
<h2 v-else>Notifications</h2>
</div>
<template v-if="!history">
<Button v-if="hasRead" @click="updateRoute()"> <HistoryIcon /> View history </Button>
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
<CheckCheckIcon /> Mark all as read
</Button>
</template>
</div>
<Chips
v-if="notifTypes.length > 1"
v-model="selectedType"
:items="notifTypes"
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')"
:capitalize="false"
/>
<p v-if="pending">Loading notifications...</p>
<template v-else-if="error">
<p>Error loading notifications:</p>
<pre>
{{ error }}
</pre>
</template>
<template v-else-if="notifications && notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
:key="notification.id"
:notifications="notifications"
class="universal-card recessed"
:notification="notification"
:auth="auth"
raised
@update:notifications="() => refresh()"
/>
</template>
<p v-else>You don't have any unread notifications.</p>
<Pagination :page="page" :count="pages" @switch-page="changePage" />
</section>
</div>
</template>
<script setup>
import { Button } from '@modrinth/ui'
import { HistoryIcon } from '@modrinth/assets'
import {
fetchExtraNotificationData,
groupNotifications,
markAsRead,
} from '~/helpers/notifications.js'
import NotificationItem from '~/components/ui/NotificationItem.vue'
import Chips from '~/components/ui/Chips.vue'
import CheckCheckIcon from '~/assets/images/utils/check-check.svg?component'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import Pagination from '~/components/ui/Pagination.vue'
useHead({
title: 'Notifications - Modrinth',
})
const auth = await useAuth()
const route = useNativeRoute()
const router = useNativeRouter()
const history = computed(() => {
return route.name === 'dashboard-notifications-history'
})
const selectedType = ref('all')
const page = ref(1)
const perPage = ref(50)
const { data, pending, error, refresh } = await useAsyncData(
async () => {
const pageNum = page.value - 1
const notifications = await useBaseFetch(`user/${auth.value.user.id}/notifications`)
const showRead = history.value
const hasRead = notifications.some((notif) => notif.read)
const types = [
...new Set(
notifications
.filter((notification) => {
return showRead || !notification.read
})
.map((notification) => notification.type)
),
]
const filteredNotifications = notifications.filter(
(notification) =>
(selectedType.value === 'all' || notification.type === selectedType.value) &&
(showRead || !notification.read)
)
const pages = Math.ceil(filteredNotifications.length / perPage.value)
return fetchExtraNotificationData(
filteredNotifications.slice(pageNum * perPage.value, perPage.value + pageNum * perPage.value)
).then((notifications) => {
return {
notifications,
types: types.length > 1 ? ['all', ...types] : types,
pages,
hasRead,
}
})
},
{ watch: [page, history, selectedType] }
)
const notifications = computed(() => {
if (data.value === null) {
return []
}
return groupNotifications(data.value.notifications, history.value)
})
const notifTypes = computed(() => data.value.types)
const pages = computed(() => data.value.pages)
const hasRead = computed(() => data.value.hasRead)
function updateRoute() {
if (history.value) {
router.push('/dashboard/notifications')
} else {
router.push('/dashboard/notifications/history')
}
selectedType.value = 'all'
page.value = 1
}
async function readAll() {
const ids = notifications.value.flatMap((notification) => [
notification.id,
...(notification.grouped_notifs ? notification.grouped_notifs.map((notif) => notif.id) : []),
])
const updateNotifs = await markAsRead(ids)
allNotifs.value = updateNotifs(allNotifs.value)
}
function changePage(newPage) {
page.value = newPage
if (process.client) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
</script>
<style lang="scss" scoped>
.read-toggle-input {
display: flex;
align-items: center;
gap: var(--spacing-card-md);
.label__title {
margin: 0;
}
}
.header__title {
h2 {
margin: 0 auto 0 0;
}
}
</style>

View File

@@ -0,0 +1 @@
<template><div /></template>

View File

@@ -0,0 +1,204 @@
<template>
<div>
<OrganizationCreateModal ref="createOrgModal" />
<section class="universal-card">
<div class="header__row">
<h2 class="header__title">Organizations</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="openCreateOrgModal">
<PlusIcon />
Create organization
</button>
</div>
</div>
<template v-if="orgs?.length > 0">
<div class="orgs-grid">
<nuxt-link
v-for="org in orgs"
:key="org.id"
:to="`/organization/${org.slug}`"
class="universal-card button-base recessed org"
:class="{ 'is-disabled': onlyAcceptedMembers(org.members).length === 0 }"
>
<Avatar :src="org.icon_url" :alt="org.name" class="icon" />
<div class="details">
<div class="title">
{{ org.name }}
</div>
<div class="description">
{{ org.description }}
</div>
<span class="stat-bar">
<div class="stats">
<UsersIcon />
<span>
{{ onlyAcceptedMembers(org.members).length }} member<template
v-if="onlyAcceptedMembers(org.members).length !== 1"
>s</template
>
</span>
</div>
</span>
</div>
</nuxt-link>
</div>
</template>
<template v-else> Make an organization! </template>
</section>
</div>
</template>
<script setup>
import { PlusIcon, UsersIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import { useAuth } from '~/composables/auth.js'
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
const createOrgModal = ref(null)
const auth = await useAuth()
const uid = computed(() => auth.value.user?.id || null)
const { data: orgs, error } = useAsyncData('organizations', () => {
if (!uid.value) return Promise.resolve(null)
return useBaseFetch('user/' + uid.value + '/organizations', {
apiVersion: 3,
})
})
const onlyAcceptedMembers = (members) => members.filter((member) => member?.accepted)
if (error.value) {
createError({
statusCode: 500,
message: 'Failed to fetch organizations',
})
}
const openCreateOrgModal = () => {
createOrgModal.value?.show()
}
</script>
<style scoped lang="scss">
.project-meta-item {
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: var(--spacing-card-sm);
.project-title {
margin-bottom: var(--spacing-card-sm);
}
}
.orgs-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@media screen and (max-width: 750px) {
grid-template-columns: repeat(1, 1fr);
}
gap: var(--gap-md);
.org {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.icon {
width: 100% !important;
height: min(6rem, 20vw) !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
}
}
}
.grid-table {
display: grid;
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding: var(--spacing-card-sm);
// Left edge of table
&:first-child {
padding-left: var(--spacing-card-bg);
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
}
.hover-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,674 @@
<template>
<div>
<Modal ref="editLinksModal" header="Edit links">
<div class="universal-modal links-modal">
<p>
Any links you specify below will be overwritten on each of the selected projects. Any you
leave blank will be ignored. You can clear a link from all selected projects using the
trash can button.
</p>
<section class="links">
<label
for="issue-tracker-input"
title="A place for users to report bugs, issues, and concerns about your project."
>
<span class="label__title">Issue tracker</span>
</label>
<div class="input-group shrink-first">
<input
id="issue-tracker-input"
v-model="editLinks.issues.val"
:disabled="editLinks.issues.clear"
type="url"
:placeholder="
editLinks.issues.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
"
maxlength="2048"
/>
<button
v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button"
:data-active="editLinks.issues.clear"
@click="editLinks.issues.clear = !editLinks.issues.clear"
>
<TrashIcon />
</button>
</div>
<label
for="source-code-input"
title="A page/repository containing the source code for your project"
>
<span class="label__title">Source code</span>
</label>
<div class="input-group shrink-first">
<input
id="source-code-input"
v-model="editLinks.source.val"
:disabled="editLinks.source.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.source.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
"
/>
<button
v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button"
:data-active="editLinks.source.clear"
@click="editLinks.source.clear = !editLinks.source.clear"
>
<TrashIcon />
</button>
</div>
<label
for="wiki-page-input"
title="A page containing information, documentation, and help for the project."
>
<span class="label__title">Wiki page</span>
</label>
<div class="input-group shrink-first">
<input
id="wiki-page-input"
v-model="editLinks.wiki.val"
:disabled="editLinks.wiki.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.wiki.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
"
/>
<button
v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button"
:data-active="editLinks.wiki.clear"
@click="editLinks.wiki.clear = !editLinks.wiki.clear"
>
<TrashIcon />
</button>
</div>
<label for="discord-invite-input" title="An invitation link to your Discord server.">
<span class="label__title">Discord invite</span>
</label>
<div class="input-group shrink-first">
<input
id="discord-invite-input"
v-model="editLinks.discord.val"
:disabled="editLinks.discord.clear"
type="url"
maxlength="2048"
:placeholder="
editLinks.discord.clear
? 'Existing link will be cleared'
: 'Enter a valid Discord invite URL'
"
/>
<button
v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button"
:data-active="editLinks.discord.clear"
@click="editLinks.discord.clear = !editLinks.discord.clear"
>
<TrashIcon />
</button>
</div>
</section>
<p>
Changes will be applied to
<strong>{{ selectedProjects.length }}</strong> project{{
selectedProjects.length > 1 ? 's' : ''
}}.
</p>
<ul>
<li
v-for="project in selectedProjects.slice(
0,
editLinks.showAffected ? selectedProjects.length : 3
)"
:key="project.id"
>
{{ project.title }}
</li>
<li v-if="!editLinks.showAffected && selectedProjects.length > 3">
<strong>and {{ selectedProjects.length - 3 }} more...</strong>
</li>
</ul>
<Checkbox
v-if="selectedProjects.length > 3"
v-model="editLinks.showAffected"
:label="editLinks.showAffected ? 'Less' : 'More'"
description="Show all loaders"
:border="false"
:collapsing-toggle-style="true"
/>
<div class="push-right input-group">
<button class="iconified-button" @click="$refs.editLinksModal.hide()">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="bulkEditLinks()">
<SaveIcon />
Save changes
</button>
</div>
</div>
</Modal>
<ModalCreation ref="modal_creation" />
<section class="universal-card">
<div class="header__row">
<h2 class="header__title">Projects</h2>
<div class="input-group">
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
</div>
</div>
<p v-if="projects.length < 1">
You don't have any projects yet. Click the green button above to begin.
</p>
<template v-else>
<p>You can edit multiple projects at once by selecting them below.</p>
<div class="input-group">
<button
class="iconified-button"
:disabled="selectedProjects.length === 0"
@click="$refs.editLinksModal.show()"
>
<EditIcon />
Edit links
</button>
<div class="push-right">
<div class="labeled-control-row">
Sort by
<Multiselect
v-model="sortBy"
:searchable="false"
class="small-select"
:options="['Name', 'Status', 'Type']"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="projects = updateSort(projects, sortBy, descending)"
/>
<button
v-tooltip="descending ? 'Descending' : 'Ascending'"
class="square-button"
@click="updateDescending()"
>
<DescendingIcon v-if="descending" />
<AscendingIcon v-else />
</button>
</div>
</div>
</div>
<div class="grid-table">
<div class="grid-table__row grid-table__header">
<div>
<Checkbox
:model-value="selectedProjects === projects"
@update:model-value="
selectedProjects === projects
? (selectedProjects = [])
: (selectedProjects = projects)
"
/>
</div>
<div>Icon</div>
<div>Name</div>
<div>ID</div>
<div>Type</div>
<div>Status</div>
<div />
</div>
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
<div>
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div>
<nuxt-link
tabindex="-1"
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`"
>
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.title"
no-shadow
/>
</nuxt-link>
</div>
<div>
<span class="project-title">
<IssuesIcon
v-if="project.moderator_message"
aria-label="Project has a message from the moderators. View the project to see more."
/>
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`"
>
{{ project.title }}
</nuxt-link>
</span>
</div>
<div>
<CopyCode :text="project.id" />
</div>
<div>
{{ $formatProjectType($getProjectTypeForUrl(project.project_type, project.loaders)) }}
</div>
<div>
<Badge v-if="project.status" :type="project.status" class="status" />
</div>
<div>
<nuxt-link
class="square-button"
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
>
<SettingsIcon />
</nuxt-link>
</div>
</div>
</div>
</template>
</section>
</div>
</template>
<script>
import { Multiselect } from 'vue-multiselect'
import Badge from '~/components/ui/Badge.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
import Modal from '~/components/ui/Modal.vue'
import Avatar from '~/components/ui/Avatar.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import SettingsIcon from '~/assets/images/utils/settings.svg?component'
import TrashIcon from '~/assets/images/utils/trash.svg?component'
import IssuesIcon from '~/assets/images/utils/issues.svg?component'
import PlusIcon from '~/assets/images/utils/plus.svg?component'
import CrossIcon from '~/assets/images/utils/x.svg?component'
import EditIcon from '~/assets/images/utils/edit.svg?component'
import SaveIcon from '~/assets/images/utils/save.svg?component'
import AscendingIcon from '~/assets/images/utils/sort-asc.svg?component'
import DescendingIcon from '~/assets/images/utils/sort-desc.svg?component'
export default defineNuxtComponent({
components: {
Avatar,
Badge,
SettingsIcon,
TrashIcon,
Checkbox,
IssuesIcon,
PlusIcon,
CrossIcon,
EditIcon,
SaveIcon,
Modal,
ModalCreation,
Multiselect,
CopyCode,
AscendingIcon,
DescendingIcon,
},
async setup() {
const { formatMessage } = useVIntl()
const user = await useUser()
await initUserProjects()
return { formatMessage, user: ref(user) }
},
data() {
return {
projects: this.updateSort(this.user.projects, 'Name'),
versions: [],
selectedProjects: [],
sortBy: 'Name',
descending: false,
editLinks: {
showAffected: false,
source: {
val: '',
clear: false,
},
discord: {
val: '',
clear: false,
},
wiki: {
val: '',
clear: false,
},
issues: {
val: '',
clear: false,
},
},
}
},
head: {
title: 'Projects - Modrinth',
},
created() {
this.UPLOAD_VERSION = 1 << 0
this.DELETE_VERSION = 1 << 1
this.EDIT_DETAILS = 1 << 2
this.EDIT_BODY = 1 << 3
this.MANAGE_INVITES = 1 << 4
this.REMOVE_MEMBER = 1 << 5
this.EDIT_MEMBER = 1 << 6
this.DELETE_PROJECT = 1 << 7
},
methods: {
updateDescending() {
this.descending = !this.descending
this.projects = this.updateSort(this.projects, this.sortBy, this.descending)
},
updateSort(projects, sort, descending) {
let sortedArray = projects
switch (sort) {
case 'Name':
sortedArray = projects.slice().sort((a, b) => {
return a.title.localeCompare(b.title)
})
break
case 'Status':
sortedArray = projects.slice().sort((a, b) => {
if (a.status < b.status) {
return -1
}
if (a.status > b.status) {
return 1
}
return 0
})
break
case 'Type':
sortedArray = projects.slice().sort((a, b) => {
if (a.project_type < b.project_type) {
return -1
}
if (a.project_type > b.project_type) {
return 1
}
return 0
})
break
default:
break
}
if (descending) {
sortedArray = sortedArray.reverse()
}
return sortedArray
},
async bulkEditLinks() {
try {
const baseData = {
issues_url: this.editLinks.issues.clear ? null : this.editLinks.issues.val.trim(),
source_url: this.editLinks.source.clear ? null : this.editLinks.source.val.trim(),
wiki_url: this.editLinks.wiki.clear ? null : this.editLinks.wiki.val.trim(),
discord_url: this.editLinks.discord.clear ? null : this.editLinks.discord.val.trim(),
}
if (!baseData.issues_url?.length ?? 1 > 0) {
delete baseData.issues_url
}
if (!baseData.source_url?.length ?? 1 > 0) {
delete baseData.source_url
}
if (!baseData.wiki_url?.length ?? 1 > 0) {
delete baseData.wiki_url
}
if (!baseData.discord_url?.length ?? 1 > 0) {
delete baseData.discord_url
}
await useBaseFetch(
`projects?ids=${JSON.stringify(this.selectedProjects.map((x) => x.id))}`,
{
method: 'PATCH',
body: baseData,
}
)
this.$refs.editLinksModal.hide()
this.$notify({
group: 'main',
title: 'Success',
text: "Bulk edited selected project's links.",
type: 'success',
})
this.selectedProjects = []
this.editLinks.issues.val = ''
this.editLinks.source.val = ''
this.editLinks.wiki.val = ''
this.editLinks.discord.val = ''
this.editLinks.issues.clear = false
this.editLinks.source.clear = false
this.editLinks.wiki.clear = false
this.editLinks.discord.clear = false
} catch (e) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: e,
type: 'error',
})
}
},
},
})
</script>
<style lang="scss" scoped>
.grid-table {
display: grid;
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 1fr) minmax(min-content, 1fr) min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
outline: 1px solid transparent;
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-card-sm);
// Left edge of table
&:first-child {
padding-left: var(--spacing-card-bg);
}
// Right edge of table
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.grid-table__row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id status settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: status;
padding-top: 0;
}
:nth-child(7) {
grid-area: settings;
}
}
.grid-table__header {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5),
:nth-child(6) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.grid-table__row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
.grid-table__header {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
}
.project-title {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
svg {
color: var(--color-orange);
}
}
.status {
margin-top: var(--spacing-card-xs);
}
.hover-link:hover {
text-decoration: underline;
}
.labeled-control-row {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
align-items: center;
gap: var(--spacing-card-md);
white-space: nowrap;
}
.small-select {
width: -moz-fit-content;
width: fit-content;
}
.label-button[data-active='true'] {
--background-color: var(--color-red);
--text-color: var(--color-brand-inverted);
}
.links-modal {
.links {
display: grid;
gap: var(--spacing-card-sm);
grid-template-columns: 1fr 2fr;
.input-group {
flex-wrap: nowrap;
}
@media screen and (max-width: 530px) {
grid-template-columns: 1fr;
.input-group {
flex-wrap: wrap;
}
}
}
ul {
margin: 0 0 var(--spacing-card-sm) 0;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<ReportView
:auth="auth"
:report-id="route.params.id"
:breadcrumbs-stack="[{ href: '/dashboard/reports', label: 'Active reports' }]"
/>
</template>
<script setup>
import ReportView from '~/components/ui/report/ReportView.vue'
const route = useNativeRoute()
const auth = await useAuth()
useHead({
title: `Report ${route.params.id} - Modrinth`,
})
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div>
<section class="universal-card">
<h2>Reports</h2>
<ReportsList :auth="auth" />
</section>
</div>
</template>
<script setup>
import ReportsList from '~/components/ui/report/ReportsList.vue'
const auth = await useAuth()
useHead({
title: 'Active reports - Modrinth',
})
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div>
<section class="universal-card">
<h2>Revenue</h2>
<div v-if="auth.user.payout_data.balance >= minWithdraw">
<p>
You have
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong>
available to withdraw.
</p>
</div>
<p v-else>
You have made
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
</p>
<div class="input-group">
<nuxt-link
v-if="auth.user.payout_data.balance >= minWithdraw"
class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon /> View transfer history
</NuxtLink>
</div>
<p>
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
</p>
</section>
<section class="universal-card">
<h2>Payout methods</h2>
<h3>PayPal</h3>
<template v-if="auth.user.auth_providers.includes('paypal')">
<p>
Your PayPal {{ auth.user.payout_data.paypal_country }} account is currently connected with
email
{{ auth.user.payout_data.paypal_address }}
</p>
<button class="btn" @click="removeAuthProvider('paypal')">
<XIcon /> Disconnect account
</button>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a class="btn" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
<PayPalIcon />
Sign in with PayPal
</a>
</template>
<h3>Tremendous</h3>
<p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
<label class="hidden" for="venmo">Venmo address</label>
<input
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
type="search"
name="search"
placeholder="@example"
autocomplete="off"
/>
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
</section>
</div>
</template>
<script setup>
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from '@modrinth/assets'
const auth = await useAuth()
const minWithdraw = ref(0.01)
async function updateVenmo() {
startLoading()
try {
const data = {
venmo_handle: auth.value.user.payout_data.venmo_handle ?? null,
}
await useBaseFetch(`user/${auth.value.user.id}`, {
method: 'PATCH',
body: data,
apiVersion: 3,
})
await useAuth(auth.value.token)
} catch (err) {
const data = useNuxtApp()
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
strong {
color: var(--color-text-dark);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div>
<section class="universal-card payout-history">
<Breadcrumbs
current-title="Transfer history"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Transfer history</h2>
<p>All of your withdrawals from your Modrinth balance will be listed here:</p>
<div class="input-group">
<DropdownSelect
v-model="selectedYear"
:options="years"
:display-name="(x) => (x === 'all' ? 'All years' : x)"
name="Year filter"
/>
<DropdownSelect
v-model="selectedMethod"
:options="methods"
:display-name="
(x) => (x === 'all' ? 'Any method' : x === 'paypal' ? 'PayPal' : capitalizeString(x))
"
name="Method filter"
/>
</div>
<p>
{{
selectedYear !== 'all'
? selectedMethod !== 'all'
? formatMessage(messages.transfersTotalYearMethod, {
amount: $formatMoney(totalAmount),
year: selectedYear,
method: selectedMethod,
})
: formatMessage(messages.transfersTotalYear, {
amount: $formatMoney(totalAmount),
year: selectedYear,
})
: selectedMethod !== 'all'
? formatMessage(messages.transfersTotalMethod, {
amount: $formatMoney(totalAmount),
method: selectedMethod,
})
: formatMessage(messages.transfersTotal, { amount: $formatMoney(totalAmount) })
}}
</p>
<div
v-for="payout in filteredPayouts"
:key="payout.id"
class="universal-card recessed payout"
>
<div class="platform">
<PayPalIcon v-if="payout.method === 'paypal'" />
<TremendousIcon v-else-if="payout.method === 'tremendous'" />
<VenmoIcon v-else-if="payout.method === 'venmo'" />
<UnknownIcon v-else />
</div>
<div class="payout-info">
<div>
<strong>
{{ $dayjs(payout.created).format('MMMM D, YYYY [at] h:mm A') }}
</strong>
</div>
<div>
<span class="amount">{{ $formatMoney(payout.amount) }}</span>
<template v-if="payout.fee">⋅ Fee {{ $formatMoney(payout.fee) }}</template>
</div>
<div class="payout-status">
<span>
<Badge v-if="payout.status === 'success'" color="green" type="Success" />
<Badge v-else-if="payout.status === 'cancelling'" color="yellow" type="Cancelling" />
<Badge v-else-if="payout.status === 'cancelled'" color="red" type="Cancelled" />
<Badge v-else-if="payout.status === 'failed'" color="red" type="Failed" />
<Badge v-else-if="payout.status === 'in-transit'" color="yellow" type="In transit" />
<Badge v-else :type="payout.status" />
</span>
<template v-if="payout.method">
<span>⋅</span>
<span>{{ $formatWallet(payout.method) }} ({{ payout.method_address }})</span>
</template>
</div>
</div>
<div class="input-group">
<button
v-if="payout.status === 'in-transit'"
class="iconified-button raised-button"
@click="cancelPayout(payout.id)"
>
<XIcon /> Cancel payment
</button>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { DropdownSelect } from '@modrinth/ui'
import { XIcon, PayPalIcon, UnknownIcon } from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import { Badge, Breadcrumbs } from '@modrinth/ui'
import dayjs from 'dayjs'
import TremendousIcon from '~/assets/images/external/tremendous.svg?component'
import VenmoIcon from '~/assets/images/external/venmo-small.svg?component'
const vintl = useVIntl()
const { formatMessage } = vintl
useHead({
title: 'Transfer history - Modrinth',
})
const data = await useNuxtApp()
const auth = await useAuth()
const { data: payouts, refresh } = await useAsyncData(`payout`, () =>
useBaseFetch(`payout`, {
apiVersion: 3,
})
)
const sortedPayouts = computed(() =>
payouts.value.sort((a, b) => dayjs(b.created) - dayjs(a.created))
)
const years = computed(() => {
const values = sortedPayouts.value.map((x) => dayjs(x.created).year())
return ['all', ...new Set(values)]
})
const selectedYear = ref('all')
const methods = computed(() => {
const values = sortedPayouts.value.filter((x) => x.method).map((x) => x.method)
return ['all', ...new Set(values)]
})
const selectedMethod = ref('all')
const filteredPayouts = computed(() =>
sortedPayouts.value
.filter((x) => selectedYear.value === 'all' || dayjs(x.created).year() === selectedYear.value)
.filter((x) => selectedMethod.value === 'all' || x.method === selectedMethod.value)
)
const totalAmount = computed(() =>
filteredPayouts.value.reduce((sum, payout) => sum + payout.amount, 0)
)
async function cancelPayout(id) {
startLoading()
try {
await useBaseFetch(`payout/${id}`, {
method: 'DELETE',
apiVersion: 3,
})
await refresh()
await useAuth(auth.value.token)
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
const messages = defineMessages({
transfersTotal: {
id: 'revenue.transfers.total',
defaultMessage: 'You have withdrawn {amount} in total.',
},
transfersTotalYear: {
id: 'revenue.transfers.total.year',
defaultMessage: 'You have withdrawn {amount} in {year}.',
},
transfersTotalMethod: {
id: 'revenue.transfers.total.method',
defaultMessage: 'You have withdrawn {amount} through {method}.',
},
transfersTotalYearMethod: {
id: 'revenue.transfers.total.year_method',
defaultMessage: 'You have withdrawn {amount} in {year} through {method}.',
},
})
</script>
<style lang="scss" scoped>
.payout {
display: flex;
flex-direction: column;
gap: 0.5rem;
.platform {
display: flex;
padding: 0.75rem;
background-color: var(--color-raised-bg);
width: fit-content;
height: fit-content;
border-radius: 20rem;
svg {
width: 2rem;
height: 2rem;
}
}
.payout-status {
display: flex;
gap: 0.5ch;
}
.amount {
color: var(--color-heading);
font-weight: 500;
}
@media screen and (min-width: 800px) {
flex-direction: row;
align-items: center;
.input-group {
margin-left: auto;
}
}
}
</style>

View File

@@ -0,0 +1,531 @@
<template>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Withdraw</h2>
<h3>Region</h3>
<Multiselect
id="country-multiselect"
v-model="country"
class="country-multiselect"
placeholder="Select country..."
track-by="id"
label="name"
:options="countries"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<h3>Withdraw method</h3>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="search"
name="search"
placeholder="Search options..."
autocomplete="off"
/>
</div>
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods.filter((x) =>
x.name.toLowerCase().includes(search.toLowerCase())
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"
@click="() => (selectedMethodId = method.id)"
>
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
<template v-if="method.image_url && method.name !== 'ACH'">
<div class="preview-badges">
<span class="badge">
{{
getRangeOfMethod(method)
.map($formatMoney)
.map((i) => i.replace('.00', ''))
.join('')
}}
</span>
</div>
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
</template>
<div v-else class="placeholder">
<template v-if="method.type === 'venmo'">
<VenmoIcon class="enlarge" />
</template>
<template v-else>
<PayPalIcon v-if="method.type === 'paypal'" />
<span>{{ method.name }}</span>
</template>
</div>
</div>
<div class="label">
<RadioButtonChecked v-if="selectedMethodId === method.id" class="radio" />
<RadioButtonIcon v-else class="radio" />
<span>{{ method.name }}</span>
</div>
</button>
</div>
</div>
<h3>Amount</h3>
<p>
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your
<strong>{{ $formatMoney(auth.user.payout_data.balance) }}</strong> balance would you like to
transfer to {{ selectedMethod.name }}?
</p>
<div class="confirmation-input">
<template v-if="selectedMethod.interval.fixed">
<Chips
v-model="amount"
:items="selectedMethod.interval.fixed.values"
:format-label="(val) => '$' + val"
/>
</template>
<template v-else-if="minWithdrawAmount == maxWithdrawAmount">
<div>
<p>
This method has a fixed transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
>.
</p>
</div>
</template>
<template v-else>
<div>
<p>
This method has a minimum transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
>.
</p>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
<p>
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
</p>
</div>
</template>
</div>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ $formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ $formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
I confirm that I am initiating a transfer and I will receive further instructions on how
to redeem this payment via email to: {{ withdrawAccount }}
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ $formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
I agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</template>
<template v-else>
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
{{ error }}
</span>
</template>
</div>
<div class="button-group">
<nuxt-link to="/dashboard/revenue" class="iconified-button">
<XIcon />
Cancel
</nuxt-link>
<button
:disabled="
knownErrors.length > 0 ||
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees)
"
class="iconified-button brand-button"
@click="withdraw"
>
<TransferIcon />
Withdraw
</button>
</div>
</section>
</template>
<script setup>
import { Multiselect } from 'vue-multiselect'
import {
PayPalIcon,
SearchIcon,
RadioButtonIcon,
RadioButtonChecked,
XIcon,
TransferIcon,
} from '@modrinth/assets'
import { Chips, Checkbox, Breadcrumbs } from '@modrinth/ui'
import { all } from 'iso-3166-1'
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
const auth = await useAuth()
const data = useNuxtApp()
const countries = computed(() =>
all().map((x) => ({
id: x.alpha2,
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
}))
)
const search = ref('')
const amount = ref('')
const country = ref(
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? 'US'))
)
const { data: payoutMethods, refresh: refreshPayoutMethods } = await useAsyncData(
`payout/methods?country=${country.value.id}`,
() => useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 })
)
const selectedMethodId = ref(payoutMethods.value[0].id)
const selectedMethod = computed(() =>
payoutMethods.value.find((x) => x.id === selectedMethodId.value)
)
const parsedAmount = computed(() => {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(amount.value)
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
})
const fees = computed(() => {
return Math.min(
Math.max(
selectedMethod.value.fee.min,
selectedMethod.value.fee.percentage * parsedAmount.value
),
selectedMethod.value.fee.max ?? Number.MAX_VALUE
)
})
const getIntervalRange = (intervalType) => {
if (!intervalType) {
return []
}
const { min, max, values } = intervalType
if (values) {
const first = values[0]
const last = values.slice(-1)[0]
return first === last ? [first] : [first, last]
}
return min === max ? [min] : [min, max]
}
const getRangeOfMethod = (method) => {
return getIntervalRange(method.interval?.fixed || method.interval?.standard)
}
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0
})
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value
})
const withdrawAccount = computed(() => {
if (selectedMethod.value.type === 'paypal') {
return auth.value.user.payout_data.paypal_address
} else if (selectedMethod.value.type === 'venmo') {
return auth.value.user.payout_data.venmo_handle
} else {
return auth.value.user.email
}
})
const knownErrors = computed(() => {
const errors = []
if (selectedMethod.value.type === 'paypal' && !auth.value.user.payout_data.paypal_address) {
errors.push('Please link your PayPal account in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'venmo' && !auth.value.user.payout_data.venmo_handle) {
errors.push('Please set your Venmo handle in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'tremendous') {
if (!auth.value.user.email) {
errors.push('Please set your email address in your account settings to proceed.')
}
if (!auth.value.user.email_verified) {
errors.push('Please verify your email address to proceed.')
}
}
if (!parsedAmount.value && amount.value.length > 0) {
errors.push(`${amount.value} is not a valid amount`)
} else if (
parsedAmount.value > auth.value.user.payout_data.balance ||
parsedAmount.value > maxWithdrawAmount.value
) {
const maxAmount = Math.min(auth.value.user.payout_data.balance, maxWithdrawAmount.value)
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`)
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value)
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`)
}
return errors
})
const agreedTransfer = ref(false)
const agreedFees = ref(false)
const agreedTerms = ref(false)
watch(country, async () => {
await refreshPayoutMethods()
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id
}
})
watch(selectedMethod, () => {
if (selectedMethod.value.interval?.fixed) {
amount.value = selectedMethod.value.interval.fixed.values[0]
}
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
amount.value = maxWithdrawAmount.value
}
agreedTransfer.value = false
agreedFees.value = false
agreedTerms.value = false
})
async function withdraw() {
startLoading()
try {
const auth = await useAuth()
await useBaseFetch(`payout`, {
method: 'POST',
body: {
amount: parsedAmount.value,
method: selectedMethod.value.type,
method_id: selectedMethod.value.id,
},
apiVersion: 3,
})
await useAuth(auth.value.token)
await navigateTo('/dashboard/revenue')
data.$notify({
group: 'main',
title: 'Withdrawal complete',
text:
selectedMethod.value.type === 'tremendous'
? 'An email has been sent to your account with further instructions on how to redeem your payout!'
: `Payment has been sent to your ${data.$formatWallet(
selectedMethod.value.type
)} account!`,
type: 'success',
})
} catch (err) {
data.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
</script>
<style lang="scss" scoped>
.withdraw-options-scroll {
max-height: 460px;
overflow-y: auto;
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
}
.withdraw-options {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--gap-lg);
padding-right: 0.5rem;
@media screen and (min-width: 300px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.withdraw-option {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-text);
&.selected {
color: var(--color-contrast);
.label svg {
color: var(--color-brand);
}
}
.preview {
display: flex;
justify-content: center;
aspect-ratio: 30 / 19;
position: relative;
.preview-badges {
// These will float over the image in the bottom right corner
position: absolute;
bottom: 0;
right: 0;
padding: var(--gap-sm) var(--gap-xs);
.badge {
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
padding: var(--gap-xs) var(--gap-sm);
font-size: var(--font-size-xs);
}
}
&.show-bg {
background-color: var(--color-bg);
}
img {
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
user-select: none;
width: 100%;
height: auto;
object-fit: cover;
}
.placeholder {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
width: 2rem;
height: auto;
}
span {
font-weight: var(--font-weight-bold);
font-size: 2rem;
font-style: italic;
}
.enlarge {
width: auto;
height: 1.5rem;
}
}
}
.label {
display: flex;
align-items: center;
padding: var(--gap-md) var(--gap-lg);
svg {
min-height: 1rem;
min-width: 1rem;
margin-right: 0.5rem;
}
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.invalid {
color: var(--color-red);
}
.confirm-text {
margin: var(--spacing-card-md) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
.iconified-input {
margin-bottom: var(--spacing-card-md);
}
.country-multiselect,
.iconified-input {
max-width: 16rem;
}
.rewards-checkbox {
a {
margin-left: 0.5ch;
}
}
</style>