feat: content tab rewrite for worlds (#5136)
* feat: base content card component * fix: tooltips + colors * feat: fix orgs * feat: base content tab internals rewrite * feat: fix invalidmodal * feat: add ContentModpackCard * fix: extract types * draft: layout * feat: unlink modal * feat: impl content tab * fix: lint * fix: toggling * temp: disable updating stuff * feat: selection v-model * feat: bulk selection * feat: mods tab rough draft * feat: use fuse.js * feat: add project combobox * clean up project combobox * feat: start install to play modal * fix: events * feat: use v-on * feat: bulk actions + fix floating action bar width * feat: figma alignments * feat: migrate toggle to tailwind * fix: row borders * feat: disabled state * feat: virtual list impl for card table based on window scroll * fix: lint * feat: virtualization + smaller contentcard items * feat: use ContentCardTable + ContentCardItems * feat: fix gap + border issues on last elm * feat: cleanup + use proper searching * fix: use TeleportOverflowMenu * fix: fallback to svg if src is invalid on avatar component * fix: storybook * feat: start on updater modal * feat: finish content updater modal * feat: i18n pass * feat: impl modal * feat(app): backend changes for content tab refactor (#5237) * feat: include_changelog=false for updater modal * fix: hash overrides * feat: update checking for modpack * feat: qa * feat: modpack content modal * fix: padding in table to match modals + tightness * fix: lint * feat: delete modal * feat: fix toggle bugs * fix: prepr * fix: duplicate messages * qa: full width search * qa: use bg-surface-1.5 * qa: animation for filter pills * qa: standardize hover colors * fix: border-[1px] is border * qa: mass de-select actually mass selecting * qa: match figma designs for floating action bar * qa: modal fixes * q: modal fixes x2 * fix: table border * qa: confirm modals * qa: modal alignment * qa: re-add stuck heading + dedupe logic * qa: dedupe virtual scrolling + remove dead components * qa: responsiveness for content table + link fixes * qa: version column link, tooltips + lint fixes * qa: instance busy protections * fix: installation freeze bug * chore: remove old mods page * refactor: deduplicate layout * chore: delete old content page(s) * qa * qa * qa * feat: sort btn - to iterate * fix: ml * feat: date added * fix: lint * fix: formatting.ts removal * feat: get_dependencies_as_content_items * qa: final QA changes * refactor: deduplicate + polish content.rs * feat: hook up content.vue with v1 * feat: hide v1 content api behind frontend feature flag * fix: query keys + copy on empty state * chore: i18n pass * feat: reimpl unlink + upload endpoint * feat: use bulk endpoints v1 * fix: lint * fix: flags * fix: responsiveness via container queries * fix: lint * qa: 1 * qa: fixes * qa: fix ssr issues with browse content * qa: header page divider * qa: modals * fix: prepr * fix: issues * fix: lint * fix: toggle v1 ff * qa: 5 * qa: delete modal copy * feat: creation flow modals (#5383) * refactor: delete content v0 usages + impl * feat: qa + fixes * feat: installing banner using state event * feat: fix modpack card bugs + filtering issues * refactor: delete backups v0 api module * feat: v1 servers GET endpoint * fix: backups * feat: swap to kyros upload v1 addon * fix: use tanstack for loader.vue * feat: finish install from discovery modal * qa: bug fixes * feat: set up installation settings * fix: lint * fix: typos * fix: bugs * fix: disable inline content * feat: content tab improvements — upload UX, installation settings, and client-only indicators Upload cancellation and navigation guard: - Add ConfirmLeaveModal that prompts when navigating away during upload - Cancel in-flight XHR uploads when user confirms leaving the page - Add beforeunload handler to warn on browser/tab close during upload - Track uploadedBytes/totalBytes in UploadState for progress display - Replace Collapsible with Transition for upload progress admonition - Show byte progress and percentage in upload banner - Clamp upload progress to prevent exceeding 100% Installation settings (server.properties): - Add KnownPropertiesFields and PropertiesFields types to Archon types - Add buildProperties() to creation flow context to collect gamemode, difficulty, seed, world type, structures, and generator settings - Pass properties through installContent on onboarding, discovery, and ServerSetupModal flows Server setup and discovery flow improvements: - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent - Replace loaderApiNames lookup with toApiLoader() helper - Remove eraseDataOnInstall toggle — always use soft_override: false - Simplify modpack install on discovery page to use first available version and route through creation flow modal for both onboarding and non-onboarding - Differentiate post-install navigation: content page for onboarding, loader options for existing servers Modpack update flow: - Replace updateModpack() call with installContent() using soft_override: true to support version selection in the content updater modal Client-only mod indicators: - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment) - Add environment to ContentItem and isClientOnly to ContentCardTableItem - Show orange TriangleAlertIcon with tooltip on client-only mods in content table - Add "Client-only" filter pill to content filtering (controlled via showClientOnlyFilter on ContentManagerContext) - Apply client-only indicators in both ContentPageLayout and ModpackContentModal Misc: - Add CLAUDE.md note about using prepr commands for lint checks - Export ConfirmLeaveModal from instances barrel * fix: piping * fix: switch content disable for linked server instances * feat: client only filter * fix: prepr * feat: hasUpdate shape update * feat: bulk update endpoint impl for content in panel * feat: websocket state impl again with new phases * fix: ws * fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug * fix: qa bugs * fix: lint, a11y and i18n * refactor: set up layouts folder properly * fix: linked data cache stuff + lint * feat: move installationsettings to shared layout * fix: lint * fix: issues * feat: temp fuck staging up * fix: lockfile * fix: data sync issues on loader.vue * fix: lint * Hide shader configuration files from content list (#5499) * feat: workaround search problem + split out reset * fix: qa * fix: changelog not showing on first open * fix: qa + optimistic updating improvements * fix: prepr+lint * fix: qa * feat: qa * fix: lint * fix: lint * fix: build * fix: build * fix: type errors * fix: fade and JAVA_HOME passthrough * feat: qa * feat: impl diff shit * fix: qa * fix: app qa * feat: update diff modal * fix: endpoint * fix: qa * fix: qa * fix: use bulk in modpack modal * feat: abort signal impl + fix issues * fix: diff modal trunc * feat: qa * fix: qa * feat: tooltip content tab * fix: prepr * fix: dismiss on settings btn * feat: qa * feat: dont clear handlers on disconnect * fix: lint * fix: wrangler + introduce staging-archon env file --------- Signed-off-by: Calum H. <calum@modrinth.com> Co-authored-by: tdgao <mr.trumgao@gmail.com> Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
830
packages/ui/src/layouts/wrapped/hosting/manage/content.vue
Normal file
830
packages/ui/src/layouts/wrapped/hosting/manage/content.vue
Normal file
@@ -0,0 +1,830 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import ConfirmLeaveModal from '../../../shared/content-tab/components/modals/ConfirmLeaveModal.vue'
|
||||
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
||||
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
||||
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
|
||||
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
|
||||
import type {
|
||||
ContentModpackData,
|
||||
UploadState,
|
||||
} from '../../../shared/content-tab/providers/content-manager'
|
||||
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
|
||||
import type {
|
||||
ContentItem,
|
||||
ContentModpackCardCategory,
|
||||
ContentModpackCardProject,
|
||||
ContentModpackCardVersion,
|
||||
} from '../../../shared/content-tab/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToRemoveContent: {
|
||||
id: 'hosting.content.failed-to-remove',
|
||||
defaultMessage: 'Failed to remove content',
|
||||
},
|
||||
failedToToggle: {
|
||||
id: 'hosting.content.failed-to-toggle',
|
||||
defaultMessage: 'Failed to toggle {name}',
|
||||
},
|
||||
failedToUpload: {
|
||||
id: 'hosting.content.failed-to-upload',
|
||||
defaultMessage: 'Failed to upload file',
|
||||
},
|
||||
failedToUnlink: {
|
||||
id: 'hosting.content.failed-to-unlink',
|
||||
defaultMessage: 'Failed to unlink modpack',
|
||||
},
|
||||
failedToLoadModpackContent: {
|
||||
id: 'hosting.content.failed-to-load-modpack-content',
|
||||
defaultMessage: 'Failed to load modpack content',
|
||||
},
|
||||
failedToLoadVersions: {
|
||||
id: 'hosting.content.failed-to-load-versions',
|
||||
defaultMessage: 'Failed to load versions',
|
||||
},
|
||||
failedToUpdate: {
|
||||
id: 'hosting.content.failed-to-update',
|
||||
defaultMessage: 'Failed to update',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
showClientOnlyFilter?: boolean
|
||||
}>(),
|
||||
{
|
||||
showClientOnlyFilter: false,
|
||||
},
|
||||
)
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const serverId = route.params.id as string
|
||||
|
||||
const type = computed(() => {
|
||||
const loader = server.value?.loader?.toLowerCase()
|
||||
if (loader === 'paper' || loader === 'purpur') return 'plugin'
|
||||
if (loader === 'vanilla') return 'datapack'
|
||||
return 'mod'
|
||||
})
|
||||
|
||||
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
|
||||
|
||||
const contentQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const modpackProjectId = computed(() => contentQuery.data.value?.modpack?.spec.project_id ?? null)
|
||||
|
||||
const modpackVersionsQuery = useQuery({
|
||||
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpackProjectId.value]),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v2.getProjectVersions(modpackProjectId.value!, {
|
||||
include_changelog: false,
|
||||
}),
|
||||
enabled: computed(() => !!modpackProjectId.value),
|
||||
})
|
||||
|
||||
const projectQuery = useQuery({
|
||||
queryKey: computed(() => ['labrinth', 'project', modpackProjectId.value]),
|
||||
queryFn: () => client.labrinth.projects_v2.get(modpackProjectId.value!),
|
||||
enabled: computed(() => !!modpackProjectId.value),
|
||||
})
|
||||
|
||||
const modpack = computed<ContentModpackData | null>(() => {
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
if (!mp) return null
|
||||
const project = projectQuery.data.value
|
||||
return {
|
||||
project: {
|
||||
id: mp.spec.project_id,
|
||||
slug: project?.slug ?? mp.spec.project_id,
|
||||
title: mp.title ?? mp.spec.project_id,
|
||||
icon_url: mp.icon_url ?? undefined,
|
||||
description: mp.description ?? '',
|
||||
downloads: mp.downloads ?? 0,
|
||||
followers: mp.followers ?? 0,
|
||||
} as ContentModpackCardProject,
|
||||
projectLink: `/project/${project?.slug ?? mp.spec.project_id}`,
|
||||
version: {
|
||||
id: mp.spec.version_id,
|
||||
version_number: mp.version_number ?? '',
|
||||
date_published: mp.date_published ?? '',
|
||||
} as ContentModpackCardVersion,
|
||||
versionLink: `/project/${project?.slug ?? mp.spec.project_id}/version/${mp.spec.version_id}`,
|
||||
owner: mp.owner
|
||||
? {
|
||||
id: mp.owner.id,
|
||||
name: mp.owner.name,
|
||||
avatar_url: mp.owner.icon_url ?? undefined,
|
||||
type: mp.owner.type,
|
||||
link:
|
||||
mp.owner.type === 'organization'
|
||||
? `/organization/${mp.owner.id}`
|
||||
: `/user/${mp.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
categories: (project?.display_categories ?? []).map((name) => ({
|
||||
name,
|
||||
icon: name,
|
||||
project_type: 'modpack',
|
||||
header: 'categories',
|
||||
})) as ContentModpackCardCategory[],
|
||||
hasUpdate: !!mp.has_update,
|
||||
}
|
||||
})
|
||||
|
||||
function friendlyAddonName(addon: Archon.Content.v1.Addon): string {
|
||||
if (addon.name) return addon.name
|
||||
let cleanName = addon.filename
|
||||
const lastDotIndex = cleanName.lastIndexOf('.')
|
||||
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
|
||||
return cleanName
|
||||
}
|
||||
|
||||
const modpackAddons = ref<Archon.Content.v1.Addon[]>([])
|
||||
|
||||
const addonLookup = computed(() => {
|
||||
const map = new Map<string, Archon.Content.v1.Addon>()
|
||||
for (const addon of contentQuery.data.value?.addons ?? []) {
|
||||
map.set(addon.filename, addon)
|
||||
}
|
||||
for (const addon of modpackAddons.value) {
|
||||
map.set(addon.filename, addon)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const contentItems = computed<ContentItem[]>(() => {
|
||||
return (contentQuery.data.value?.addons ?? []).map(addonToContentItem)
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) =>
|
||||
client.archon.content_v1.deleteAddon(serverId, worldId.value!, {
|
||||
filename: addon.filename,
|
||||
kind: addon.kind,
|
||||
}),
|
||||
onMutate: async ({ addon }) => {
|
||||
await queryClient.cancelQueries({ queryKey: queryKey.value })
|
||||
const previousData = queryClient.getQueryData<Archon.Content.v1.Addons>(queryKey.value)
|
||||
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).filter((a) => a.filename !== addon.filename),
|
||||
}
|
||||
})
|
||||
return { previousData }
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
},
|
||||
onError: (err, _vars, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(queryKey.value, context.previousData)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToRemoveContent),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ addon }: { addon: Archon.Content.v1.Addon }) => {
|
||||
const request: Archon.Content.v1.RemoveAddonRequest = {
|
||||
filename: addon.filename,
|
||||
kind: addon.kind,
|
||||
}
|
||||
if (addon.disabled) {
|
||||
await client.archon.content_v1.enableAddon(serverId, worldId.value!, request)
|
||||
} else {
|
||||
await client.archon.content_v1.disableAddon(serverId, worldId.value!, request)
|
||||
}
|
||||
return { filename: addon.filename, newDisabled: !addon.disabled }
|
||||
},
|
||||
onSuccess: ({ filename, newDisabled }) => {
|
||||
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((a) =>
|
||||
a.filename === filename ? { ...a, disabled: newDisabled } : a,
|
||||
),
|
||||
}
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
},
|
||||
onError: (_err, { addon }) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: formatMessage(messages.failedToToggle, { name: friendlyAddonName(addon) }),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function handleToggleEnabled(item: ContentItem) {
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
await toggleMutation.mutateAsync({ addon })
|
||||
}
|
||||
|
||||
async function handleDeleteItem(item: ContentItem) {
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
await deleteMutation.mutateAsync({ addon })
|
||||
}
|
||||
|
||||
function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAddonRequest[] {
|
||||
return items.flatMap((item) => {
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return []
|
||||
return [{ filename: addon.filename, kind: addon.kind }]
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBulkDelete(items: ContentItem[]) {
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
await client.archon.content_v1.deleteAddons(serverId, worldId.value!, requests)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
}
|
||||
|
||||
async function handleBulkEnable(items: ContentItem[]) {
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
}
|
||||
|
||||
async function handleBulkDisable(items: ContentItem[]) {
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
}
|
||||
|
||||
const uploadState = ref<UploadState>({
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
|
||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
|
||||
|
||||
let activeUploadCancel: (() => void) | null = null
|
||||
|
||||
const isUploading = computed(() => uploadState.value.isUploading)
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isUploading.value) {
|
||||
e.preventDefault()
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
watch(isUploading, (uploading) => {
|
||||
if (uploading) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (isUploading.value) {
|
||||
const shouldLeave = (await confirmLeaveModal.value?.prompt()) ?? false
|
||||
if (shouldLeave) {
|
||||
activeUploadCancel?.()
|
||||
}
|
||||
return shouldLeave
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const updatingProject = ref<ContentItem | null>(null)
|
||||
const updatingModpack = ref(false)
|
||||
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
|
||||
const loadingVersions = ref(false)
|
||||
const loadingChangelog = ref(false)
|
||||
|
||||
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
|
||||
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const isModpackUpdateDowngrade = ref(false)
|
||||
|
||||
const currentGameVersion = computed(() => contentQuery.data.value?.game_version ?? '')
|
||||
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
|
||||
|
||||
function handleBrowseContent() {
|
||||
router.push({
|
||||
path: `/discover/${type.value}s`,
|
||||
query: { sid: serverId, wid: worldId.value },
|
||||
})
|
||||
}
|
||||
|
||||
function handleUploadFiles() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.accept = type.value === 'datapack' ? '.zip' : '.jar'
|
||||
input.onchange = async () => {
|
||||
if (!input.files) return
|
||||
const files = Array.from(input.files)
|
||||
const wid = worldId.value
|
||||
if (!wid) return
|
||||
|
||||
uploadState.value = {
|
||||
isUploading: true,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
|
||||
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
|
||||
onProgress: (p) => {
|
||||
uploadState.value.currentFileProgress = p.progress
|
||||
uploadState.value.uploadedBytes = p.loaded
|
||||
uploadState.value.totalBytes = p.total
|
||||
},
|
||||
})
|
||||
activeUploadCancel = () => handle.cancel()
|
||||
|
||||
try {
|
||||
await handle.promise
|
||||
uploadState.value.completedFiles = files.length
|
||||
await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'Upload cancelled') return
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToUpload),
|
||||
})
|
||||
} finally {
|
||||
activeUploadCancel = null
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
|
||||
return {
|
||||
project: {
|
||||
id: addon.project_id ?? addon.filename,
|
||||
slug: addon.project_id ?? addon.filename,
|
||||
title: friendlyAddonName(addon),
|
||||
icon_url: addon.icon_url ?? undefined,
|
||||
},
|
||||
version: {
|
||||
id: addon.version?.id ?? addon.filename,
|
||||
version_number: addon.version?.name ?? formatMessage(commonMessages.unknownLabel),
|
||||
file_name: addon.filename,
|
||||
},
|
||||
owner: addon.owner
|
||||
? {
|
||||
id: addon.owner.id,
|
||||
name: addon.owner.name,
|
||||
type: addon.owner.type,
|
||||
avatar_url: addon.owner.icon_url ?? undefined,
|
||||
link: `/${addon.owner.type}/${addon.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
enabled: !addon.disabled,
|
||||
file_name: addon.filename,
|
||||
project_type: addon.kind,
|
||||
has_update: !!addon.has_update,
|
||||
update_version_id: addon.has_update,
|
||||
environment: addon.version?.environment ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewModpackContent() {
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
const data = await client.archon.content_v1.getAddons(serverId, worldId.value!, {
|
||||
from_modpack: true,
|
||||
})
|
||||
modpackAddons.value = data.addons ?? []
|
||||
const items = (data.addons ?? []).map(addonToContentItem)
|
||||
modpackContentModal.value?.show(items)
|
||||
} catch (err) {
|
||||
modpackContentModal.value?.hide()
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadModpackContent),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackContentToggle(item: ContentItem) {
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ addon })
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, {
|
||||
enabled: !item.enabled,
|
||||
disabled: false,
|
||||
})
|
||||
} catch {
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
|
||||
// Optimistic update
|
||||
for (const item of items) {
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === item.file_name ? { ...a, disabled: !enable } : a,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, { enabled: enable })
|
||||
}
|
||||
|
||||
try {
|
||||
if (enable) {
|
||||
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
|
||||
} else {
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
} catch {
|
||||
// Revert
|
||||
for (const item of items) {
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === item.file_name ? { ...a, disabled: enable } : a,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, { enabled: !enable })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModpackUnlink() {
|
||||
modpackUnlinkModal.value?.show()
|
||||
}
|
||||
|
||||
async function handleModpackUnlinkConfirm() {
|
||||
try {
|
||||
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
|
||||
await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkUpdate(items: ContentItem[]) {
|
||||
const addons = items
|
||||
.filter((item) => item.has_update)
|
||||
.map((item) => ({
|
||||
filename: item.file_name,
|
||||
version_id: item.update_version_id ?? undefined,
|
||||
}))
|
||||
if (addons.length === 0) return
|
||||
await client.archon.content_v1.updateAddons(serverId, worldId.value!, addons)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
}
|
||||
|
||||
async function handleUpdateItem(fileNameKey: string) {
|
||||
const item = contentItems.value.find((i) => i.file_name === fileNameKey)
|
||||
if (!item?.has_update || !item.project?.id || !item.version?.id) return
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
loadingChangelog.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackUpdate() {
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
if (!mp?.spec.project_id) return
|
||||
|
||||
updatingModpack.value = true
|
||||
updatingProject.value = null
|
||||
loadingChangelog.value = false
|
||||
|
||||
const cached = modpackVersionsQuery.data.value
|
||||
if (cached) {
|
||||
const sorted = [...cached].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = sorted
|
||||
loadingVersions.value = false
|
||||
} else {
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(mp.spec.version_id ?? undefined)
|
||||
|
||||
if (!cached) {
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(mp.spec.project_id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog) return
|
||||
loadingChangelog.value = true
|
||||
try {
|
||||
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
} catch {
|
||||
// Silently fail on changelog fetch
|
||||
} finally {
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog) return
|
||||
try {
|
||||
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
} catch {
|
||||
// Silently fail on hover prefetch
|
||||
}
|
||||
}
|
||||
|
||||
function resetUpdateState() {
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = null
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = false
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
|
||||
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
if (updatingModpack.value) {
|
||||
const currentVersionId = contentQuery.data.value?.modpack?.spec.version_id
|
||||
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
|
||||
isModpackUpdateDowngrade.value = currentVersion
|
||||
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
|
||||
: false
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
modpackUpdateModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
performUpdate(selectedVersion)
|
||||
}
|
||||
|
||||
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
try {
|
||||
if (updatingModpack.value) {
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
if (!mp) return
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: mp.spec.project_id,
|
||||
version_id: selectedVersion.id,
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
} else if (updatingProject.value) {
|
||||
const addon = addonLookup.value.get(updatingProject.value.file_name)
|
||||
if (addon) {
|
||||
await client.archon.content_v1.updateAddon(serverId, worldId.value!, {
|
||||
filename: addon.filename,
|
||||
version_id: selectedVersion.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToUpdate),
|
||||
})
|
||||
} finally {
|
||||
resetUpdateState()
|
||||
}
|
||||
}
|
||||
|
||||
function handleModpackUpdateConfirm() {
|
||||
if (pendingModpackUpdateVersion.value) {
|
||||
performUpdate(pendingModpackUpdateVersion.value)
|
||||
pendingModpackUpdateVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleModpackUpdateCancel() {
|
||||
pendingModpackUpdateVersion.value = null
|
||||
}
|
||||
|
||||
provideContentManager({
|
||||
items: contentItems,
|
||||
loading: computed(() => contentQuery.isLoading.value),
|
||||
error: computed(() => contentQuery.error.value ?? null),
|
||||
modpack,
|
||||
isPackLocked: ref(false),
|
||||
isBusy: computed(() => busyReasons.value.length > 0),
|
||||
busyMessage: computed(() => {
|
||||
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
|
||||
const nonBannerReasons = bannerCoversInstalling
|
||||
? busyReasons.value.filter(
|
||||
(r) =>
|
||||
r.reason.id !== 'servers.busy.installing' &&
|
||||
r.reason.id !== 'servers.busy.syncing-content',
|
||||
)
|
||||
: busyReasons.value
|
||||
return nonBannerReasons.length > 0 ? formatMessage(nonBannerReasons[0].reason) : null
|
||||
}),
|
||||
getItemId: (item) => item.file_name,
|
||||
contentTypeLabel: type,
|
||||
toggleEnabled: handleToggleEnabled,
|
||||
deleteItem: handleDeleteItem,
|
||||
bulkDeleteItems: handleBulkDelete,
|
||||
bulkEnableItems: handleBulkEnable,
|
||||
bulkDisableItems: handleBulkDisable,
|
||||
refresh: async () => {
|
||||
await contentQuery.refetch()
|
||||
},
|
||||
browse: handleBrowseContent,
|
||||
uploadFiles: handleUploadFiles,
|
||||
uploadState,
|
||||
showClientOnlyFilter: props.showClientOnlyFilter,
|
||||
deletionContext: 'server',
|
||||
hasUpdateSupport: true,
|
||||
updateItem: handleUpdateItem,
|
||||
bulkUpdateItems: handleBulkUpdate,
|
||||
updateModpack: handleModpackUpdate,
|
||||
viewModpackContent: handleViewModpackContent,
|
||||
unlinkModpack: handleModpackUnlink,
|
||||
openSettings: () => router.push(`/hosting/manage/${serverId}/options/loader`),
|
||||
mapToTableItem: (item) => {
|
||||
const projectType = item.project_type ?? type.value
|
||||
return {
|
||||
id: item.file_name,
|
||||
project: item.project,
|
||||
projectLink: item.project?.id ? `/${projectType}/${item.project.id}` : undefined,
|
||||
version: item.version,
|
||||
versionLink:
|
||||
item.project?.id && item.version?.id
|
||||
? `/${projectType}/${item.project.id}/version/${item.version.id}`
|
||||
: undefined,
|
||||
owner: item.owner
|
||||
? { ...item.owner, link: `/${item.owner.type}/${item.owner.id}` }
|
||||
: undefined,
|
||||
enabled: item.enabled,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentPageLayout>
|
||||
<template #modals>
|
||||
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="modpack?.project.title"
|
||||
:modpack-icon-url="modpack?.project.icon_url"
|
||||
enable-toggle
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||
/>
|
||||
<ContentUpdaterModal
|
||||
v-if="updatingProject || updatingModpack"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="updatingProjectVersions"
|
||||
:current-game-version="currentGameVersion"
|
||||
:current-loader="currentLoader"
|
||||
:current-version-id="
|
||||
updatingModpack
|
||||
? (contentQuery.data.value?.modpack?.spec.version_id ?? '')
|
||||
: (updatingProject?.version?.id ?? '')
|
||||
"
|
||||
:is-app="false"
|
||||
:is-modpack="updatingModpack"
|
||||
:project-icon-url="
|
||||
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
|
||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
@version-hover="handleVersionHover"
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
server
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user