288 lines
8.3 KiB
Vue

<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>
<button
class="btn btn-highlight"
:disabled="projectsFiltered.length === 0"
@click="goToProjects()"
>
<ModerationIcon /> Start moderating
</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.name }}</span>
<span>{{ $formatProjectType(project.inferred_project_type) }}</span>
</span>
</nuxt-link>
</div>
<div class="mobile-row">
by
<nuxt-link
v-if="project.owner"
:to="`/user/${project.owner.user.username}`"
class="iconified-link"
>
<Avatar :src="project.owner.user.avatar_url" circle size="xxs" raised />
<span>{{ project.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="project.org"
:to="`/organization/${project.org.slug}`"
class="iconified-link"
>
<Avatar :src="project.org.icon_url" circle size="xxs" raised />
<span>{{ project.org.name }}</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?component'
import EyeIcon from '~/assets/images/utils/eye.svg?component'
import SortAscIcon from '~/assets/images/utils/sort-asc.svg?component'
import SortDescIcon from '~/assets/images/utils/sort-desc.svg?component'
import WarningIcon from '~/assets/images/utils/issues.svg?component'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
import Badge from '~/components/ui/Badge.vue'
import { formatProjectType } from '~/plugins/shorthands.js'
useHead({
title: 'Review projects - Modrinth',
})
const app = useNuxtApp()
const router = useRouter()
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', { internal: true })
)
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_types[0], 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_id)
const organizationIds = projects.value.filter((x) => x.organization).map((x) => x.organization)
const url = `teams?ids=${encodeURIComponent(JSON.stringify(teamIds))}`
const orgUrl = `organizations?ids=${encodeURIComponent(JSON.stringify(organizationIds))}`
const { data: result } = await useAsyncData(url, () => useBaseFetch(url))
const { data: orgs } = await useAsyncData(orgUrl, () => useBaseFetch(orgUrl, { apiVersion: 3 }))
if (result.value) {
members.value = result.value
projects.value = projects.value.map((project) => {
project.owner = members.value
.flat()
.find((x) => x.team_id === project.team_id && x.role === 'Owner')
project.org = orgs.value.find((x) => x.id === project.organization)
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_types[0],
project.loaders
)
return project
})
}
}
async function goToProjects() {
const project = projectsFiltered.value[0]
await router.push({
name: 'type-id',
params: {
type: project.project_types[0],
id: project.slug ? project.slug : project.id,
},
state: {
showChecklist: true,
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
},
})
}
</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-orange);
}
.danger {
color: var(--color-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>