Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/frontend/src/components/CreatorStatusStats.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { isStaff } from '@modrinth/utils'
import { computed } from 'vue'

import { useAuthState } from '~/composables/auth'
import { useCreatorProjectStats } from '~/composables/creator-project-stats'

import ProjectStatusStats from './ProjectStatusStats.vue'

const props = defineProps<{
id: string
kind: 'user' | 'organization'
}>()
const auth = useAuthState()
const enabled = computed(() => isStaff(auth.value.user) && !!props.id)
const { memberStats, organizationStats } = useCreatorProjectStats(
computed(() => (props.kind === 'user' ? [props.id] : [])),
computed(() => (props.kind === 'organization' ? props.id : '')),
enabled,
)
const stats = computed(() =>
props.kind === 'user' ? (memberStats.value[props.id] ?? []) : organizationStats.value,
)
</script>

<template>
<ProjectStatusStats v-if="enabled && stats.some((stat) => stat.count > 0)" :stats="stats" />
</template>
115 changes: 93 additions & 22 deletions apps/frontend/src/components/ProjectStatusStats.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,42 @@
<Tooltip
v-for="stat in statusStats"
:key="stat.status"
:text="formatMessage(stat.message, { count: stat.count })"
:aria-label="formatMessage(stat.message, { count: stat.count })"
:text="stat.tooltip"
:aria-label="stat.tooltip"
:panel-class="
stat.status === 'account_locked'
? 'max-w-[300px] whitespace-pre-line break-words text-left'
: undefined
"
tabindex="0"
class="flex items-center gap-0.5 font-medium tabular-nums"
:class="stat.color"
>
<component :is="stat.icon" class="size-[12px] shrink-0" aria-hidden="true" />
<span aria-hidden="true">{{ stat.count }}</span>
<span v-if="!stat.account" aria-hidden="true">{{ stat.count }}</span>
<template v-if="stat.items.length" #popper>
<div v-if="stat.status === 'account_locked'" class="flex flex-col gap-1.5 text-left">
<div>Account locked</div>
<div>Reason: {{ stat.items.join('; ') }}</div>
</div>
<div v-else class="text-left">
<div>Account restricted</div>
<ul class="m-0 mt-1 list-disc pl-4">
<li v-for="(item, index) in stat.items" :key="index">{{ item }}</li>
</ul>
</div>
</template>
</Tooltip>
</div>
</template>

<script setup lang="ts">
import { CheckIcon, ShieldAlertIcon } from '@modrinth/assets'
import { BanIcon, CheckIcon, ShieldAlertIcon, UserLockIcon, UserRoundXIcon } from '@modrinth/assets'
import { defineMessages, PROJECT_STATUS_ICONS, Tooltip, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'

const props = defineProps<{
stats: { status: string; count: number }[]
stats: { status: string; count: number; details?: string | string[] }[]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
Expand Down Expand Up @@ -69,6 +87,11 @@ const messages = defineMessages({
defaultMessage: '{count} unknown',
},
})
const accountStatuses = {
shared_instances_banned: { label: 'Banned from shared instances', icon: BanIcon },
account_locked: { label: 'Account locked', icon: UserLockIcon },
account_restricted: { label: 'Account restricted', icon: UserRoundXIcon },
}
const statusMessages = {
tech_review_failed: messages.techReviewFailedCount,
approved: messages.approvedCount,
Expand All @@ -95,23 +118,71 @@ const statusColors = {
unknown: 'text-orange',
tech_review_failed: 'text-red',
}
const statusOrder = [
'tech_review_failed',
'shared_instances_banned',
'account_restricted',
'account_locked',
'approved',
'unlisted',
'private',
'withheld',
'rejected',
'draft',
'processing',
'archived',
'scheduled',
'unknown',
]
const statusStats = computed(() =>
Object.entries(statusMessages)
.map(([key, message]) => {
const status = key as keyof typeof statusMessages
return {
status,
message,
count: props.stats.find((stat) => stat.status === status)?.count ?? 0,
icon:
status === 'tech_review_failed'
? ShieldAlertIcon
: status === 'approved'
? CheckIcon
: PROJECT_STATUS_ICONS[status],
color: statusColors[status],
}
})
.filter((stat) => stat.count > 0),
[
...Object.entries(statusMessages)
.map(([key, message]) => {
const status = key as keyof typeof statusMessages
const count = props.stats.find((stat) => stat.status === status)?.count ?? 0
return {
status,
tooltip: formatMessage(message, { count }),
account: false,
items: [] as string[],
count,
icon:
status === 'tech_review_failed'
? ShieldAlertIcon
: status === 'approved'
? CheckIcon
: PROJECT_STATUS_ICONS[status],
color: statusColors[status],
}
})
.filter((stat) => stat.count > 0),
...Object.entries(accountStatuses).flatMap(([status, { label, icon }]) => {
const stat = props.stats.find((stat) => stat.status === status && stat.count > 0)
const items =
(status === 'account_restricted' || status === 'account_locked') && stat?.details
? Array.isArray(stat.details)
? stat.details
: [stat.details]
: []
const details = Array.isArray(stat?.details) ? stat.details.join('; ') : stat?.details
return stat
? [
{
status,
count: stat.count,
icon,
color: 'text-red',
account: true,
items,
tooltip: details
? status === 'account_locked'
? `${label}\nReason: ${details}`
: `${label}: ${details}`
: label,
},
]
: []
}),
].toSorted((a, b) => statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)),
)
</script>
76 changes: 76 additions & 0 deletions apps/frontend/src/components/stories/ProjectStatusStats.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import ProjectStatusStats from '../ProjectStatusStats.vue'

const projectStats = [
{ status: 'tech_review_failed', count: 2 },
{ status: 'approved', count: 24 },
{ status: 'unlisted', count: 3 },
{ status: 'private', count: 4 },
{ status: 'withheld', count: 2 },
{ status: 'rejected', count: 5 },
{ status: 'draft', count: 6 },
{ status: 'processing', count: 1 },
{ status: 'archived', count: 7 },
{ status: 'scheduled', count: 1 },
{ status: 'unknown', count: 1 },
]
const accountStats = [
{ status: 'shared_instances_banned', count: 1 },
{
status: 'account_restricted',
count: 1,
details: ['Create new projects', 'Upload new versions', 'Repeated policy violations'],
},
{
status: 'account_locked',
count: 1,
details: 'Account compromised; contact support to restore access.',
},
]

const meta = {
title: 'Website/Moderation/ProjectStatusStats',
component: ProjectStatusStats,
args: {
stats: [
{ status: 'approved', count: 12 },
{ status: 'rejected', count: 2 },
],
},
parameters: { layout: 'centered' },
} satisfies Meta<typeof ProjectStatusStats>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const AllBadges: StoryObj = {
render: () => ({
components: { ProjectStatusStats },
setup: () => ({ projectStats, accountStats }),
template: /* html */ `
<div class="flex max-w-sm flex-col gap-4 p-4">
<div>
<p class="m-0 font-semibold text-contrast">Project statuses</p>
<ProjectStatusStats :stats="projectStats" />
</div>
<div>
<p class="m-0 font-semibold text-contrast">Account restrictions</p>
<ProjectStatusStats :stats="accountStats" />
</div>
<div>
<p class="m-0 font-semibold text-contrast">Combined</p>
<ProjectStatusStats :stats="[...projectStats, ...accountStats]" />
</div>
<p class="m-0 text-sm text-secondary">Hover or focus a badge to see its label and restriction details.</p>
</div>
`,
}),
}

export const Empty: Story = { args: { stats: [] } }
export const ZeroCounts: Story = {
args: { stats: [...projectStats, ...accountStats].map((stat) => ({ ...stat, count: 0 })) },
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { Avatar, CopyCode } from '@modrinth/ui'
import { computed } from 'vue'

import CreatorStatusStats from '~/components/CreatorStatusStats.vue'

import ModerationOwnerLink, { type ModerationOwner } from './ModerationOwnerLink.vue'
export type { ModerationOwner }

defineProps<{
const props = defineProps<{
avatarUrl?: string | null
title: string
titleTo?: string | null
owner?: ModerationOwner | null
statsOwner?: ModerationOwner | null
circle?: boolean
padTransparentCorners?: boolean
}>()
const statsSubject = computed(() => props.statsOwner ?? props.owner)
</script>

<template>
Expand Down Expand Up @@ -50,7 +55,18 @@ defineProps<{
<slot name="badges" />
</div>

<ModerationOwnerLink v-if="owner" :owner="owner" />
<div v-if="owner || statsSubject" class="flex flex-wrap items-center gap-2">
<ModerationOwnerLink v-if="owner" :owner="owner" />
<ClientOnly v-else-if="statsSubject">
<CopyCode v-tooltip="'Copy user ID'" :text="statsSubject.id" />
</ClientOnly>
<CreatorStatusStats
v-if="statsSubject"
:id="statsSubject.id"
:kind="statsSubject.kind"
class="!mt-0"
/>
</div>
<slot name="subtitle" />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@
:title="reportItemTitle"
:title-to="reportItemTitleTo"
:owner="itemOwner"
:stats-owner="
report.item_type === 'user' && report.user
? {
kind: 'user',
id: report.user.id,
name: report.user.username,
}
: null
"
:circle="report.item_type === 'user'"
:pad-transparent-corners="report.item_type === 'shared-instance'"
>
Expand Down Expand Up @@ -888,6 +897,7 @@ async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
await client.sharedinstances.moderation_v1.blacklistUsers({
user_ids: [owner.id],
})
await queryClient.invalidateQueries({ queryKey: ['shared-instance-blacklist', 'v1', owner.id] })

if (sharedInstanceDetails.value) {
sharedInstanceDetails.value = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import { computed, ref, useId } from 'vue'

import { useModerationSettings } from '~/composables/moderation'

defineProps<{
const props = defineProps<{
embedded?: boolean
section?: 'public-pages' | 'general'
}>()

const localhostHostname = 'localhost:3000'
Expand Down Expand Up @@ -113,6 +114,7 @@ const groups = [
id: 'project-pages',
title: messages.projectPages,
settings: [
general.ShowCreatorStatsOnPublicPages,
general.EnableProjectKeybinds,
general.SlicerButtonInVersions,
general.AlternativeHostname,
Expand All @@ -128,6 +130,11 @@ const groups = [
const filteredGroups = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return groups
.filter((group) => {
if (props.section === 'public-pages') return group.id === 'project-pages'
if (props.section === 'general') return group.id !== 'project-pages'
return true
})
.map((group) => ({
...group,
settings: group.settings.filter((setting) =>
Expand All @@ -154,9 +161,10 @@ const configuredSettings = useModerationSettings()
<template>
<div :class="{ 'universal-card': !embedded }">
<h2 class="m-0 mb-2.5 text-2xl">
{{ formatMessage(messages.title) }}
{{ formatMessage(section === 'public-pages' ? messages.projectPages : messages.title) }}
</h2>
<Input
v-if="section !== 'public-pages'"
v-model="search"
type="search"
:icon="SearchIcon"
Expand All @@ -170,10 +178,14 @@ const configuredSettings = useModerationSettings()
<section
v-for="group in filteredGroups"
:key="group.id"
:aria-labelledby="`${settingsId}-${group.id}`"
class="mt-8"
:aria-labelledby="section === 'public-pages' ? undefined : `${settingsId}-${group.id}`"
:class="section === 'public-pages' ? 'mt-6' : 'mt-8'"
>
<h3 :id="`${settingsId}-${group.id}`" class="mb-6 text-xl font-semibold">
<h3
v-if="section !== 'public-pages'"
:id="`${settingsId}-${group.id}`"
class="mb-6 text-xl font-semibold"
>
{{ formatMessage(group.title) }}
</h3>
<div class="flex flex-col gap-6">
Expand Down
Loading
Loading