mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 01:26:23 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<div v-if="open" class="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div
|
||||
:class="visible ? 'opacity-100' : 'opacity-0'"
|
||||
class="fullscreen-overlay fixed inset-0 cursor-pointer backdrop-blur-xl transition-opacity duration-300 ease-out"
|
||||
@click="hide"
|
||||
/>
|
||||
<div
|
||||
class="relative z-[1] max-h-screen w-full max-w-[28rem] overflow-y-auto px-4 py-10 transition-all duration-300 ease-out"
|
||||
:class="visible ? 'scale-100 opacity-100' : 'scale-95 opacity-0'"
|
||||
>
|
||||
<div class="flex w-full flex-col items-center gap-6">
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<div
|
||||
v-if="countdown > 0"
|
||||
class="relative mx-auto flex size-[120px] items-center justify-center"
|
||||
>
|
||||
<svg class="absolute size-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
|
||||
<circle
|
||||
class="stroke-surface-4"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke-width="6"
|
||||
/>
|
||||
<circle
|
||||
class="stroke-brand transition-[stroke-dashoffset] duration-[50ms] ease-linear"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke-width="6"
|
||||
:stroke-dasharray="circumference"
|
||||
:stroke-dashoffset="strokeDashoffset"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="z-[1] text-5xl font-bold text-contrast">{{ countdown }}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="m-0 text-center text-3xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.openingApp) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center gap-4 rounded-3xl border border-solid border-surface-5 bg-surface-3 p-6"
|
||||
>
|
||||
<div class="flex w-full items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar :src="instance.icon" :alt="instance.name" size="48px" />
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
<span class="flex items-center gap-1.5 text-sm font-medium leading-4 text-primary">
|
||||
<span>{{ formatMessage(messages.managedBy) }}</span>
|
||||
<Avatar
|
||||
:src="instance.inviterAvatar"
|
||||
:alt="instance.inviterName"
|
||||
size="16px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span>{{ instance.inviterName }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 text-left">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.whyUseApp)
|
||||
}}</span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-base">
|
||||
<div
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span>{{ formatMessage(messages.benefitJoin) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-base">
|
||||
<div
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span>{{ formatMessage(messages.benefitInstall) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-base">
|
||||
<div
|
||||
class="flex size-5 shrink-0 items-center justify-center rounded-full border border-solid border-brand bg-brand-highlight text-brand"
|
||||
>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<span>{{ formatMessage(messages.benefitUpdate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="countdown > 0" class="text-center text-secondary">{{
|
||||
formatMessage(messages.openingAutomatically)
|
||||
}}</span>
|
||||
<div v-else class="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<ButtonStyled>
|
||||
<button class="flex-1" type="button" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<a
|
||||
class="flex-1"
|
||||
href="https://modrinth.com/app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.getApp) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onUnmounted, ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
openingApp: {
|
||||
id: 'modal.shared-instance.open-in-app.title',
|
||||
defaultMessage: 'Opening Modrinth App',
|
||||
},
|
||||
managedBy: {
|
||||
id: 'modal.shared-instance.open-in-app.managed-by',
|
||||
defaultMessage: 'Managed by',
|
||||
},
|
||||
whyUseApp: {
|
||||
id: 'modal.shared-instance.open-in-app.why-use',
|
||||
defaultMessage: 'Why use the Modrinth App',
|
||||
},
|
||||
benefitJoin: {
|
||||
id: 'modal.shared-instance.open-in-app.benefit.join',
|
||||
defaultMessage: 'Join the shared instance in one click',
|
||||
},
|
||||
benefitInstall: {
|
||||
id: 'modal.shared-instance.open-in-app.benefit.install',
|
||||
defaultMessage: 'Automatically install the required game and loader',
|
||||
},
|
||||
benefitUpdate: {
|
||||
id: 'modal.shared-instance.open-in-app.benefit.update',
|
||||
defaultMessage: 'Keep shared content updated when the instance changes',
|
||||
},
|
||||
openingAutomatically: {
|
||||
id: 'modal.shared-instance.open-in-app.opening-automatically',
|
||||
defaultMessage: 'The Modrinth App will open automatically...',
|
||||
},
|
||||
getApp: {
|
||||
id: 'modal.shared-instance.open-in-app.get-app',
|
||||
defaultMessage: 'Get Modrinth App',
|
||||
},
|
||||
})
|
||||
|
||||
export interface SharedInstanceInviteModalData {
|
||||
inviteId: string
|
||||
name: string
|
||||
icon?: string | null
|
||||
inviterName: string
|
||||
inviterAvatar?: string | null
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const visible = ref(false)
|
||||
const countdown = ref(3)
|
||||
const countdownProgress = ref(1)
|
||||
const instance = ref<SharedInstanceInviteModalData>({
|
||||
inviteId: '',
|
||||
name: '',
|
||||
icon: null,
|
||||
inviterName: '',
|
||||
inviterAvatar: null,
|
||||
})
|
||||
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
||||
let progressInterval: ReturnType<typeof setInterval> | null = null
|
||||
let showTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const circumference = 2 * Math.PI * 45
|
||||
const strokeDashoffset = computed(() => circumference * (1 - countdownProgress.value))
|
||||
const appLink = computed(() => `modrinth://share/${encodeURIComponent(instance.value.inviteId)}`)
|
||||
|
||||
function startCountdown() {
|
||||
countdown.value = 3
|
||||
countdownProgress.value = 1
|
||||
|
||||
const totalDuration = 3000
|
||||
const progressUpdateInterval = 16
|
||||
const progressDecrement = progressUpdateInterval / totalDuration
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
countdownProgress.value = Math.max(0, countdownProgress.value - progressDecrement)
|
||||
}, progressUpdateInterval)
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) stopCountdown()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval)
|
||||
progressInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function show(options: { instance: SharedInstanceInviteModalData }) {
|
||||
instance.value = options.instance
|
||||
open.value = true
|
||||
document.body.style.overflow = 'hidden'
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
|
||||
await nextTick()
|
||||
window.open(appLink.value, '_self')
|
||||
|
||||
showTimeout = setTimeout(() => {
|
||||
visible.value = true
|
||||
startCountdown()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
stopCountdown()
|
||||
if (showTimeout) clearTimeout(showTimeout)
|
||||
|
||||
hideTimeout = setTimeout(() => {
|
||||
open.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') hide()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
stopCountdown()
|
||||
if (showTimeout) clearTimeout(showTimeout)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fullscreen-overlay {
|
||||
background: linear-gradient(to bottom, rgb(66 131 92 / 23%) 0%, rgb(17 35 43 / 40%) 97%);
|
||||
}
|
||||
</style>
|
||||
@@ -89,14 +89,18 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<NuxtLink
|
||||
v-if="report.item_type !== 'shared-instance'"
|
||||
:to="reportItemUrl"
|
||||
target="_blank"
|
||||
class="text-base font-semibold text-contrast hover:underline"
|
||||
>
|
||||
{{ reportItemTitle }}
|
||||
</NuxtLink>
|
||||
<span v-else class="text-base font-semibold text-contrast">
|
||||
{{ reportItemTitle }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="report.project?.project_type"
|
||||
@@ -118,6 +122,20 @@
|
||||
>
|
||||
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="report.item_type === 'shared-instance' && report.shared_instance_version_id"
|
||||
class="text-sm text-secondary"
|
||||
>
|
||||
Version {{ report.shared_instance_version_id }}
|
||||
</span>
|
||||
<CopyCode v-if="report.item_type === 'shared-instance'" :text="report.item_id" />
|
||||
<span
|
||||
v-if="report.item_type === 'shared-instance' && sharedInstanceQuarantined"
|
||||
class="bg-orange-highlight inline-flex items-center gap-1 rounded-full border border-solid border-orange px-2.5 py-1 text-sm font-semibold text-orange"
|
||||
>
|
||||
<LockIcon class="size-4" />
|
||||
Quarantined
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="report.target" class="flex items-center gap-1">
|
||||
@@ -135,6 +153,24 @@
|
||||
{{ report.target.name }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="report.item_type === 'shared-instance' && sharedInstanceDetails"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Avatar
|
||||
:src="sharedInstanceDetails.owner.avatar_url"
|
||||
size="1.5rem"
|
||||
circle
|
||||
class="border border-surface-5 bg-surface-4 !shadow-none"
|
||||
/>
|
||||
<NuxtLink
|
||||
:to="`/user/${sharedInstanceDetails.owner.username}`"
|
||||
target="_blank"
|
||||
class="text-sm font-medium text-secondary hover:underline"
|
||||
>
|
||||
{{ sharedInstanceDetails.owner.username }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,6 +190,39 @@
|
||||
:closed="reportClosed"
|
||||
@update-thread="updateThread"
|
||||
>
|
||||
<template #afterMessages>
|
||||
<template v-if="report.item_type === 'shared-instance'">
|
||||
<div
|
||||
v-if="sharedInstanceLoading"
|
||||
class="flex items-center gap-2 border-0 border-t border-solid border-surface-4 px-4 py-6 text-secondary"
|
||||
>
|
||||
<LoaderCircleIcon class="size-5 animate-spin" />
|
||||
Loading shared instance details…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="sharedInstanceError"
|
||||
class="flex flex-col items-start gap-3 border-0 border-t border-solid border-surface-4 px-4 py-6"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">
|
||||
Shared instance details could not be loaded
|
||||
</span>
|
||||
<span class="text-sm text-secondary">{{ sharedInstanceError }}</span>
|
||||
</div>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="loadSharedInstanceDetails">Try again</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<SharedInstanceReportContext
|
||||
v-else-if="sharedInstanceDetails"
|
||||
:details="sharedInstanceDetails"
|
||||
:ban-pending="sharedInstanceBanPending"
|
||||
:load-version-content="loadSharedInstanceVersionContent"
|
||||
@ban-owner="banSharedInstanceOwner"
|
||||
@content-error="showSharedInstanceContentError"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template #closedActions>
|
||||
<ButtonStyled v-if="isStaff(auth.user)" color="green">
|
||||
<button class="mt-2 w-full gap-2 sm:w-auto" @click="reopenReport()">
|
||||
@@ -184,29 +253,55 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, ClipboardCopyIcon, ExternalIcon } from '@modrinth/assets'
|
||||
import type { Labrinth, SharedInstances } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
ExternalIcon,
|
||||
LoaderCircleIcon,
|
||||
LockIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
CollapsibleRegion,
|
||||
type ContentItem,
|
||||
CopyCode,
|
||||
getProjectTypeIcon,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
import ThreadView from '../thread/ThreadView.vue'
|
||||
import SharedInstanceReportContext, {
|
||||
type SharedInstanceOwnerInstance,
|
||||
type SharedInstanceReportDetails,
|
||||
type SharedInstanceReportUser,
|
||||
} from './SharedInstanceReportContext.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const auth = await useAuth()
|
||||
|
||||
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
|
||||
project_id?: string
|
||||
version_id?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
report: ExtendedReport
|
||||
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
|
||||
sharedInstanceVersionContentLoader?: (
|
||||
instanceId: string,
|
||||
version: number,
|
||||
) => Promise<ContentItem[]>
|
||||
}>()
|
||||
|
||||
const reportThread = ref<{
|
||||
@@ -214,11 +309,25 @@ const reportThread = ref<{
|
||||
sendReply: (privateMessage?: boolean) => Promise<void>
|
||||
} | null>(null)
|
||||
const isThreadCollapsed = ref(true)
|
||||
const sharedInstanceDetails = ref<SharedInstanceReportDetails | null>(null)
|
||||
const sharedInstanceLoading = ref(false)
|
||||
const sharedInstanceError = ref<string | null>(null)
|
||||
const sharedInstanceBanPending = ref(false)
|
||||
const sharedInstanceVersions = new Map<string, SharedInstances.Instances.v1.InstanceVersion>()
|
||||
let sharedInstanceDetailsRequest: Promise<void> | null = null
|
||||
|
||||
watch(isThreadCollapsed, (collapsed) => {
|
||||
if (!collapsed) void loadSharedInstanceDetails()
|
||||
})
|
||||
|
||||
const didCloseReport = ref(false)
|
||||
const reportClosed = computed(() => {
|
||||
return didCloseReport.value || props.report.closed
|
||||
})
|
||||
const sharedInstanceQuarantined = computed(
|
||||
() =>
|
||||
sharedInstanceDetails.value?.quarantine ?? props.report.shared_instance?.quarantine ?? false,
|
||||
)
|
||||
|
||||
const threadWithReportBody = computed(() => {
|
||||
if (!props.report.thread) return null
|
||||
@@ -322,6 +431,299 @@ function updateThread(newThread: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getSharedInstanceVersion(
|
||||
instanceId: string,
|
||||
versionNumber: number,
|
||||
): Promise<SharedInstances.Instances.v1.InstanceVersion> {
|
||||
const cacheKey = `${instanceId}:${versionNumber}`
|
||||
const cachedVersion = sharedInstanceVersions.get(cacheKey)
|
||||
if (cachedVersion) return cachedVersion
|
||||
|
||||
const instanceVersion = await client.sharedinstances.instances_v1.getVersion(
|
||||
instanceId,
|
||||
versionNumber,
|
||||
)
|
||||
sharedInstanceVersions.set(cacheKey, instanceVersion)
|
||||
return instanceVersion
|
||||
}
|
||||
|
||||
async function getOtherSharedInstances(ownerId: string): Promise<SharedInstanceOwnerInstance[]> {
|
||||
const instanceIds = await client.sharedinstances.instances_v1.getForUser(ownerId)
|
||||
const otherInstanceIds = [...new Set(instanceIds)].filter(
|
||||
(instanceId) => instanceId !== props.report.item_id,
|
||||
)
|
||||
const results = await Promise.allSettled(
|
||||
otherInstanceIds.map(async (instanceId) => {
|
||||
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),
|
||||
])
|
||||
sharedInstanceVersions.set(`${instanceId}:${latestVersion.version}`, 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((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
async function loadSharedInstanceDetails() {
|
||||
if (props.report.item_type !== 'shared-instance' || sharedInstanceDetails.value) return
|
||||
if (sharedInstanceDetailsRequest) return sharedInstanceDetailsRequest
|
||||
|
||||
sharedInstanceDetailsRequest = (async () => {
|
||||
sharedInstanceLoading.value = true
|
||||
sharedInstanceError.value = null
|
||||
|
||||
try {
|
||||
if (props.sharedInstanceDetailsLoader) {
|
||||
sharedInstanceDetails.value = await props.sharedInstanceDetailsLoader()
|
||||
return
|
||||
}
|
||||
|
||||
const [instance, instanceUsers] = await Promise.all([
|
||||
props.report.shared_instance ??
|
||||
client.sharedinstances.instances_v1.get(props.report.item_id),
|
||||
client.sharedinstances.instances_v1.getUsers(props.report.item_id),
|
||||
])
|
||||
const userIds = [...new Set(instanceUsers.users.map((user) => user.id))]
|
||||
const users = userIds.length ? await client.labrinth.users_v2.getMultiple(userIds) : []
|
||||
const usersById = new Map(users.map((user) => [user.id, user]))
|
||||
const ownerMembership = instanceUsers.users.find((user) => user.join_type === 'owner')
|
||||
|
||||
if (!ownerMembership) {
|
||||
throw new Error('The shared instance has no owner.')
|
||||
}
|
||||
|
||||
const toReportUser = (
|
||||
membership: (typeof instanceUsers.users)[number],
|
||||
): SharedInstanceReportUser => {
|
||||
const user = usersById.get(membership.id)
|
||||
return {
|
||||
id: membership.id,
|
||||
username: user?.username ?? membership.id,
|
||||
avatar_url: user?.avatar_url,
|
||||
joined_at: membership.joined_at,
|
||||
last_played: membership.last_played,
|
||||
join_type: membership.join_type,
|
||||
}
|
||||
}
|
||||
|
||||
const reportedVersion = props.report.shared_instance_version_id
|
||||
const versionNumbers = reportedVersion
|
||||
? Array.from({ length: reportedVersion }, (_, index) => reportedVersion - index)
|
||||
: []
|
||||
const [versionDetails, otherInstancesResult] = await Promise.all([
|
||||
Promise.all(
|
||||
versionNumbers.map(async (versionNumber) => {
|
||||
try {
|
||||
const version = await getSharedInstanceVersion(props.report.item_id, versionNumber)
|
||||
return {
|
||||
version: version.version,
|
||||
game_version: version.game_version,
|
||||
loader: version.loader,
|
||||
loader_version: version.loader_version,
|
||||
}
|
||||
} catch {
|
||||
return { version: versionNumber }
|
||||
}
|
||||
}),
|
||||
),
|
||||
getOtherSharedInstances(ownerMembership.id)
|
||||
.then((instances) => ({ instances, loaded: true }))
|
||||
.catch(() => ({ instances: [], loaded: false })),
|
||||
])
|
||||
|
||||
sharedInstanceDetails.value = {
|
||||
id: props.report.item_id,
|
||||
name: instance.name,
|
||||
icon_url: instance.icon,
|
||||
quarantine: instance.quarantine,
|
||||
owner: toReportUser(ownerMembership),
|
||||
members: instanceUsers.users
|
||||
.filter((user) => user.id !== ownerMembership.id)
|
||||
.map(toReportUser),
|
||||
reported_version: versionDetails[0],
|
||||
previous_versions: versionDetails.slice(1),
|
||||
other_instances: otherInstancesResult.instances,
|
||||
other_instances_loaded: otherInstancesResult.loaded,
|
||||
}
|
||||
} catch (error) {
|
||||
sharedInstanceError.value = getErrorMessage(error, 'Failed to load shared instance details.')
|
||||
} finally {
|
||||
sharedInstanceLoading.value = false
|
||||
sharedInstanceDetailsRequest = null
|
||||
}
|
||||
})()
|
||||
|
||||
return sharedInstanceDetailsRequest
|
||||
}
|
||||
|
||||
async function loadSharedInstanceVersionContent(
|
||||
instanceId: string,
|
||||
versionNumber: number,
|
||||
): Promise<ContentItem[]> {
|
||||
if (props.report.item_type !== 'shared-instance') return []
|
||||
if (props.sharedInstanceVersionContentLoader) {
|
||||
return props.sharedInstanceVersionContentLoader(instanceId, versionNumber)
|
||||
}
|
||||
|
||||
const instanceVersion = await getSharedInstanceVersion(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]),
|
||||
]
|
||||
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 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'
|
||||
|
||||
return sharedInstanceContentItem(
|
||||
version,
|
||||
project,
|
||||
fileName,
|
||||
dependency.project_id ?? fileName,
|
||||
!project && !version,
|
||||
)
|
||||
})
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function showSharedInstanceContentError(error: unknown) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to load version content',
|
||||
text: getErrorMessage(
|
||||
error,
|
||||
'The content for this shared instance version could not be loaded.',
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (typeof error === 'string') return error
|
||||
if (!error || typeof error !== 'object') return fallback
|
||||
|
||||
const requestError = error as {
|
||||
message?: string
|
||||
data?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
return requestError.data?.description ?? requestError.message ?? fallback
|
||||
}
|
||||
|
||||
const reportItemAvatarUrl = computed(() => {
|
||||
switch (props.report.item_type) {
|
||||
case 'project':
|
||||
@@ -329,6 +731,8 @@ const reportItemAvatarUrl = computed(() => {
|
||||
return props.report.project?.icon_url || ''
|
||||
case 'user':
|
||||
return props.report.user?.avatar_url || ''
|
||||
case 'shared-instance':
|
||||
return sharedInstanceDetails.value?.icon_url || props.report.shared_instance?.icon || ''
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
@@ -336,6 +740,11 @@ const reportItemAvatarUrl = computed(() => {
|
||||
|
||||
const reportItemTitle = computed(() => {
|
||||
if (props.report.item_type === 'user') return props.report.user?.username || 'Unknown User'
|
||||
if (props.report.item_type === 'shared-instance') {
|
||||
return (
|
||||
sharedInstanceDetails.value?.name || props.report.shared_instance?.name || 'Shared instance'
|
||||
)
|
||||
}
|
||||
|
||||
return props.report.project?.title || 'Unknown Project'
|
||||
})
|
||||
@@ -348,6 +757,8 @@ const reportItemUrl = computed(() => {
|
||||
return `/${props.report.project?.project_type}/${props.report.project?.slug}`
|
||||
case 'version':
|
||||
return `/${props.report.project?.project_type}/${props.report.project?.slug}/version/${props.report.version?.id}`
|
||||
case 'shared-instance':
|
||||
return ''
|
||||
default:
|
||||
return `/${props.report.item_type}/${props.report.id}`
|
||||
}
|
||||
@@ -370,4 +781,40 @@ function copyId() {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
|
||||
if (sharedInstanceBanPending.value) return
|
||||
sharedInstanceBanPending.value = true
|
||||
|
||||
try {
|
||||
await client.sharedinstances.moderation_v1.blacklistUsers({
|
||||
user_ids: [owner.id],
|
||||
})
|
||||
|
||||
if (sharedInstanceDetails.value) {
|
||||
sharedInstanceDetails.value = {
|
||||
...sharedInstanceDetails.value,
|
||||
quarantine: true,
|
||||
other_instances: sharedInstanceDetails.value.other_instances.map((instance) => ({
|
||||
...instance,
|
||||
quarantine: true,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Owner banned from shared instances',
|
||||
text: `${owner.username} has been banned and all of their shared instances have been quarantined.`,
|
||||
})
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to ban shared instance owner',
|
||||
text: getErrorMessage(error, `Could not ban ${owner.username} from shared instances.`),
|
||||
})
|
||||
} finally {
|
||||
sharedInstanceBanPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BanIcon,
|
||||
BoxesIcon,
|
||||
BoxIcon,
|
||||
EyeIcon,
|
||||
HistoryIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
UserPlusIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
ConfirmLeaveModal,
|
||||
type ContentItem,
|
||||
injectModrinthClient,
|
||||
ModpackContentModal,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface SharedInstanceReportUser {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
joined_at?: string | null
|
||||
last_played?: string | null
|
||||
join_type?: 'owner' | 'invite' | 'link'
|
||||
}
|
||||
|
||||
export interface SharedInstanceReportVersion {
|
||||
version: number
|
||||
game_version?: string
|
||||
loader?: string
|
||||
loader_version?: string
|
||||
created?: string
|
||||
}
|
||||
|
||||
export interface SharedInstanceOwnerInstance {
|
||||
id: string
|
||||
name: string
|
||||
icon_url?: string | null
|
||||
latest_version: number
|
||||
member_count: number
|
||||
quarantine: boolean
|
||||
}
|
||||
|
||||
export interface SharedInstanceReportDetails {
|
||||
id: string
|
||||
name: string
|
||||
icon_url?: string | null
|
||||
quarantine: boolean
|
||||
owner: SharedInstanceReportUser
|
||||
members: SharedInstanceReportUser[]
|
||||
reported_version?: SharedInstanceReportVersion
|
||||
previous_versions: SharedInstanceReportVersion[]
|
||||
other_instances: SharedInstanceOwnerInstance[]
|
||||
other_instances_loaded?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
details: SharedInstanceReportDetails
|
||||
banPending?: boolean
|
||||
loadVersionContent?: (instanceId: string, version: number) => Promise<ContentItem[]>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
banOwner: [owner: SharedInstanceReportUser]
|
||||
contentError: [error: unknown]
|
||||
}>()
|
||||
|
||||
const contentModal = ref<InstanceType<typeof ModpackContentModal> | null>(null)
|
||||
const banModal = ref<InstanceType<typeof ConfirmLeaveModal> | null>(null)
|
||||
const client = injectModrinthClient()
|
||||
const contentByVersion = new Map<string, ContentItem[]>()
|
||||
const contentInstance = ref<Pick<SharedInstanceOwnerInstance, 'id' | 'name' | 'icon_url'>>({
|
||||
id: props.details.id,
|
||||
name: props.details.name,
|
||||
icon_url: props.details.icon_url,
|
||||
})
|
||||
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
|
||||
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
type MemberTableColumn = 'username' | 'lastPlayed' | 'joined' | 'method'
|
||||
type MemberTableRow = {
|
||||
id: string
|
||||
username: string
|
||||
avatarUrl?: string | null
|
||||
lastPlayed: string | null
|
||||
joined: string | null
|
||||
method: 'owner' | 'invite' | 'link'
|
||||
}
|
||||
|
||||
const memberColumns: TableColumn<MemberTableColumn>[] = [
|
||||
{
|
||||
key: 'username',
|
||||
label: 'Username',
|
||||
width: 'clamp(14rem, 30%, 26rem)',
|
||||
},
|
||||
{
|
||||
key: 'lastPlayed',
|
||||
label: 'Last played',
|
||||
width: 'clamp(7rem, 15%, 13rem)',
|
||||
headerClass: 'whitespace-nowrap !px-2',
|
||||
cellClass: 'whitespace-nowrap !px-2',
|
||||
},
|
||||
{
|
||||
key: 'joined',
|
||||
label: 'Joined',
|
||||
width: 'clamp(7rem, 14%, 12rem)',
|
||||
headerClass: 'whitespace-nowrap !px-2',
|
||||
cellClass: 'whitespace-nowrap !px-2',
|
||||
},
|
||||
{
|
||||
key: 'method',
|
||||
label: 'Method',
|
||||
headerClass: 'whitespace-nowrap !px-2',
|
||||
cellClass: 'whitespace-nowrap !px-2',
|
||||
},
|
||||
]
|
||||
|
||||
const allVersions = computed(() => [
|
||||
...(props.details.reported_version ? [props.details.reported_version] : []),
|
||||
...props.details.previous_versions,
|
||||
])
|
||||
const { data: ownerBlacklistStatus, isFetching: ownerBlacklistStatusFetching } = useQuery({
|
||||
queryKey: computed(() => ['shared-instance-blacklist', 'v1', props.details.owner.id]),
|
||||
queryFn: () => client.sharedinstances.users_v1.getBlacklistStatus(props.details.owner.id),
|
||||
retry: false,
|
||||
})
|
||||
const banDisabled = computed(
|
||||
() =>
|
||||
!!props.banPending ||
|
||||
props.details.quarantine ||
|
||||
ownerBlacklistStatusFetching.value ||
|
||||
ownerBlacklistStatus.value?.blacklisted === true,
|
||||
)
|
||||
const banButtonLabel = computed(() => {
|
||||
if (props.banPending) return 'Banning owner…'
|
||||
if (ownerBlacklistStatus.value?.blacklisted) return 'Owner already banned'
|
||||
if (props.details.quarantine) return 'Instance already quarantined'
|
||||
return 'Ban from shared instances'
|
||||
})
|
||||
const memberRows = computed<MemberTableRow[]>(() =>
|
||||
[props.details.owner, ...props.details.members.filter((user) => user.joined_at != null)].map(
|
||||
(user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url,
|
||||
lastPlayed: user.last_played ?? null,
|
||||
joined: user.joined_at ?? null,
|
||||
method: user.join_type ?? 'invite',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
async function promptBanOwner() {
|
||||
if (banDisabled.value || !(await banModal.value?.prompt())) return
|
||||
emit('banOwner', props.details.owner)
|
||||
}
|
||||
|
||||
async function viewVersionContent(
|
||||
instance: Pick<SharedInstanceOwnerInstance, 'id' | 'name' | 'icon_url'>,
|
||||
version: number,
|
||||
) {
|
||||
contentInstance.value = instance
|
||||
contentModal.value?.showLoading()
|
||||
|
||||
try {
|
||||
const cacheKey = `${instance.id}:${version}`
|
||||
let content = contentByVersion.get(cacheKey)
|
||||
if (!content) {
|
||||
content = props.loadVersionContent ? await props.loadVersionContent(instance.id, version) : []
|
||||
contentByVersion.set(cacheKey, content)
|
||||
}
|
||||
contentModal.value?.setItems(content)
|
||||
} catch (error) {
|
||||
contentModal.value?.hide()
|
||||
emit('contentError', error)
|
||||
}
|
||||
}
|
||||
|
||||
function viewReportedInstanceVersion(version: SharedInstanceReportVersion) {
|
||||
return viewVersionContent(
|
||||
{
|
||||
id: props.details.id,
|
||||
name: props.details.name,
|
||||
icon_url: props.details.icon_url,
|
||||
},
|
||||
version.version,
|
||||
)
|
||||
}
|
||||
|
||||
function formattedLoader(version: SharedInstanceReportVersion) {
|
||||
const loader = version.loader
|
||||
? version.loader.charAt(0).toUpperCase() + version.loader.slice(1)
|
||||
: 'Vanilla'
|
||||
return version.loader_version ? `${loader} ${version.loader_version}` : loader
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="shared-instance-message relative grid items-center gap-x-[var(--spacing-card-sm)] gap-y-[var(--spacing-card-xs)] break-words border-0 border-t border-solid border-surface-4 px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="shared-instance-message__icon backed-svg circle raised mb-auto border border-solid border-surface-5 shadow-[var(--shadow-card)]"
|
||||
style="--size: 2rem"
|
||||
>
|
||||
<BoxesIcon />
|
||||
</div>
|
||||
<div
|
||||
class="shared-instance-message__author flex shrink-0 flex-wrap items-center gap-[var(--spacing-card-xs)] font-bold text-[var(--color-text)]"
|
||||
>
|
||||
<span>Shared instance details</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"
|
||||
>
|
||||
Report context
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shared-instance-message__body overflow-hidden rounded-2xl border border-solid border-surface-4 bg-surface-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<UsersIcon class="size-5 text-secondary" />
|
||||
Owner and members
|
||||
</div>
|
||||
|
||||
<Table :columns="memberColumns" :data="memberRows" row-key="id" table-min-width="42rem">
|
||||
<template #cell-username="{ row }">
|
||||
<NuxtLink
|
||||
:to="`/user/${row.username}`"
|
||||
target="_blank"
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-2 text-primary hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
:src="row.avatarUrl"
|
||||
:alt="`${row.username}'s avatar`"
|
||||
:tint-by="row.id"
|
||||
size="24px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate font-medium">{{ row.username }}</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template #cell-lastPlayed="{ row }">
|
||||
<span v-if="row.lastPlayed" v-tooltip="formatDateTime(row.lastPlayed)">
|
||||
{{ formatRelativeTime(row.lastPlayed) }}
|
||||
</span>
|
||||
<span v-else>Never</span>
|
||||
</template>
|
||||
<template #cell-joined="{ row }">
|
||||
<span v-if="row.joined" v-tooltip="formatDateTime(row.joined)">
|
||||
{{ formatRelativeTime(row.joined) }}
|
||||
</span>
|
||||
<span v-else>Unknown</span>
|
||||
</template>
|
||||
<template #cell-method="{ row }">
|
||||
<span class="inline-flex min-w-0 items-center gap-2">
|
||||
<UsersIcon v-if="row.method === 'owner'" class="size-5 shrink-0" aria-hidden="true" />
|
||||
<LinkIcon
|
||||
v-else-if="row.method === 'link'"
|
||||
class="size-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<UserPlusIcon v-else class="size-5 shrink-0" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{
|
||||
row.method === 'owner'
|
||||
? 'Owner'
|
||||
: row.method === 'link'
|
||||
? 'Share link'
|
||||
: 'Direct invite'
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="border-0 border-t border-solid border-surface-4 p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<HistoryIcon class="size-5 text-secondary" />
|
||||
Version history
|
||||
</div>
|
||||
|
||||
<div v-if="allVersions.length" class="grid max-h-64 gap-2 overflow-y-auto lg:grid-cols-2">
|
||||
<div
|
||||
v-for="version in allVersions"
|
||||
:key="version.version"
|
||||
class="flex flex-col gap-2 rounded-xl bg-surface-2 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<div class="backed-svg shrink-0">
|
||||
<VersionIcon />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-contrast">Version {{ version.version }}</span>
|
||||
<span
|
||||
v-if="version.version === details.reported_version?.version"
|
||||
class="bg-orange-highlight rounded-full px-2 py-0.5 text-xs font-semibold text-orange"
|
||||
>
|
||||
Reported
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="version.game_version" class="truncate text-sm text-secondary">
|
||||
Minecraft {{ version.game_version }} · {{ formattedLoader(version) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="w-full sm:w-auto" @click="viewReportedInstanceVersion(version)">
|
||||
<EyeIcon class="size-4" />
|
||||
View content
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-sm text-secondary">
|
||||
No version was attached to this report.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 border-0 border-t border-solid border-surface-4 p-4">
|
||||
<div class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<BoxIcon class="size-5 text-secondary" />
|
||||
Other instances owned by {{ details.owner.username }}
|
||||
</div>
|
||||
|
||||
<div v-if="details.other_instances.length" class="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
v-for="instance in details.other_instances"
|
||||
:key="instance.id"
|
||||
class="flex min-w-0 items-center gap-2 rounded-xl bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar :src="instance.icon_url" :tint-by="instance.id" size="2.5rem" no-shadow />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
|
||||
<LockIcon
|
||||
v-if="instance.quarantine"
|
||||
v-tooltip="'Quarantined'"
|
||||
class="size-4 shrink-0 text-orange"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">
|
||||
Version {{ instance.latest_version }} · {{ instance.member_count }}
|
||||
{{ instance.member_count === 1 ? 'member' : 'members' }}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="`View ${instance.name} version ${instance.latest_version} content`"
|
||||
:aria-label="`View ${instance.name} version ${instance.latest_version} content`"
|
||||
@click="viewVersionContent(instance, instance.latest_version)"
|
||||
>
|
||||
<EyeIcon class="size-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else-if="details.other_instances_loaded" class="text-sm text-secondary">
|
||||
This owner has no other shared instances.
|
||||
</span>
|
||||
<span v-else class="text-sm text-secondary">
|
||||
Other instances are unavailable from the current shared instances API.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<footer
|
||||
class="flex flex-col gap-3 border-0 border-t border-solid border-surface-4 bg-surface-2 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex max-w-2xl flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">Shared instances service ban</span>
|
||||
<span class="text-sm text-secondary">
|
||||
Banning this owner quarantines all of their shared instances and prevents members from
|
||||
launching them.
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="banDisabled" class="w-full gap-2 sm:w-auto" @click="promptBanOwner">
|
||||
<BanIcon class="size-4" />
|
||||
{{ banButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<ModpackContentModal
|
||||
ref="contentModal"
|
||||
header="Shared instance content"
|
||||
:modpack-name="contentInstance.name"
|
||||
:modpack-icon-url="contentInstance.icon_url ?? undefined"
|
||||
/>
|
||||
<ConfirmLeaveModal
|
||||
ref="banModal"
|
||||
title="Ban from shared instances?"
|
||||
:header="`Ban ${details.owner.username} from shared instances`"
|
||||
body="This permanently prevents the owner from creating or managing shared instances. All of their shared instances will be quarantined, and members will be unable to launch them."
|
||||
stay-label="Cancel"
|
||||
leave-label="Ban owner"
|
||||
admonition-type="critical"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shared-instance-message {
|
||||
grid-template:
|
||||
'icon author'
|
||||
'icon body';
|
||||
grid-template-columns: min-content minmax(0, 1fr);
|
||||
grid-template-rows: min-content 1fr;
|
||||
}
|
||||
|
||||
.shared-instance-message__icon {
|
||||
grid-area: icon;
|
||||
}
|
||||
|
||||
.shared-instance-message__author {
|
||||
grid-area: author;
|
||||
}
|
||||
|
||||
.shared-instance-message__body {
|
||||
grid-area: body;
|
||||
}
|
||||
</style>
|
||||
@@ -63,6 +63,18 @@
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="report.item_type === 'shared-instance'" class="item-info">
|
||||
<div class="backed-svg" :class="{ raised: raised }">
|
||||
<BoxesIcon />
|
||||
</div>
|
||||
<div class="stacked">
|
||||
<span class="title">Shared instance</span>
|
||||
<span>
|
||||
Version {{ report.shared_instance_version_id ?? 'unknown' }} ·
|
||||
<CopyCode :text="report.item_id" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="item-info">
|
||||
<div class="backed-svg" :class="{ raised: raised }">
|
||||
<UnknownIcon />
|
||||
@@ -106,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
|
||||
import { BoxesIcon, ReportIcon, UnknownIcon, VersionIcon } from '@modrinth/assets'
|
||||
import { Avatar, Badge, CopyCode, useFormatDateTime, useRelativeTime } from '@modrinth/ui'
|
||||
import { formatProjectType, renderHighlightedString } from '@modrinth/utils'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
raised
|
||||
@update-thread="() => updateThreadLocal()"
|
||||
/>
|
||||
<slot name="afterMessages" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center space-y-3 py-12">
|
||||
<MessageIcon class="size-12 text-secondary" />
|
||||
|
||||
Reference in New Issue
Block a user