You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
24
apps/frontend/src/pages/dashboard/analytics.vue
Normal file
24
apps/frontend/src/pages/dashboard/analytics.vue
Normal 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>
|
||||
235
apps/frontend/src/pages/dashboard/collections.vue
Normal file
235
apps/frontend/src/pages/dashboard/collections.vue
Normal 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>
|
||||
222
apps/frontend/src/pages/dashboard/index.vue
Normal file
222
apps/frontend/src/pages/dashboard/index.vue
Normal 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>
|
||||
176
apps/frontend/src/pages/dashboard/notifications.vue
Normal file
176
apps/frontend/src/pages/dashboard/notifications.vue
Normal 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>
|
||||
@@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
204
apps/frontend/src/pages/dashboard/organizations.vue
Normal file
204
apps/frontend/src/pages/dashboard/organizations.vue
Normal 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>
|
||||
674
apps/frontend/src/pages/dashboard/projects.vue
Normal file
674
apps/frontend/src/pages/dashboard/projects.vue
Normal 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>
|
||||
17
apps/frontend/src/pages/dashboard/report/[id].vue
Normal file
17
apps/frontend/src/pages/dashboard/report/[id].vue
Normal 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>
|
||||
16
apps/frontend/src/pages/dashboard/reports.vue
Normal file
16
apps/frontend/src/pages/dashboard/reports.vue
Normal 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>
|
||||
113
apps/frontend/src/pages/dashboard/revenue/index.vue
Normal file
113
apps/frontend/src/pages/dashboard/revenue/index.vue
Normal 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>
|
||||
227
apps/frontend/src/pages/dashboard/revenue/transfers.vue
Normal file
227
apps/frontend/src/pages/dashboard/revenue/transfers.vue
Normal 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>
|
||||
531
apps/frontend/src/pages/dashboard/revenue/withdraw.vue
Normal file
531
apps/frontend/src/pages/dashboard/revenue/withdraw.vue
Normal 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>
|
||||
Reference in New Issue
Block a user