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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 3613 additions and 776 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-check"><path d="M18 6 7 17l-5-5"/><path d="m22 10-7.5 7.5L13 16"/></svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 12H3"></path>
<path d="M16 6H3"></path>
<path d="M10 18H3"></path>
<path d="M21 6v10a2 2 0 0 1-2 2h-4"></path>
<path d="m16 16-2 2 2 2"></path>
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 8-9.04 9.06a2.82 2.82 0 1 0 3.98 3.98L16 12"></path>
<circle cx="17" cy="7" r="5"></circle>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@ -0,0 +1,4 @@
<svg width="512" height="514" viewBox="0 0 512 514" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M503.16 323.56C514.55 281.47 515.32 235.91 503.2 190.76C466.57 54.2299 326.04 -26.8001 189.33 9.77991C83.8101 38.0199 11.3899 128.07 0.689941 230.47H43.99C54.29 147.33 113.74 74.7298 199.75 51.7098C306.05 23.2598 415.13 80.6699 453.17 181.38L411.03 192.65C391.64 145.8 352.57 111.45 306.3 96.8198L298.56 140.66C335.09 154.13 364.72 184.5 375.56 224.91C391.36 283.8 361.94 344.14 308.56 369.17L320.09 412.16C390.25 383.21 432.4 310.3 422.43 235.14L464.41 223.91C468.91 252.62 467.35 281.16 460.55 308.07L503.16 323.56Z" fill="currentColor"/>
<path d="M321.99 504.22C185.27 540.8 44.7501 459.77 8.11011 323.24C3.84011 307.31 1.17 291.33 0 275.46H43.27C44.36 287.37 46.4699 299.35 49.6799 311.29C53.0399 323.8 57.45 335.75 62.79 347.07L101.38 323.92C98.1299 316.42 95.39 308.6 93.21 300.47C69.17 210.87 122.41 118.77 212.13 94.7601C229.13 90.2101 246.23 88.4401 262.93 89.1501L255.19 133C244.73 133.05 234.11 134.42 223.53 137.25C157.31 154.98 118.01 222.95 135.75 289.09C136.85 293.16 138.13 297.13 139.59 300.99L188.94 271.38L174.07 231.95L220.67 184.08L279.57 171.39L296.62 192.38L269.47 219.88L245.79 227.33L228.87 244.72L237.16 267.79C237.16 267.79 253.95 285.63 253.98 285.64L277.7 279.33L294.58 260.79L331.44 249.12L342.42 273.82L304.39 320.45L240.66 340.63L212.08 308.81L162.26 338.7C187.8 367.78 226.2 383.93 266.01 380.56L277.54 423.55C218.13 431.41 160.1 406.82 124.05 361.64L85.6399 384.68C136.25 451.17 223.84 484.11 309.61 461.16C371.35 444.64 419.4 402.56 445.42 349.38L488.06 364.88C457.17 431.16 398.22 483.82 321.99 504.22Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-more-horizontal"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 17 4 12 9 7"></polyline>
<path d="M20 18v-2a4 4 0 0 0-4-4H4"></path>
</svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 11h4"></path>
<path d="M11 15h7"></path>
<path d="M11 19h10"></path>
<path d="M9 7 6 4 3 7"></path>
<path d="M6 6v14"></path>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 5h10"></path>
<path d="M11 9h7"></path>
<path d="M11 13h4"></path>
<path d="m3 17 3 3 3-3"></path>
<path d="M6 18V4"></path>
</svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@ -166,7 +166,6 @@
@extend .padding-lg;
position: relative;
min-height: var(--font-size-2xl);
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
@ -213,6 +212,11 @@
&.moderation-card {
background-color: var(--color-warning-banner-bg);
}
&.recessed {
background-color: var(--color-bg);
box-shadow: none;
}
}
.universal-labels {
@ -328,7 +332,9 @@
.resizable-textarea-wrapper + *,
.input-div + *
) {
margin-block-start: var(--spacing-card-md);
&:not(:empty) {
margin-block-start: var(--spacing-card-md);
}
}
:where(button, .button, .iconified-button) {
@ -480,6 +486,7 @@
justify-content: space-between;
flex-wrap: wrap;
row-gap: 0.5rem;
min-height: 3.75rem;
}
/*
@ -528,6 +535,15 @@
&:active {
color: var(--color-link-active);
}
&.no-underline {
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
}
.title-link {
@ -767,15 +783,6 @@
}
}
.button-animation {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
&:active:not(&:disabled) {
transform: scale(0.95);
}
}
.button-base {
@extend .button-animation;
font-weight: 500;
@ -810,6 +817,14 @@
&:active:not(&:disabled) {
background-color: var(--color-raised-bg);
}
&.brand-button {
color: var(--color-brand);
}
&.danger-button {
color: var(--color-special-red);
}
}
tr.button-transparent {
@ -851,11 +866,6 @@ tr.button-transparent {
filter: brightness(0.8);
}
// For some reason this within the above block makes it universal and not only applied to children. SCSS bug maybe?
&:active:not(&.disabled) button:not(&:disabled) {
transform: scale(0.95);
}
&.disabled {
cursor: not-allowed;
filter: grayscale(50%);
@ -1461,13 +1471,17 @@ button {
flex-shrink: 1;
}
}
&.right-aligned {
justify-content: end;
}
}
.input-stack {
display: flex;
flex-direction: column;
> * {
> *:not(:last-child) {
margin-bottom: var(--spacing-card-sm);
}
@ -1605,3 +1619,84 @@ button {
height: 0;
overflow: hidden;
}
.backed-svg {
--size: 2.5rem;
border-radius: var(--size-rounded-sm);
background-color: var(--color-button-bg);
width: var(--size);
height: var(--size);
display: inline-flex;
justify-content: center;
align-items: center;
svg {
width: calc(0.6 * var(--size));
height: calc(0.6 * var(--size));
}
&.circle {
border-radius: 50%;
}
&.raised {
background-color: var(--color-raised-bg);
}
}
a.iconified-link,
a.iconified-stacked-link {
display: contents;
.space {
opacity: 0;
}
.title {
font-weight: bold;
}
.stacked {
display: inline-flex;
flex-direction: column;
}
&:focus-visible .title,
&:hover .title {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active .title {
filter: var(--active-filter);
}
}
a.iconified-link {
&:focus-visible span,
&:hover span {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active span {
filter: var(--active-filter);
}
}
a.subtle-link {
&:focus-visible,
&:hover {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active {
filter: var(--active-filter);
}
}
.inline-svg svg,
svg.inline-svg {
vertical-align: middle;
}

View File

@ -2,12 +2,12 @@ html {
@extend .dark-mode;
--dark-color-text: #b0bac5;
--dark-color-text-dark: #ecf9fb;
--color-text-secondary: var(--color-icon);
}
.light-mode {
--color-icon: #6b7280;
--color-text: hsl(221, 39%, 11%);
--color-text-secondary: var(--color-icon);
--color-text-inactive: hsl(215, 14%, 34%);
--color-text-dark: #1a202c;
--color-heading: #2c313d;
@ -260,6 +260,9 @@ html {
--landing-green-label-bg: rgba(0, 216, 69, 0.15);
--landing-raw-bg: #000;
--hover-filter: brightness(120%);
--active-filter: brightness(140%);
}
.oled-mode {
@ -271,6 +274,10 @@ html {
--color-button-bg-hover: #2d2d32;
--color-button-bg-active: #3c3c40;
--color-table-alternate-row: #19191c;
--color-divider-dark: #2c3134;
--color-warning-banner-bg: hsl(0, 45%, 11%);
--color-ad: #0d1828;
}
body {

View File

@ -4,7 +4,7 @@
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
}`"
} ${raised ? 'raised' : ''}`"
:src="src"
:alt="alt"
:loading="loading"
@ -12,7 +12,9 @@
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''}`"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
raised ? 'raised' : ''
}`"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@ -47,7 +49,7 @@ export default {
type: String,
default: 'sm',
validator(value) {
return ['xs', 'sm', 'md', 'lg'].includes(value)
return ['xxs', 'xs', 'sm', 'md', 'lg'].includes(value)
},
},
circle: {
@ -62,6 +64,10 @@ export default {
type: String,
default: 'eager',
},
raised: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -89,6 +95,12 @@ export default {
background-color: var(--color-button-bg);
object-fit: contain;
&.size-xxs {
--size: 1.25rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
@ -122,5 +134,9 @@ export default {
&.pixelated {
image-rendering: pixelated;
}
&.raised {
background-color: var(--color-raised-bg);
}
}
</style>

View File

@ -1,22 +1,23 @@
<template>
<span :class="'version-badge ' + color + ' type--' + type">
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }} </template>
<span :class="'badge ' + color + ' type--' + type">
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
<!-- User roles -->
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team </template>
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator </template>
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator</template>
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'"><ListIcon /> Listed</template>
<template v-else-if="type === 'approved-general'"><CheckIcon /> Approved</template>
<template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template>
<template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template>
<template v-else-if="type === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled </template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived </template>
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived</template>
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review </template>
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review</template>
<!-- Team members -->
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
@ -25,6 +26,9 @@
<!-- Transaction statuses -->
<template v-else-if="type === 'success'"><CheckIcon /> Success</template>
<!-- Report status -->
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
</span>
@ -43,6 +47,7 @@ import ProcessingIcon from '~/assets/images/utils/updated.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import LockIcon from '~/assets/images/utils/lock.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import CloseIcon from '~/assets/images/utils/check-circle.svg'
export default {
components: {
@ -58,6 +63,7 @@ export default {
CheckIcon,
LockIcon,
CalendarIcon,
CloseIcon,
},
props: {
type: {
@ -73,13 +79,12 @@ export default {
</script>
<style lang="scss" scoped>
.version-badge {
display: flex;
align-items: center;
.badge {
font-weight: bold;
width: fit-content;
--badge-color: var(--color-special-gray);
color: var(--badge-color);
white-space: nowrap;
.circle {
width: 0.5rem;
@ -91,9 +96,12 @@ export default {
}
svg {
margin-right: 0.25rem;
vertical-align: -15%;
width: 1em;
height: 1em;
}
&.type--closed,
&.type--withheld,
&.type--rejected,
&.red {
@ -111,6 +119,7 @@ export default {
&.type--accepted,
&.type--admin,
&.type--success,
&.type--approved-general,
&.green {
--badge-color: var(--color-special-green);
}

View File

@ -4,7 +4,7 @@
v-for="item in items"
:key="item"
class="iconified-button"
:class="{ selected: selected === item }"
:class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
@ -37,6 +37,10 @@ export default {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
},
emits: ['update:modelValue'],
computed: {
@ -73,7 +77,9 @@ export default {
flex-wrap: wrap;
.iconified-button {
text-transform: capitalize;
&.capitalize {
text-transform: capitalize;
}
svg {
width: 1em;

View File

@ -0,0 +1,21 @@
<template>
<nuxt-link v-if="isLink" :to="to">
<slot />
</nuxt-link>
<span v-else>
<slot />
</span>
</template>
<script setup>
defineProps({
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
})
</script>

View File

@ -38,7 +38,7 @@ export default {
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: flex;
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);

View File

@ -0,0 +1,33 @@
<template>
<div class="double-icon">
<slot name="primary" />
<div class="secondary">
<slot name="secondary" />
</div>
</div>
</template>
<style lang="scss" scoped>
.double-icon {
position: relative;
height: fit-content;
line-height: 0;
.secondary {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-bg);
padding: var(--spacing-card-xs);
border-radius: 50%;
aspect-ratio: 1 / 1;
width: fit-content;
height: fit-content;
line-height: 0;
svg {
width: 1rem;
height: 1rem;
}
}
}
</style>

View File

@ -0,0 +1,537 @@
<template>
<div
class="notification"
:class="{
'has-body': hasBody,
compact: compact,
read: notification.read,
}"
>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<NotificationIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ModerationIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<InvitationIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<NotificationIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link
>, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link :to="`/user/${invitedBy.username}`" class="iconified-link title-link">
<Avatar :src="invitedBy.avatar_url" circle size="xxs" no-shadow :raised="raised" />
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="$tag.rejectedStatuses.includes(notification.body.new_status)">
has been <Badge :type="notification.body.new_status" />
</template>
<template v-else>
updated from
<Badge :type="notification.body.old_status" />
to
<Badge :type="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link
>, has received
<template v-if="notification.grouped_notifs"> messages </template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }} </nuxt-link
>.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<div
v-for="notif in notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]"
:key="notif.id"
class="version-link"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version.name)"
class="text-link no-underline"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="notif.extra_data.version.loaders"
:type="notif.extra_data.project.project_type"
class="categories"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span
v-tooltip="
$dayjs(notif.extra_data.version.date_published).format('MMMM D, YYYY [at] h:mm A')
"
class="date"
>
{{ fromNow(notif.extra_data.version.date_published) }}
</span>
</span>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge"> <ReadIcon /> Read </span>
<span v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')">
<CalendarIcon /> Received {{ fromNow(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite'">
<button
v-tooltip="`Accept`"
class="iconified-button square-button brand-button button-transparent"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
</button>
<button
v-tooltip="`Decline`"
class="iconified-button square-button danger-button button-transparent"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<CrossIcon />
</button>
</template>
<button
v-else-if="!notification.read"
v-tooltip="`Mark as read`"
class="iconified-button square-button button-transparent"
@click="read()"
>
<CrossIcon />
</button>
</div>
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template v-if="type === 'team_invite' && !notification.read">
<button
class="iconified-button brand-button"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon /> Accept
</button>
<button
class="iconified-button danger-button"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<CrossIcon /> Decline
</button>
</template>
<button
v-else-if="!notification.read"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="read()"
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="$cosmetics.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<nuxt-link
v-if="notification.link && notification.link !== '#'"
class="iconified-button"
:class="{ 'raised-button': raised }"
:to="notification.link"
target="_blank"
>
<ExternalIcon />
Open link
</nuxt-link>
<button
v-for="(action, actionIndex) in notification.actions"
:key="actionIndex"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="performAction(notification, actionIndex)"
>
<CheckIcon v-if="action.title === 'Accept'" />
<CrossIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
<button
v-if="notification.actions.length === 0 && !notification.read"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="performAction(notification, null)"
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="$cosmetics.developerMode" :text="notification.id" />
</div>
</div>
</div>
</template>
<script setup>
import InvitationIcon from '~/assets/images/utils/user-plus.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import NotificationIcon from '~/assets/images/sidebar/notifications.svg'
import ReadIcon from '~/assets/images/utils/check-circle.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import VersionIcon from '~/assets/images/utils/version.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import ExternalIcon from '~/assets/images/utils/external.svg'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { getProjectLink, getVersionLink } from '~/helpers/projects.js'
import { getUserLink } from '~/helpers/users.js'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
import { markAsRead } from '~/helpers/notifications.js'
import { renderString } from '~/helpers/parse.js'
import DoubleIcon from '~/components/ui/DoubleIcon.vue'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import Categories from '~/components/ui/search/Categories.vue'
const app = useNuxtApp()
const emit = defineEmits(['update:notifications'])
const props = defineProps({
notification: {
type: Object,
required: true,
},
notifications: {
type: Array,
required: true,
},
raised: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
})
const type = computed(() =>
!props.notification.body || props.notification.body.type === 'legacy_markdown'
? null
: props.notification.body.type
)
const thread = computed(() => props.notification.extra_data.thread)
const report = computed(() => props.notification.extra_data.report)
const project = computed(() => props.notification.extra_data.project)
const version = computed(() => props.notification.extra_data.version)
const user = computed(() => props.notification.extra_data.user)
const invitedBy = computed(() => props.notification.extra_data.invited_by)
const threadLink = computed(() => {
if (report.value) {
return `/dashboard/report/${report.value.id}`
} else if (project.value) {
return `${getProjectLink(project.value)}/moderation#messages`
}
return '#'
})
const hasBody = computed(() => !type.value || thread.value || type.value === 'project_update')
async function read() {
try {
const ids = [
props.notification.id,
...(props.notification.grouped_notifs
? props.notification.grouped_notifs.map((notif) => notif.id)
: []),
]
const updateNotifs = await markAsRead(ids)
const newNotifs = updateNotifs(props.notifications)
emit('update:notifications', newNotifs)
} catch (err) {
app.$notify({
group: 'main',
title: 'Error marking notification as read',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function performAction(notification, actionIndex) {
startLoading()
try {
await read()
await userDeleteNotification(notification.id)
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
...app.$defaultHeaders(),
})
}
} catch (err) {
app.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
function getMessages() {
const messages = []
if (props.notification.body.message_id) {
messages.push(props.notification.body.message_id)
}
if (props.notification.grouped_notifs) {
for (const notif of props.notification.grouped_notifs) {
if (notif.body.message_id) {
messages.push(notif.body.message_id)
}
}
}
return messages
}
</script>
<style lang="scss" scoped>
.notification {
display: grid;
grid-template:
'icon title'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content min-content;
gap: var(--spacing-card-sm);
&.compact {
grid-template:
'icon title actions'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: auto min-content;
}
&.has-body {
grid-template:
'icon title'
'body body'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content auto auto min-content;
&.compact {
grid-template:
'icon title actions'
'body body body'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content auto min-content;
}
}
.label__title,
.label__description,
h1,
h2,
h3,
h4,
:deep(p) {
margin: 0 !important;
}
.notification__icon {
grid-area: icon;
}
.notification__title {
grid-area: title;
color: var(--color-heading);
margin-block: auto;
display: inline-block;
vertical-align: middle;
line-height: 1.25rem;
.iconified-link {
display: inline;
img {
vertical-align: middle;
position: relative;
top: -2px;
}
}
}
.notification__body {
grid-area: body;
.version-list {
margin: 0;
padding: 0;
list-style-type: none;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--spacing-card-sm);
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
.version-link {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
.version-info {
display: contents;
:deep(span) {
color: var(--color-text);
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
}
}
}
.notification__date {
grid-area: date;
color: var(--color-text-secondary);
svg {
vertical-align: top;
}
.read-badge {
font-weight: bold;
color: var(--color-text);
margin-right: var(--spacing-card-xs);
}
}
.notification__actions {
grid-area: actions;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
}
.unknown-type {
color: var(--color-special-red);
}
.title-link {
&:not(:hover) {
text-decoration: none;
}
font-weight: bold;
}
.moderation-color {
color: var(--color-special-orange);
}
.creator-color {
color: var(--color-special-blue);
}
}
</style>

View File

@ -141,15 +141,6 @@ a {
filter: grayscale(50%);
opacity: 0.5;
}
&:hover:not(&:disabled) {
filter: brightness(0.85);
}
&:active:not(&:disabled) {
transform: scale(0.95);
filter: brightness(0.8);
}
}
.has-icon {

View File

@ -70,7 +70,7 @@
</div>
<div
v-if="showUpdatedDate"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm:ss A')"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<EditIcon aria-hidden="true" />
@ -78,7 +78,7 @@
</div>
<div
v-else
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm:ss A')"
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<CalendarIcon aria-hidden="true" />

View File

@ -1,4 +1,18 @@
<template>
<div v-if="$auth.user && showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
</p>
<div class="input-group">
<button class="iconified-button brand-button" @click="acceptInvite()">
<CheckIcon />Accept
</button>
<button class="iconified-button danger-button" @click="declineInvite()">
<CrossIcon />Decline
</button>
</div>
</div>
<div
v-if="
$auth.user &&
@ -63,17 +77,6 @@
/>{{ nag.title }}</span
>
{{ nag.description }}
<Checkbox
v-if="
nag.status === 'review' &&
project.moderator_message &&
$tag.rejectedStatuses.includes(project.status)
"
v-model="acknowledgedMessage"
description="Acknowledge staff message in sidebar"
>
I acknowledge that I have addressed the staff's message on the sidebar
</Checkbox>
<NuxtLink
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
@ -103,18 +106,19 @@
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import DropdownIcon from '~/assets/images/utils/dropdown.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import RequiredIcon from '~/assets/images/utils/asterisk.svg'
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import SendIcon from '~/assets/images/utils/send.svg'
import Checkbox from '~/components/ui/Checkbox.vue'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
export default {
components: {
Checkbox,
ChevronRightIcon,
DropdownIcon,
CheckIcon,
CrossIcon,
RequiredIcon,
SuggestionIcon,
ModerationIcon,
@ -135,6 +139,10 @@ export default {
type: Object,
default: null,
},
allMembers: {
type: Object,
default: null,
},
isSettings: {
type: Boolean,
default: false,
@ -173,11 +181,19 @@ export default {
}
},
},
},
data() {
return {
acknowledgedMessage: !this.project.moderator_message,
}
updateMembers: {
type: Function,
default() {
return () => {
this.$notify({
group: 'main',
title: 'An error occurred',
text: 'updateMembers function not found',
type: 'error',
})
}
},
},
},
computed: {
featuredGalleryImage() {
@ -326,13 +342,10 @@ export default {
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: 'review',
link: null,
action: {
onClick: this.submitForReview,
title: 'Resubmit for review',
disabled: () =>
!this.acknowledgedMessage ||
this.nags.filter((x) => x.condition && x.status === 'required').length > 0,
link: {
path: 'moderation',
title: 'Visit moderation page',
hide: this.routeName === 'type-id-moderation',
},
},
]
@ -349,8 +362,23 @@ export default {
)
)
},
showInvitation() {
if (this.allMembers && this.$auth) {
const member = this.allMembers.find((x) => x.user.id === this.$auth.user.id)
return member && !member.accepted
}
return false
},
},
methods: {
acceptInvite() {
acceptTeamInvite(this.project.team)
this.updateMembers()
},
declineInvite() {
removeSelfFromTeam(this.project.team)
this.updateMembers()
},
sortByTrue(a, b, ifEqual = 0) {
if (a === b) {
return ifEqual
@ -382,6 +410,9 @@ export default {
</script>
<style lang="scss" scoped>
.invited {
}
.author-actions {
&:empty {
display: none;

View File

@ -0,0 +1,188 @@
<template>
<div class="report">
<div v-if="report.item_type === 'project'" class="item-info">
<nuxt-link
:to="`/${$getProjectTypeForUrl(report.project.project_type, report.project.loaders)}/${
report.project.slug
}`"
class="iconified-stacked-link"
>
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders)
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'user'" class="item-info">
<nuxt-link :to="`/user/${report.user.username}`" class="iconified-stacked-link">
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.user.username }}</span>
<span>User</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }"><VersionIcon /></div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders)
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
<span>Unknown report type</span>
</div>
<div class="report-type">
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<ThreadSummary
v-if="thread"
:thread="thread"
class="thread-summary"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
/>
<div class="reporter-info">
<ReportIcon class="inline-svg" /> Reported by
<span v-if="$auth.user.id === report.reporterUser.id">you</span>
<nuxt-link v-else :to="`/user/${report.reporterUser.username}`" class="iconified-link">
<Avatar
:src="report.reporterUser.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
/>
<span>{{ report.reporterUser.username }}</span>
</nuxt-link>
<span>&nbsp;</span>
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
fromNow(report.created)
}}</span>
<CopyCode v-if="$cosmetics.developerMode" :text="report.id" class="report-id" />
</div>
</div>
</template>
<script setup>
import { renderHighlightedString } from '~/helpers/highlight.js'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import ReportIcon from '~/assets/images/utils/report.svg'
import UnknownIcon from '~/assets/images/utils/unknown.svg'
import VersionIcon from '~/assets/images/utils/version.svg'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
defineProps({
report: {
type: Object,
required: true,
},
raised: {
type: Boolean,
default: false,
},
thread: {
type: Object,
default: null,
},
showMessage: {
type: Boolean,
default: true,
},
moderation: {
type: Boolean,
default: false,
},
})
</script>
<style lang="scss" scoped>
.report {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
flex-wrap: wrap;
.report-type {
grid-area: type;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
margin-top: var(--spacing-card-xs);
}
.item-info {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
color: var(--color-heading);
grid-area: title;
img,
.backed-svg {
margin-right: var(--spacing-card-xs);
}
}
.markdown-body {
grid-area: body;
}
.reporter-info {
grid-area: reporter;
gap: var(--spacing-card-xs);
color: var(--color-text-secondary);
img {
vertical-align: middle;
position: relative;
top: -1px;
margin-right: var(--spacing-card-xs);
}
a {
gap: var(--spacing-card-xs);
}
}
.action {
grid-area: action;
}
.thread-summary {
grid-area: summary;
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
.report-id {
margin-left: var(--spacing-card-sm);
}
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="breadcrumbsStack"
:current-title="`Report ${reportId}`"
:link-stack="breadcrumbsStack"
/>
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" />
</section>
<section class="universal-card">
<h2>Messages</h2>
<ConversationThread :thread="thread" :report="report" :update-thread="updateThread" />
</section>
</div>
</template>
<script setup>
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
const props = defineProps({
reportId: {
type: String,
required: true,
},
breadcrumbsStack: {
type: Array,
default: null,
},
})
const app = useNuxtApp()
const report = ref(null)
await fetchReport().then((result) => {
report.value = result
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`, app.$defaultHeaders())
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
async function updateThread(newThread) {
rawThread.value = newThread
report.value = await fetchReport()
}
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`, app.$defaultHeaders())
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
const userIds = []
userIds.push(rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
userIds.push(rawReport.value.item_id)
}
const versionId = rawReport.value.item_type === 'version' ? rawReport.value.item_id : null
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${JSON.stringify(userIds)}`, app.$defaultHeaders())
)
users = usersVal.value
}
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`, app.$defaultHeaders())
)
version = versionVal.value
}
const projectId = version
? version.project_id
: rawReport.value.item_type === 'project'
? rawReport.value.item_id
: null
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`, app.$defaultHeaders())
)
project = projectVal.value
}
const reportData = rawReport.value
reportData.project = project
reportData.version = version
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
reportData.user = users.find((user) => user.id === rawReport.value.item_id)
}
return reportData
}
</script>
<style lang="scss" scoped>
.stacked {
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,95 @@
<template>
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === $auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open)
)"
:key="report.id"
:report="report"
:thread="report.thread"
:moderation="moderation"
raised
class="universal-card recessed"
/>
</template>
<script setup>
import Chips from '~/components/ui/Chips.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
defineProps({
moderation: {
type: Boolean,
default: false,
},
})
const app = useNuxtApp()
const viewMode = ref('open')
const reports = ref([])
let { data: rawReports } = await useAsyncData('report', () =>
useBaseFetch('report', app.$defaultHeaders())
)
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, '')
return report
})
const reporterUsers = rawReports.map((report) => report.reporter)
const reportedUsers = rawReports
.filter((report) => report.item_type === 'user')
.map((report) => report.item_id)
const versionReports = rawReports.filter((report) => report.item_type === 'version')
const versionIds = [...new Set(versionReports.map((report) => report.item_id))]
const userIds = [...new Set(reporterUsers.concat(reportedUsers))]
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
]
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${JSON.stringify(userIds)}`, app.$defaultHeaders())
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
useBaseFetch(`versions?ids=${JSON.stringify(versionIds)}`, app.$defaultHeaders())
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
useBaseFetch(`threads?ids=${JSON.stringify(threadIds)}`, app.$defaultHeaders())
),
])
const reportedProjects = rawReports
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id)
const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
useBaseFetch(`projects?ids=${JSON.stringify(projectIds)}`, app.$defaultHeaders())
)
reports.value = rawReports.map((report) => {
report.reporterUser = users.value.find((user) => user.id === report.reporter)
if (report.item_type === 'user') {
report.user = users.value.find((user) => user.id === report.item_id)
} else if (report.item_type === 'project') {
report.project = projects.value.find((project) => project.id === report.item_id)
} else if (report.item_type === 'version') {
report.version = versions.value.find((version) => version.id === report.item_id)
report.project = projects.value.find((project) => project.id === report.version.project_id)
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report
)
}
report.open = true
return report
})
</script>

View File

@ -46,9 +46,12 @@ export default {
display: flex;
align-items: center;
flex-direction: row;
margin-right: var(--spacing-card-md);
&:not(.version-badge) {
&:not(:last-child) {
margin-right: var(--spacing-card-md);
}
&:not(.badge) {
color: var(--color-icon);
}

View File

@ -0,0 +1,381 @@
<template>
<div>
<Modal
ref="modalSubmit"
:header="isRejected(project) ? 'Resubmit for review' : 'Submit for review'"
>
<div class="modal-submit universal-body">
<span>
You're submitting <span class="project-title">{{ project.title }}</span> to be reviewed
again by the moderators.
</span>
<span>
Make sure you have addressed the comments from the moderation team.
<span class="known-errors">
Repeated submissions without addressing the moderators' comments may result in an
account suspension.
</span>
</span>
<Checkbox
v-model="submissionConfirmation"
description="Confirm I have addressed the messages from the moderators"
>
I confirm that I have properly addressed the moderators' comments.
</Checkbox>
<div class="input-group push-right">
<button
class="iconified-button moderation-button"
:disabled="!submissionConfirmation"
@click="resubmit()"
>
<ModerationIcon /> Resubmit for review
</button>
</div>
</div>
</Modal>
<div v-if="$cosmetics.developerMode" class="thread-id">
Thread ID: <CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="messages universal-card recessed">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
raised
/>
</div>
<span v-if="report && report.closed">
This thread is closed and new messages cannot be sent to it.
</span>
<template v-else-if="!report || !report.closed">
<div class="resizable-textarea-wrapper">
<Chips v-model="replyViewMode" class="chips" :items="['source', 'preview']" />
<textarea
v-if="replyViewMode === 'source'"
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
/>
<div v-else class="markdown-body preview" v-html="renderString(replyBody)" />
</div>
<div class="input-group">
<button
v-if="sortedMessages.length > 0"
class="iconified-button brand-button"
:disabled="!replyBody"
@click="sendReply()"
>
<ReplyIcon /> Reply
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="!replyBody"
@click="sendReply()"
>
<SendIcon /> Send
</button>
<template v-if="currentMember && !isStaff($auth.user)">
<template v-if="isRejected(project)">
<button
v-if="replyBody"
class="iconified-button moderation-button"
@click="openResubmitModal(true)"
>
<ModerationIcon /> Resubmit for review with reply
</button>
<button
v-else
class="iconified-button moderation-button"
@click="openResubmitModal(false)"
>
<ModerationIcon /> Resubmit for review
</button>
</template>
</template>
<div class="spacer"></div>
<div class="input-group extra-options">
<template v-if="report">
<template v-if="isStaff($auth.user)">
<button
v-if="replyBody"
class="iconified-button danger-button"
@click="closeReport(true)"
>
<CloseIcon /> Close with reply
</button>
<button v-else class="iconified-button danger-button" @click="closeReport()">
<CloseIcon /> Close thread
</button>
</template>
</template>
<template v-if="project">
<template v-if="isStaff($auth.user)">
<button
v-if="replyBody"
class="iconified-button brand-button"
@click="sendReply(requestedStatus)"
>
<CheckIcon /> Approve with reply
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="isApproved(project)"
@click="setStatus(requestedStatus)"
>
<CheckIcon /> Approve project
</button>
<button
v-if="replyBody"
class="iconified-button moderation-button"
:disabled="project.status === 'withheld'"
@click="sendReply('withheld')"
>
<EyeOffIcon /> Withhold with reply
</button>
<button
v-else
class="iconified-button moderation-button"
:disabled="project.status === 'withheld'"
@click="setStatus('withheld')"
>
<EyeOffIcon /> Withhold project
</button>
<button
v-if="replyBody"
class="iconified-button danger-button"
:disabled="project.status === 'rejected'"
@click="sendReply('rejected')"
>
<CrossIcon /> Reject with reply
</button>
<button
v-else
class="iconified-button danger-button"
:disabled="project.status === 'rejected'"
@click="setStatus('rejected')"
>
<CrossIcon /> Reject project
</button>
</template>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import Chips from '~/components/ui/Chips.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import ReplyIcon from '~/assets/images/utils/reply.svg'
import SendIcon from '~/assets/images/utils/send.svg'
import CloseIcon from '~/assets/images/utils/check-circle.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import EyeOffIcon from '~/assets/images/utils/eye-off.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import { renderString } from '~/helpers/parse.js'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
import { isStaff } from '~/helpers/users.js'
import { isApproved, isRejected } from '~/helpers/projects.js'
import Modal from '~/components/ui/Modal.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
project: {
type: Object,
required: false,
default: null,
},
updateThread: {
type: Function,
required: false,
default: () => {},
},
setStatus: {
type: Function,
required: false,
default: () => {},
},
currentMember: {
type: Object,
default() {
return null
},
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
return members
})
const replyViewMode = ref('source')
const replyBody = ref('')
const sortedMessages = computed(() => {
if (props.thread !== null) {
return props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
}
return []
})
const modalSubmit = ref(null)
async function updateThreadLocal() {
let threadId = null
if (props.project) {
threadId = props.project.thread_id
} else if (props.report) {
threadId = props.report.thread_id
}
let thread = null
if (threadId) {
thread = await useBaseFetch(`thread/${threadId}`, app.$defaultHeaders())
}
props.updateThread(thread)
}
async function sendReply(status = null) {
try {
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body: {
body: {
type: 'text',
body: replyBody.value,
},
},
...app.$defaultHeaders(),
})
replyBody.value = ''
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
}
} catch (err) {
app.$notify({
group: 'main',
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function closeReport(reply) {
if (reply) {
await sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
...app.$defaultHeaders(),
})
await updateThreadLocal()
} catch (err) {
app.$notify({
group: 'main',
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const replyWithSubmission = ref(false)
const submissionConfirmation = ref(false)
function openResubmitModal(reply) {
submissionConfirmation.value = false
replyWithSubmission.value = reply
modalSubmit.value.show()
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply('processing')
} else {
await props.setStatus('processing')
}
modalSubmit.value.hide()
}
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
</script>
<style lang="scss" scoped>
.messages {
display: flex;
flex-direction: column;
padding: var(--spacing-card-md);
}
.resizable-textarea-wrapper {
margin-bottom: var(--spacing-card-sm);
textarea {
padding: var(--spacing-card-bg);
width: 100%;
}
.chips {
margin-bottom: var(--spacing-card-md);
}
.preview {
overflow-y: auto;
}
}
.thread-id {
margin-bottom: var(--spacing-card-md);
font-weight: bold;
color: var(--color-heading);
}
.input-group {
.spacer {
flex-grow: 1;
flex-shrink: 1;
}
.extra-options {
flex-basis: fit-content;
}
}
.modal-submit {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
gap: var(--spacing-card-lg);
.project-title {
font-weight: bold;
}
}
</style>

View File

@ -0,0 +1,309 @@
<template>
<div
class="message"
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: message.body.private,
}"
>
<template v-if="members[message.author_id]">
<ConditionalNuxtLink
class="message__icon"
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
tabindex="-1"
aria-hidden="true"
>
<Avatar
class="message__icon"
:src="members[message.author_id].avatar_url"
circle
:raised="raised"
/>
</ConditionalNuxtLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<PrivateIcon
v-if="message.body.private"
v-tooltip="'Only visible by moderators'"
class="private-icon"
/>
<ConditionalNuxtLink
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
>
{{ members[message.author_id].username }}
</ConditionalNuxtLink>
<ModeratorIcon
v-if="members[message.author_id].role === 'moderator'"
v-tooltip="'Moderator'"
/>
<ModrinthIcon
v-else-if="members[message.author_id].role === 'admin'"
v-tooltip="'Modrinth Team'"
/>
<MicIcon
v-if="report && message.author_id === report.reporterUser.id"
v-tooltip="'Reporter'"
class="reporter-icon"
/>
</span>
</template>
<template v-else>
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
<ModeratorIcon />
</div>
<span class="message__author moderation-color">
Moderator
<ModeratorIcon v-tooltip="'Moderator'" />
</span>
</template>
<div
v-if="message.body.type === 'text'"
class="message__body markdown-body"
v-html="formattedMessage"
/>
<div v-else class="message__body status-message">
<span v-if="message.body.type === 'deleted'"> posted a message that has been deleted. </span>
<template v-else-if="message.body.type === 'status_change'">
<span v-if="message.body.new_status === 'processing'">
submitted the project for review.
</span>
<span v-else>
changed the project's status from <Badge :type="message.body.old_status" /> to
<Badge :type="message.body.new_status" />.
</span>
</template>
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
</div>
<span class="message__date">
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
{{ timeSincePosted }}
</span>
</span>
<!-- <div class="message__actions">-->
<!-- <Button icon-only><MoreIcon /></Button>-->
<!-- </div>-->
</div>
</template>
<script setup>
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import ModeratorIcon from '~/assets/images/sidebar/admin.svg'
import ModrinthIcon from '~/assets/images/utils/modrinth.svg'
import MicIcon from '~/assets/images/utils/mic.svg'
import PrivateIcon from '~/assets/images/utils/lock.svg'
import { renderString } from '~/helpers/parse.js'
import ConditionalNuxtLink from '~/components/ui/ConditionalNuxtLink.vue'
const props = defineProps({
message: {
type: Object,
required: true,
},
report: {
type: Object,
default: null,
},
members: {
type: Object,
default: () => {},
},
forceCompact: {
type: Boolean,
default: false,
},
noLinks: {
type: Boolean,
default: false,
},
raised: {
type: Boolean,
default: false,
},
})
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
if (props.forceCompact) {
const hasImage = body.includes('<img')
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, '')
if (noHtml.trim()) {
return noHtml
} else if (hasImage) {
return 'sent an image.'
} else {
return 'sent a message.'
}
}
return body
})
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
</script>
<style lang="scss" scoped>
.message {
--gap-size: var(--spacing-card-xs);
display: flex;
flex-direction: row;
gap: var(--gap-size);
flex-wrap: wrap;
align-items: center;
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-md);
word-break: break-word;
.avatar,
.backed-svg {
--size: 1.5rem;
}
&.has-body {
--gap-size: var(--spacing-card-sm);
display: grid;
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
column-gap: var(--gap-size);
row-gap: var(--spacing-card-xs);
.message__icon {
margin-bottom: auto;
}
.avatar,
.backed-svg {
--size: 3rem;
}
}
&:not(.no-actions):hover,
&:not(.no-actions):focus-within {
background-color: var(--color-table-alternate-row);
.message__actions {
opacity: 1;
}
}
&.no-actions {
padding: 0;
.message__actions {
display: none;
}
}
}
.message__icon {
grid-area: icon;
}
.message__author {
grid-area: author;
font-weight: bold;
display: flex;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
flex-shrink: 0;
}
.message__date {
grid-area: date;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.message__actions {
grid-area: actions;
margin-left: auto;
opacity: 0;
}
.message__body {
grid-area: body;
}
.status-message > span {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
}
a {
display: flex;
align-items: center;
text-decoration: none;
}
a:focus-visible + .message__author a,
a:hover + .message__author a,
.message__author a:focus-visible,
.message__author a:hover {
text-decoration: underline;
filter: var(--hover-filter);
}
a:active + .message__author a,
.message__author a:active {
filter: var(--active-filter);
}
.moderation-color,
role-moderator {
color: var(--color-special-orange);
}
.role-admin {
color: var(--color-brand-green);
}
.reporter-icon {
color: var(--color-special-purple);
}
.private-icon {
color: var(--color-special-gray);
}
@media screen and (min-width: 600px) {
.message {
//grid-template:
// 'icon author body'
// 'date date date';
//grid-template-columns: min-content auto 1fr;
&.has-body {
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
}
}
}
@media screen and (min-width: 1024px) {
.message {
//grid-template: 'icon author body date';
//grid-template-columns: min-content auto 1fr auto;
&.has-body {
grid-template:
'icon author date actions'
'icon body body actions';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr auto;
}
}
}
.private {
color: var(--color-icon);
}
</style>

View File

@ -0,0 +1,142 @@
<template>
<nuxt-link :to="link" class="thread-summary" :class="{ raised: raised }">
<div class="thread-title-row">
<span v-if="report" class="thread-title">Report thread</span>
<span v-else class="thread-title">Thread</span>
<span class="thread-messages"
>{{ props.thread.messages.length }} messages <ChevronRightIcon
/></span>
</div>
<template v-if="displayMessages.length > 0">
<ThreadMessage
v-for="message in displayMessages"
:key="message.id"
:message="message"
:report="report"
:members="members"
force-compact
no-links
/>
</template>
<span v-else>There are no messages in this thread yet.</span>
</nuxt-link>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
raised: {
type: Boolean,
default: false,
},
link: {
type: String,
required: true,
},
messages: {
type: Array,
required: false,
default() {
return []
},
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
members[app.$auth.user.id] = app.$auth.user
return members
})
const displayMessages = computed(() => {
const sortedMessages = props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
if (props.messages.length > 0) {
return sortedMessages.filter((msg) => props.messages.includes(msg.id))
} else {
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []
}
})
</script>
<style lang="scss" scoped>
.thread-summary {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
padding: var(--spacing-card-bg);
border-radius: var(--size-rounded-card);
border: 1px solid var(--color-divider-dark);
gap: var(--spacing-card-sm);
.thread-title-row {
display: flex;
flex-direction: row;
align-items: center;
.thread-title {
font-weight: bold;
color: var(--color-heading);
}
.thread-messages {
margin-left: auto;
color: var(--color-link);
svg {
vertical-align: top;
}
}
}
.thread-message {
.user {
font-weight: bold;
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
.thread-message,
.thread-message > span {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
}
&.raised {
background-color: var(--color-raised-bg);
}
&:hover .thread-title-row,
&:focus-visible .thread-title-row {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active .thread-title-row {
filter: var(--active-filter);
}
}
</style>

View File

@ -15,6 +15,7 @@ export const useCosmetics = () =>
modpacksAlphaNotice: true,
advancedRendering: true,
externalLinksNewTab: true,
developerMode: false,
notUsingBlockers: false,
searchDisplayMode: {
mod: 'list',

View File

@ -109,3 +109,20 @@ export const userDeleteNotification = async (id) => {
user.notifications = user.notifications.filter((x) => x.id !== id)
}
export const userDeleteNotifications = async (ids) => {
const user = (await useUser()).value
user.notifications = user.notifications.filter((x) => !ids.includes(x.id))
}
export const userReadNotifications = async (ids) => {
const user = (await useUser()).value
user.notifications = user.notifications.map((x) => {
if (ids.includes(x.id)) {
x.read = true
}
return x
})
}

191
helpers/notifications.js Normal file
View File

@ -0,0 +1,191 @@
import { useNuxtApp } from '#app'
import { userReadNotifications } from '~/composables/user.js'
async function getBulk(type, ids) {
const auth = (await useAuth()).value
if (ids.length === 0) {
return []
}
const url = `${type}?ids=${JSON.stringify([...new Set(ids)])}`
const { data: bulkFetch } = await useAsyncData(url, () => useBaseFetch(url, auth.headers))
return bulkFetch.value
}
export async function fetchNotifications() {
try {
const auth = (await useAuth()).value
const { data: notifications } = await useAsyncData(`user/${auth.user.id}/notifications`, () =>
useBaseFetch(`user/${auth.user.id}/notifications`, auth.headers)
)
const projectIds = []
const reportIds = []
const threadIds = []
const userIds = []
const versionIds = []
for (const notification of notifications.value) {
if (notification.body) {
if (notification.body.project_id) {
projectIds.push(notification.body.project_id)
}
if (notification.body.version_id) {
versionIds.push(notification.body.version_id)
}
if (notification.body.report_id) {
reportIds.push(notification.body.report_id)
}
if (notification.body.thread_id) {
threadIds.push(notification.body.thread_id)
}
if (notification.body.invited_by) {
userIds.push(notification.body.invited_by)
}
}
}
const reports = await getBulk('reports', reportIds)
for (const report of reports) {
if (report.item_type === 'project') {
projectIds.push(report.item_id)
} else if (report.item_type === 'user') {
userIds.push(report.item_id)
} else if (report.item_type === 'version') {
versionIds.push(report.item_id)
}
}
const versions = await getBulk('versions', versionIds)
for (const version of versions) {
projectIds.push(version.project_id)
}
const projects = await getBulk('projects', projectIds)
const threads = await getBulk('threads', threadIds)
const users = await getBulk('users', userIds)
for (const notification of notifications.value) {
notification.extra_data = {}
if (notification.body) {
if (notification.body.project_id) {
notification.extra_data.project = projects.find(
(x) => x.id === notification.body.project_id
)
}
if (notification.body.report_id) {
notification.extra_data.report = reports.find((x) => x.id === notification.body.report_id)
const type = notification.extra_data.report.item_type
if (type === 'project') {
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.report.item_id
)
} else if (type === 'user') {
notification.extra_data.user = users.find(
(x) => x.id === notification.extra_data.report.item_id
)
} else if (type === 'version') {
notification.extra_data.version = versions.find(
(x) => x.id === notification.extra_data.report.item_id
)
notification.extra_data.project = projects.find(
(x) => x.id === notification.extra_data.version.project_id
)
}
}
if (notification.body.thread_id) {
notification.extra_data.thread = threads.find((x) => x.id === notification.body.thread_id)
}
if (notification.body.invited_by) {
notification.extra_data.invited_by = users.find(
(x) => x.id === notification.body.invited_by
)
}
if (notification.body.version_id) {
notification.extra_data.version = versions.find(
(x) => x.id === notification.body.version_id
)
}
}
}
return notifications.value
} catch (error) {
const app = useNuxtApp()
app.$notify({
group: 'main',
title: 'Error loading notifications',
text: error.data ? error.data.description : error,
type: 'error',
})
}
return null
}
export function groupNotifications(notifications, includeRead = false) {
const grouped = []
for (const notification of notifications.filter((notif) => includeRead || !notif.read)) {
// Group notifications of the same thread or project id
if (notification.body) {
const index = grouped.findIndex(
(notif) =>
(notif.body.thread_id === notification.body.thread_id ||
notif.body.project_id === notification.body.project_id) &&
notification.read === notif.read
)
const notif = grouped[index]
if (
notif &&
(notification.body.type === 'moderator_message' ||
notification.body.type === 'project_update')
) {
let groupedNotifs = notif.grouped_notifs
if (!groupedNotifs) {
groupedNotifs = []
}
groupedNotifs.push(notification)
grouped[index].grouped_notifs = groupedNotifs
} else {
grouped.push(notification)
}
} else {
grouped.push(notification)
}
}
return grouped
}
export async function markAsRead(ids) {
try {
const auth = (await useAuth()).value
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
method: 'PATCH',
headers: {
Authorization: auth.token,
},
})
await userReadNotifications(ids)
return (notifications) => {
const newNotifs = notifications
newNotifs.forEach((notif) => {
if (ids.includes(notif.id)) {
notif.read = true
}
})
return newNotifs
}
} catch (err) {
const app = useNuxtApp()
app.$notify({
group: 'main',
title: 'Error marking notification as read',
text: err.data ? err.data.description : err,
type: 'error',
})
return () => {}
}
}

78
helpers/projects.js Normal file
View File

@ -0,0 +1,78 @@
export const getProjectTypeForUrl = (type, categories) => {
const app = useNuxtApp()
return getProjectTypeForUrlShorthand(app, type, categories)
}
export const getProjectTypeForUrlShorthand = (app, type, categories) => {
if (type === 'mod') {
const isMod = categories.some((category) => {
return app.$tag.loaderData.modLoaders.includes(category)
})
const isPlugin = categories.some((category) => {
return app.$tag.loaderData.allPluginLoaders.includes(category)
})
const isDataPack = categories.some((category) => {
return app.$tag.loaderData.dataPackLoaders.includes(category)
})
if (isDataPack) {
return 'datapack'
} else if (isPlugin) {
return 'plugin'
} else if (isMod) {
return 'mod'
} else {
return 'mod'
}
} else {
return type
}
}
export const getProjectLink = (project) => {
return `/${getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}`
}
export const getVersionLink = (project, version) => {
return getProjectLink(project) + '/version/' + version.id
}
export const isApproved = (project) => {
return project && APPROVED_PROJECT_STATUSES.includes(project.status)
}
export const isListed = (project) => {
return project && LISTED_PROJECT_STATUSES.includes(project.status)
}
export const isUnlisted = (project) => {
return project && UNLISTED_PROJECT_STATUSES.includes(project.status)
}
export const isPrivate = (project) => {
return project && PRIVATE_PROJECT_STATUSES.includes(project.status)
}
export const isRejected = (project) => {
return project && REJECTED_PROJECT_STATUSES.includes(project.status)
}
export const isUnderReview = (project) => {
return project && UNDER_REVIEW_PROJECT_STATUSES.includes(project.status)
}
export const isDraft = (project) => {
return project && DRAFT_PROJECT_STATUSES.includes(project.status)
}
export const APPROVED_PROJECT_STATUSES = ['approved', 'archived', 'unlisted', 'private']
export const LISTED_PROJECT_STATUSES = ['approved', 'archived']
export const UNLISTED_PROJECT_STATUSES = ['unlisted', 'withheld']
export const PRIVATE_PROJECT_STATUSES = ['private', 'rejected', 'processing']
export const REJECTED_PROJECT_STATUSES = ['rejected', 'withheld']
export const UNDER_REVIEW_PROJECT_STATUSES = ['processing']
export const DRAFT_PROJECT_STATUSES = ['draft']

18
helpers/teams.js Normal file
View File

@ -0,0 +1,18 @@
export const acceptTeamInvite = async (teamId) => {
const app = useNuxtApp()
await useBaseFetch(`team/${teamId}/join`, {
method: 'POST',
...app.$defaultHeaders(),
})
}
export const removeSelfFromTeam = async (teamId) => {
const app = useNuxtApp()
await removeTeamMember(teamId, app.$auth.user.id)
}
export const removeTeamMember = async (teamId, userId) => {
const app = useNuxtApp()
await useBaseFetch(`team/${teamId}/members/${userId}`, {
method: 'DELETE',
...app.$defaultHeaders(),
})
}

26
helpers/threads.js Normal file
View File

@ -0,0 +1,26 @@
export function addReportMessage(thread, report) {
if (!thread || !report) {
return thread
}
if (
!thread.members.some((user) => {
return user.id === report.reporterUser.id
})
) {
thread.members.push(report.reporterUser)
}
if (!thread.messages.some((message) => message.id === 'original')) {
thread.messages.push({
id: 'original',
author_id: report.reporterUser.id,
body: {
type: 'text',
body: report.body,
private: false,
replying_to: null,
},
created: report.created,
})
}
return thread
}

9
helpers/users.js Normal file
View File

@ -0,0 +1,9 @@
export const getUserLink = (user) => {
return `/user/${user.username}`
}
export const isStaff = (user) => {
return user && STAFF_ROLES.includes(user.role)
}
export const STAFF_ROLES = ['moderator', 'admin']

View File

@ -15,9 +15,9 @@
<section class="user-controls">
<nuxt-link
v-if="auth.user"
to="/notifications"
to="/dashboard/notifications"
class="control-button button-transparent"
:class="{ bubble: user.notifications.length > 0 }"
:class="{ bubble: user.notifications.some((notif) => !notif.read) }"
title="Notifications"
>
<NotificationIcon aria-hidden="true" />
@ -63,15 +63,15 @@
<span class="title">Create a project</span>
</button>
<hr class="divider" />
<NuxtLink class="item button-transparent" to="/notifications">
<NuxtLink class="item button-transparent" to="/dashboard/notifications">
<NotificationIcon class="icon" />
<span class="title">Notifications</span>
</NuxtLink>
<NuxtLink class="item button-transparent" to="/dashboard">
<ChartIcon class="icon" />
<span class="title">Dashboard</span><span class="beta-badge">BETA</span>
<span class="title">Dashboard</span>
</NuxtLink>
<NuxtLink class="item button-transparent" to="/settings/follows">
<NuxtLink class="item button-transparent" to="/dashboard/follows">
<HeartIcon class="icon" />
<span class="title">Following</span>
</NuxtLink>
@ -170,7 +170,7 @@
<PlusIcon aria-hidden="true" />
Create a project
</button>
<NuxtLink class="iconified-button" to="/settings/follows">
<NuxtLink class="iconified-button" to="/dashboard/follows">
<HeartIcon aria-hidden="true" />
Following
</NuxtLink>
@ -214,7 +214,7 @@
</button>
<template v-if="auth.user">
<NuxtLink
to="/notifications"
to="/dashboard/notifications"
class="tab button-animation"
:class="{
bubble: user.notifications.length > 0,
@ -263,7 +263,7 @@
</main>
<footer>
<div class="logo-info" role="region" aria-label="Modrinth information">
<BrandTextLogo aria-hidden="true" class="text-logo" />
<BrandTextLogo aria-hidden="true" class="text-logo" @click="developerModeIncrement()" />
<p>
Modrinth is
<a
@ -362,6 +362,7 @@ import NavRow from '~/components/ui/NavRow.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import Avatar from '~/components/ui/Avatar.vue'
const app = useNuxtApp()
const auth = await useAuth()
const user = await useUser()
@ -377,6 +378,32 @@ useHead({
},
],
})
let developerModeCounter = 0
function developerModeIncrement() {
if (developerModeCounter >= 5) {
app.$cosmetics.developerMode = !app.$cosmetics.developerMode
developerModeCounter = 0
if (app.$cosmetics.developerMode) {
app.$notify({
group: 'main',
title: 'Developer mode activated',
text: 'Developer mode has been enabled',
type: 'success',
})
} else {
app.$notify({
group: 'main',
title: 'Developer mode deactivated',
text: 'Developer mode has been disabled',
type: 'success',
})
}
} else {
developerModeCounter++
}
}
</script>
<script>
export default defineNuxtComponent({
@ -500,6 +527,7 @@ export default defineNuxtComponent({
<style lang="scss">
@import '~/assets/styles/global.scss';
@import 'omorphia/dist/style.css';
.layout {
min-height: 100vh;

View File

@ -43,6 +43,7 @@
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "^13.0.1",
"omorphia": "^0.4.31",
"vue-multiselect": "^3.0.0-alpha.2",
"xss": "^1.0.14"
},

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

View File

@ -1,3 +1,5 @@
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
export default defineNuxtPlugin((nuxtApp) => {
const tagStore = nuxtApp.$tag
const authStore = nuxtApp.$auth
@ -121,33 +123,9 @@ export default defineNuxtPlugin((nuxtApp) => {
return type
})
nuxtApp.provide('getProjectTypeForUrl', (type, categories) => {
if (type === 'mod') {
const isMod = categories.some((category) => {
return tagStore.loaderData.modLoaders.includes(category)
})
const isPlugin = categories.some((category) => {
return tagStore.loaderData.allPluginLoaders.includes(category)
})
const isDataPack = categories.some((category) => {
return tagStore.loaderData.dataPackLoaders.includes(category)
})
if (isDataPack) {
return 'datapack'
} else if (isPlugin) {
return 'plugin'
} else if (isMod) {
return 'mod'
} else {
return 'mod'
}
} else {
return type
}
})
nuxtApp.provide('getProjectTypeForUrl', (type, loaders) =>
getProjectTypeForUrlShorthand(nuxtApp, type, loaders)
)
nuxtApp.provide('cycleValue', cycleValue)
const sortedCategories = tagStore.categories.slice().sort((a, b) => {
const headerCompare = a.header.localeCompare(b.header)

31
pnpm-lock.yaml generated
View File

@ -31,6 +31,9 @@ dependencies:
markdown-it:
specifier: ^13.0.1
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
omorphia:
specifier: ^0.4.31
version: 0.4.31
vue-multiselect:
specifier: ^3.0.0-alpha.2
version: 3.0.0-alpha.2
@ -2010,7 +2013,6 @@ packages:
/@vue/devtools-api@6.5.0:
resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==}
dev: true
/@vue/reactivity-transform@3.3.4:
resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==}
@ -4155,6 +4157,11 @@ packages:
engines: {node: '>=12.0.0'}
dev: false
/highlight.js@11.8.0:
resolution: {integrity: sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==}
engines: {node: '>=12.0.0'}
dev: false
/hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
dev: true
@ -5384,6 +5391,19 @@ packages:
resolution: {integrity: sha512-9CIOSq5945rI045GFtcO3uudyOkYVY1nyfFxVQp+9BRgslr8jPNiSSrsFGg/BNTUFOLqx0P5tng6G32brIPw0w==}
dev: true
/omorphia@0.4.31:
resolution: {integrity: sha512-xeb9bD42VFRDKCkKz678hBYCIS//Atd4/hx6/YmboJLMEIjIJfS2Ocf9G53G52XkfS4DWs9CIzKz71NDh86kxQ==}
dependencies:
dayjs: 1.11.7
floating-vue: 2.0.0-beta.20(vue@3.3.4)
highlight.js: 11.8.0
markdown-it: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
vue: 3.3.4
vue-router: 4.2.2(vue@3.3.4)
vue-select: 4.0.0-beta.6(vue@3.3.4)
xss: 1.0.14
dev: false
/on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@ -7208,7 +7228,14 @@ packages:
dependencies:
'@vue/devtools-api': 6.5.0
vue: 3.3.4
dev: true
/vue-select@4.0.0-beta.6(vue@3.3.4):
resolution: {integrity: sha512-K+zrNBSpwMPhAxYLTCl56gaMrWZGgayoWCLqe5rWwkB8aUbAUh7u6sXjIR7v4ckp2WKC7zEEUY27g6h1MRsIHw==}
peerDependencies:
vue: 3.x
dependencies:
vue: 3.3.4
dev: false
/vue-template-compiler@2.7.14:
resolution: {integrity: sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==}