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:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
@@ -33,6 +33,7 @@ import ContentModpackCard from './components/ContentModpackCard.vue'
import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmDisableModal from './components/modals/ConfirmDisableModal.vue'
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
@@ -45,7 +46,12 @@ import {
useContentSelection,
} from './composables'
import { injectContentManager } from './providers/content-manager'
import type { BulkOperationStatus, ContentCardTableItem, ContentItem } from './types'
import type {
BulkOperationStatus,
ContentActionWarning,
ContentCardTableItem,
ContentItem,
} from './types'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentPageLayout')
@@ -280,13 +286,14 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
toggleDisabled: ctx.isBusy.value,
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
hasUpdate: item.has_update,
hasUpdate: base.hasUpdate ?? item.has_update,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
hideSwitchVersion: !base.versionLink,
hideDelete: base.hideDelete,
hideSwitchVersion: base.hideSwitchVersion ?? !base.versionLink,
overflowOptions: ctx.getOverflowOptions?.(item),
}
})
@@ -322,8 +329,12 @@ const hasOutdatedProjects = computed(() => {
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
const pendingDeletionWarning = ref<ContentActionWarning | null>(null)
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
const confirmDisableModal = ref<InstanceType<typeof ConfirmDisableModal>>()
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
const pendingDisableItems = ref<ContentItem[]>([])
const pendingDisableWarning = ref<ContentActionWarning | null>(null)
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
const pendingDependencyWarningDependents = ref<
Array<{
@@ -340,17 +351,30 @@ function mapToDisplayItem(item: ContentItem) {
}
}
function canDeleteItem(item: ContentItem) {
return ctx.canDeleteItem?.(item) ?? true
}
function canToggleItem(item: ContentItem) {
return ctx.canToggleItem?.(item) ?? true
}
const deletableSelectedItems = computed(() => selectedItems.value.filter(canDeleteItem))
const toggleableSelectedItems = computed(() => selectedItems.value.filter(canToggleItem))
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
if (items.length === 0) return
pendingDeletionItems.value = items
const deletableItems = items.filter(canDeleteItem)
if (deletableItems.length === 0) return
pendingDeletionItems.value = deletableItems
pendingDeletionWarning.value = ctx.getDeleteWarning?.(deletableItems) ?? null
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
pendingDependencyWarningDisableTargets.value = []
const deletingIds = new Set(items.map(getItemId))
const deletingIds = new Set(deletableItems.map(getItemId))
const warning = ctx.getDeleteDependencyWarning
? await Promise.resolve()
.then(() => ctx.getDeleteDependencyWarning!(items))
.then(() => ctx.getDeleteDependencyWarning!(deletableItems))
.catch(() => null)
: null
if (warning) {
@@ -366,7 +390,7 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
const relevantDependencyIds = new Set(
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
)
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
const warningItems = deletableItems.filter((item) => relevantDependencyIds.has(getItemId(item)))
if (warningItems.length === 0) {
showDeletionConfirmation(event)
return
@@ -390,7 +414,11 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
}
async function showDeletionConfirmation(event?: MouseEvent) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
if (
!pendingDeletionWarning.value &&
(event?.shiftKey || skipNonEssentialWarnings.value) &&
!ctx.isBusy.value
) {
confirmDelete()
} else {
await nextTick()
@@ -419,6 +447,11 @@ async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: bo
pendingDependencyWarningItems.value = []
pendingDependencyWarningDependents.value = []
if (pendingDeletionWarning.value) {
confirmDeletionModal.value?.show()
return
}
await confirmDelete()
}
@@ -427,6 +460,10 @@ async function disablePendingDependencyWarningDependents() {
pendingDependencyWarningDisableTargets.value = []
if (items.length === 0) return
await promptDisableItems(items)
}
async function disableItemsWithoutWarning(items: ContentItem[]) {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(items)
return
@@ -447,6 +484,7 @@ async function confirmDelete() {
if (ctx.isBusy.value) return
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
pendingDeletionWarning.value = null
if (itemsToDelete.length === 0) return
if (ctx.bulkDeleteItems && itemsToDelete.length > 1) {
@@ -495,10 +533,75 @@ async function confirmDelete() {
await disablePendingDependencyWarningDependents()
}
async function promptDisableItems(items: ContentItem[]) {
if (items.length === 0) return
pendingDisableItems.value = items
const warning = ctx.getDisableWarning?.(items) ?? null
if (warning) {
pendingDisableWarning.value = warning
confirmDisableModal.value?.show()
return
}
await confirmDisable()
}
async function confirmDisable() {
if (ctx.isBusy.value) return
const itemsToDisable = [...pendingDisableItems.value]
pendingDisableItems.value = []
pendingDisableWarning.value = null
if (itemsToDisable.length === 0) return
if (ctx.bulkDisableItems && itemsToDisable.length > 1) {
isBulkOperating.value = true
bulkOperation.value = 'disable'
bulkProgress.value = 0
bulkTotal.value = itemsToDisable.length
bulkWaiting.value = true
try {
await disableItemsWithoutWarning(itemsToDisable)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkProgress.value = 0
bulkTotal.value = 0
bulkWaiting.value = false
}
return
}
if (itemsToDisable.length === 1) {
const item = itemsToDisable[0]
const id = getItemId(item)
markChanging(id)
try {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(itemsToDisable)
} else {
await ctx.toggleEnabled(item)
}
} finally {
unmarkChanging(id)
}
return
}
await runBulk('disable', itemsToDisable, (item) => disableItemsWithoutWarning([item]), {
onComplete: clearSelection,
})
}
async function handleToggleEnabledById(id: string, _value: boolean) {
if (ctx.isBusy.value) return
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (!item) return
if (!canToggleItem(item)) return
if (!_value) {
await promptDisableItems([item])
return
}
markChanging(id)
try {
await ctx.toggleEnabled(item)
@@ -509,7 +612,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
async function bulkEnable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => !item.enabled)
const items = toggleableSelectedItems.value.filter((item) => !item.enabled)
if (items.length === 0) return
if (ctx.bulkEnableItems) {
isBulkOperating.value = true
@@ -534,27 +637,9 @@ async function bulkEnable() {
async function bulkDisable() {
if (ctx.isBusy.value) return
const items = selectedItems.value.filter((item) => item.enabled)
const items = toggleableSelectedItems.value.filter((item) => item.enabled)
if (items.length === 0) return
if (ctx.bulkDisableItems) {
isBulkOperating.value = true
bulkOperation.value = 'disable'
bulkProgress.value = 0
bulkTotal.value = items.length
bulkWaiting.value = true
try {
await ctx.bulkDisableItems(items)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkProgress.value = 0
bulkTotal.value = 0
bulkWaiting.value = false
}
return
}
await runBulk('disable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
await promptDisableItems(items)
}
function handleUpdateById(id: string) {
@@ -696,16 +781,12 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled"
:disabled-text="ctx.modpack.value.disabledText"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
v-on="{
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
}"
@dismiss-content-hint="ctx.dismissContentHint?.()"
/>
<template v-if="ctx.items.value.length > 0">
@@ -737,20 +818,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
/>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
v-tooltip="
@@ -765,6 +832,20 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
</div>
</div>
@@ -947,6 +1028,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:bulk-item-count="bulkItemCount"
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
:get-item-id="getItemId"
:toggle-items="toggleableSelectedItems"
@clear="clearSelection"
@enable="bulkEnable"
@disable="bulkDisable"
@@ -1013,9 +1095,10 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</template>
<template #actions-end>
<div class="mx-1 h-6 w-px bg-surface-5" />
<div v-if="deletableSelectedItems.length > 0" class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled
v-if="deletableSelectedItems.length > 0"
type="transparent"
color="red"
color-fill="text"
@@ -1036,12 +1119,22 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
ref="confirmDeletionModal"
:count="pendingDeletionItems.length"
:item-type="ctx.contentTypeLabel.value"
:warning="pendingDeletionWarning"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@delete="confirmDelete"
/>
<ConfirmDisableModal
ref="confirmDisableModal"
:count="pendingDisableItems.length"
:item-type="ctx.contentTypeLabel.value"
:warning="pendingDisableWarning"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@disable="confirmDisable"
/>
<ContentDependencyWarningModal
ref="contentDependencyWarningModal"
:items="pendingDependencyWarningItems"