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:
Prospector
2023-07-15 20:39:33 -07:00
committed by GitHub
parent 1a2b45eebd
commit a5613ebb10
67 changed files with 3613 additions and 776 deletions

View File

@@ -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>

View 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>

View File

@@ -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',

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -290,10 +290,6 @@ async function handleFiles(files) {
flex-direction: column;
gap: var(--spacing-card-xs);
}
&:active:not(&:disabled) {
transform: scale(0.99) !important;
}
}
}

View File

@@ -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',

View File

@@ -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',
})

View File

@@ -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>

View 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>

View File

@@ -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 {

View 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>

View 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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -1241,11 +1241,9 @@ const rows = shallowRef([
}
.notifs-demo {
.notifications .notification {
img {
width: 5rem;
height: 5rem;
}
.notifications .notification .avatar {
width: 5rem;
height: 5rem;
}
}
}

View File

@@ -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>

View 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>

View 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>

View 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>

View 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
View 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>

View File

@@ -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>

View File

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

View File

@@ -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()

View File

@@ -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) }}