mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 04:56:52 +00:00
feat: moderation additions for shared instances (#7285)
* feat: moderation additions for shared instances * fix: lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -65,11 +65,17 @@ export interface SharedInstanceReportDetails {
|
|||||||
other_instances_loaded?: boolean
|
other_instances_loaded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
details: SharedInstanceReportDetails
|
defineProps<{
|
||||||
banPending?: boolean
|
details: SharedInstanceReportDetails
|
||||||
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
|
banPending?: boolean
|
||||||
}>()
|
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
|
||||||
|
contextType?: 'report' | 'moderation'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
contextType: 'report',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
banOwner: [owner: SharedInstanceReportUser]
|
banOwner: [owner: SharedInstanceReportUser]
|
||||||
@@ -223,7 +229,7 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
|||||||
<span
|
<span
|
||||||
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
|
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
|
||||||
>
|
>
|
||||||
Report context
|
{{ contextType === 'report' ? 'Report context' : 'Moderation context' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,7 +316,10 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-semibold text-contrast">Version {{ version.version }}</span>
|
<span class="font-semibold text-contrast">Version {{ version.version }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="version.version === details.reported_version?.version"
|
v-if="
|
||||||
|
contextType === 'report' &&
|
||||||
|
version.version === details.reported_version?.version
|
||||||
|
"
|
||||||
class="bg-orange-highlight rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
|
class="bg-orange-highlight rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
|
||||||
>
|
>
|
||||||
Reported
|
Reported
|
||||||
@@ -333,7 +342,11 @@ function formattedLoader(version: SharedInstanceReportVersion) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="text-sm text-secondary">
|
<span v-else class="text-sm text-secondary">
|
||||||
No version was attached to this report.
|
{{
|
||||||
|
contextType === 'report'
|
||||||
|
? 'No version was attached to this report.'
|
||||||
|
: 'No version history is available.'
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import type { AbstractModrinthClient, Labrinth, SharedInstances } from '@modrinth/api-client'
|
||||||
|
import type { ContentItem } from '@modrinth/ui'
|
||||||
|
|
||||||
|
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
|
||||||
|
project_id?: string
|
||||||
|
version_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSharedInstanceContentLoader(client: AbstractModrinthClient) {
|
||||||
|
const instanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
|
||||||
|
|
||||||
|
function cacheVersion(instanceId: string, version: SharedInstances.Instances.v1.InstanceVersion) {
|
||||||
|
instanceVersions.set(`${instanceId}:${version.version}`, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVersion(
|
||||||
|
instanceId: string,
|
||||||
|
versionNumber: number,
|
||||||
|
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
|
||||||
|
const cacheKey = `${instanceId}:${versionNumber}`
|
||||||
|
const cachedVersion = instanceVersions.get(cacheKey)
|
||||||
|
if (cachedVersion) return cachedVersion
|
||||||
|
|
||||||
|
const version = await client.sharedinstances.instances_v1.getVersion(instanceId, versionNumber)
|
||||||
|
instanceVersions.set(cacheKey, version)
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersionContent(
|
||||||
|
instanceId: string,
|
||||||
|
versionNumber: number,
|
||||||
|
): Promise<ContentItem[]> {
|
||||||
|
const instanceVersion = await getVersion(instanceId, versionNumber)
|
||||||
|
const modpackVersionId = instanceVersion.modpack_id
|
||||||
|
const directVersionIds = (instanceVersion.modrinth_ids ?? []).filter(
|
||||||
|
(versionId) => versionId !== modpackVersionId,
|
||||||
|
)
|
||||||
|
const modpackVersion = modpackVersionId
|
||||||
|
? await client.labrinth.versions_v2.getVersion(modpackVersionId)
|
||||||
|
: null
|
||||||
|
const modpackDependencies = (modpackVersion?.dependencies ??
|
||||||
|
[]) as SharedInstanceVersionDependency[]
|
||||||
|
const dependencyVersionIds = modpackDependencies.flatMap((dependency) =>
|
||||||
|
dependency.version_id ? [dependency.version_id] : [],
|
||||||
|
)
|
||||||
|
const uniqueVersionIds = [...new Set([...directVersionIds, ...dependencyVersionIds])]
|
||||||
|
const versions = uniqueVersionIds.length
|
||||||
|
? await client.labrinth.versions_v2.getVersions(uniqueVersionIds)
|
||||||
|
: []
|
||||||
|
const dependencyProjectIds = modpackDependencies.flatMap((dependency) =>
|
||||||
|
dependency.project_id ? [dependency.project_id] : [],
|
||||||
|
)
|
||||||
|
const projectIds = [
|
||||||
|
...new Set([
|
||||||
|
...versions.map((version) => version.project_id),
|
||||||
|
...dependencyProjectIds,
|
||||||
|
...(modpackVersion ? [modpackVersion.project_id] : []),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
const projects = projectIds.length
|
||||||
|
? await client.labrinth.projects_v2.getMultiple(projectIds)
|
||||||
|
: []
|
||||||
|
const versionsById = new Map(versions.map((version) => [version.id, version]))
|
||||||
|
const projectsById = new Map(projects.map((project) => [project.id, project]))
|
||||||
|
const modpackProject = modpackVersion ? projectsById.get(modpackVersion.project_id) : undefined
|
||||||
|
|
||||||
|
const directContent: ContentItem[] = [...new Set(directVersionIds)].flatMap((versionId) => {
|
||||||
|
const version = versionsById.get(versionId)
|
||||||
|
if (!version) return []
|
||||||
|
|
||||||
|
const project = projectsById.get(version.project_id)
|
||||||
|
return [sharedInstanceContentItem(version, project)]
|
||||||
|
})
|
||||||
|
|
||||||
|
const modpackContent: ContentItem[] = modpackDependencies.map((dependency) => {
|
||||||
|
const version = dependency.version_id ? versionsById.get(dependency.version_id) : undefined
|
||||||
|
const project = dependency.project_id
|
||||||
|
? projectsById.get(dependency.project_id)
|
||||||
|
: version
|
||||||
|
? projectsById.get(version.project_id)
|
||||||
|
: undefined
|
||||||
|
const primaryFile = version
|
||||||
|
? (version.files.find((file) => file.primary) ?? version.files[0])
|
||||||
|
: undefined
|
||||||
|
const fileName =
|
||||||
|
primaryFile?.filename ??
|
||||||
|
dependency.file_name ??
|
||||||
|
project?.title ??
|
||||||
|
version?.name ??
|
||||||
|
'Unknown'
|
||||||
|
|
||||||
|
const item = sharedInstanceContentItem(
|
||||||
|
version,
|
||||||
|
project,
|
||||||
|
fileName,
|
||||||
|
dependency.project_id ?? fileName,
|
||||||
|
!project && !version,
|
||||||
|
)
|
||||||
|
return modpackProject
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
source: { project: modpackProject },
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
})
|
||||||
|
|
||||||
|
const externalContent: ContentItem[] = instanceVersion.external_files.map((file, index) => ({
|
||||||
|
id: `external:${file.file_type}:${file.file_name}:${index}`,
|
||||||
|
file_name: file.file_name,
|
||||||
|
size: file.file_size,
|
||||||
|
project_type: file.file_type,
|
||||||
|
has_update: false,
|
||||||
|
update_version_id: null,
|
||||||
|
source_kind: 'shared_instance',
|
||||||
|
external: true,
|
||||||
|
external_url: file.url,
|
||||||
|
project: {
|
||||||
|
id: file.file_name,
|
||||||
|
slug: file.file_name,
|
||||||
|
title: file.file_name,
|
||||||
|
icon_url: undefined,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...externalContent, ...modpackContent, ...directContent]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cacheVersion, getVersion, loadVersionContent }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedInstanceContentItem(
|
||||||
|
version: Labrinth.Versions.v2.Version | undefined,
|
||||||
|
project: Labrinth.Projects.v2.Project | undefined,
|
||||||
|
fallbackFileName?: string,
|
||||||
|
fallbackProjectId = version?.project_id ?? fallbackFileName ?? 'unknown',
|
||||||
|
external = false,
|
||||||
|
): ContentItem {
|
||||||
|
const primaryFile = version
|
||||||
|
? (version.files.find((file) => file.primary) ?? version.files[0])
|
||||||
|
: undefined
|
||||||
|
const fileName =
|
||||||
|
primaryFile?.filename ?? fallbackFileName ?? project?.title ?? version?.name ?? 'Unknown'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: version?.id ?? project?.id ?? fileName,
|
||||||
|
file_name: fileName,
|
||||||
|
size: primaryFile?.size,
|
||||||
|
project_type: project?.project_type ?? 'mod',
|
||||||
|
has_update: false,
|
||||||
|
update_version_id: null,
|
||||||
|
source_kind: 'shared_instance',
|
||||||
|
external,
|
||||||
|
project: {
|
||||||
|
id: project?.id ?? fallbackProjectId,
|
||||||
|
slug: project?.slug ?? fallbackProjectId,
|
||||||
|
title: project?.title ?? version?.name ?? fileName,
|
||||||
|
icon_url: project?.icon_url ?? undefined,
|
||||||
|
},
|
||||||
|
...(version
|
||||||
|
? {
|
||||||
|
version: {
|
||||||
|
id: version.id,
|
||||||
|
version_number: version.version_number,
|
||||||
|
file_name: fileName,
|
||||||
|
date_published: version.date_published,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="contextModal"
|
||||||
|
:header="
|
||||||
|
selectedInstance ? `${selectedInstance.name} moderation context` : 'Shared instance context'
|
||||||
|
"
|
||||||
|
no-padding
|
||||||
|
scrollable
|
||||||
|
max-width="90rem"
|
||||||
|
width="calc(100vw - 2rem)"
|
||||||
|
max-content-height="85vh"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="selectedContextPending"
|
||||||
|
class="flex min-h-64 items-center justify-center gap-2 text-secondary"
|
||||||
|
>
|
||||||
|
<LoaderCircleIcon class="size-6 animate-spin" />
|
||||||
|
Loading moderation context…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="selectedContextError" class="p-6">
|
||||||
|
<Admonition
|
||||||
|
type="critical"
|
||||||
|
header="Failed to load moderation context"
|
||||||
|
:body="selectedContextErrorMessage"
|
||||||
|
show-actions-underneath
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button :disabled="selectedContextFetching" @click="refetchSelectedContext()">
|
||||||
|
<LoaderCircleIcon v-if="selectedContextFetching" class="animate-spin" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Admonition>
|
||||||
|
</div>
|
||||||
|
<SharedInstanceReportContext
|
||||||
|
v-else-if="selectedContext"
|
||||||
|
:key="selectedContext.id"
|
||||||
|
:details="selectedContext"
|
||||||
|
:ban-pending="banOwnerPending"
|
||||||
|
:load-version-content="sharedInstanceContent.loadVersionContent"
|
||||||
|
context-type="moderation"
|
||||||
|
class="!border-t-0"
|
||||||
|
@ban-owner="banSharedInstanceOwner"
|
||||||
|
@content-error="showSharedInstanceContentError"
|
||||||
|
/>
|
||||||
|
</NewModal>
|
||||||
|
|
||||||
|
<div class="normal-page no-sidebar !pb-6">
|
||||||
|
<div class="normal-page__content flex flex-col gap-4">
|
||||||
|
<header
|
||||||
|
class="flex flex-col gap-3 border-0 border-b border-solid border-divider pb-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
:src="user?.avatar_url"
|
||||||
|
:alt="user?.username ?? userId"
|
||||||
|
:tint-by="user?.id ?? userId"
|
||||||
|
size="48px"
|
||||||
|
circle
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="m-0 truncate text-2xl font-extrabold text-contrast">
|
||||||
|
{{ user?.username ?? userId }}'s shared instances
|
||||||
|
</h1>
|
||||||
|
<p class="m-0 text-secondary">All shared instances this user owns or belongs to.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonLink :to="`/user/${user?.id ?? userId}`" target="_blank" class="w-fit">
|
||||||
|
<UserIcon />
|
||||||
|
User profile
|
||||||
|
<ExternalIcon class="size-4" />
|
||||||
|
</ButtonLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isPending"
|
||||||
|
class="flex min-h-48 items-center justify-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-3 text-secondary"
|
||||||
|
>
|
||||||
|
<LoaderCircleIcon class="size-6 animate-spin" />
|
||||||
|
Loading shared instances…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Admonition
|
||||||
|
v-else-if="error"
|
||||||
|
type="critical"
|
||||||
|
header="Failed to load shared instances"
|
||||||
|
:body="errorMessage"
|
||||||
|
show-actions-underneath
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button :disabled="isFetching" @click="retry">
|
||||||
|
<LoaderCircleIcon v-if="isFetching" class="animate-spin" />
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Admonition>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<Admonition
|
||||||
|
v-if="sharedInstances?.unavailableCount"
|
||||||
|
type="warning"
|
||||||
|
header="Some shared instances are unavailable"
|
||||||
|
:body="`${sharedInstances.unavailableCount} ${sharedInstances.unavailableCount === 1 ? 'instance could' : 'instances could'} not be loaded.`"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!sharedInstances?.instances.length"
|
||||||
|
class="flex min-h-48 flex-col items-center justify-center gap-2 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6 text-center"
|
||||||
|
>
|
||||||
|
<BoxesIcon class="size-10 text-secondary" />
|
||||||
|
<h2 class="m-0 text-lg font-semibold text-contrast">No shared instances</h2>
|
||||||
|
<p class="m-0 text-secondary">
|
||||||
|
This user does not own or belong to any shared instances.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-3 md:grid-cols-2">
|
||||||
|
<article
|
||||||
|
v-for="instance in sharedInstances.instances"
|
||||||
|
:key="instance.id"
|
||||||
|
class="flex min-w-0 flex-col gap-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
:src="instance.iconUrl"
|
||||||
|
:alt="instance.name"
|
||||||
|
:tint-by="instance.id"
|
||||||
|
size="48px"
|
||||||
|
no-shadow
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<h2 class="m-0 truncate text-lg font-semibold text-contrast">
|
||||||
|
{{ instance.name }}
|
||||||
|
</h2>
|
||||||
|
<span
|
||||||
|
v-if="instance.quarantine"
|
||||||
|
class="bg-orange-highlight inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
|
||||||
|
>
|
||||||
|
<LockIcon class="size-3.5" />
|
||||||
|
Quarantined
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-secondary">{{ relationLabel(instance.membership) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 text-sm text-primary sm:grid-cols-2">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<VersionIcon class="size-5 shrink-0 text-secondary" />
|
||||||
|
<span class="truncate">
|
||||||
|
<template v-if="instance.latestVersion">
|
||||||
|
Version {{ instance.latestVersion.version }} ·
|
||||||
|
{{ formattedVersion(instance.latestVersion) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>Version unavailable</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UsersIcon class="size-5 shrink-0 text-secondary" />
|
||||||
|
{{ instance.memberCount }}
|
||||||
|
{{ instance.memberCount === 1 ? 'member' : 'members' }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<HistoryIcon class="size-5 shrink-0 text-secondary" />
|
||||||
|
<span
|
||||||
|
v-if="instance.membership?.last_played"
|
||||||
|
v-tooltip="formatDateTime(instance.membership.last_played)"
|
||||||
|
>
|
||||||
|
Last played {{ formatRelativeTime(instance.membership.last_played) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>Never played</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CalendarIcon class="size-5 shrink-0 text-secondary" />
|
||||||
|
<span
|
||||||
|
v-if="instance.membership?.joined_at"
|
||||||
|
v-tooltip="formatDateTime(instance.membership.joined_at)"
|
||||||
|
>
|
||||||
|
Joined {{ formatRelativeTime(instance.membership.joined_at) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>Join date unavailable</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto flex flex-col gap-1.5">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-secondary">
|
||||||
|
Instance ID
|
||||||
|
</span>
|
||||||
|
<CopyCode :text="instance.id" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="colored"
|
||||||
|
color="orange"
|
||||||
|
class="w-full"
|
||||||
|
@click="openInstanceContext(instance)"
|
||||||
|
>
|
||||||
|
<ScaleIcon />
|
||||||
|
View context
|
||||||
|
</Button>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SharedInstances } from '@modrinth/api-client'
|
||||||
|
import {
|
||||||
|
BoxesIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
ExternalIcon,
|
||||||
|
HistoryIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
LockIcon,
|
||||||
|
ScaleIcon,
|
||||||
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
|
VersionIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
Admonition,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
ButtonLink,
|
||||||
|
CopyCode,
|
||||||
|
injectModrinthClient,
|
||||||
|
injectNotificationManager,
|
||||||
|
NewModal,
|
||||||
|
useFormatDateTime,
|
||||||
|
useRelativeTime,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { computed, onServerPrefetch, ref } from 'vue'
|
||||||
|
|
||||||
|
import SharedInstanceReportContext, {
|
||||||
|
type SharedInstanceOwnerInstance,
|
||||||
|
type SharedInstanceReportDetails,
|
||||||
|
type SharedInstanceReportUser,
|
||||||
|
} from '~/components/ui/moderation/SharedInstanceReportContext.vue'
|
||||||
|
import { createSharedInstanceContentLoader } from '~/helpers/shared-instance-content'
|
||||||
|
|
||||||
|
type InstanceMembership = SharedInstances.Instances.v1.InstanceUser
|
||||||
|
type InstanceVersion = SharedInstances.Instances.v1.InstanceVersion
|
||||||
|
type UserSharedInstance = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
iconUrl: string | null
|
||||||
|
quarantine: boolean
|
||||||
|
memberCount: number
|
||||||
|
membership?: InstanceMembership
|
||||||
|
latestVersion: InstanceVersion | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const userId = computed(() => String(route.params.user ?? ''))
|
||||||
|
const canLoadUser = computed(() => userId.value.length > 0)
|
||||||
|
const selectedInstance = ref<UserSharedInstance | null>(null)
|
||||||
|
const contextModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||||
|
const sharedInstanceContent = createSharedInstanceContentLoader(client)
|
||||||
|
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
|
||||||
|
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: user,
|
||||||
|
error: userError,
|
||||||
|
isPending: userPending,
|
||||||
|
isFetching: userFetching,
|
||||||
|
refetch: refetchUser,
|
||||||
|
suspense: userSuspense,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: computed(() => ['user', 'v3', userId.value] as const),
|
||||||
|
queryFn: () => client.labrinth.users_v3.get(userId.value),
|
||||||
|
enabled: canLoadUser,
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: sharedInstances,
|
||||||
|
error: sharedInstancesError,
|
||||||
|
isPending: sharedInstancesPending,
|
||||||
|
isFetching: sharedInstancesFetching,
|
||||||
|
refetch: refetchSharedInstances,
|
||||||
|
suspense: sharedInstancesSuspense,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: computed(() => ['shared-instances', 'user', userId.value] as const),
|
||||||
|
queryFn: loadSharedInstances,
|
||||||
|
enabled: canLoadUser,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedInstanceId = computed(() => selectedInstance.value?.id ?? null)
|
||||||
|
const {
|
||||||
|
data: selectedContext,
|
||||||
|
error: selectedContextError,
|
||||||
|
isPending: selectedContextPending,
|
||||||
|
isFetching: selectedContextFetching,
|
||||||
|
refetch: refetchSelectedContext,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: computed(() => sharedInstanceContextQueryKey(selectedInstanceId.value)),
|
||||||
|
queryFn: () => loadSharedInstanceContext(selectedInstanceId.value),
|
||||||
|
enabled: computed(() => selectedInstanceId.value !== null),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const banOwnerMutation = useMutation({
|
||||||
|
mutationFn: (owner: SharedInstanceReportUser) =>
|
||||||
|
client.sharedinstances.moderation_v1.blacklistUsers({ user_ids: [owner.id] }),
|
||||||
|
onSuccess: async (_data, owner) => {
|
||||||
|
const instanceId = selectedInstanceId.value
|
||||||
|
if (instanceId) {
|
||||||
|
queryClient.setQueryData<SharedInstanceReportDetails>(
|
||||||
|
sharedInstanceContextQueryKey(instanceId),
|
||||||
|
(details) =>
|
||||||
|
details
|
||||||
|
? {
|
||||||
|
...details,
|
||||||
|
quarantine: true,
|
||||||
|
other_instances: details.other_instances.map((instance) => ({
|
||||||
|
...instance,
|
||||||
|
quarantine: true,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Owner banned from shared instances',
|
||||||
|
text: `${owner.username} has been banned and all of their shared instances have been quarantined.`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shared-instances', 'user', userId.value] }),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['shared-instance-blacklist', 'v1', owner.id],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
onError: (requestError, owner) => {
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Failed to ban shared instance owner',
|
||||||
|
text: getErrorMessage(requestError, `Could not ban ${owner.username} from shared instances.`),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPending = computed(() => userPending.value || sharedInstancesPending.value)
|
||||||
|
const isFetching = computed(() => userFetching.value || sharedInstancesFetching.value)
|
||||||
|
const error = computed(() => userError.value ?? sharedInstancesError.value)
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
getErrorMessage(error.value, 'The shared instances service could not be reached.'),
|
||||||
|
)
|
||||||
|
const selectedContextErrorMessage = computed(() =>
|
||||||
|
getErrorMessage(selectedContextError.value, 'The moderation context could not be loaded.'),
|
||||||
|
)
|
||||||
|
const banOwnerPending = computed(() => banOwnerMutation.isPending.value)
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() => `${user.value?.username ?? userId.value}'s shared instances - Modrinth`),
|
||||||
|
})
|
||||||
|
|
||||||
|
onServerPrefetch(() => Promise.all([userSuspense(), sharedInstancesSuspense()]))
|
||||||
|
|
||||||
|
async function loadSharedInstances(): Promise<{
|
||||||
|
instances: UserSharedInstance[]
|
||||||
|
unavailableCount: number
|
||||||
|
}> {
|
||||||
|
const currentUserId = userId.value
|
||||||
|
const instanceIds = [
|
||||||
|
...new Set(await client.sharedinstances.instances_v1.getForUser(currentUserId)),
|
||||||
|
]
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
instanceIds.map(async (instanceId): Promise<UserSharedInstance> => {
|
||||||
|
const [instance, instanceUsers, latestVersion] = await Promise.all([
|
||||||
|
client.sharedinstances.instances_v1.get(instanceId),
|
||||||
|
client.sharedinstances.instances_v1.getUsers(instanceId),
|
||||||
|
client.sharedinstances.instances_v1.getLatestVersion(instanceId).catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: instanceId,
|
||||||
|
name: instance.name,
|
||||||
|
iconUrl: instance.icon,
|
||||||
|
quarantine: instance.quarantine,
|
||||||
|
memberCount: instanceUsers.users.length,
|
||||||
|
membership: instanceUsers.users.find((membership) => membership.id === currentUserId),
|
||||||
|
latestVersion,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const instances = results
|
||||||
|
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
|
||||||
|
.sort((first, second) => first.name.localeCompare(second.name))
|
||||||
|
const unavailableCount = results.length - instances.length
|
||||||
|
|
||||||
|
if (instanceIds.length > 0 && instances.length === 0) {
|
||||||
|
throw new Error("None of this user's shared instances could be loaded.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { instances, unavailableCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedInstanceContextQueryKey(instanceId: string | null) {
|
||||||
|
return ['shared-instance', 'moderation-context', 'v1', instanceId] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInstanceContext(instance: UserSharedInstance) {
|
||||||
|
selectedInstance.value = instance
|
||||||
|
contextModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSharedInstanceContext(
|
||||||
|
instanceId: string | null,
|
||||||
|
): Promise<SharedInstanceReportDetails> {
|
||||||
|
if (!instanceId) throw new Error('No shared instance was selected.')
|
||||||
|
|
||||||
|
const [instance, instanceUsers, latestVersion] = await Promise.all([
|
||||||
|
client.sharedinstances.instances_v1.get(instanceId),
|
||||||
|
client.sharedinstances.instances_v1.getUsers(instanceId),
|
||||||
|
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
|
||||||
|
])
|
||||||
|
sharedInstanceContent.cacheVersion(instanceId, latestVersion)
|
||||||
|
|
||||||
|
const memberIds = [...new Set(instanceUsers.users.map((membership) => membership.id))]
|
||||||
|
const members = memberIds.length ? await client.labrinth.users_v2.getMultiple(memberIds) : []
|
||||||
|
const membersById = new Map(members.map((member) => [member.id, member]))
|
||||||
|
const ownerMembership = instanceUsers.users.find((membership) => membership.join_type === 'owner')
|
||||||
|
if (!ownerMembership) throw new Error('The shared instance has no owner.')
|
||||||
|
|
||||||
|
const toContextUser = (membership: InstanceMembership): SharedInstanceReportUser => {
|
||||||
|
const member = membersById.get(membership.id)
|
||||||
|
return {
|
||||||
|
id: membership.id,
|
||||||
|
username: member?.username ?? membership.id,
|
||||||
|
avatar_url: member?.avatar_url,
|
||||||
|
joined_at: membership.joined_at,
|
||||||
|
last_played: membership.last_played,
|
||||||
|
join_type: membership.join_type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionNumbers = Array.from(
|
||||||
|
{ length: latestVersion.version + 1 },
|
||||||
|
(_value, index) => latestVersion.version - index,
|
||||||
|
)
|
||||||
|
const [versionDetails, otherInstancesResult] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
versionNumbers.map(async (versionNumber) => {
|
||||||
|
try {
|
||||||
|
const version = await sharedInstanceContent.getVersion(instanceId, versionNumber)
|
||||||
|
return {
|
||||||
|
version: version.version,
|
||||||
|
game_version: version.game_version,
|
||||||
|
loader: version.loader,
|
||||||
|
loader_version: version.loader_version,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { version: versionNumber }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
loadOtherSharedInstances(ownerMembership.id, instanceId)
|
||||||
|
.then((instances) => ({ instances, loaded: true }))
|
||||||
|
.catch(() => ({ instances: [], loaded: false })),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: instanceId,
|
||||||
|
name: instance.name,
|
||||||
|
icon_url: instance.icon,
|
||||||
|
quarantine: instance.quarantine,
|
||||||
|
owner: toContextUser(ownerMembership),
|
||||||
|
members: instanceUsers.users
|
||||||
|
.filter((membership) => membership.id !== ownerMembership.id)
|
||||||
|
.map(toContextUser),
|
||||||
|
reported_version: versionDetails[0],
|
||||||
|
previous_versions: versionDetails.slice(1),
|
||||||
|
other_instances: otherInstancesResult.instances,
|
||||||
|
other_instances_loaded: otherInstancesResult.loaded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOtherSharedInstances(
|
||||||
|
ownerId: string,
|
||||||
|
selectedInstanceId: string,
|
||||||
|
): Promise<SharedInstanceOwnerInstance[]> {
|
||||||
|
const instanceIds = await client.sharedinstances.instances_v1.getForUser(ownerId)
|
||||||
|
const otherInstanceIds = [...new Set(instanceIds)].filter(
|
||||||
|
(instanceId) => instanceId !== selectedInstanceId,
|
||||||
|
)
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
otherInstanceIds.map(async (instanceId): Promise<SharedInstanceOwnerInstance> => {
|
||||||
|
const [instance, instanceUsers, latestVersion] = await Promise.all([
|
||||||
|
client.sharedinstances.instances_v1.get(instanceId),
|
||||||
|
client.sharedinstances.instances_v1.getUsers(instanceId),
|
||||||
|
client.sharedinstances.instances_v1.getLatestVersion(instanceId),
|
||||||
|
])
|
||||||
|
sharedInstanceContent.cacheVersion(instanceId, latestVersion)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: instanceId,
|
||||||
|
name: instance.name,
|
||||||
|
icon_url: instance.icon,
|
||||||
|
latest_version: latestVersion.version,
|
||||||
|
member_count: instanceUsers.users.length,
|
||||||
|
quarantine: instance.quarantine,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
|
||||||
|
.sort((first, second) => first.name.localeCompare(second.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
|
||||||
|
banOwnerMutation.mutate(owner)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSharedInstanceContentError(requestError: unknown) {
|
||||||
|
addNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Failed to load version content',
|
||||||
|
text: getErrorMessage(
|
||||||
|
requestError,
|
||||||
|
'The content for this shared instance version could not be loaded.',
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationLabel(membership?: InstanceMembership): string {
|
||||||
|
if (!membership) return 'Membership unavailable'
|
||||||
|
if (membership.join_type === 'owner') return 'Owner'
|
||||||
|
if (membership.join_type === 'link') return 'Joined via share link'
|
||||||
|
return 'Joined via direct invite'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formattedVersion(version: InstanceVersion): string {
|
||||||
|
const loader = version.loader
|
||||||
|
? version.loader.charAt(0).toUpperCase() + version.loader.slice(1)
|
||||||
|
: 'Vanilla'
|
||||||
|
const formattedLoader = version.loader_version ? `${loader} ${version.loader_version}` : loader
|
||||||
|
return `Minecraft ${version.game_version} · ${formattedLoader}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(requestError: unknown, fallback: string): string {
|
||||||
|
if (typeof requestError === 'string') return requestError
|
||||||
|
if (!requestError || typeof requestError !== 'object') return fallback
|
||||||
|
|
||||||
|
const typedError = requestError as {
|
||||||
|
message?: string
|
||||||
|
data?: { description?: string }
|
||||||
|
}
|
||||||
|
return typedError.data?.description ?? typedError.message ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
void Promise.all([refetchUser(), refetchSharedInstances()])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -106,6 +106,18 @@
|
|||||||
{{ formatMessage(messages.acceptInvite) }}
|
{{ formatMessage(messages.acceptInvite) }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<ButtonLink
|
||||||
|
v-if="isStaff && moderationUserId"
|
||||||
|
:to="`/admin/shared-instances/${moderationUserId}`"
|
||||||
|
target="_blank"
|
||||||
|
type="colored"
|
||||||
|
color="orange"
|
||||||
|
>
|
||||||
|
<ScaleIcon />
|
||||||
|
View shared instances
|
||||||
|
<ExternalIcon class="size-4" />
|
||||||
|
</ButtonLink>
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2.5">
|
<div class="flex w-full flex-col gap-2.5">
|
||||||
<span class="pl-3 font-medium text-primary">{{
|
<span class="pl-3 font-medium text-primary">{{
|
||||||
formatMessage(messages.knowThisUser)
|
formatMessage(messages.knowThisUser)
|
||||||
@@ -124,14 +136,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
|
ExternalIcon,
|
||||||
InviteBackgroundIllustration,
|
InviteBackgroundIllustration,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
|
ScaleIcon,
|
||||||
UserPlusIcon,
|
UserPlusIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Admonition,
|
Admonition,
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
|
ButtonLink,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
@@ -189,6 +204,7 @@ const messages = defineMessages({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const auth = await useAuth()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const inviteId = computed(() => String(route.params.inviteId))
|
const inviteId = computed(() => String(route.params.inviteId))
|
||||||
const openInAppModal = useTemplateRef('openInAppModal')
|
const openInAppModal = useTemplateRef('openInAppModal')
|
||||||
@@ -232,6 +248,11 @@ const inviterAvatar = computed(() => {
|
|||||||
if (!inviter.value) return null
|
if (!inviter.value) return null
|
||||||
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
|
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
|
||||||
})
|
})
|
||||||
|
const moderationUserId = computed(() => (inviter.value?.type === 'user' ? inviter.value.id : null))
|
||||||
|
const isStaff = computed(() => {
|
||||||
|
const role = auth.value.user?.role
|
||||||
|
return role === 'admin' || role === 'moderator'
|
||||||
|
})
|
||||||
|
|
||||||
function acceptInvite() {
|
function acceptInvite() {
|
||||||
if (!invite.value) return
|
if (!invite.value) return
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ import {
|
|||||||
AffiliateIcon,
|
AffiliateIcon,
|
||||||
BadgeCheckIcon,
|
BadgeCheckIcon,
|
||||||
BanIcon,
|
BanIcon,
|
||||||
|
BoxesIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
@@ -140,6 +141,10 @@ const messages = defineMessages({
|
|||||||
id: 'profile.button.info',
|
id: 'profile.button.info',
|
||||||
defaultMessage: 'View user details',
|
defaultMessage: 'View user details',
|
||||||
},
|
},
|
||||||
|
sharedInstancesButton: {
|
||||||
|
id: 'profile.button.shared-instances',
|
||||||
|
defaultMessage: 'View shared instances',
|
||||||
|
},
|
||||||
officialAccount: {
|
officialAccount: {
|
||||||
id: 'profile.official-account',
|
id: 'profile.official-account',
|
||||||
defaultMessage: 'Official Modrinth account',
|
defaultMessage: 'Official Modrinth account',
|
||||||
@@ -215,6 +220,7 @@ const emit = defineEmits<{
|
|||||||
openBilling: []
|
openBilling: []
|
||||||
toggleAffiliate: []
|
toggleAffiliate: []
|
||||||
openInfo: []
|
openInfo: []
|
||||||
|
openSharedInstances: []
|
||||||
openAnalytics: []
|
openAnalytics: []
|
||||||
editUser: []
|
editUser: []
|
||||||
}>()
|
}>()
|
||||||
@@ -293,6 +299,14 @@ const moreActions = computed<OverflowMenuOption[]>(() => [
|
|||||||
tone: 'orange',
|
tone: 'orange',
|
||||||
shown: props.showStaffActions && props.isStaff,
|
shown: props.showStaffActions && props.isStaff,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-shared-instances',
|
||||||
|
label: formatMessage(messages.sharedInstancesButton),
|
||||||
|
icon: BoxesIcon,
|
||||||
|
action: () => emit('openSharedInstances'),
|
||||||
|
tone: 'orange',
|
||||||
|
shown: props.showStaffActions && props.isStaff,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'open-analytics',
|
id: 'open-analytics',
|
||||||
label: formatMessage(messages.analyticsButton),
|
label: formatMessage(messages.analyticsButton),
|
||||||
|
|||||||
@@ -189,6 +189,7 @@
|
|||||||
@open-billing="openPath(`/admin/billing/${user.id}`)"
|
@open-billing="openPath(`/admin/billing/${user.id}`)"
|
||||||
@toggle-affiliate="toggleAffiliate"
|
@toggle-affiliate="toggleAffiliate"
|
||||||
@open-info="openUserDetails"
|
@open-info="openUserDetails"
|
||||||
|
@open-shared-instances="openPath(`/admin/shared-instances/${user.id}`)"
|
||||||
@open-analytics="
|
@open-analytics="
|
||||||
openPath(`/dashboard/analytics?user=${encodeURIComponent(user.username)}`)
|
openPath(`/dashboard/analytics?user=${encodeURIComponent(user.username)}`)
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -3113,6 +3113,9 @@
|
|||||||
"profile.button.set-affiliate": {
|
"profile.button.set-affiliate": {
|
||||||
"defaultMessage": "Set as affiliate"
|
"defaultMessage": "Set as affiliate"
|
||||||
},
|
},
|
||||||
|
"profile.button.shared-instances": {
|
||||||
|
"defaultMessage": "View shared instances"
|
||||||
|
},
|
||||||
"profile.button.unblock": {
|
"profile.button.unblock": {
|
||||||
"defaultMessage": "Unblock"
|
"defaultMessage": "Unblock"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user