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