fix: final content tab qa (#5611)
* fix: queued admonition always showing * fix: dont apply grayscale to checkbox in content card item * fix: actual stable id for disable/enable/bulk state * fix: vue-router resolve workaround * fix: show disable/enable btns same time * fix: remove mr-2 on toggle * fix: type errors + add ModpackAlreadyInstalledModal * fix: bulk actions + overflow menu hitting ad container * fix: responsiveness of ContentSelectionBar * feat: better backup naming for inline backups + sorting fixes * fix: lint * fix: typo
This commit is contained in:
@@ -68,11 +68,11 @@ import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
|
||||
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
|
||||
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
|
||||
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
|
||||
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
@@ -151,6 +151,9 @@ const {
|
||||
handleBrowseModpacks,
|
||||
searchModpacks,
|
||||
getProjectVersions,
|
||||
setModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance,
|
||||
} = setupProviders(notificationManager)
|
||||
|
||||
const news = ref([])
|
||||
@@ -424,7 +427,9 @@ const {
|
||||
handleNavigate: handleContentInstallNavigate,
|
||||
handleCancel: handleContentInstallCancel,
|
||||
setContentInstallModal,
|
||||
setInstallConfirmModal: setContentInstallConfirmModal,
|
||||
setModpackAlreadyInstalledModal: setContentInstallModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
|
||||
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
|
||||
} = contentInstall
|
||||
|
||||
@@ -438,8 +443,9 @@ const {
|
||||
} = serverInstall
|
||||
|
||||
const modInstallModal = ref()
|
||||
const modpackAlreadyInstalledModal = ref()
|
||||
const contentInstallModpackAlreadyInstalledModal = ref()
|
||||
const addServerToInstanceModal = ref()
|
||||
const installConfirmModal = ref()
|
||||
const incompatibilityWarningModal = ref()
|
||||
const installToPlayModal = ref()
|
||||
const updateToPlayModal = ref()
|
||||
@@ -519,8 +525,9 @@ onMounted(() => {
|
||||
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
|
||||
|
||||
setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
|
||||
setContentInstallConfirmModal(installConfirmModal.value)
|
||||
setContentInstallModal(modInstallModal.value)
|
||||
setContentInstallModpackAlreadyInstalledModal(contentInstallModpackAlreadyInstalledModal.value)
|
||||
setModpackAlreadyInstalledModal(modpackAlreadyInstalledModal.value)
|
||||
setServerAddServerToInstanceModal(addServerToInstanceModal.value)
|
||||
setServerInstallToPlayModal(installToPlayModal.value)
|
||||
setServerUpdateToPlayModal(updateToPlayModal.value)
|
||||
@@ -1295,9 +1302,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
@navigate="handleContentInstallNavigate"
|
||||
@cancel="handleContentInstallCancel"
|
||||
/>
|
||||
<ModpackAlreadyInstalledModal
|
||||
ref="modpackAlreadyInstalledModal"
|
||||
@create-anyway="handleModpackDuplicateCreateAnyway"
|
||||
@go-to-instance="handleModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<AddServerToInstanceModal ref="addServerToInstanceModal" />
|
||||
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
|
||||
<InstallConfirmModal ref="installConfirmModal" />
|
||||
<ModpackAlreadyInstalledModal
|
||||
ref="contentInstallModpackAlreadyInstalledModal"
|
||||
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
|
||||
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
|
||||
/>
|
||||
<InstallToPlayModal ref="installToPlayModal" />
|
||||
<UpdateToPlayModal ref="updateToPlayModal" />
|
||||
</template>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup>
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, injectNotificationManager } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { create_profile_and_install as pack_install } from '@/helpers/pack'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const versionId = ref()
|
||||
const project = ref()
|
||||
const confirmModal = ref(null)
|
||||
const installing = ref(false)
|
||||
|
||||
const onInstall = ref(() => {})
|
||||
const onCreateInstance = ref(() => {})
|
||||
|
||||
defineExpose({
|
||||
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
|
||||
project.value = projectVal
|
||||
versionId.value = versionIdVal
|
||||
installing.value = false
|
||||
confirmModal.value.show()
|
||||
|
||||
onInstall.value = callback
|
||||
onCreateInstance.value = createInstanceCallback
|
||||
|
||||
trackEvent('PackInstallStart')
|
||||
},
|
||||
})
|
||||
|
||||
async function install() {
|
||||
installing.value = true
|
||||
confirmModal.value.hide()
|
||||
|
||||
await pack_install(
|
||||
project.value.id,
|
||||
versionId.value,
|
||||
project.value.title,
|
||||
project.value.icon_url,
|
||||
onCreateInstance.value,
|
||||
).catch(handleError)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.value.id,
|
||||
version_id: versionId.value,
|
||||
title: project.value.title,
|
||||
source: 'ConfirmModal',
|
||||
})
|
||||
|
||||
onInstall.value(versionId.value)
|
||||
installing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="confirmModal" header="Are you sure?" :on-hide="onInstall">
|
||||
<div class="modal-body">
|
||||
<p>You already have this modpack installed. Are you sure you want to install it again?</p>
|
||||
<div class="input-group push-right">
|
||||
<Button @click="() => $refs.confirmModal.hide()"><XIcon />Cancel</Button>
|
||||
<Button color="primary" :disabled="installing" @click="install()"
|
||||
><DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="warning" max-width="500px">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody, { instanceName }) }}
|
||||
</Admonition>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="handleGoToInstance">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.goToInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="handleCreateAnyway">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, PlusIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.modpack-already-installed.header',
|
||||
defaultMessage: 'Modpack already installed',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'app.instance.modpack-already-installed.admonition-header',
|
||||
defaultMessage: 'Duplicate modpack',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'app.instance.modpack-already-installed.admonition-body',
|
||||
defaultMessage: 'This modpack is already installed in the "{instanceName}" instance.',
|
||||
},
|
||||
goToInstance: {
|
||||
id: 'app.instance.modpack-already-installed.go-to-instance',
|
||||
defaultMessage: 'Go to instance',
|
||||
},
|
||||
createAnyway: {
|
||||
id: 'app.instance.modpack-already-installed.create-anyway',
|
||||
defaultMessage: 'Create anyway',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'go-to-instance', instancePath: string): void
|
||||
(e: 'create-anyway'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceName = ref('')
|
||||
const instancePath = ref('')
|
||||
|
||||
function show(name: string, path: string) {
|
||||
instanceName.value = name
|
||||
instancePath.value = path
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleGoToInstance() {
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance', instancePath.value)
|
||||
}
|
||||
|
||||
function handleCreateAnyway() {
|
||||
modal.value?.hide()
|
||||
emit('create-anyway')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
4
apps/app-frontend/src/helpers/types.d.ts
vendored
4
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
import type { ModrinthId } from '@modrinth/utils'
|
||||
|
||||
type GameInstance = {
|
||||
export type GameInstance = {
|
||||
path: string
|
||||
install_stage: InstallStage
|
||||
|
||||
@@ -46,7 +46,7 @@ type LinkedData = {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
|
||||
export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
|
||||
|
||||
type ContentFile = {
|
||||
metadata?: {
|
||||
|
||||
@@ -41,6 +41,21 @@
|
||||
"app.instance.confirm-delete.header": {
|
||||
"message": "Delete instance"
|
||||
},
|
||||
"app.instance.modpack-already-installed.admonition-body": {
|
||||
"message": "This modpack is already installed in the \"{instanceName}\" instance."
|
||||
},
|
||||
"app.instance.modpack-already-installed.admonition-header": {
|
||||
"message": "Duplicate modpack"
|
||||
},
|
||||
"app.instance.modpack-already-installed.create-anyway": {
|
||||
"message": "Create anyway"
|
||||
},
|
||||
"app.instance.modpack-already-installed.go-to-instance": {
|
||||
"message": "Go to instance"
|
||||
},
|
||||
"app.instance.modpack-already-installed.header": {
|
||||
"message": "Modpack already installed"
|
||||
},
|
||||
"app.instance.mods.content-type-project": {
|
||||
"message": "project"
|
||||
},
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateConfirmModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
:backup-tip="
|
||||
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
@@ -471,7 +476,7 @@ async function handleModpackContentToggle(item: ContentItem) {
|
||||
}
|
||||
|
||||
async function handleModpackContentBulkToggle(items: ContentItem[]) {
|
||||
await Promise.all(items.map((item) => toggleDisableMod(item)))
|
||||
await Promise.all(items.map((item) => _toggleDisableMod(item)))
|
||||
}
|
||||
|
||||
async function handleModpackContent() {
|
||||
@@ -814,13 +819,12 @@ provideContentManager({
|
||||
isPackLocked,
|
||||
isBusy: isInstanceBusy,
|
||||
isBulkOperating,
|
||||
getItemId: (item) => item.file_path ?? item.file_name,
|
||||
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
|
||||
toggleEnabled: toggleDisableMod,
|
||||
bulkEnableItems: (items) =>
|
||||
Promise.all(items.map((item) => toggleDisableMod(item))).then(() => {}),
|
||||
Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}),
|
||||
bulkDisableItems: (items) =>
|
||||
Promise.all(items.map((item) => toggleDisableMod(item))).then(() => {}),
|
||||
Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}),
|
||||
deleteItem: removeMod,
|
||||
bulkDeleteItems: (items) => Promise.all(items.map((item) => removeMod(item))).then(() => {}),
|
||||
refresh: () => initProjects('must_revalidate'),
|
||||
@@ -838,7 +842,7 @@ provideContentManager({
|
||||
dismissContentHint,
|
||||
shareItems: handleShareItems,
|
||||
mapToTableItem: (item) => ({
|
||||
id: item.file_path ?? item.file_name,
|
||||
id: item.id,
|
||||
project: item.project ?? {
|
||||
id: item.file_name,
|
||||
slug: null,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
remove_project,
|
||||
} from '@/helpers/profile.js'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { GameInstance, InstanceLoader } from '@/helpers/types'
|
||||
import {
|
||||
findPreferredVersion,
|
||||
installVersionDependencies,
|
||||
@@ -37,13 +38,8 @@ interface ModalRef {
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
interface InstallConfirmModalRef {
|
||||
show: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
version: string,
|
||||
callback: (versionId?: string) => void,
|
||||
createInstanceCallback: (profile: string) => void,
|
||||
) => void
|
||||
interface ModpackAlreadyInstalledModalRef {
|
||||
show: (instanceName: string, instancePath: string) => void
|
||||
}
|
||||
|
||||
interface IncompatibilityWarningModalRef {
|
||||
@@ -92,7 +88,9 @@ export interface ContentInstallContext {
|
||||
handleNavigate: (instance: ContentInstallInstance) => void
|
||||
handleCancel: () => void
|
||||
setContentInstallModal: (ref: ModalRef) => void
|
||||
setInstallConfirmModal: (ref: InstallConfirmModalRef) => void
|
||||
setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void
|
||||
handleModpackDuplicateCreateAnyway: () => Promise<void>
|
||||
handleModpackDuplicateGoToInstance: (instancePath: string) => void
|
||||
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
|
||||
install: (
|
||||
projectId: string,
|
||||
@@ -140,12 +138,13 @@ export function createContentInstall(opts: {
|
||||
) {
|
||||
const primaryFile = version?.files?.find((f) => f.primary) ?? version?.files?.[0]
|
||||
const placeholder: ContentItem = {
|
||||
id: `__installing_${project.id}`,
|
||||
file_name: `__installing_${project.id}`,
|
||||
project: {
|
||||
id: project.id,
|
||||
slug: project.slug ?? null,
|
||||
slug: project.slug ?? '',
|
||||
title: project.title,
|
||||
icon_url: project.icon_url ?? null,
|
||||
icon_url: project.icon_url ?? undefined,
|
||||
},
|
||||
version: version
|
||||
? {
|
||||
@@ -183,18 +182,26 @@ export function createContentInstall(opts: {
|
||||
}
|
||||
|
||||
let modalRef: ModalRef | null = null
|
||||
let installConfirmModalRef: InstallConfirmModalRef | null = null
|
||||
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
|
||||
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
|
||||
let currentProject: Labrinth.Projects.v2.Project | null = null
|
||||
let currentVersions: Labrinth.Versions.v2.Version[] = []
|
||||
let currentCallback: (versionId?: string) => void = () => {}
|
||||
let profileMap: Record<string, GameInstance> = {}
|
||||
|
||||
let pendingModpackInstall: {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
version: string
|
||||
source: string
|
||||
callback: (versionId?: string) => void
|
||||
createInstanceCallback: (profile: string) => void
|
||||
} | null = null
|
||||
|
||||
async function showModInstallModal(
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
onInstall: (versionId?: string) => void,
|
||||
hints?: { preferredLoader?: string; preferredGameVersion?: string },
|
||||
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
|
||||
) {
|
||||
currentProject = project
|
||||
currentVersions = versions
|
||||
@@ -379,10 +386,10 @@ export function createContentInstall(opts: {
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: profile.loader,
|
||||
game_version: profile.game_version,
|
||||
id: currentProject.id,
|
||||
id: currentProject!.id,
|
||||
version_id: version.id,
|
||||
project_type: currentProject.project_type,
|
||||
title: currentProject.title,
|
||||
project_type: currentProject!.project_type,
|
||||
title: currentProject!.title,
|
||||
source: 'ProjectInstallModal',
|
||||
})
|
||||
currentCallback(version.id)
|
||||
@@ -433,10 +440,10 @@ export function createContentInstall(opts: {
|
||||
trackEvent('ProjectInstall', {
|
||||
loader: data.loader,
|
||||
game_version: data.gameVersion,
|
||||
id: currentProject.id,
|
||||
id: currentProject!.id,
|
||||
version_id: version.id,
|
||||
project_type: currentProject.project_type,
|
||||
title: currentProject.title,
|
||||
project_type: currentProject!.project_type,
|
||||
title: currentProject!.title,
|
||||
source: 'ProjectInstallModal',
|
||||
})
|
||||
|
||||
@@ -470,28 +477,28 @@ export function createContentInstall(opts: {
|
||||
if (project.project_type === 'modpack') {
|
||||
const version = versionId ?? project.versions[project.versions.length - 1]
|
||||
const packs = await list()
|
||||
const existingPack = packs.find((pack) => pack.linked_data?.project_id === project.id)
|
||||
|
||||
if (
|
||||
packs.length === 0 ||
|
||||
!packs.find((pack) => pack.linked_data?.project_id === project.id)
|
||||
) {
|
||||
await packInstall(
|
||||
project.id,
|
||||
version,
|
||||
project.title,
|
||||
project.icon_url,
|
||||
createInstanceCallback,
|
||||
)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
version_id: version,
|
||||
title: project.title,
|
||||
source,
|
||||
})
|
||||
callback(version)
|
||||
} else {
|
||||
installConfirmModalRef?.show(project, version, callback, createInstanceCallback)
|
||||
if (existingPack) {
|
||||
pendingModpackInstall = { project, version, source, callback, createInstanceCallback }
|
||||
modpackAlreadyInstalledModalRef?.show(existingPack.name, existingPack.path)
|
||||
return
|
||||
}
|
||||
|
||||
await packInstall(
|
||||
project.id,
|
||||
version,
|
||||
project.title,
|
||||
project.icon_url,
|
||||
createInstanceCallback,
|
||||
)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
version_id: version,
|
||||
title: project.title,
|
||||
source,
|
||||
})
|
||||
callback(version)
|
||||
} else if (instancePath) {
|
||||
const [instanceOrNull, instanceProjects, versions] = await Promise.all([
|
||||
get(instancePath),
|
||||
@@ -577,8 +584,31 @@ export function createContentInstall(opts: {
|
||||
setContentInstallModal(ref: ModalRef) {
|
||||
modalRef = ref
|
||||
},
|
||||
setInstallConfirmModal(ref: InstallConfirmModalRef) {
|
||||
installConfirmModalRef = ref
|
||||
setModpackAlreadyInstalledModal(ref: ModpackAlreadyInstalledModalRef) {
|
||||
modpackAlreadyInstalledModalRef = ref
|
||||
},
|
||||
async handleModpackDuplicateCreateAnyway() {
|
||||
if (!pendingModpackInstall) return
|
||||
const { project, version, source, callback, createInstanceCallback } = pendingModpackInstall
|
||||
pendingModpackInstall = null
|
||||
await packInstall(
|
||||
project.id,
|
||||
version,
|
||||
project.title,
|
||||
project.icon_url,
|
||||
createInstanceCallback,
|
||||
)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
version_id: version,
|
||||
title: project.title,
|
||||
source,
|
||||
})
|
||||
callback(version)
|
||||
},
|
||||
handleModpackDuplicateGoToInstance(instancePath: string) {
|
||||
pendingModpackInstall = null
|
||||
opts.router.push(`/instance/${encodeURIComponent(instancePath)}/`)
|
||||
},
|
||||
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
|
||||
incompatibilityWarningModalRef = ref
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import type { AbstractWebNotificationManager, CreationFlowContextValue } from '@modrinth/ui'
|
||||
import { provide, useTemplateRef } from 'vue'
|
||||
import type {
|
||||
AbstractWebNotificationManager,
|
||||
CreationFlowContextValue,
|
||||
CreationFlowModal,
|
||||
} from '@modrinth/ui'
|
||||
import { provide, ref, useTemplateRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
|
||||
import { import_instance } from '@/helpers/import.js'
|
||||
import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack'
|
||||
import { create, list } from '@/helpers/profile.js'
|
||||
import type { InstanceLoader } from '@/helpers/types'
|
||||
|
||||
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
|
||||
const { handleError } = notificationManager
|
||||
const router = useRouter()
|
||||
|
||||
const installationModal = useTemplateRef('installationModal')
|
||||
const installationModal =
|
||||
useTemplateRef<ComponentExposed<typeof CreationFlowModal>>('installationModal')
|
||||
const modpackAlreadyInstalledModal = ref<InstanceType<typeof ModpackAlreadyInstalledModal>>()
|
||||
|
||||
function setModpackAlreadyInstalledModal(
|
||||
modal: InstanceType<typeof ModpackAlreadyInstalledModal>,
|
||||
) {
|
||||
modpackAlreadyInstalledModal.value = modal
|
||||
}
|
||||
|
||||
async function fetchExistingInstanceNames(): Promise<string[]> {
|
||||
const instances = await list().catch(handleError)
|
||||
@@ -23,10 +38,34 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
|
||||
installationModal.value?.show()
|
||||
})
|
||||
|
||||
async function handleCreate(config: CreationFlowContextValue) {
|
||||
installationModal.value?.hide()
|
||||
async function proceedWithModpackCreation(
|
||||
projectId: string,
|
||||
versionId: string,
|
||||
name: string,
|
||||
iconUrl?: string,
|
||||
) {
|
||||
await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
|
||||
}
|
||||
|
||||
async function handleCreate(config: CreationFlowContextValue) {
|
||||
try {
|
||||
if (config.modpackSelection.value) {
|
||||
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
|
||||
|
||||
const instances = await list().catch(handleError)
|
||||
const existingInstance = instances?.find((i) => i.linked_data?.project_id === projectId)
|
||||
|
||||
if (existingInstance) {
|
||||
pendingModpackCreation.value = { projectId, versionId, name, iconUrl }
|
||||
installationModal.value?.hide()
|
||||
modpackAlreadyInstalledModal.value?.show(existingInstance.name, existingInstance.path)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
installationModal.value?.hide()
|
||||
|
||||
if (config.isImportMode.value) {
|
||||
for (const [launcherName, instanceSet] of Object.entries(
|
||||
config.importSelectedInstances.value,
|
||||
@@ -43,8 +82,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
|
||||
|
||||
if (config.modpackSelection.value) {
|
||||
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
|
||||
await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError)
|
||||
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
|
||||
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,26 +104,40 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
|
||||
|
||||
await create(
|
||||
name,
|
||||
config.selectedGameVersion.value,
|
||||
loader,
|
||||
config.selectedGameVersion.value!,
|
||||
loader as InstanceLoader,
|
||||
loaderVersion,
|
||||
iconPath,
|
||||
false,
|
||||
).catch(handleError)
|
||||
|
||||
trackEvent('InstanceCreate', {
|
||||
profile_name: name,
|
||||
game_version: config.selectedGameVersion.value,
|
||||
loader,
|
||||
loader_version: loaderVersion,
|
||||
has_icon: !!iconPath,
|
||||
source: 'CreationModal',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
handleError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingModpackCreation = ref<{
|
||||
projectId: string
|
||||
versionId: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
} | null>(null)
|
||||
|
||||
async function handleModpackDuplicateCreateAnyway() {
|
||||
if (!pendingModpackCreation.value) return
|
||||
const { projectId, versionId, name, iconUrl } = pendingModpackCreation.value
|
||||
pendingModpackCreation.value = null
|
||||
await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
|
||||
}
|
||||
|
||||
function handleModpackDuplicateGoToInstance(instancePath: string) {
|
||||
pendingModpackCreation.value = null
|
||||
router.push(`/instance/${encodeURIComponent(instancePath)}/`)
|
||||
}
|
||||
|
||||
function handleBrowseModpacks() {
|
||||
installationModal.value?.hide()
|
||||
router.push('/browse/modpack')
|
||||
@@ -113,5 +165,8 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
|
||||
handleBrowseModpacks,
|
||||
searchModpacks,
|
||||
getProjectVersions,
|
||||
setModpackAlreadyInstalledModal,
|
||||
handleModpackDuplicateCreateAnyway,
|
||||
handleModpackDuplicateGoToInstance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"vue-confetti-explosion": "^1.0.2",
|
||||
"vue-router": "*",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.5.2",
|
||||
|
||||
@@ -35,8 +35,9 @@ pub struct ContentItem {
|
||||
pub file_name: String,
|
||||
/// Relative path to the file within the profile
|
||||
pub file_path: String,
|
||||
/// SHA1 hash of the file
|
||||
pub hash: String,
|
||||
/// Stable frontend identifier (SHA1 hash of file content, survives renames).
|
||||
/// Not a project or version ID.
|
||||
pub id: String,
|
||||
/// File size in bytes
|
||||
pub size: u64,
|
||||
/// Whether the file is enabled (not .disabled)
|
||||
@@ -542,7 +543,7 @@ async fn profile_files_to_content_items(
|
||||
ContentItem {
|
||||
file_name: file.file_name.clone(),
|
||||
file_path: path.clone(),
|
||||
hash: file.hash.clone(),
|
||||
id: file.hash.clone(),
|
||||
size: file.size,
|
||||
enabled: !file.file_name.ends_with(".disabled"),
|
||||
project_type: file.project_type,
|
||||
@@ -726,7 +727,7 @@ pub async fn dependencies_to_content_items(
|
||||
)
|
||||
}),
|
||||
file_path: String::new(),
|
||||
hash: String::new(),
|
||||
id: String::new(),
|
||||
size: version
|
||||
.and_then(|v| v.files.first())
|
||||
.map(|f| f.size as u64)
|
||||
|
||||
@@ -19,6 +19,7 @@ import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
|
||||
import _ArrowUpIcon from './icons/arrow-up.svg?component'
|
||||
import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component'
|
||||
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
|
||||
import _ArrowUpZAIcon from './icons/arrow-up-z-a.svg?component'
|
||||
import _AsteriskIcon from './icons/asterisk.svg?component'
|
||||
import _BadgeCheckIcon from './icons/badge-check.svg?component'
|
||||
import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
|
||||
@@ -404,6 +405,7 @@ export const ArrowLeftRightIcon = _ArrowLeftRightIcon
|
||||
export const ArrowUpIcon = _ArrowUpIcon
|
||||
export const ArrowUpDownIcon = _ArrowUpDownIcon
|
||||
export const ArrowUpRightIcon = _ArrowUpRightIcon
|
||||
export const ArrowUpZAIcon = _ArrowUpZAIcon
|
||||
export const AsteriskIcon = _AsteriskIcon
|
||||
export const BadgeCheckIcon = _BadgeCheckIcon
|
||||
export const BadgeDollarSignIcon = _BadgeDollarSignIcon
|
||||
|
||||
19
packages/assets/icons/arrow-up-z-a.svg
Normal file
19
packages/assets/icons/arrow-up-z-a.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v0.562.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-arrow-up-z-a"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m3 8 4-4 4 4" />
|
||||
<path d="M7 4v16" />
|
||||
<path d="M15 4h5l-5 6h5" />
|
||||
<path d="M15 20v-3.5a2.5 2.5 0 0 1 5 0V20" />
|
||||
<path d="M20 18h-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 446 B |
@@ -1,11 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
shown: boolean
|
||||
ariaLabel?: string
|
||||
}>()
|
||||
|
||||
const toolbarEl = ref<HTMLElement | null>(null)
|
||||
const compact = ref(false)
|
||||
|
||||
function checkCompact() {
|
||||
const el = toolbarEl.value
|
||||
if (!el) return
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement
|
||||
clone.classList.remove('bar-compact')
|
||||
clone.style.position = 'absolute'
|
||||
clone.style.visibility = 'hidden'
|
||||
clone.style.pointerEvents = 'none'
|
||||
clone.style.width = `${el.offsetWidth}px`
|
||||
|
||||
el.parentElement!.appendChild(clone)
|
||||
const needsCompact = clone.offsetHeight > 70
|
||||
clone.remove()
|
||||
|
||||
compact.value = needsCompact
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null
|
||||
|
||||
watch(
|
||||
toolbarEl,
|
||||
(el) => {
|
||||
observer?.disconnect()
|
||||
if (!el) return
|
||||
observer = new ResizeObserver(() => {
|
||||
checkCompact()
|
||||
})
|
||||
observer.observe(el.parentElement!)
|
||||
checkCompact()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.shown,
|
||||
(shown) => {
|
||||
@@ -15,6 +52,7 @@ watch(
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
document?.body.classList.remove('floating-action-bar-shown')
|
||||
})
|
||||
</script>
|
||||
@@ -27,9 +65,11 @@ onUnmounted(() => {
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
ref="toolbarEl"
|
||||
role="toolbar"
|
||||
:aria-label="ariaLabel"
|
||||
class="relative overflow-clip flex items-center gap-2 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-4 py-3 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="{ 'bar-compact': compact }"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -81,4 +121,12 @@ onUnmounted(() => {
|
||||
.intercom-lightweight-app-launcher {
|
||||
z-index: 9 !important;
|
||||
}
|
||||
|
||||
.bar-compact .bar-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bar-compact .cq-show-icon {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -100,15 +100,17 @@
|
||||
|
||||
<InlineBackupCreator
|
||||
v-if="ctx.flowType === 'reset-server'"
|
||||
backup-name="Before reinstall"
|
||||
ref="backupCreator"
|
||||
backup-name="Before reset server"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="ctx.isBackingUp.value = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets'
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
|
||||
@@ -125,6 +127,11 @@ import { capitalize } from '../shared'
|
||||
|
||||
const debug = useDebugLogger('FinalConfigStage')
|
||||
const ctx = injectCreationFlowContext()
|
||||
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator> | null>(null)
|
||||
watch(backupCreator, (creator) => {
|
||||
ctx.cancelBackup.value = creator?.cancelBackup ?? null
|
||||
})
|
||||
const {
|
||||
worldName,
|
||||
gamemode,
|
||||
|
||||
@@ -115,6 +115,10 @@ export interface CreationFlowContextValue {
|
||||
// Loading state (set when finish() is called, cleared on reset)
|
||||
loading: Ref<boolean>
|
||||
|
||||
// Backup state (set by InlineBackupCreator in reset-server flow)
|
||||
isBackingUp: Ref<boolean>
|
||||
cancelBackup: Ref<(() => void) | null>
|
||||
|
||||
// Modal
|
||||
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
|
||||
stageConfigs: StageConfigInput<CreationFlowContextValue>[]
|
||||
@@ -240,6 +244,8 @@ export function createCreationFlowContext(
|
||||
|
||||
const hardReset = ref(isInitialSetup)
|
||||
const loading = ref(false)
|
||||
const isBackingUp = ref(false)
|
||||
const cancelBackup = ref<(() => void) | null>(null)
|
||||
|
||||
// hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows)
|
||||
const hideLoaderChips = computed(() => setupType.value === 'vanilla')
|
||||
@@ -292,6 +298,8 @@ export function createCreationFlowContext(
|
||||
|
||||
hardReset.value = isInitialSetup
|
||||
loading.value = false
|
||||
isBackingUp.value = false
|
||||
cancelBackup.value = null
|
||||
}
|
||||
|
||||
function setSetupType(type: SetupType) {
|
||||
@@ -401,6 +409,8 @@ export function createCreationFlowContext(
|
||||
importSearchQuery,
|
||||
hardReset,
|
||||
loading,
|
||||
isBackingUp,
|
||||
cancelBackup,
|
||||
modal,
|
||||
stageConfigs: resolvedStageConfigs,
|
||||
onBack,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:context="ctx"
|
||||
:fade="fade"
|
||||
disable-progress
|
||||
@hide="$emit('hide')"
|
||||
@hide="handleHide"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -89,5 +89,10 @@ function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
ctx.cancelBackup.value?.()
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, ctx })
|
||||
</script>
|
||||
|
||||
@@ -44,7 +44,7 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
|
||||
icon: isFinish ? PlusIcon : RightArrowIcon,
|
||||
iconPosition: isFinish ? ('before' as const) : ('after' as const),
|
||||
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
|
||||
disabled: isForwardBlocked(ctx),
|
||||
disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value,
|
||||
loading: isFinish && ctx.loading.value,
|
||||
onClick: () => {
|
||||
if (isFinish) {
|
||||
|
||||
@@ -109,7 +109,7 @@ const description = computed(() => {
|
||||
const messages = defineMessages({
|
||||
fallbackName: {
|
||||
id: 'servers.backups.admonition.fallback-name',
|
||||
defaultMessage: 'your backup',
|
||||
defaultMessage: 'Your backup',
|
||||
},
|
||||
backupQueuedTitle: {
|
||||
id: 'servers.backups.admonition.backup-queued.title',
|
||||
|
||||
@@ -84,10 +84,10 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
|
||||
// 1. Active WS entries (real-time progress from backupsState)
|
||||
for (const [id, entry] of backupsState.entries()) {
|
||||
const backup = findBackup(id)
|
||||
seenIds.add(id)
|
||||
if (entry.create && entry.create.state === 'ongoing') {
|
||||
const key = `${id}:create`
|
||||
if (!dismissedIds.has(key)) {
|
||||
seenIds.add(id)
|
||||
result.push({
|
||||
key,
|
||||
backupId: id,
|
||||
@@ -102,7 +102,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
|
||||
if (entry.restore && entry.restore.state === 'ongoing') {
|
||||
const key = `${id}:restore`
|
||||
if (!dismissedIds.has(key)) {
|
||||
seenIds.add(id)
|
||||
result.push({
|
||||
key,
|
||||
backupId: id,
|
||||
|
||||
@@ -178,10 +178,10 @@ const calculateMenuPosition = () => {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||
}
|
||||
|
||||
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||
left = triggerRect.left
|
||||
} else if (triggerRect.right - menuWidth - margin >= 0) {
|
||||
if (triggerRect.right - menuWidth >= margin) {
|
||||
left = triggerRect.right - menuWidth
|
||||
} else if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||
left = triggerRect.left
|
||||
} else {
|
||||
left = Math.max(margin, window.innerWidth - menuWidth - margin)
|
||||
}
|
||||
|
||||
@@ -87,22 +87,23 @@ const deleteHovered = ref(false)
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4 transition-[filter,opacity] duration-200"
|
||||
:class="[
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none',
|
||||
enabled === false && !disabled ? 'grayscale opacity-50' : '',
|
||||
]"
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-if="showCheckbox"
|
||||
:model-value="selected ?? false"
|
||||
:disabled="disabled"
|
||||
:aria-label="`Select ${project.title}`"
|
||||
class="shrink-0"
|
||||
@update:model-value="selected = $event"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-3 transition-[filter,opacity] duration-200"
|
||||
:class="enabled === false && !disabled ? 'grayscale opacity-50' : ''"
|
||||
>
|
||||
<div
|
||||
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
|
||||
class="relative shrink-0"
|
||||
@@ -256,7 +257,7 @@ const deleteHovered = ref(false)
|
||||
:model-value="enabled"
|
||||
:disabled="disabled"
|
||||
:aria-label="project.title"
|
||||
class="mr-2 my-auto"
|
||||
class="my-auto"
|
||||
@update:model-value="(val) => emit('update:enabled', val as boolean)"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { PowerIcon, PowerOffIcon } from '@modrinth/assets'
|
||||
import { PowerIcon, PowerOffIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -61,6 +61,14 @@ const messages = defineMessages({
|
||||
id: 'content.selection-bar.bulk.deleting-waiting',
|
||||
defaultMessage: 'Deleting {contentType}...',
|
||||
},
|
||||
allAlreadyEnabled: {
|
||||
id: 'content.selection-bar.all-already-enabled',
|
||||
defaultMessage: 'All selected content is already enabled',
|
||||
},
|
||||
allAlreadyDisabled: {
|
||||
id: 'content.selection-bar.all-already-disabled',
|
||||
defaultMessage: 'All selected content is already disabled',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
@@ -95,6 +103,7 @@ const emit = defineEmits<{
|
||||
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
|
||||
|
||||
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
|
||||
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
|
||||
|
||||
const selectedCountText = computed(() => {
|
||||
const count = props.selectedItems.length || props.bulkTotal
|
||||
@@ -135,12 +144,14 @@ const bulkProgressMessage = computed(() => {
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.clearButton)"
|
||||
class="!text-primary"
|
||||
:disabled="isBulkOperating"
|
||||
:class="{ 'opacity-60 pointer-events-none': isBulkOperating }"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ formatMessage(commonMessages.clearButton) }}
|
||||
<XIcon class="hidden cq-show-icon" />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -148,16 +159,30 @@ const bulkProgressMessage = computed(() => {
|
||||
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
|
||||
<slot name="actions" />
|
||||
|
||||
<ButtonStyled v-if="allDisabled" type="transparent">
|
||||
<button :disabled="isBusy" @click="emit('enable')">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
allEnabled ? formatMessage(messages.allAlreadyEnabled) : formatMessage(messages.enable)
|
||||
"
|
||||
:disabled="isBusy || allEnabled"
|
||||
@click="emit('enable')"
|
||||
>
|
||||
<PowerIcon />
|
||||
{{ formatMessage(messages.enable) }}
|
||||
<span class="bar-label">{{ formatMessage(messages.enable) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else type="transparent">
|
||||
<button :disabled="isBusy" @click="emit('disable')">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
allDisabled
|
||||
? formatMessage(messages.allAlreadyDisabled)
|
||||
: formatMessage(messages.disable)
|
||||
"
|
||||
:disabled="isBusy || allDisabled"
|
||||
@click="emit('disable')"
|
||||
>
|
||||
<PowerOffIcon />
|
||||
{{ formatMessage(messages.disable) }}
|
||||
<span class="bar-label">{{ formatMessage(messages.disable) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before bulk update"
|
||||
:backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -73,6 +73,7 @@ const messages = defineMessages({
|
||||
defineProps<{
|
||||
count: number
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before deletion"
|
||||
:backup-name="backupTip ? `Before deletion (${backupTip})` : 'Before deletion'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -78,9 +78,11 @@ withDefaults(
|
||||
count: number
|
||||
itemType: string
|
||||
variant?: 'instance' | 'server'
|
||||
backupTip?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'instance',
|
||||
backupTip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="downgrade ? 'Before modpack downgrade' : 'Before modpack update'"
|
||||
:backup-name="backupName"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -55,13 +55,21 @@ import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
downgrade?: boolean
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const backupName = computed(() => {
|
||||
const action = props.downgrade ? 'downgrade' : 'update'
|
||||
return props.backupTip
|
||||
? `Before modpack ${action} (${props.backupTip})`
|
||||
: `Before modpack ${action}`
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-modpack-update.header',
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before reinstall"
|
||||
:backup-name="backupTip ? `Before reinstall (${backupTip})` : 'Before reinstall'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -72,6 +72,7 @@ const messages = defineMessages({
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before unlink"
|
||||
:backup-name="backupTip ? `Before unlink (${backupTip})` : 'Before unlink'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
@@ -50,6 +50,7 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -3,19 +3,16 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
export function useContentSelection(
|
||||
items: Ref<ContentItem[]>,
|
||||
getItemId: (item: ContentItem) => string,
|
||||
) {
|
||||
export function useContentSelection(items: Ref<ContentItem[]>) {
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
items.value.filter((item) => selectedIds.value.includes(getItemId(item))),
|
||||
items.value.filter((item) => selectedIds.value.includes(item.id)),
|
||||
)
|
||||
|
||||
watch(items, (newItems) => {
|
||||
if (selectedIds.value.length === 0) return
|
||||
const validIds = new Set(newItems.map(getItemId))
|
||||
const validIds = new Set(newItems.map((item) => item.id))
|
||||
const pruned = selectedIds.value.filter((id) => validIds.has(id))
|
||||
if (pruned.length !== selectedIds.value.length) {
|
||||
selectedIds.value = pruned
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDownAZIcon,
|
||||
ArrowDownZAIcon,
|
||||
ArrowUpZAIcon,
|
||||
ClockArrowDownIcon,
|
||||
ClockArrowUpIcon,
|
||||
CodeIcon,
|
||||
@@ -171,8 +171,8 @@ const sortLabels: Record<SortMode, () => string> = {
|
||||
function cycleSortMode() {
|
||||
const modes: SortMode[] = [
|
||||
'alphabetical-asc',
|
||||
'date-added-newest',
|
||||
'alphabetical-desc',
|
||||
'date-added-newest',
|
||||
'date-added-oldest',
|
||||
]
|
||||
const idx = modes.indexOf(sortMode.value)
|
||||
@@ -235,7 +235,6 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
|
||||
|
||||
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
|
||||
ctx.items,
|
||||
ctx.getItemId,
|
||||
)
|
||||
|
||||
const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation()
|
||||
@@ -292,7 +291,7 @@ const pendingDeletionItems = ref<ContentItem[]>([])
|
||||
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
|
||||
|
||||
function handleDeleteById(id: string, event?: MouseEvent) {
|
||||
const item = ctx.items.value.find((i) => ctx.getItemId(i) === id)
|
||||
const item = ctx.items.value.find((i) => i.id === id)
|
||||
if (item) {
|
||||
pendingDeletionItems.value = [item]
|
||||
if (event?.shiftKey) {
|
||||
@@ -334,7 +333,7 @@ async function confirmDelete() {
|
||||
|
||||
if (itemsToDelete.length === 1) {
|
||||
const item = itemsToDelete[0]
|
||||
const id = ctx.getItemId(item)
|
||||
const id = item.id
|
||||
markChanging(id)
|
||||
await ctx.deleteItem(item)
|
||||
removeFromSelection(id)
|
||||
@@ -347,14 +346,14 @@ async function confirmDelete() {
|
||||
itemsToDelete,
|
||||
async (item) => {
|
||||
await ctx.deleteItem(item)
|
||||
removeFromSelection(ctx.getItemId(item))
|
||||
removeFromSelection(item.id)
|
||||
},
|
||||
{ onComplete: clearSelection },
|
||||
)
|
||||
}
|
||||
|
||||
async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
const item = ctx.items.value.find((i) => ctx.getItemId(i) === id)
|
||||
const item = ctx.items.value.find((i) => i.id === id)
|
||||
if (!item) return
|
||||
markChanging(id)
|
||||
try {
|
||||
@@ -647,7 +646,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
"
|
||||
@click="cycleSortMode"
|
||||
>
|
||||
<ArrowDownZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
v-else-if="sortMode === 'date-added-newest'"
|
||||
/><ClockArrowUpIcon
|
||||
v-else-if="sortMode === 'date-added-oldest'"
|
||||
@@ -667,7 +666,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
"
|
||||
@click="cycleSortMode"
|
||||
>
|
||||
<ArrowDownZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
||||
v-else-if="sortMode === 'date-added-newest'"
|
||||
/><ClockArrowUpIcon
|
||||
v-else-if="sortMode === 'date-added-oldest'"
|
||||
@@ -790,9 +789,13 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="ctx.isBusy.value" @click="promptUpdateSelected">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.updateButton)"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="promptUpdateSelected"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(commonMessages.updateButton) }}
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.updateButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
@@ -818,7 +821,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
]"
|
||||
>
|
||||
<ShareIcon />
|
||||
{{ formatMessage(messages.share) }}
|
||||
<span class="bar-label">{{ formatMessage(messages.share) }}</span>
|
||||
<DropdownIcon />
|
||||
<template #share-names>
|
||||
<TextCursorInputIcon />
|
||||
@@ -849,9 +852,13 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="ctx.isBusy.value" @click="showBulkDeleteModal">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
||||
:disabled="ctx.isBusy.value"
|
||||
@click="showBulkDeleteModal"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -862,6 +869,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:count="pendingDeletionItems.length"
|
||||
:item-type="ctx.contentTypeLabel.value"
|
||||
:variant="ctx.deletionContext ?? 'instance'"
|
||||
:backup-tip="pendingDeletionItems.map((i) => i.project.title).join(', ')"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
<ConfirmBulkUpdateModal
|
||||
@@ -875,6 +883,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
v-if="ctx.unlinkModpack"
|
||||
ref="confirmUnlinkModal"
|
||||
:server="ctx.deletionContext === 'server'"
|
||||
:backup-tip="ctx.modpack.value?.project.title"
|
||||
@unlink="ctx.unlinkModpack!()"
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,8 +51,7 @@ export interface ContentManagerContext {
|
||||
disableAddContent?: Ref<boolean> | ComputedRef<boolean>
|
||||
disableAddContentTooltip?: string
|
||||
|
||||
// Identity & labelling
|
||||
getItemId: (item: ContentItem) => string
|
||||
// Labelling
|
||||
contentTypeLabel: Ref<string> | ComputedRef<string>
|
||||
|
||||
// Core actions
|
||||
|
||||
@@ -44,9 +44,9 @@ export interface ContentItem extends Omit<
|
||||
ContentCardTableItem,
|
||||
'id' | 'projectLink' | 'disabled' | 'overflowOptions'
|
||||
> {
|
||||
id: string
|
||||
file_name: string
|
||||
file_path?: string
|
||||
hash?: string
|
||||
size?: number
|
||||
project_type: string
|
||||
has_update: boolean
|
||||
|
||||
@@ -679,6 +679,9 @@ const messages = defineMessages({
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isUpdateDowngrade"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="
|
||||
[ctx.modpack.value?.title, pendingUpdateVersion?.version_number].filter(Boolean).join(' ')
|
||||
"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
@@ -686,9 +689,15 @@ const messages = defineMessages({
|
||||
<ConfirmReinstallModal
|
||||
ref="reinstallModal"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@reinstall="handleReinstall"
|
||||
/>
|
||||
<ConfirmUnlinkModal ref="unlinkModal" :server="ctx.isServer" @unlink="handleUnlink" />
|
||||
<ConfirmUnlinkModal
|
||||
ref="unlinkModal"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@unlink="handleUnlink"
|
||||
/>
|
||||
|
||||
<ContentDiffModal
|
||||
v-if="form.pendingPreview.value"
|
||||
|
||||
@@ -487,6 +487,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
|
||||
link: `/${addon.owner.type}/${addon.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
id: addon.id,
|
||||
enabled: !addon.disabled,
|
||||
file_name: addon.filename,
|
||||
project_type: addon.kind,
|
||||
@@ -604,8 +605,8 @@ async function handleBulkUpdate(items: ContentItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateItem(fileNameKey: string) {
|
||||
const item = contentItems.value.find((i) => i.file_name === fileNameKey)
|
||||
async function handleUpdateItem(id: string) {
|
||||
const item = contentItems.value.find((i) => i.id === id)
|
||||
if (!item?.has_update || !item.project?.id || !item.version?.id) return
|
||||
|
||||
updatingModpack.value = false
|
||||
@@ -872,7 +873,6 @@ provideContentManager({
|
||||
})
|
||||
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
|
||||
}),
|
||||
getItemId: (item) => item.file_path ?? item.file_name,
|
||||
contentTypeLabel: type,
|
||||
toggleEnabled: handleToggleEnabled,
|
||||
deleteItem: handleDeleteItem,
|
||||
@@ -898,7 +898,7 @@ provideContentManager({
|
||||
mapToTableItem: (item) => {
|
||||
const projectType = item.project_type ?? type.value
|
||||
return {
|
||||
id: item.file_path ?? item.file_name,
|
||||
id: item.id,
|
||||
project: item.project,
|
||||
projectLink: item.project?.id ? `/${projectType}/${item.project.id}` : undefined,
|
||||
version: item.version,
|
||||
@@ -962,6 +962,11 @@ provideContentManager({
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
:backup-tip="
|
||||
[modpack?.project.title, pendingModpackUpdateVersion?.version_number]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
"
|
||||
server
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
|
||||
@@ -374,6 +374,12 @@
|
||||
"content.page-layout.uploading-files": {
|
||||
"defaultMessage": "Uploading files ({completed}/{total})"
|
||||
},
|
||||
"content.selection-bar.all-already-disabled": {
|
||||
"defaultMessage": "All selected content is already disabled"
|
||||
},
|
||||
"content.selection-bar.all-already-enabled": {
|
||||
"defaultMessage": "All selected content is already enabled"
|
||||
},
|
||||
"content.selection-bar.bulk.deleting": {
|
||||
"defaultMessage": "Deleting {progress}/{total} {contentType}..."
|
||||
},
|
||||
@@ -2151,7 +2157,7 @@
|
||||
"defaultMessage": "Creating backup"
|
||||
},
|
||||
"servers.backups.admonition.fallback-name": {
|
||||
"defaultMessage": "your backup"
|
||||
"defaultMessage": "Your backup"
|
||||
},
|
||||
"servers.backups.admonition.restore-failed.description": {
|
||||
"defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues."
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -350,6 +350,9 @@ importers:
|
||||
vue-confetti-explosion:
|
||||
specifier: ^1.0.2
|
||||
version: 1.0.2(vue@3.5.27(typescript@5.9.3))
|
||||
vue-router:
|
||||
specifier: '*'
|
||||
version: 4.6.4(vue@3.5.27(typescript@5.9.3))
|
||||
vue-typed-virtual-list:
|
||||
specifier: ^1.0.10
|
||||
version: 1.0.10(vue@3.5.27(typescript@5.9.3))
|
||||
@@ -8689,6 +8692,7 @@ packages:
|
||||
tar@7.5.7:
|
||||
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
terser@5.46.0:
|
||||
resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==}
|
||||
@@ -8975,6 +8979,7 @@ packages:
|
||||
|
||||
unplugin-vue-router@0.19.2:
|
||||
resolution: {integrity: sha512-u5dgLBarxE5cyDK/hzJGfpCTLIAyiTXGlo85COuD4Nssj6G7NxS+i9mhCWz/1p/ud1eMwdcUbTXehQe41jYZUA==}
|
||||
deprecated: 'Merged into vuejs/router. Migrate: https://router.vuejs.org/guide/migration/v4-to-v5.html'
|
||||
peerDependencies:
|
||||
'@vue/compiler-sfc': ^3.5.17
|
||||
vue-router: ^4.6.0
|
||||
@@ -9406,8 +9411,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.4:
|
||||
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
|
||||
|
||||
vue-component-type-helpers@3.2.5:
|
||||
resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==}
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-confetti-explosion@1.0.2:
|
||||
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
|
||||
@@ -12755,7 +12760,7 @@ snapshots:
|
||||
storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.27(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.5
|
||||
vue-component-type-helpers: 3.2.6
|
||||
|
||||
'@stripe/stripe-js@7.9.0': {}
|
||||
|
||||
@@ -19524,7 +19529,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.4: {}
|
||||
|
||||
vue-component-type-helpers@3.2.5: {}
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user