mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +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:
@@ -1,4 +1,5 @@
|
||||
BASE_URL=http://127.0.0.1:8000/v2/
|
||||
BROWSER_BASE_URL=http://127.0.0.1:8000/v2/
|
||||
PYRO_BASE_URL=https://staging-archon.modrinth.com
|
||||
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com
|
||||
PROD_OVERRIDE=true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
BASE_URL=https://api.modrinth.com/v2/
|
||||
BROWSER_BASE_URL=https://api.modrinth.com/v2/
|
||||
PYRO_BASE_URL=https://archon.modrinth.com
|
||||
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com
|
||||
PROD_OVERRIDE=true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
BASE_URL=https://staging-api.modrinth.com/v2/
|
||||
BROWSER_BASE_URL=https://staging-api.modrinth.com/v2/
|
||||
PYRO_BASE_URL=https://staging-archon.modrinth.com
|
||||
SHARED_INSTANCES_API_BASE_URL=https://staging-shared-instances.modrinth.com
|
||||
PROD_OVERRIDE=true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
BASE_URL=https://api.modrinth.com/v2/
|
||||
BROWSER_BASE_URL=https://api.modrinth.com/v2/
|
||||
PYRO_BASE_URL=https://staging-archon.modrinth.com
|
||||
SHARED_INSTANCES_API_BASE_URL=https://shared-instances.modrinth.com
|
||||
PROD_OVERRIDE=true
|
||||
|
||||
@@ -7,6 +7,7 @@ import svgLoader from 'vite-svg-loader'
|
||||
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
|
||||
|
||||
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
||||
const STAGING_SHARED_INSTANCES_API_URL = 'https://staging-shared-instances.modrinth.com'
|
||||
const API_CLIENT_SOURCE = fileURLToPath(
|
||||
new URL('../../packages/api-client/src/index.ts', import.meta.url),
|
||||
)
|
||||
@@ -210,6 +211,7 @@ export default defineNuxtConfig({
|
||||
// @ts-ignore
|
||||
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY,
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
sharedInstancesBaseUrl: getSharedInstancesApiUrl(),
|
||||
intercomIdentitySecret:
|
||||
process.env.INTERCOM_IDENTITY_SECRET ??
|
||||
// @ts-ignore
|
||||
@@ -217,6 +219,7 @@ export default defineNuxtConfig({
|
||||
public: {
|
||||
apiBaseUrl: getApiUrl(),
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
sharedInstancesBaseUrl: getSharedInstancesApiUrl(),
|
||||
siteUrl: getDomain(),
|
||||
intercomAppId:
|
||||
process.env.INTERCOM_APP_ID ||
|
||||
@@ -354,6 +357,15 @@ function getApiUrl() {
|
||||
return process.env.BROWSER_BASE_URL ?? globalThis.BROWSER_BASE_URL ?? STAGING_API_URL
|
||||
}
|
||||
|
||||
function getSharedInstancesApiUrl() {
|
||||
return (
|
||||
process.env.SHARED_INSTANCES_API_BASE_URL ??
|
||||
// @ts-ignore
|
||||
globalThis.SHARED_INSTANCES_API_BASE_URL ??
|
||||
STAGING_SHARED_INSTANCES_API_URL
|
||||
)
|
||||
}
|
||||
|
||||
function isProduction() {
|
||||
return process.env.NODE_ENV === 'production'
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -123,6 +123,7 @@ setupLoadingStateProvider()
|
||||
const client = createModrinthClient(auth.value, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
provideModrinthClient(client)
|
||||
|
||||
@@ -29,7 +29,12 @@ async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
|
||||
|
||||
export function createModrinthClient(
|
||||
auth: Ref<{ token: string | undefined }>,
|
||||
config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string },
|
||||
config: {
|
||||
apiBaseUrl: string
|
||||
archonBaseUrl: string
|
||||
sharedInstancesBaseUrl: string
|
||||
rateLimitKey?: string
|
||||
},
|
||||
): NuxtModrinthClient {
|
||||
const flags = useFeatureFlags()
|
||||
const optionalFeatures = [
|
||||
@@ -40,6 +45,7 @@ export function createModrinthClient(
|
||||
labrinthBaseUrl: config.apiBaseUrl,
|
||||
archonBaseUrl: () =>
|
||||
withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging),
|
||||
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
|
||||
archonSentryCapture: () => flags.value.archonSentryCapture,
|
||||
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
|
||||
features: [
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { AbstractModrinthClient, Labrinth, SharedInstances } from '@modrinth/api-client'
|
||||
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
|
||||
import type {
|
||||
Organization,
|
||||
Project,
|
||||
Report,
|
||||
TeamMember,
|
||||
Thread,
|
||||
User,
|
||||
Version,
|
||||
} from '@modrinth/utils'
|
||||
import type { Organization, Project, TeamMember, Thread, User, Version } from '@modrinth/utils'
|
||||
|
||||
export const useModerationCache = () => ({
|
||||
threads: useState<Map<string, Thread>>('moderation-report-cache-threads', () => new Map()),
|
||||
@@ -17,10 +9,17 @@ export const useModerationCache = () => ({
|
||||
versions: useState<Map<string, Version>>('moderation-report-cache-versions', () => new Map()),
|
||||
teams: useState<Map<string, TeamMember[]>>('moderation-report-cache-teams', () => new Map()),
|
||||
orgs: useState<Map<string, Organization>>('moderation-report-cache-orgs', () => new Map()),
|
||||
sharedInstances: useState<Map<string, SharedInstances.Instances.v1.Instance>>(
|
||||
'moderation-report-cache-shared-instances',
|
||||
() => new Map(),
|
||||
),
|
||||
})
|
||||
|
||||
// TODO: @AlexTMjugador - backend should do all of these functions.
|
||||
export async function enrichReportBatch(reports: Report[]): Promise<ExtendedReport[]> {
|
||||
export async function enrichReportBatch(
|
||||
reports: Labrinth.Reports.v3.Report[],
|
||||
client: AbstractModrinthClient,
|
||||
): Promise<ExtendedReport[]> {
|
||||
if (reports.length === 0) return []
|
||||
|
||||
const cache = useModerationCache()
|
||||
@@ -41,8 +40,16 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
|
||||
.filter((r) => r.item_type === 'project')
|
||||
.map((r) => r.item_id)
|
||||
.filter((id) => !cache.projects.value.has(id))
|
||||
const sharedInstanceIDs = [
|
||||
...new Set(
|
||||
reports
|
||||
.filter((r) => r.item_type === 'shared-instance')
|
||||
.map((r) => r.item_id)
|
||||
.filter((id) => !cache.sharedInstances.value.has(id)),
|
||||
),
|
||||
]
|
||||
|
||||
const [newThreads, newVersions, newUsers] = await Promise.all([
|
||||
const [newThreads, newVersions, newUsers, newSharedInstances] = await Promise.all([
|
||||
threadIDs.length > 0
|
||||
? (fetchSegmented(threadIDs, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`) as Promise<
|
||||
Thread[]
|
||||
@@ -59,11 +66,22 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
|
||||
(ids) => `users?ids=${asEncodedJsonArray(ids)}`,
|
||||
) as Promise<User[]>)
|
||||
: Promise.resolve([]),
|
||||
Promise.allSettled(
|
||||
sharedInstanceIDs.map(async (instanceId) => ({
|
||||
id: instanceId,
|
||||
instance: await client.sharedinstances.instances_v1.get(instanceId),
|
||||
})),
|
||||
),
|
||||
])
|
||||
|
||||
newThreads.forEach((t) => cache.threads.value.set(t.id, t))
|
||||
newVersions.forEach((v) => cache.versions.value.set(v.id, v))
|
||||
newUsers.forEach((u) => cache.users.value.set(u.id, u))
|
||||
newSharedInstances.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
cache.sharedInstances.value.set(result.value.id, result.value.instance)
|
||||
}
|
||||
})
|
||||
|
||||
const allVersions = [...newVersions, ...Array.from(cache.versions.value.values())]
|
||||
const fullProjectIds = new Set([
|
||||
@@ -177,6 +195,10 @@ export async function enrichReportBatch(reports: Report[]): Promise<ExtendedRepo
|
||||
user: report.item_type === 'user' ? cache.users.value.get(report.item_id) : undefined,
|
||||
version,
|
||||
target,
|
||||
shared_instance:
|
||||
report.item_type === 'shared-instance'
|
||||
? cache.sharedInstances.value.get(report.item_id)
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2921,6 +2921,30 @@
|
||||
"layout.publish.email-verification-required.title": {
|
||||
"message": "Email verification required"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.benefit.install": {
|
||||
"message": "Automatically install the required game and loader"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.benefit.join": {
|
||||
"message": "Join the shared instance in one click"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.benefit.update": {
|
||||
"message": "Keep shared content updated when the instance changes"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.get-app": {
|
||||
"message": "Get Modrinth App"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.managed-by": {
|
||||
"message": "Managed by"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.opening-automatically": {
|
||||
"message": "The Modrinth App will open automatically..."
|
||||
},
|
||||
"modal.shared-instance.open-in-app.title": {
|
||||
"message": "Opening Modrinth App"
|
||||
},
|
||||
"modal.shared-instance.open-in-app.why-use": {
|
||||
"message": "Why use the Modrinth App"
|
||||
},
|
||||
"moderation.exclude-technical-review": {
|
||||
"message": "Exclude TR"
|
||||
},
|
||||
@@ -5264,6 +5288,33 @@
|
||||
"settings.sidebar.label.display": {
|
||||
"message": "Display"
|
||||
},
|
||||
"shared-instance.invite.accept": {
|
||||
"message": "Accept invite"
|
||||
},
|
||||
"shared-instance.invite.inviter.unknown": {
|
||||
"message": "A Modrinth user"
|
||||
},
|
||||
"shared-instance.invite.joined-count": {
|
||||
"message": "{count, plural, one {# person has} other {# people have}} already joined"
|
||||
},
|
||||
"shared-instance.invite.loading": {
|
||||
"message": "Loading invite…"
|
||||
},
|
||||
"shared-instance.invite.title": {
|
||||
"message": "{name} has invited you to play"
|
||||
},
|
||||
"shared-instance.invite.trust.description": {
|
||||
"message": "This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust."
|
||||
},
|
||||
"shared-instance.invite.trust.heading": {
|
||||
"message": "Do you know this user?"
|
||||
},
|
||||
"shared-instance.invite.unavailable.description": {
|
||||
"message": "It may have expired, reached its use limit, or been removed by the person who shared it."
|
||||
},
|
||||
"shared-instance.invite.unavailable.title": {
|
||||
"message": "This invite isn't available"
|
||||
},
|
||||
"ui.latest-news-row.latest-news": {
|
||||
"message": "Latest news from Modrinth"
|
||||
},
|
||||
|
||||
@@ -99,6 +99,7 @@ async function getProjectMiddlewareClient() {
|
||||
return createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,12 +154,15 @@ const { data, isPending, error, refetch } = useQuery({
|
||||
const notifications = await client.labrinth.notifications_v2.getUserNotifications(
|
||||
auth.value?.user?.id,
|
||||
)
|
||||
const visibleNotifications = notifications.filter(
|
||||
(n) => !n.type?.startsWith('shared_instance_'),
|
||||
)
|
||||
|
||||
const typesInFeed = [
|
||||
...new Set(notifications.filter((n) => showRead || !n.read).map((n) => n.type)),
|
||||
...new Set(visibleNotifications.filter((n) => showRead || !n.read).map((n) => n.type)),
|
||||
]
|
||||
|
||||
const filtered = notifications.filter(
|
||||
const filtered = visibleNotifications.filter(
|
||||
(n) =>
|
||||
(selectedType.value === 'all' || n.type === selectedType.value) && (showRead || !n.read),
|
||||
)
|
||||
@@ -173,7 +176,7 @@ const { data, isPending, error, refetch } = useQuery({
|
||||
notifications: notifs,
|
||||
notifTypes: typesInFeed.length > 1 ? ['all', ...typesInFeed] : typesInFeed,
|
||||
pages,
|
||||
hasRead: notifications.some((n) => n.read),
|
||||
hasRead: visibleNotifications.some((n) => n.read),
|
||||
}))
|
||||
},
|
||||
enabled: computed(() => !!auth.value?.user?.id),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import type { Report } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
|
||||
import ModerationReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
@@ -13,8 +12,8 @@ const { data: report } = useQuery({
|
||||
queryKey: computed(() => ['report', reportId]),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const report = (await client.labrinth.reports_v3.get(reportId)) as Report
|
||||
const enrichedReport = (await enrichReportBatch([report]))[0]
|
||||
const report = await client.labrinth.reports_v3.get(reportId)
|
||||
const enrichedReport = (await enrichReportBatch([report], client))[0]
|
||||
return enrichedReport
|
||||
} catch (error) {
|
||||
console.error('Error fetching report:', error)
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
|
||||
<ReportCard v-for="report in paginatedReports" v-else :key="report.id" :report="report" />
|
||||
<ReportCard v-for="report in paginatedReports" :key="report.id" :report="report" />
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
|
||||
@@ -204,13 +204,13 @@ import {
|
||||
type ComboboxOption,
|
||||
commonMessages,
|
||||
FloatingPanel,
|
||||
injectModrinthClient,
|
||||
MultiSelect,
|
||||
type MultiSelectItem,
|
||||
Pagination,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Report, User } from '@modrinth/utils'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
|
||||
@@ -222,6 +222,7 @@ const { formatMessage } = useVIntl()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = await useAuth()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
|
||||
const startTime = performance.now()
|
||||
@@ -231,19 +232,19 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
|
||||
|
||||
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
|
||||
|
||||
let reports: Report[]
|
||||
let reports: Labrinth.Reports.v3.Report[]
|
||||
let hasMoreReports = true
|
||||
while (hasMoreReports) {
|
||||
reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, {
|
||||
apiVersion: 3,
|
||||
})) as Report[]
|
||||
})) as Labrinth.Reports.v3.Report[]
|
||||
|
||||
hasMoreReports = reports.length > 0
|
||||
if (!hasMoreReports) {
|
||||
break
|
||||
}
|
||||
|
||||
const enrichmentPromise = enrichReportBatch(reports)
|
||||
const enrichmentPromise = enrichReportBatch(reports, client)
|
||||
enrichmentPromises.push(enrichmentPromise)
|
||||
|
||||
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
|
||||
@@ -323,6 +324,7 @@ const projectTypeFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'plugin', label: 'Plugins' },
|
||||
{ value: 'shader', label: 'Shaders' },
|
||||
{ value: 'minecraft_java_server', label: 'Servers' },
|
||||
{ value: 'shared-instance', label: 'Shared instance' },
|
||||
]
|
||||
|
||||
const currentReportTargetFilter = ref('all')
|
||||
@@ -331,6 +333,7 @@ const reportTargetFilterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'project', label: 'Projects' },
|
||||
{ value: 'user', label: 'Users' },
|
||||
{ value: 'version', label: 'Versions' },
|
||||
{ value: 'shared-instance', label: 'Shared instances' },
|
||||
]
|
||||
|
||||
const currentReportIssueFilter = ref('all')
|
||||
@@ -505,7 +508,9 @@ const filteredReports = computed(() => {
|
||||
}
|
||||
|
||||
const projectTypeFilterPredicate = (report: ExtendedReport) => {
|
||||
return projectTypeFilter === 'all' || report.project?.project_type === projectTypeFilter
|
||||
if (projectTypeFilter === 'all') return true
|
||||
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
|
||||
return report.project?.project_type === projectTypeFilter
|
||||
}
|
||||
|
||||
const reportTargetFilterPredicate = (report: ExtendedReport) => {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<main
|
||||
class="relative isolate min-h-screen overflow-hidden bg-surface-1 px-4 pb-16 pt-36 sm:pt-52"
|
||||
>
|
||||
<div
|
||||
class="absolute left-1/2 top-0 -z-10 h-[23.3125rem] w-full -translate-x-1/2 overflow-hidden rounded-none !rounded-b-none sm:top-10 sm:w-[min(70rem,100%)] sm:rounded-[2.375rem]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="InviteBackgroundIllustration"
|
||||
class="absolute inset-0 size-full scale-[2] object-cover object-[center_25%]"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
v-if="instanceIcon"
|
||||
:src="instanceIcon"
|
||||
class="absolute -inset-1/2 size-[200%] object-cover opacity-90 blur-[160px] saturate-200 [mix-blend-mode:color]"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-[linear-gradient(to_bottom,transparent_20%,var(--surface-1)_100%)]"
|
||||
/>
|
||||
</div>
|
||||
<SharedInstanceInviteOpenInAppModal ref="openInAppModal" />
|
||||
|
||||
<div v-if="isPending" class="relative z-10 flex flex-col items-center gap-4 text-secondary">
|
||||
<LoaderCircleIcon class="size-10 animate-spin" />
|
||||
<span>{{ formatMessage(messages.loadingInvite) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error || !invite"
|
||||
class="relative z-10 mx-auto flex max-w-md flex-col items-center gap-5 text-center"
|
||||
>
|
||||
<CircleAlertIcon class="size-12 text-secondary" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.unavailableTitle) }}
|
||||
</h1>
|
||||
<p class="m-0 text-primary">
|
||||
{{ formatMessage(messages.unavailableDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="relative z-10 mx-auto flex w-full max-w-[26.25rem] flex-col items-center gap-7"
|
||||
>
|
||||
<div class="invite-avatar relative size-[120px] shrink-0 rounded-full">
|
||||
<Avatar
|
||||
:src="inviterAvatar"
|
||||
:alt="inviterName"
|
||||
size="120px"
|
||||
circle
|
||||
class="!border-0 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<div class="flex flex-col items-center gap-2.5">
|
||||
<h1 class="m-0 text-2xl font-semibold leading-8 text-contrast">
|
||||
{{ formatMessage(messages.invitationTitle, { name: inviterName }) }}
|
||||
</h1>
|
||||
<div
|
||||
class="flex w-max max-w-[calc(100vw-2rem)] flex-nowrap items-center justify-center gap-1.5 overflow-hidden whitespace-nowrap text-lg leading-6 text-primary"
|
||||
>
|
||||
<span
|
||||
class="flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-xl font-semibold text-contrast"
|
||||
>
|
||||
<Avatar :src="instanceIcon" :alt="invite.instance_name" size="32px" no-shadow />
|
||||
<span class="min-w-0 truncate whitespace-nowrap">{{ invite.instance_name }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="instanceUsers.length"
|
||||
class="flex items-center gap-2 rounded-full border border-solid border-surface-3 bg-surface-2 px-4 py-2 text-primary [box-shadow:0_1px_1px_rgb(0_0_0/12%)]"
|
||||
>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Avatar
|
||||
v-for="(user, index) in visibleInstanceUsers"
|
||||
:key="user.id"
|
||||
:src="user.avatar"
|
||||
:alt="user.name"
|
||||
:tint-by="user.id"
|
||||
size="28px"
|
||||
circle
|
||||
no-shadow
|
||||
class="!border-2 !border-surface-2"
|
||||
:class="{ '-mr-1.5': index < visibleInstanceUsers.length - 1 || hiddenUserCount > 0 }"
|
||||
/>
|
||||
<div
|
||||
v-if="hiddenUserCount"
|
||||
class="flex size-7 items-center justify-center rounded-full border-2 border-solid border-surface-2 bg-surface-3 text-xs font-medium leading-4 text-primary"
|
||||
>
|
||||
+{{ hiddenUserCount }}
|
||||
</div>
|
||||
</div>
|
||||
{{ formatMessage(messages.joinedCount, { count: instanceUsers.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button type="button" @click="acceptInvite">
|
||||
<UserPlusIcon />
|
||||
{{ formatMessage(messages.acceptInvite) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<div class="flex w-full flex-col gap-2.5">
|
||||
<span class="pl-3 font-medium text-primary">{{
|
||||
formatMessage(messages.knowThisUser)
|
||||
}}</span>
|
||||
<Admonition type="neutral" class="shadow-card !text-primary">
|
||||
<template #icon="{ iconClass }">
|
||||
<CircleAlertIcon :class="[...iconClass, '!text-orange']" />
|
||||
</template>
|
||||
{{ formatMessage(messages.trustWarning) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
InviteBackgroundIllustration,
|
||||
LoaderCircleIcon,
|
||||
UserPlusIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onServerPrefetch, useTemplateRef } from 'vue'
|
||||
|
||||
import SharedInstanceInviteOpenInAppModal from '~/components/ui/SharedInstanceInviteOpenInAppModal.vue'
|
||||
|
||||
useSeoMeta({
|
||||
robots: 'noindex',
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingInvite: {
|
||||
id: 'shared-instance.invite.loading',
|
||||
defaultMessage: 'Loading invite…',
|
||||
},
|
||||
unavailableTitle: {
|
||||
id: 'shared-instance.invite.unavailable.title',
|
||||
defaultMessage: "This invite isn't available",
|
||||
},
|
||||
unavailableDescription: {
|
||||
id: 'shared-instance.invite.unavailable.description',
|
||||
defaultMessage:
|
||||
'It may have expired, reached its use limit, or been removed by the person who shared it.',
|
||||
},
|
||||
invitationTitle: {
|
||||
id: 'shared-instance.invite.title',
|
||||
defaultMessage: '{name} has invited you to play',
|
||||
},
|
||||
unknownInviter: {
|
||||
id: 'shared-instance.invite.inviter.unknown',
|
||||
defaultMessage: 'A Modrinth user',
|
||||
},
|
||||
joinedCount: {
|
||||
id: 'shared-instance.invite.joined-count',
|
||||
defaultMessage: '{count, plural, one {# person has} other {# people have}} already joined',
|
||||
},
|
||||
acceptInvite: {
|
||||
id: 'shared-instance.invite.accept',
|
||||
defaultMessage: 'Accept invite',
|
||||
},
|
||||
knowThisUser: {
|
||||
id: 'shared-instance.invite.trust.heading',
|
||||
defaultMessage: 'Do you know this user?',
|
||||
},
|
||||
trustWarning: {
|
||||
id: 'shared-instance.invite.trust.description',
|
||||
defaultMessage:
|
||||
'This invite was created by another Modrinth user, not Modrinth. Only accept invites from people you trust.',
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const client = injectModrinthClient()
|
||||
const inviteId = computed(() => String(route.params.inviteId))
|
||||
const openInAppModal = useTemplateRef('openInAppModal')
|
||||
|
||||
const {
|
||||
data: invite,
|
||||
error,
|
||||
isPending,
|
||||
suspense,
|
||||
} = useQuery({
|
||||
queryKey: computed(() => ['shared-instance-invite', inviteId.value]),
|
||||
queryFn: () => client.sharedinstances.invites_v1.get(inviteId.value),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const instanceIcon = computed(() => invite.value?.instance_icon ?? null)
|
||||
const instanceUsers = computed(() => {
|
||||
if (!invite.value) return []
|
||||
const managerIds = new Set(
|
||||
invite.value.managers.flatMap((manager) => (manager.type === 'user' ? [manager.id] : [])),
|
||||
)
|
||||
return (
|
||||
invite.value.instance_users?.filter(
|
||||
(user) => user.joined_at !== null && !managerIds.has(user.id),
|
||||
) ?? []
|
||||
)
|
||||
})
|
||||
const visibleInstanceUsers = computed(() => instanceUsers.value.slice(0, 4))
|
||||
const hiddenUserCount = computed(() =>
|
||||
Math.max(0, instanceUsers.value.length - visibleInstanceUsers.value.length),
|
||||
)
|
||||
|
||||
onServerPrefetch(() => suspense())
|
||||
|
||||
const inviter = computed(
|
||||
() =>
|
||||
invite.value?.managers.find((manager) => manager.type === 'user') ?? invite.value?.managers[0],
|
||||
)
|
||||
const inviterName = computed(() => inviter.value?.name ?? formatMessage(messages.unknownInviter))
|
||||
const inviterAvatar = computed(() => {
|
||||
if (!inviter.value) return null
|
||||
return inviter.value.type === 'user' ? inviter.value.avatar : inviter.value.icon
|
||||
})
|
||||
|
||||
function acceptInvite() {
|
||||
if (!invite.value) return
|
||||
|
||||
openInAppModal.value?.show({
|
||||
instance: {
|
||||
inviteId: inviteId.value,
|
||||
name: invite.value.instance_name,
|
||||
icon: invite.value.instance_icon,
|
||||
inviterName: inviterName.value,
|
||||
inviterAvatar: inviterAvatar.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-avatar {
|
||||
box-shadow:
|
||||
0 0 0 8px rgb(255 255 255 / 5%),
|
||||
0 24px 48px rgb(0 0 0 / 3%),
|
||||
0 10px 18px rgb(0 0 0 / 3%),
|
||||
0 5px 8px rgb(0 0 0 / 4%),
|
||||
0 2px 4px rgb(0 0 0 / 4%);
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ export function setupModrinthClientProvider(auth: Awaited<ReturnType<typeof useA
|
||||
const client = createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
sharedInstancesBaseUrl: config.public.sharedInstancesBaseUrl,
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
provideModrinthClient(client)
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ServerModrinthClientOptions {
|
||||
export function useServerModrinthClient(options?: ServerModrinthClientOptions): NuxtModrinthClient {
|
||||
const config = useRuntimeConfig(options?.event)
|
||||
const apiBaseUrl = (config.apiBaseUrl || config.public.apiBaseUrl).replace('/v2/', '/')
|
||||
const sharedInstancesBaseUrl =
|
||||
config.sharedInstancesBaseUrl || config.public.sharedInstancesBaseUrl
|
||||
|
||||
const features = []
|
||||
|
||||
@@ -39,6 +41,7 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions):
|
||||
|
||||
const clientConfig: NuxtClientConfig = {
|
||||
labrinthBaseUrl: apiBaseUrl,
|
||||
sharedInstancesBaseUrl,
|
||||
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
|
||||
features,
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"BASE_URL": "https://api.modrinth.com/v2/",
|
||||
"BROWSER_BASE_URL": "https://api.modrinth.com/v2/",
|
||||
"PYRO_BASE_URL": "https://archon.modrinth.com/",
|
||||
"SHARED_INSTANCES_API_BASE_URL": "https://shared-instances.modrinth.com",
|
||||
"STRIPE_PUBLISHABLE_KEY": "pk_live_51JbFxJJygY5LJFfKLVVldb10HlLt24p421OWRsTOWc5sXYFOnFUXWieSc6HD3PHo25ktx8db1WcHr36XGFvZFVUz00V9ixrCs5"
|
||||
},
|
||||
"env": {
|
||||
@@ -58,6 +59,7 @@
|
||||
"BASE_URL": "https://staging-api.modrinth.com/v2/",
|
||||
"BROWSER_BASE_URL": "https://staging-api.modrinth.com/v2/",
|
||||
"PYRO_BASE_URL": "https://staging-archon.modrinth.com/",
|
||||
"SHARED_INSTANCES_API_BASE_URL": "https://staging-shared-instances.modrinth.com",
|
||||
"STRIPE_PUBLISHABLE_KEY": "pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b"
|
||||
},
|
||||
"secrets_store_secrets": [
|
||||
|
||||
Reference in New Issue
Block a user