You've already forked AstralRinth
forked from didirus/AstralRinth
Threads and more! (#1232)
* Begin UI for threads and moderation overhaul * Hide close button on non-report threads * Fix review age coloring * Add project count * Remove action buttons from queue page and add queued date to project page * Hook up to actual data * Remove unused icon * Get up to 1000 projects in queue * prettier * more prettier * Changed all the things * lint * rebuild * Add omorphia * Workaround formatjs bug in ThreadSummary.vue * Fix notifications page on prod * Fix a few notifications and threads bugs * lockfile * Fix duplicate button styles * more fixes and polishing * More fixes * Remove legacy pages * More bugfixes * Add some error catching for reports and notifications * More error handling * fix lint * Add inbox links * Remove loading component and rename member header * Rely on threads always existing * Handle if project update notifs are not grouped * oops * Fix chips on notifications page * Import ModalModeration * finish threads --------- Co-authored-by: triphora <emma@modrinth.com> Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
<template><div /></template>
|
||||
46
pages/moderation/index.vue
Normal file
46
pages/moderation/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Statistics</h2>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Projects</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.projects) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Versions</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.versions) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Files</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.files) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Authors</div>
|
||||
<div class="value">
|
||||
{{ formatNumber(stats.authors) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { formatNumber } from '~/plugins/shorthands.js'
|
||||
|
||||
useHead({
|
||||
title: 'Staff overview - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const { data: stats } = await useAsyncData('statistics', () =>
|
||||
useBaseFetch('statistics', app.$defaultHeaders())
|
||||
)
|
||||
</script>
|
||||
41
pages/moderation/messages.vue
Normal file
41
pages/moderation/messages.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Messages</h2>
|
||||
|
||||
<ThreadSummary
|
||||
v-for="thread in inbox"
|
||||
:key="thread.id"
|
||||
:thread="thread"
|
||||
:link="getLink(thread)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Moderation inbox - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const { data: inbox } = await useAsyncData('thread/inbox', () =>
|
||||
useBaseFetch('thread/inbox', app.$defaultHeaders())
|
||||
)
|
||||
|
||||
function getLink(thread) {
|
||||
if (thread.report_id) {
|
||||
return `/moderation/report/${thread.report_id}`
|
||||
} else if (thread.project_id) {
|
||||
return `/project/${thread.project_id}/moderation`
|
||||
}
|
||||
return null
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.thread-summary:not(:last-child) {
|
||||
margin-bottom: var(--spacing-card-md);
|
||||
}
|
||||
</style>
|
||||
15
pages/moderation/report/[id].vue
Normal file
15
pages/moderation/report/[id].vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<ReportView
|
||||
:report-id="route.params.id"
|
||||
:breadcrumbs-stack="[{ href: '/moderation/reports', label: 'Reports' }]"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportView from '~/components/ui/report/ReportView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
useHead({
|
||||
title: `Report ${route.params.id} - Modrinth`,
|
||||
})
|
||||
</script>
|
||||
16
pages/moderation/reports.vue
Normal file
16
pages/moderation/reports.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2>Reports</h2>
|
||||
<ReportsList moderation />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import ReportsList from '~/components/ui/report/ReportsList.vue'
|
||||
|
||||
useHead({
|
||||
title: 'Reports - Modrinth',
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
254
pages/moderation/review.vue
Normal file
254
pages/moderation/review.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<h2>Review projects</h2>
|
||||
<div class="input-group">
|
||||
<Chips
|
||||
v-model="projectType"
|
||||
:items="projectTypes"
|
||||
:format-label="(x) => (x === 'all' ? 'All' : $formatProjectType(x) + 's')"
|
||||
/>
|
||||
<button v-if="oldestFirst" class="iconified-button push-right" @click="oldestFirst = false">
|
||||
<SortDescIcon />Sorting by oldest
|
||||
</button>
|
||||
<button v-else class="iconified-button push-right" @click="oldestFirst = true">
|
||||
<SortAscIcon />Sorting by newest
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="projectType !== 'all'" class="project-count">
|
||||
Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
|
||||
projects in the queue.
|
||||
</p>
|
||||
<p v-else class="project-count">There are {{ projects.length }} projects in the queue.</p>
|
||||
<p v-if="projectsOver24Hours.length > 0" class="warning project-count">
|
||||
<WarningIcon /> {{ projectsOver24Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 24 hours.
|
||||
</p>
|
||||
<p v-if="projectsOver48Hours.length > 0" class="danger project-count">
|
||||
<WarningIcon /> {{ projectsOver48Hours.length }} {{ projectTypePlural }}
|
||||
have been in the queue for over 48 hours.
|
||||
</p>
|
||||
<div
|
||||
v-for="project in projectsFiltered.sort((a, b) => {
|
||||
if (oldestFirst) {
|
||||
return b.age - a.age
|
||||
} else {
|
||||
return a.age - b.age
|
||||
}
|
||||
})"
|
||||
:key="`project-${project.id}`"
|
||||
class="universal-card recessed project"
|
||||
>
|
||||
<div class="project-title">
|
||||
<div class="mobile-row">
|
||||
<nuxt-link
|
||||
:to="`/${project.inferred_project_type}/${project.slug}`"
|
||||
class="iconified-stacked-link"
|
||||
>
|
||||
<Avatar :src="project.icon_url" size="xs" no-shadow raised />
|
||||
<span class="stacked">
|
||||
<span class="title">{{ project.title }}</span>
|
||||
<span>{{ $formatProjectType(project.inferred_project_type) }}</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
by
|
||||
<nuxt-link :to="`/user/${project.owner.username}`" class="iconified-link">
|
||||
<Avatar :src="project.owner.avatar_url" circle size="xxs" raised />
|
||||
<span>{{ project.owner.username }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="mobile-row">
|
||||
is requesting to be
|
||||
<Badge :type="project.requested_status ? project.requested_status : 'approved'" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<nuxt-link
|
||||
:to="`/${project.inferred_project_type}/${project.slug}`"
|
||||
class="iconified-button raised-button"
|
||||
><EyeIcon /> View project</nuxt-link
|
||||
>
|
||||
</div>
|
||||
<span v-if="project.queued" :class="`submitter-info ${project.age_warning}`">
|
||||
<WarningIcon v-if="project.age_warning" />
|
||||
Submitted
|
||||
<span v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')">{{
|
||||
fromNow(project.queued)
|
||||
}}</span>
|
||||
</span>
|
||||
<span v-else class="submitter-info"><UnknownIcon /> Unknown queue date</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from '~/components/ui/Chips.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import UnknownIcon from '~/assets/images/utils/unknown.svg'
|
||||
import EyeIcon from '~/assets/images/utils/eye.svg'
|
||||
import SortAscIcon from '~/assets/images/utils/sort-asc.svg'
|
||||
import SortDescIcon from '~/assets/images/utils/sort-desc.svg'
|
||||
import WarningIcon from '~/assets/images/utils/issues.svg'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import { formatProjectType } from '~/plugins/shorthands.js'
|
||||
|
||||
useHead({
|
||||
title: 'Review projects - Modrinth',
|
||||
})
|
||||
|
||||
const app = useNuxtApp()
|
||||
|
||||
const now = app.$dayjs()
|
||||
const TIME_24H = 86400000
|
||||
const TIME_48H = TIME_24H * 2
|
||||
|
||||
const { data: projects } = await useAsyncData('moderation/projects?count=1000', () =>
|
||||
useBaseFetch('moderation/projects?count=1000', app.$defaultHeaders())
|
||||
)
|
||||
const members = ref([])
|
||||
const projectType = ref('all')
|
||||
const oldestFirst = ref(true)
|
||||
|
||||
const projectsFiltered = computed(() =>
|
||||
projects.value.filter(
|
||||
(x) =>
|
||||
projectType.value === 'all' ||
|
||||
app.$getProjectTypeForUrl(x.project_type, x.loaders) === projectType.value
|
||||
)
|
||||
)
|
||||
|
||||
const projectsOver24Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_24H && project.age < TIME_48H)
|
||||
)
|
||||
const projectsOver48Hours = computed(() =>
|
||||
projectsFiltered.value.filter((project) => project.age >= TIME_48H)
|
||||
)
|
||||
const projectTypePlural = computed(() =>
|
||||
projectType.value === 'all'
|
||||
? 'projects'
|
||||
: (formatProjectType(projectType.value) + 's').toLowerCase()
|
||||
)
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const set = new Set()
|
||||
set.add('all')
|
||||
|
||||
if (projects.value) {
|
||||
for (const project of projects.value) {
|
||||
set.add(project.inferred_project_type)
|
||||
}
|
||||
}
|
||||
|
||||
return [...set]
|
||||
})
|
||||
|
||||
if (projects.value) {
|
||||
const teamIds = projects.value.map((x) => x.team)
|
||||
|
||||
await useAsyncData(
|
||||
'teams?ids=' + JSON.stringify(teamIds),
|
||||
() => useBaseFetch('teams?ids=' + JSON.stringify(teamIds), app.$defaultHeaders()),
|
||||
{
|
||||
transform: (result) => {
|
||||
if (result) {
|
||||
members.value = result
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
project.owner = members.value
|
||||
.flat()
|
||||
.find((x) => x.team_id === project.team && x.role === 'Owner').user
|
||||
project.age = project.queued ? now - app.$dayjs(project.queued) : Number.MAX_VALUE
|
||||
project.age_warning = ''
|
||||
if (project.age > TIME_24H * 2) {
|
||||
project.age_warning = 'danger'
|
||||
} else if (project.age > TIME_24H) {
|
||||
project.age_warning = 'warning'
|
||||
}
|
||||
project.inferred_project_type = app.$getProjectTypeForUrl(
|
||||
project.project_type,
|
||||
project.loaders
|
||||
)
|
||||
return project
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.project {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-card-sm);
|
||||
@media screen and (min-width: 650px) {
|
||||
display: grid;
|
||||
grid-template: 'title action' 'date action';
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.submitter-info {
|
||||
margin: 0;
|
||||
grid-area: date;
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--color-special-orange);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--color-special-red);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
margin-block: var(--spacing-card-md);
|
||||
|
||||
svg {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group {
|
||||
grid-area: action;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
display: flex;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.mobile-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.mobile-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--spacing-card-xs);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.avatar) {
|
||||
flex-shrink: 0;
|
||||
|
||||
&.size-xs {
|
||||
margin-right: var(--spacing-card-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user