You've already forked AstralRinth
forked from didirus/AstralRinth
Threads and more! (#1232)
* Begin UI for threads and moderation overhaul * Hide close button on non-report threads * Fix review age coloring * Add project count * Remove action buttons from queue page and add queued date to project page * Hook up to actual data * Remove unused icon * Get up to 1000 projects in queue * prettier * more prettier * Changed all the things * lint * rebuild * Add omorphia * Workaround formatjs bug in ThreadSummary.vue * Fix notifications page on prod * Fix a few notifications and threads bugs * lockfile * Fix duplicate button styles * more fixes and polishing * More fixes * Remove legacy pages * More bugfixes * Add some error catching for reports and notifications * More error handling * fix lint * Add inbox links * Remove loading component and rename member header * Rely on threads always existing * Handle if project update notifs are not grouped * oops * Fix chips on notifications page * Import ModalModeration * finish threads --------- Co-authored-by: triphora <emma@modrinth.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
@@ -94,7 +94,7 @@
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<ProjectPublishingChecklist
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
@@ -104,6 +104,8 @@
|
||||
:set-processing="setProcessing"
|
||||
:collapsed="collapsedChecklist"
|
||||
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||
:all-members="allMembers"
|
||||
:update-members="updateMembers"
|
||||
/>
|
||||
<NuxtPage
|
||||
v-model:project="project"
|
||||
@@ -258,7 +260,7 @@
|
||||
</div>
|
||||
<div class="dates">
|
||||
<div
|
||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm:ss A')"
|
||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="date"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
@@ -266,13 +268,22 @@
|
||||
<span class="value">{{ fromNow(project.published) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm:ss A')"
|
||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="date"
|
||||
>
|
||||
<UpdateIcon aria-hidden="true" />
|
||||
<span class="label">Updated</span>
|
||||
<span class="value">{{ fromNow(project.updated) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="project.status === 'processing' && project.queued"
|
||||
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="date"
|
||||
>
|
||||
<QueuedIcon aria-hidden="true" />
|
||||
<span class="label">Submitted</span>
|
||||
<span class="value">{{ fromNow(project.queued) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="card-divider" />
|
||||
<div class="input-group">
|
||||
@@ -361,17 +372,21 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
$tag.approvedStatuses.includes(project.status) || project.status === 'processing'
|
||||
$tag.approvedStatuses.includes(project.status) ||
|
||||
project.status === 'processing' ||
|
||||
($tag.rejectedStatuses.includes(project.status) && project.status !== 'withheld')
|
||||
"
|
||||
class="iconified-button danger-button"
|
||||
@click="openModerationModal('withheld')"
|
||||
>
|
||||
<EyeIcon />
|
||||
<EyeOffIcon />
|
||||
Withhold
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
$tag.approvedStatuses.includes(project.status) || project.status === 'processing'
|
||||
$tag.approvedStatuses.includes(project.status) ||
|
||||
project.status === 'processing' ||
|
||||
($tag.rejectedStatuses.includes(project.status) && project.status !== 'rejected')
|
||||
"
|
||||
class="iconified-button danger-button"
|
||||
@click="openModerationModal('rejected')"
|
||||
@@ -383,15 +398,19 @@
|
||||
<EditIcon />
|
||||
Edit message
|
||||
</button>
|
||||
<nuxt-link class="iconified-button" to="/moderation">
|
||||
<nuxt-link class="iconified-button" to="/moderation/review">
|
||||
<ModerationIcon />
|
||||
Visit moderation queue
|
||||
Visit review queue
|
||||
</nuxt-link>
|
||||
<nuxt-link class="iconified-button" to="/moderation/reports">
|
||||
<ReportIcon />
|
||||
Visit reports
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="normal-page__content">
|
||||
<ProjectPublishingChecklist
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
:project="project"
|
||||
:versions="versions"
|
||||
@@ -401,6 +420,8 @@
|
||||
:set-processing="setProcessing"
|
||||
:collapsed="collapsedChecklist"
|
||||
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||
:all-members="allMembers"
|
||||
:update-members="updateMembers"
|
||||
/>
|
||||
<div v-else-if="project.status === 'withheld'" class="card warning" aria-label="Warning">
|
||||
{{ project.title }} has been removed from search by Modrinth's moderators. Please use
|
||||
@@ -455,6 +476,13 @@
|
||||
}/versions`,
|
||||
shown: versions.length > 0 || !!currentMember,
|
||||
},
|
||||
{
|
||||
label: 'Moderation',
|
||||
href: `/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}/moderation`,
|
||||
shown: !!currentMember,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div v-if="$auth.user && currentMember" class="input-group">
|
||||
@@ -689,6 +717,38 @@
|
||||
<CopyCode :text="project.id" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<a
|
||||
v-if="
|
||||
config.public.apiBaseUrl.startsWith('https://api.modrinth.com') &&
|
||||
config.public.siteUrl !== 'https://modrinth.com'
|
||||
"
|
||||
class="iconified-button"
|
||||
:href="`https://modrinth.com/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`"
|
||||
rel="noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
View on modrinth.com
|
||||
</a>
|
||||
<a
|
||||
v-else-if="
|
||||
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com') &&
|
||||
config.public.siteUrl !== 'https://staging.modrinth.com'
|
||||
"
|
||||
class="iconified-button"
|
||||
:href="`https://staging.modrinth.com/${project.project_type}/${
|
||||
project.slug ? project.slug : project.id
|
||||
}`"
|
||||
rel="noopener nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
View on staging.modrinth.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -700,7 +760,9 @@ import CheckIcon from '~/assets/images/utils/check.svg'
|
||||
import ClearIcon from '~/assets/images/utils/clear.svg'
|
||||
import DownloadIcon from '~/assets/images/utils/download.svg'
|
||||
import UpdateIcon from '~/assets/images/utils/updated.svg'
|
||||
import QueuedIcon from '~/assets/images/utils/list-end.svg'
|
||||
import CodeIcon from '~/assets/images/sidebar/mod.svg'
|
||||
import ExternalIcon from '~/assets/images/utils/external.svg'
|
||||
import ReportIcon from '~/assets/images/utils/report.svg'
|
||||
import HeartIcon from '~/assets/images/utils/heart.svg'
|
||||
import IssuesIcon from '~/assets/images/utils/issues.svg'
|
||||
@@ -713,7 +775,7 @@ import PayPalIcon from '~/assets/images/external/paypal.svg'
|
||||
import OpenCollectiveIcon from '~/assets/images/external/opencollective.svg'
|
||||
import UnknownIcon from '~/assets/images/utils/unknown-donation.svg'
|
||||
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
|
||||
import EyeIcon from '~/assets/images/utils/eye.svg'
|
||||
import EyeOffIcon from '~/assets/images/utils/eye-off.svg'
|
||||
import BoxIcon from '~/assets/images/utils/box.svg'
|
||||
import Promotion from '~/components/ads/Promotion.vue'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
@@ -727,7 +789,7 @@ import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
import ProjectPublishingChecklist from '~/components/ui/ProjectPublishingChecklist.vue'
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import SettingsIcon from '~/assets/images/utils/settings.svg'
|
||||
import UsersIcon from '~/assets/images/utils/users.svg'
|
||||
import CategoriesIcon from '~/assets/images/utils/tags.svg'
|
||||
@@ -744,6 +806,7 @@ import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const user = await useUser()
|
||||
|
||||
@@ -1070,6 +1133,23 @@ function openModerationModal(status) {
|
||||
modalModeration.value.show()
|
||||
}
|
||||
|
||||
async function updateMembers() {
|
||||
allMembers.value = await useAsyncData(
|
||||
`project/${route.params.id}/members`,
|
||||
() => useBaseFetch(`project/${route.params.id}/members`, data.$defaultHeaders()),
|
||||
{
|
||||
transform: (members) => {
|
||||
members.forEach((it, index) => {
|
||||
members[index].avatar_url = it.user.avatar_url
|
||||
members[index].name = it.user.username
|
||||
})
|
||||
|
||||
return members
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const collapsedChecklist = ref(false)
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
193
pages/[type]/[id]/moderation.vue
Normal file
193
pages/[type]/[id]/moderation.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Project status</h2>
|
||||
<Badge :type="project.status" />
|
||||
<p v-if="isApproved(project)">
|
||||
Your project been approved by the moderators and you may freely change project visibility in
|
||||
<router-link :to="`${getProjectLink(project)}/settings`" class="text-link"
|
||||
>your project's settings</router-link
|
||||
>.
|
||||
</p>
|
||||
<p v-else-if="isUnderReview(project)">
|
||||
Project reviews typically take 24 to 48 hours and they will leave a message below if they
|
||||
have any questions or concerns for you. If your review has taken more than 48 hours, check
|
||||
our Discord or social media for moderation delays.
|
||||
</p>
|
||||
<template v-else-if="isRejected(project)">
|
||||
<p>
|
||||
Your project does not currently meet Modrinth's
|
||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">content rules</nuxt-link>
|
||||
and the moderators have requested you make changes before it can be approved. Read the
|
||||
messages from the moderators below and address their comments before resubmitting.
|
||||
</p>
|
||||
<p class="warning">
|
||||
Repeated submissions without addressing the moderators' comments may result in an account
|
||||
suspension.
|
||||
</p>
|
||||
</template>
|
||||
<h3>Current visibility</h3>
|
||||
<ul class="visibility-info">
|
||||
<li v-if="isListed(project)">
|
||||
<CheckIcon class="good" />
|
||||
Listed in search results
|
||||
</li>
|
||||
<li v-else>
|
||||
<ExitIcon class="bad" />
|
||||
Not listed in search results
|
||||
</li>
|
||||
<li v-if="isListed(project)">
|
||||
<CheckIcon class="good" />
|
||||
Listed on the profiles of members
|
||||
</li>
|
||||
<li v-else>
|
||||
<ExitIcon class="bad" />
|
||||
Not listed on the profiles of members
|
||||
</li>
|
||||
<li v-if="isPrivate(project)">
|
||||
<ExitIcon class="bad" />
|
||||
Not accessible with a direct link
|
||||
</li>
|
||||
<li v-else>
|
||||
<CheckIcon class="good" />
|
||||
Accessible with a direct link
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section id="messages" class="universal-card">
|
||||
<h2>Messages</h2>
|
||||
<p>
|
||||
This is a private conversation thread with the Modrinth moderators. They will message you
|
||||
for issues concerning your project on Modrinth, and you are welcome to message them about
|
||||
things concerning your project.
|
||||
</p>
|
||||
<ConversationThread
|
||||
v-if="thread"
|
||||
:thread="thread"
|
||||
:update-thread="(newThread) => (thread = newThread)"
|
||||
:project="project"
|
||||
:set-status="setStatus"
|
||||
:current-member="currentMember"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import {
|
||||
getProjectLink,
|
||||
isApproved,
|
||||
isListed,
|
||||
isPrivate,
|
||||
isRejected,
|
||||
isUnderReview,
|
||||
} from '~/helpers/projects.js'
|
||||
import ExitIcon from 'assets/images/utils/x.svg'
|
||||
import CheckIcon from 'assets/images/utils/check.svg'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:project'])
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const { data: thread } = await useAsyncData(`thread/${props.project.thread_id}`, () =>
|
||||
useBaseFetch(`thread/${props.project.thread_id}`, app.$defaultHeaders())
|
||||
)
|
||||
async function setStatus(status) {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
const data = {}
|
||||
data.status = status
|
||||
await useBaseFetch(`project/${props.project.id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
...app.$defaultHeaders(),
|
||||
})
|
||||
const project = props.project
|
||||
project.status = status
|
||||
emit('update:project', project)
|
||||
thread.value = await useBaseFetch(`thread/${thread.value.id}`, app.$defaultHeaders())
|
||||
} catch (err) {
|
||||
app.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
:deep(.badge) {
|
||||
display: contents;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.unavailable-error {
|
||||
.code {
|
||||
margin-top: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.visibility-info {
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
&.good {
|
||||
color: var(--color-brand-green);
|
||||
}
|
||||
|
||||
&.bad {
|
||||
color: var(--color-special-red);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-special-orange);
|
||||
}
|
||||
</style>
|
||||
@@ -147,10 +147,12 @@
|
||||
<div class="adjacent-input">
|
||||
<label for="project-visibility">
|
||||
<span class="label__title">Visibility</span>
|
||||
<span class="label__description">
|
||||
<div class="label__description">
|
||||
Listed and archived projects are visible in search. Unlisted projects are published, but
|
||||
not visible in search or on user profiles. Private projects are only accessible by
|
||||
members of the project.
|
||||
|
||||
<p>If approved by the moderators:</p>
|
||||
<ul class="visibility-info">
|
||||
<li>
|
||||
<CheckIcon
|
||||
@@ -183,7 +185,7 @@
|
||||
{{ hasModifiedVisibility() ? 'Will be v' : 'V' }}isible via URL
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<Multiselect
|
||||
id="project-visibility"
|
||||
@@ -408,7 +410,7 @@ export default defineNuxtComponent({
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
await initUserProjects()
|
||||
await this.$router.push('/dashboard/projects')
|
||||
await this.$router.push('/dashboard/review')
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'Project deleted',
|
||||
|
||||
@@ -13,17 +13,39 @@
|
||||
project.
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES"
|
||||
class="input-group"
|
||||
>
|
||||
<input id="username" v-model="currentUsername" type="text" placeholder="Username" />
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="username"
|
||||
v-model="currentUsername"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
:disabled="(currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
|
||||
@keypress.enter="inviteTeamMember()"
|
||||
/>
|
||||
<label for="username" class="hidden">Username</label>
|
||||
<button class="iconified-button brand-button" @click="inviteTeamMember">
|
||||
<button
|
||||
class="iconified-button brand-button"
|
||||
:disabled="(currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES"
|
||||
@click="inviteTeamMember()"
|
||||
>
|
||||
<UserPlusIcon />
|
||||
Invite
|
||||
</button>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<span class="label">
|
||||
<span class="label__title">Leave project</span>
|
||||
<span class="label__description"> Remove yourself as a member of this project. </span>
|
||||
</span>
|
||||
<button
|
||||
class="iconified-button danger-button"
|
||||
:disabled="currentMember.role === 'Owner'"
|
||||
@click="leaveProject()"
|
||||
>
|
||||
<UserRemoveIcon />
|
||||
Leave project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(member, index) in allTeamMembers"
|
||||
@@ -227,6 +249,7 @@ import TransferIcon from '~/assets/images/utils/transfer.svg'
|
||||
import UserPlusIcon from '~/assets/images/utils/user-plus.svg'
|
||||
import UserRemoveIcon from '~/assets/images/utils/user-x.svg'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import { removeSelfFromTeam } from '~/helpers/teams.js'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
@@ -282,6 +305,11 @@ export default defineNuxtComponent({
|
||||
this.VIEW_PAYOUTS = 1 << 9
|
||||
},
|
||||
methods: {
|
||||
removeSelfFromTeam,
|
||||
async leaveProject() {
|
||||
await removeSelfFromTeam(project.team)
|
||||
await this.$router.push('/dashboard/projects')
|
||||
},
|
||||
async inviteTeamMember() {
|
||||
startLoading()
|
||||
|
||||
@@ -297,6 +325,7 @@ export default defineNuxtComponent({
|
||||
body: data,
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
this.currentUsername = ''
|
||||
await this.updateMembers()
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
|
||||
@@ -135,8 +135,8 @@
|
||||
</button>
|
||||
<button class="iconified-button" @click="version.featured = !version.featured">
|
||||
<StarIcon aria-hidden="true" />
|
||||
<template v-if="!version.featured"> Feature version </template>
|
||||
<template v-else> Unfeature version </template>
|
||||
<template v-if="!version.featured"> Feature version</template>
|
||||
<template v-else> Unfeature version</template>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-if="currentMember"
|
||||
@@ -160,11 +160,7 @@
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Download
|
||||
</a>
|
||||
<button
|
||||
v-if="$auth.user && !currentMember"
|
||||
class="iconified-button"
|
||||
@click="$refs.modal_version_report.show()"
|
||||
>
|
||||
<button class="iconified-button" @click="$refs.modal_version_report.show()">
|
||||
<ReportIcon aria-hidden="true" />
|
||||
Report
|
||||
</button>
|
||||
@@ -240,12 +236,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="version.dependencies.length > 0 || (isEditing && project.project_type !== 'modpack')"
|
||||
v-if="deps.length > 0 || (isEditing && project.project_type !== 'modpack')"
|
||||
class="version-page__dependencies universal-card"
|
||||
>
|
||||
<h3>Dependencies</h3>
|
||||
<div
|
||||
v-for="(dependency, index) in version.dependencies.filter((x) => !x.file_name)"
|
||||
v-for="(dependency, index) in deps.filter((x) => !x.file_name)"
|
||||
:key="index"
|
||||
class="dependency"
|
||||
:class="{ 'button-transparent': !isEditing }"
|
||||
@@ -260,11 +256,11 @@
|
||||
<span class="project-title">
|
||||
{{ dependency.project ? dependency.project.title : 'Unknown Project' }}
|
||||
</span>
|
||||
<span v-if="dependency.version">
|
||||
<span v-if="dependency.version" class="dep-type" :class="dependency.dependency_type">
|
||||
Version {{ dependency.version.version_number }} is
|
||||
{{ dependency.dependency_type }}
|
||||
</span>
|
||||
<span v-else class="dep-type">
|
||||
<span v-else class="dep-type" :class="dependency.dependency_type">
|
||||
{{ dependency.dependency_type }}
|
||||
</span>
|
||||
</nuxt-link>
|
||||
@@ -272,11 +268,11 @@
|
||||
<span class="project-title">
|
||||
{{ dependency.project ? dependency.project.title : 'Unknown Project' }}
|
||||
</span>
|
||||
<span v-if="dependency.version">
|
||||
<span v-if="dependency.version" class="dep-type" :class="dependency.dependency_type">
|
||||
Version {{ dependency.version.version_number }} is
|
||||
{{ dependency.dependency_type }}
|
||||
</span>
|
||||
<span v-else class="dep-type">
|
||||
<span v-else class="dep-type" :class="dependency.dependency_type">
|
||||
{{ dependency.dependency_type }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -290,7 +286,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(dependency, index) in version.dependencies.filter((x) => x.file_name)"
|
||||
v-for="(dependency, index) in deps.filter((x) => x.file_name)"
|
||||
:key="index"
|
||||
class="dependency"
|
||||
>
|
||||
@@ -299,7 +295,7 @@
|
||||
<span class="project-title">
|
||||
{{ dependency.file_name }}
|
||||
</span>
|
||||
<span>Added via overrides</span>
|
||||
<span class="dep-type" :class="dependency.dependency_type">Added via overrides</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditing && project.project_type !== 'modpack'" class="add-dependency">
|
||||
@@ -630,7 +626,7 @@
|
||||
<div v-if="!isEditing">
|
||||
<h4>Publication date</h4>
|
||||
<span>
|
||||
{{ $dayjs(version.date_published).format('MMMM D, YYYY [at] h:mm:ss A') }}
|
||||
{{ $dayjs(version.date_published).format('MMMM D, YYYY [at] h:mm A') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!isEditing && version.author">
|
||||
@@ -896,6 +892,8 @@ export default defineNuxtComponent({
|
||||
|
||||
oldFileTypes = version.files.map((x) => fileTypes.find((y) => y.value === x.file_type))
|
||||
|
||||
const order = ['required', 'optional', 'incompatible', 'embedded']
|
||||
|
||||
return {
|
||||
fileTypes: ref(fileTypes),
|
||||
oldFileTypes: ref(oldFileTypes),
|
||||
@@ -919,6 +917,11 @@ export default defineNuxtComponent({
|
||||
.$dayjs(version.date_published)
|
||||
.format('MMM D, YYYY')}. ${version.downloads} downloads.`
|
||||
),
|
||||
deps: computed(() =>
|
||||
version.dependencies.sort(
|
||||
(a, b) => order.indexOf(a.dependency_type) - order.indexOf(b.dependency_type)
|
||||
)
|
||||
),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -1428,6 +1431,11 @@ export default defineNuxtComponent({
|
||||
|
||||
.dep-type {
|
||||
text-transform: capitalize;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&.incompatible {
|
||||
color: var(--color-red);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1529,6 +1537,7 @@ export default defineNuxtComponent({
|
||||
h4 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -290,10 +290,6 @@ async function handleFiles(files) {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-xs);
|
||||
}
|
||||
|
||||
&:active:not(&:disabled) {
|
||||
transform: scale(0.99) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Dashboard<span class="beta-badge">BETA</span></h1>
|
||||
<h1>Dashboard</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/dashboard" label="Overview">
|
||||
<DashboardIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/projects" label="Projects">
|
||||
<NavStackItem link="/dashboard/notifications" label="Notifications">
|
||||
<NotificationsIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/follows" label="Followed projects">
|
||||
<HeartIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/reports" label="Active reports">
|
||||
<ReportIcon />
|
||||
</NavStackItem>
|
||||
|
||||
<h3>Manage</h3>
|
||||
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
||||
<ListIcon />
|
||||
</NavStackItem>
|
||||
<!-- <NavStackItem link="/dashboard/analytics" label="Analytics">-->
|
||||
<!-- <ChartIcon />-->
|
||||
<!-- </NavStackItem>-->
|
||||
<NavStackItem link="/dashboard/revenue" label="Revenue">
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
@@ -31,6 +39,9 @@ import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
import DashboardIcon from '~/assets/images/utils/dashboard.svg'
|
||||
import CurrencyIcon from '~/assets/images/utils/currency.svg'
|
||||
import ListIcon from '~/assets/images/utils/list.svg'
|
||||
import ReportIcon from '~/assets/images/utils/report.svg'
|
||||
import NotificationsIcon from '~/assets/images/utils/bell.svg'
|
||||
import HeartIcon from '~/assets/images/utils/heart.svg'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
|
||||
@@ -35,15 +35,15 @@
|
||||
<script setup>
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
|
||||
import HeartIcon from '~/assets/images/utils/heart.svg'
|
||||
import FollowIllustration from '~/assets/images/illustrations/follow_illustration.svg'
|
||||
import HeartIcon from 'assets/images/utils/heart.svg'
|
||||
import FollowIllustration from 'assets/images/illustrations/follow_illustration.svg'
|
||||
|
||||
const user = await useUser()
|
||||
if (process.client) {
|
||||
await initUserFollows()
|
||||
}
|
||||
|
||||
useHead({ title: 'Followed projects - Modrinth' })
|
||||
useHead({ title: 'Followed review - Modrinth' })
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
@@ -1,7 +1,52 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Overview</h2>
|
||||
<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>
|
||||
<section class="universal-card dashboard-notifications">
|
||||
<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"
|
||||
v-model:notifications="allNotifs"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
raised
|
||||
compact
|
||||
/>
|
||||
<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">
|
||||
<HistoryIcon /> View notification history
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card dashboard-analytics">
|
||||
<h2>Analytics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Total downloads</div>
|
||||
@@ -32,25 +77,13 @@
|
||||
}}</span
|
||||
></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 revenue</div>
|
||||
<div class="value">
|
||||
{{ $formatMoney(payouts.all_time, true) }}
|
||||
</div>
|
||||
<span>{{ $formatMoney(payouts.last_month, true) }} this month</span>
|
||||
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
|
||||
<!-- >View breakdown-->
|
||||
<!-- <ChevronRightIcon-->
|
||||
<!-- class="featured-header-chevron"-->
|
||||
<!-- aria-hidden="true"-->
|
||||
<!-- /></NuxtLink>-->
|
||||
<span>{{ $formatMoney(payouts.last_month, true) }} in the last month</span>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Current balance</div>
|
||||
@@ -69,32 +102,31 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card more-soon">
|
||||
<h2>More coming soon!</h2>
|
||||
<p>
|
||||
Stay tuned for more metrics and analytics (pretty graphs, anyone? 👀) coming to the creators
|
||||
dashboard soon!
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
|
||||
import HistoryIcon from '~/assets/images/utils/history.svg'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import NotificationItem from '~/components/ui/NotificationItem.vue'
|
||||
import { fetchNotifications, groupNotifications } from '~/helpers/notifications.js'
|
||||
|
||||
useHead({
|
||||
title: 'Creator dashboard - Modrinth',
|
||||
title: 'Dashboard - Modrinth',
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const app = useNuxtApp()
|
||||
|
||||
const [rawProjects, rawPayouts] = await Promise.all([
|
||||
useBaseFetch(`user/${auth.value.user.id}/projects`, app.$defaultHeaders()),
|
||||
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders()),
|
||||
const [{ data: projects }, { data: payouts }] = await Promise.all([
|
||||
useAsyncData(`user/${auth.value.user.id}/projects`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/projects`, app.$defaultHeaders())
|
||||
),
|
||||
useAsyncData(`user/${auth.value.user.id}/payouts`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders())
|
||||
),
|
||||
])
|
||||
|
||||
const projects = shallowRef(rawProjects)
|
||||
const payouts = ref(rawPayouts)
|
||||
const minWithdraw = ref(0.26)
|
||||
|
||||
const downloadsProjectCount = computed(
|
||||
@@ -103,4 +135,75 @@ const downloadsProjectCount = computed(
|
||||
const followersProjectCount = computed(
|
||||
() => projects.value.filter((project) => project.followers > 0).length
|
||||
)
|
||||
|
||||
const allNotifs = groupNotifications(await fetchNotifications())
|
||||
|
||||
const notifications = computed(() => allNotifs.slice(0, 3))
|
||||
const extraNotifs = computed(() => allNotifs.length - notifications.value.length)
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.dashboard-overview {
|
||||
display: grid;
|
||||
grid-template:
|
||||
'header header'
|
||||
'notifications analytics' / 1fr 1fr;
|
||||
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>
|
||||
|
||||
133
pages/dashboard/notifications.vue
Normal file
133
pages/dashboard/notifications.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<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="allNotifs && allNotifs.some((notif) => notif.read)" @click="updateRoute()">
|
||||
<HistoryIcon /> View history
|
||||
</Button>
|
||||
<Button v-if="notifications.length > 0" color="danger" @click="readAll()">
|
||||
<CheckCheckIcon /> Mark all as read
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="notifications.length > 0">
|
||||
<Chips
|
||||
v-if="notifTypes.length > 1"
|
||||
v-model="selectedType"
|
||||
:items="notifTypes"
|
||||
:format-label="
|
||||
(x) => (x === 'all' ? 'All' : $formatProjectType(x).replace('_', ' ') + 's')
|
||||
"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
v-model:notifications="allNotifs"
|
||||
class="universal-card recessed"
|
||||
:notification="notification"
|
||||
raised
|
||||
/>
|
||||
</template>
|
||||
<p v-else>You don't have any unread notifications.</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, HistoryIcon } from 'omorphia'
|
||||
import { fetchNotifications, 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'
|
||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Notifications - Modrinth',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const history = computed(() => {
|
||||
return route.name === 'dashboard-notifications-history'
|
||||
})
|
||||
|
||||
const allNotifs = ref(null)
|
||||
|
||||
const notifTypes = computed(() => {
|
||||
if (allNotifs.value === null) {
|
||||
return []
|
||||
}
|
||||
const types = [
|
||||
...new Set(
|
||||
allNotifs.value
|
||||
.filter((notification) => {
|
||||
return history.value || !notification.read
|
||||
})
|
||||
.map((notif) => notif.type)
|
||||
),
|
||||
]
|
||||
return types.length > 1 ? ['all', ...types] : types
|
||||
})
|
||||
|
||||
const notifications = computed(() => {
|
||||
if (allNotifs.value === null) {
|
||||
return []
|
||||
}
|
||||
const groupedNotifs = groupNotifications(allNotifs.value, history.value)
|
||||
return groupedNotifs.filter(
|
||||
(notif) => selectedType.value === 'all' || notif.type === selectedType.value
|
||||
)
|
||||
})
|
||||
|
||||
const selectedType = ref('all')
|
||||
|
||||
await fetchNotifications().then((result) => {
|
||||
allNotifs.value = result
|
||||
})
|
||||
|
||||
function updateRoute() {
|
||||
if (history.value) {
|
||||
router.push('/dashboard/notifications')
|
||||
} else {
|
||||
router.push('/dashboard/notifications/history')
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
</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>
|
||||
@@ -183,7 +183,7 @@
|
||||
</button>
|
||||
<div class="push-right">
|
||||
<div class="labeled-control-row">
|
||||
Sort By
|
||||
Sort by
|
||||
<Multiselect
|
||||
v-model="sortBy"
|
||||
:searchable="false"
|
||||
@@ -194,8 +194,13 @@
|
||||
:allow-empty="false"
|
||||
@update:model-value="projects = updateSort(projects, sortBy, descending)"
|
||||
/>
|
||||
<button class="square-button" @click="updateDescending()">
|
||||
<ArrowIcon :transform="`rotate(${descending ? -90 : 90})`" />
|
||||
<button
|
||||
v-tooltip="descending ? 'Descending' : 'Ascending'"
|
||||
class="square-button"
|
||||
@click="updateDescending()"
|
||||
>
|
||||
<DescendingIcon v-if="descending" />
|
||||
<AscendingIcon v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,7 +316,8 @@ import PlusIcon from '~/assets/images/utils/plus.svg'
|
||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||
import EditIcon from '~/assets/images/utils/edit.svg'
|
||||
import SaveIcon from '~/assets/images/utils/save.svg'
|
||||
import ArrowIcon from '~/assets/images/utils/left-arrow.svg'
|
||||
import AscendingIcon from '~/assets/images/utils/sort-asc.svg'
|
||||
import DescendingIcon from '~/assets/images/utils/sort-desc.svg'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
@@ -329,7 +335,8 @@ export default defineNuxtComponent({
|
||||
ModalCreation,
|
||||
Multiselect,
|
||||
CopyCode,
|
||||
ArrowIcon,
|
||||
AscendingIcon,
|
||||
DescendingIcon,
|
||||
},
|
||||
async setup() {
|
||||
const user = await useUser()
|
||||
@@ -387,13 +394,7 @@ export default defineNuxtComponent({
|
||||
switch (sort) {
|
||||
case 'Name':
|
||||
sortedArray = projects.slice().sort((a, b) => {
|
||||
if (a.title < b.title) {
|
||||
return -1
|
||||
}
|
||||
if (a.title > b.title) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
break
|
||||
case 'Status':
|
||||
@@ -633,6 +634,7 @@ export default defineNuxtComponent({
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.small-select {
|
||||
|
||||
15
pages/dashboard/report/[id].vue
Normal file
15
pages/dashboard/report/[id].vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ReportView
|
||||
: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 = useRoute()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
16
pages/dashboard/reports.vue
Normal file
16
pages/dashboard/reports.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Reports you've filed</h2>
|
||||
<ReportsList />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Active reports - Modrinth',
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -20,19 +20,6 @@
|
||||
>Enroll in the Creator Monetization Program to withdraw your revenue.</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div v-if="enrolled" class="input-group">
|
||||
<button class="iconified-button brand-button" @click="$refs.modal_transfer.show()">
|
||||
<TransferIcon /> Transfer to
|
||||
{{ $formatWallet(auth.user.payout_data.payout_wallet) }}
|
||||
</button>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon /> View transfer history
|
||||
</NuxtLink>
|
||||
<NuxtLink class="iconified-button" to="/settings/monetization">
|
||||
<SettingsIcon /> Monetization settings
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="auth.user.payout_data.balance > 0">
|
||||
You have made
|
||||
@@ -49,6 +36,22 @@
|
||||
<SettingsIcon /> Enroll in the Creator Monetization Program
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div v-if="enrolled" class="input-group">
|
||||
<button
|
||||
v-if="auth.user.payout_data.balance >= minWithdraw"
|
||||
class="iconified-button brand-button"
|
||||
@click="$refs.modal_transfer.show()"
|
||||
>
|
||||
<TransferIcon /> Transfer to
|
||||
{{ $formatWallet(auth.user.payout_data.payout_wallet) }}
|
||||
</button>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon /> View transfer history
|
||||
</NuxtLink>
|
||||
<NuxtLink class="iconified-button" to="/settings/monetization">
|
||||
<SettingsIcon /> Monetization settings
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
<h2>Processing fees</h2>
|
||||
|
||||
@@ -41,11 +41,9 @@ useHead({
|
||||
const auth = await useAuth()
|
||||
const app = useNuxtApp()
|
||||
|
||||
const [raw] = await Promise.all([
|
||||
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders()),
|
||||
])
|
||||
|
||||
const payouts = ref(raw)
|
||||
const { data: payouts } = await useAsyncData(`user/${auth.value.user.id}/payouts`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders())
|
||||
)
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.grid-table {
|
||||
|
||||
@@ -1241,11 +1241,9 @@ const rows = shallowRef([
|
||||
}
|
||||
|
||||
.notifs-demo {
|
||||
.notifications .notification {
|
||||
img {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
.notifications .notification .avatar {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,333 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalModeration
|
||||
ref="modal"
|
||||
:project="currentProject"
|
||||
:status="currentStatus"
|
||||
:on-close="onModalClose"
|
||||
/>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Moderation</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/moderation" label="All" />
|
||||
<NavStackItem
|
||||
v-for="type in moderationTypes"
|
||||
:key="type"
|
||||
:link="'/moderation/' + type"
|
||||
:label="$formatProjectType(type) + 's'"
|
||||
/>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<div class="project-list display-mode--list">
|
||||
<ProjectCard
|
||||
v-for="project in $route.params.type !== undefined
|
||||
? projects.filter((x) => x.project_type === $route.params.type)
|
||||
: projects"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.title"
|
||||
:description="project.description"
|
||||
:created-at="project.queued"
|
||||
:updated-at="project.queued"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:type="project.project_type"
|
||||
:color="project.color"
|
||||
:moderation="true"
|
||||
>
|
||||
<button
|
||||
class="iconified-button"
|
||||
@click="
|
||||
setProjectStatus(
|
||||
project,
|
||||
project.requested_status ? project.requested_status : 'approved'
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckIcon />
|
||||
Approve
|
||||
</button>
|
||||
<button class="iconified-button" @click="setProjectStatus(project, 'withheld')">
|
||||
<UnlistIcon />
|
||||
Withhold
|
||||
</button>
|
||||
<button class="iconified-button" @click="setProjectStatus(project, 'rejected')">
|
||||
<CrossIcon />
|
||||
Reject
|
||||
</button>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
<div
|
||||
v-if="$route.params.type === 'report' || $route.params.type === undefined"
|
||||
class="reports"
|
||||
>
|
||||
<div v-for="(item, index) in reports" :key="index" class="card report">
|
||||
<div class="info">
|
||||
<div class="title">
|
||||
<h3>
|
||||
{{ item.item_type }}
|
||||
<nuxt-link :to="item.url">
|
||||
{{ item.item_id }}
|
||||
</nuxt-link>
|
||||
</h3>
|
||||
reported by
|
||||
<a :href="`/user/${item.reporter}`">{{ item.reporter }}</a>
|
||||
</div>
|
||||
<div class="markdown-body" v-html="renderHighlightedString(item.body)" />
|
||||
<Badge :type="`Marked as ${item.report_type}`" color="orange" />
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="iconified-button" @click="deleteReport(index)">
|
||||
<TrashIcon /> Delete report
|
||||
</button>
|
||||
<span
|
||||
v-tooltip="$dayjs(item.created).format('[Created at] YYYY-MM-DD [at] HH:mm A')"
|
||||
class="stat"
|
||||
>
|
||||
<CalendarIcon />
|
||||
Created {{ fromNow(item.created) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="reports.length === 0 && projects.length === 0" class="error">
|
||||
<Security class="icon" />
|
||||
<br />
|
||||
<span class="text">You are up-to-date!</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Moderation</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/moderation" label="Overview">
|
||||
<ModrinthIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/review" label="Review projects">
|
||||
<ModerationIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/messages" label="Messages">
|
||||
<MessageIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/moderation/reports" label="Reports">
|
||||
<ReportIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
|
||||
import CheckIcon from '~/assets/images/utils/check.svg'
|
||||
import UnlistIcon from '~/assets/images/utils/eye-off.svg'
|
||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||
import TrashIcon from '~/assets/images/utils/trash.svg'
|
||||
import CalendarIcon from '~/assets/images/utils/calendar.svg'
|
||||
import Security from '~/assets/images/illustrations/security.svg'
|
||||
<script setup>
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
import ModalModeration from '~/components/ui/ModalModeration.vue'
|
||||
import { renderHighlightedString } from '~/helpers/highlight.js'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
ModalModeration,
|
||||
NavStack,
|
||||
NavStackItem,
|
||||
ProjectCard,
|
||||
CheckIcon,
|
||||
CrossIcon,
|
||||
UnlistIcon,
|
||||
Badge,
|
||||
Security,
|
||||
TrashIcon,
|
||||
CalendarIcon,
|
||||
},
|
||||
async setup() {
|
||||
const data = useNuxtApp()
|
||||
import ModrinthIcon from '~/assets/images/utils/modrinth.svg'
|
||||
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
|
||||
import ReportIcon from '~/assets/images/utils/report.svg'
|
||||
import MessageIcon from '~/assets/images/utils/message.svg'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const [projects, reports] = await Promise.all([
|
||||
useBaseFetch('moderation/projects', data.$defaultHeaders()),
|
||||
useBaseFetch('report', data.$defaultHeaders()),
|
||||
])
|
||||
|
||||
const newReports = await Promise.all(
|
||||
reports.map(async (report) => {
|
||||
try {
|
||||
report.item_id = report.item_id?.replace
|
||||
? report.item_id.replace(/"/g, '')
|
||||
: report.item_id
|
||||
let url = ''
|
||||
|
||||
if (report.item_type === 'user') {
|
||||
const user = await useBaseFetch(`user/${report.item_id}`, data.$defaultHeaders())
|
||||
url = `/user/${user.username}`
|
||||
report.item_id = user.username
|
||||
} else if (report.item_type === 'project') {
|
||||
const project = await useBaseFetch(`project/${report.item_id}`, data.$defaultHeaders())
|
||||
report.item_id = project.slug || report.item_id
|
||||
url = `/${project.project_type}/${report.item_id}`
|
||||
} else if (report.item_type === 'version') {
|
||||
const version = await useBaseFetch(`version/${report.item_id}`, data.$defaultHeaders())
|
||||
const project = await useBaseFetch(
|
||||
`project/${version.project_id}`,
|
||||
data.$defaultHeaders()
|
||||
)
|
||||
report.item_id = version.version_number || report.item_id
|
||||
url = `/${project.project_type}/${project.slug || project.id}/version/${report.item_id}`
|
||||
}
|
||||
|
||||
report.reporter = (
|
||||
await useBaseFetch(`user/${report.reporter}`, data.$defaultHeaders())
|
||||
).username
|
||||
|
||||
return {
|
||||
...report,
|
||||
moderation_type: 'report',
|
||||
url,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
...report,
|
||||
url: 'error',
|
||||
moderation_type: 'report',
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
projects: shallowRef(projects.sort((a, b) => data.$dayjs(a.queued) - data.$dayjs(b.queued))),
|
||||
reports: ref(newReports),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentProject: null,
|
||||
currentStatus: null,
|
||||
}
|
||||
},
|
||||
head: {
|
||||
title: 'Moderation - Modrinth',
|
||||
},
|
||||
computed: {
|
||||
moderationTypes() {
|
||||
const obj = {}
|
||||
|
||||
for (const project of this.projects) {
|
||||
obj[project.project_type] = true
|
||||
}
|
||||
|
||||
if (this.reports.length > 0) {
|
||||
obj.report = true
|
||||
}
|
||||
|
||||
return Object.keys(obj)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
renderHighlightedString,
|
||||
setProjectStatus(project, status) {
|
||||
this.currentProject = project
|
||||
this.currentStatus = status
|
||||
|
||||
this.$refs.modal.show()
|
||||
},
|
||||
onModalClose() {
|
||||
this.projects.splice(
|
||||
this.projects.findIndex((x) => this.currentProject.id === x.id),
|
||||
1
|
||||
)
|
||||
this.currentProject = null
|
||||
},
|
||||
async deleteReport(index) {
|
||||
startLoading()
|
||||
|
||||
try {
|
||||
await useBaseFetch(`report/${this.reports[index].id}`, {
|
||||
method: 'DELETE',
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
this.reports.splice(index, 1)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
stopLoading()
|
||||
},
|
||||
},
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
h1 {
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.report {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
|
||||
a {
|
||||
text-transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
min-width: fit-content;
|
||||
|
||||
.iconified-button {
|
||||
margin-left: auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.stat {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.page-contents {
|
||||
max-width: 800px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
46
pages/moderation/index.vue
Normal file
46
pages/moderation/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Statistics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.projects) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Versions</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.versions) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Files</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.files) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Authors</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.authors) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatNumber } from '~/plugins/shorthands.js'
|
||||
|
||||
useHead({
|
||||
title: 'Staff overview - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const { data: stats } = await useAsyncData('statistics', () =>
|
||||
useBaseFetch('statistics', app.$defaultHeaders())
|
||||
)
|
||||
</script>
|
||||
41
pages/moderation/messages.vue
Normal file
41
pages/moderation/messages.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Messages</h2>
|
||||
|
||||
<ThreadSummary
|
||||
v-for="thread in inbox"
|
||||
:key="thread.id"
|
||||
:thread="thread"
|
||||
:link="getLink(thread)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Moderation inbox - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const { data: inbox } = await useAsyncData('thread/inbox', () =>
|
||||
useBaseFetch('thread/inbox', app.$defaultHeaders())
|
||||
)
|
||||
|
||||
function getLink(thread) {
|
||||
if (thread.report_id) {
|
||||
return `/moderation/report/${thread.report_id}`
|
||||
} else if (thread.project_id) {
|
||||
return `/project/${thread.project_id}/moderation`
|
||||
}
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.thread-summary:not(:last-child) {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
</style>
|
||||
15
pages/moderation/report/[id].vue
Normal file
15
pages/moderation/report/[id].vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ReportView
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportView from '~/components/ui/report/ReportView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
16
pages/moderation/reports.vue
Normal file
16
pages/moderation/reports.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Reports</h2>
|
||||
<ReportsList moderation />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Reports - Modrinth',
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
254
pages/moderation/review.vue
Normal file
254
pages/moderation/review.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<h2>Review projects</h2>
|
||||
<div class="input-group">
|
||||
<Chips
|
||||
v-model="projectType"
|
||||
:items="projectTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x) + 's')"
|
||||
/>
|
||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
||||
<SortDescIcon />Sorting by oldest
|
||||
</button>
|
||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
||||
<SortAscIcon />Sorting by newest
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="projectType !== 'all'" class="project-count">
|
||||
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
|
||||
projects in the queue.
|
||||
</p>
|
||||
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
|
||||
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
|
||||
<WarningIcon /> {{ projectsOver24Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 24 hours.
|
||||
</p>
|
||||
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
|
||||
<WarningIcon /> {{ projectsOver48Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 48 hours.
|
||||
</p>
|
||||
<div
|
||||
v-for="project in projectsFiltered.sort((a, b) => {
|
||||
if (oldestFirst) {
|
||||
return b.age - a.age
|
||||
} else {
|
||||
return a.age - b.age
|
||||
}
|
||||
})"
|
||||
:key="`project-${project.id}`"
|
||||
class="universal-card recessed project"
|
||||
>
|
||||
<div class="project-title">
|
||||
<div class="mobile-row">
|
||||
<nuxt-link
|
||||
:to="`/${project.inferred_project_type}/${project.slug}`"
|
||||
class="iconified-stacked-link"
|
||||
>
|
||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
||||
<span class="stacked">
|
||||
<span class="title">{{ project.title }}</span>
|
||||
<span>{{ $formatProjectType(project.inferred_project_type) }}</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
by
|
||||
<nuxt-link :to="`/user/${project.owner.username}`" class="iconified-link">
|
||||
<Avatar :src="project.owner.avatar_url" circle size="xxs" raised />
|
||||
<span>{{ project.owner.username }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
is requesting to be
|
||||
<Badge :type="project.requested_status ? project.requested_status : 'approved'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<nuxt-link
|
||||
:to="`/${project.inferred_project_type}/${project.slug}`"
|
||||
class="iconified-button raised-button"
|
||||
><EyeIcon /> View project</nuxt-link
|
||||
>
|
||||
</div>
|
||||
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
|
||||
<WarningIcon v-if="project.age_warning" />
|
||||
Submitted
|
||||
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
|
||||
fromNow(project.queued)
|
||||
}}</span>
|
||||
</span>
|
||||
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import UnknownIcon from '~/assets/images/utils/unknown.svg'
|
||||
import EyeIcon from '~/assets/images/utils/eye.svg'
|
||||
import SortAscIcon from '~/assets/images/utils/sort-asc.svg'
|
||||
import SortDescIcon from '~/assets/images/utils/sort-desc.svg'
|
||||
import WarningIcon from '~/assets/images/utils/issues.svg'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||
|
||||
useHead({
|
||||
title: 'Review projects - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const now = app.$dayjs()
|
||||
const TIME_24H = 86400000
|
||||
const TIME_48H = TIME_24H * 2
|
||||
|
||||
const { data: projects } = await useAsyncData('moderation/projects?count=1000', () =>
|
||||
useBaseFetch('moderation/projects?count=1000', app.$defaultHeaders())
|
||||
)
|
||||
const members = ref([])
|
||||
const projectType = ref('all')
|
||||
const oldestFirst = ref(true)
|
||||
|
||||
const projectsFiltered = computed(() =>
|
||||
projects.value.filter(
|
||||
(x) =>
|
||||
projectType.value === 'all' ||
|
||||
app.$getProjectTypeForUrl(x.project_type, x.loaders) === projectType.value
|
||||
)
|
||||
)
|
||||
|
||||
const projectsOver24Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H)
|
||||
)
|
||||
const projectsOver48Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_48H)
|
||||
)
|
||||
const projectTypePlural = computed(() =>
|
||||
projectType.value === 'all'
|
||||
? 'projects'
|
||||
: (formatProjectType(projectType.value) + 's').toLowerCase()
|
||||
)
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const set = new Set()
|
||||
set.add('all')
|
||||
|
||||
if (projects.value) {
|
||||
for (const project of projects.value) {
|
||||
set.add(project.inferred_project_type)
|
||||
}
|
||||
}
|
||||
|
||||
return [...set]
|
||||
})
|
||||
|
||||
if (projects.value) {
|
||||
const teamIds = projects.value.map((x) => x.team)
|
||||
|
||||
await useAsyncData(
|
||||
'teams?ids=' + JSON.stringify(teamIds),
|
||||
() => useBaseFetch('teams?ids=' + JSON.stringify(teamIds), app.$defaultHeaders()),
|
||||
{
|
||||
transform: (result) => {
|
||||
if (result) {
|
||||
members.value = result
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
project.owner = members.value
|
||||
.flat()
|
||||
.find((x) => x.team_id === project.team && x.role === 'Owner').user
|
||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE
|
||||
project.age_warning = ''
|
||||
if (project.age > TIME_24H * 2) {
|
||||
project.age_warning = 'danger'
|
||||
} else if (project.age > TIME_24H) {
|
||||
project.age_warning = 'warning'
|
||||
}
|
||||
project.inferred_project_type = app.$getProjectTypeForUrl(
|
||||
project.project_type,
|
||||
project.loaders
|
||||
)
|
||||
return project
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.project {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
@media screen and (min-width: 650px) {
|
||||
display: grid;
|
||||
grid-template: 'title action' 'date action';
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.submitter-info {
|
||||
margin: 0;
|
||||
grid-area: date;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-special-orange);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--color-special-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
margin-block: var(--spacing-card-md);
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
grid-area: action;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.mobile-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.mobile-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar) {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.size-xs {
|
||||
margin-right: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,225 +0,0 @@
|
||||
<template>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<aside class="universal-card">
|
||||
<h1>Notifications</h1>
|
||||
<NavStack>
|
||||
<NavStackItem link="/notifications" label="All" :uses-query="true" />
|
||||
<NavStackItem
|
||||
v-for="type in notificationTypes"
|
||||
:key="type"
|
||||
:link="'/notifications/' + type"
|
||||
:label="NOTIFICATION_TYPES[type]"
|
||||
:uses-query="true"
|
||||
/>
|
||||
<h3>Manage</h3>
|
||||
<NavStackItem link="/settings/follows" label="Followed projects" chevron>
|
||||
<SettingsIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem
|
||||
v-if="user.notifications.length > 0"
|
||||
:action="clearNotifications"
|
||||
label="Clear all"
|
||||
danger
|
||||
>
|
||||
<ClearIcon />
|
||||
</NavStackItem>
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<div class="notifications">
|
||||
<div
|
||||
v-for="notification in $route.params.type !== undefined
|
||||
? user.notifications.filter((x) => x.type === $route.params.type)
|
||||
: user.notifications"
|
||||
:key="notification.id"
|
||||
class="universal-card adjacent-input"
|
||||
>
|
||||
<div class="label">
|
||||
<span class="label__title">
|
||||
<nuxt-link :to="notification.link">
|
||||
<h3 v-html="renderString(notification.title)" />
|
||||
</nuxt-link>
|
||||
</span>
|
||||
<div class="label__description">
|
||||
<p>{{ notification.text }}</p>
|
||||
<span
|
||||
v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm:ss A')"
|
||||
class="date"
|
||||
>
|
||||
<CalendarIcon />
|
||||
Received {{ fromNow(notification.created) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(action, actionIndex) in notification.actions"
|
||||
:key="actionIndex"
|
||||
class="iconified-button"
|
||||
:class="`action-button-${action.title.toLowerCase().replaceAll(' ', '-')}`"
|
||||
@click="performAction(notification, notificationIndex, actionIndex)"
|
||||
>
|
||||
{{ action.title }}
|
||||
</button>
|
||||
<button
|
||||
v-if="notification.actions.length === 0"
|
||||
class="iconified-button"
|
||||
@click="performAction(notification, notificationIndex, null)"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="user.notifications.length === 0" class="error">
|
||||
<UpToDate class="icon" />
|
||||
<br />
|
||||
<span class="text">You are up-to-date!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ClearIcon from '~/assets/images/utils/clear.svg'
|
||||
import SettingsIcon from '~/assets/images/utils/settings.svg'
|
||||
import CalendarIcon from '~/assets/images/utils/calendar.svg'
|
||||
import UpToDate from '~/assets/images/illustrations/up_to_date.svg'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
import { renderString } from '~/helpers/parse.js'
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
NavStack,
|
||||
NavStackItem,
|
||||
ClearIcon,
|
||||
SettingsIcon,
|
||||
CalendarIcon,
|
||||
UpToDate,
|
||||
},
|
||||
async setup() {
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
const user = await useUser()
|
||||
if (process.client) {
|
||||
await initUserNotifs()
|
||||
}
|
||||
|
||||
return { user: ref(user) }
|
||||
},
|
||||
head: {
|
||||
title: 'Notifications - Modrinth',
|
||||
},
|
||||
computed: {
|
||||
notificationTypes() {
|
||||
const obj = {}
|
||||
|
||||
for (const notification of this.user.notifications.filter((it) => it.type !== null)) {
|
||||
obj[notification.type] = true
|
||||
}
|
||||
|
||||
return Object.keys(obj)
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.NOTIFICATION_TYPES = {
|
||||
team_invite: 'Team invites',
|
||||
project_update: 'Project updates',
|
||||
status_update: 'Status changes',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
renderString,
|
||||
async clearNotifications() {
|
||||
try {
|
||||
const ids = this.user.notifications.map((x) => x.id)
|
||||
|
||||
await useBaseFetch(`notifications?ids=${JSON.stringify(ids)}`, {
|
||||
method: 'DELETE',
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
|
||||
for (const id of ids) {
|
||||
await userDeleteNotification(id)
|
||||
}
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
},
|
||||
async performAction(notification, _notificationIndex, actionIndex) {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`notification/${notification.id}`, {
|
||||
method: 'DELETE',
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
|
||||
await userDeleteNotification(notification.id)
|
||||
|
||||
if (actionIndex !== null) {
|
||||
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
|
||||
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
|
||||
...this.$defaultHeaders(),
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
this.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notifications {
|
||||
.label {
|
||||
.label__title {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-sm);
|
||||
align-items: baseline;
|
||||
margin-block-start: 0;
|
||||
|
||||
:deep(h3) {
|
||||
margin: 0;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label__description {
|
||||
margin: 0;
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-card-xs);
|
||||
color: var(--color-heading);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-block: 0 var(--spacing-card-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
<template><div /></template>
|
||||
@@ -12,9 +12,6 @@
|
||||
<NavStackItem link="/settings/account" label="Account">
|
||||
<UserIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/settings/follows" label="Followed projects">
|
||||
<HeartIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/settings/monetization" label="Monetization">
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
@@ -33,7 +30,6 @@ import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg'
|
||||
import UserIcon from '~/assets/images/utils/user.svg'
|
||||
import HeartIcon from '~/assets/images/utils/heart.svg'
|
||||
import CurrencyIcon from '~/assets/images/utils/currency.svg'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<div class="stats-block__item secondary-stat">
|
||||
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
|
||||
<span
|
||||
v-tooltip="$dayjs(user.created).format('MMMM D, YYYY [at] h:mm:ss A')"
|
||||
v-tooltip="$dayjs(user.created).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="secondary-stat__text date"
|
||||
>
|
||||
Joined {{ fromNow(user.created) }}
|
||||
|
||||
Reference in New Issue
Block a user