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:
Calum H.
2026-03-18 18:03:55 +00:00
committed by GitHub
parent cf1b5f5e2d
commit 1d10af09f5
35 changed files with 503 additions and 215 deletions

View File

@@ -68,11 +68,11 @@ import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.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 MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue' import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.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 UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
@@ -151,6 +151,9 @@ const {
handleBrowseModpacks, handleBrowseModpacks,
searchModpacks, searchModpacks,
getProjectVersions, getProjectVersions,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
} = setupProviders(notificationManager) } = setupProviders(notificationManager)
const news = ref([]) const news = ref([])
@@ -424,7 +427,9 @@ const {
handleNavigate: handleContentInstallNavigate, handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel, handleCancel: handleContentInstallCancel,
setContentInstallModal, setContentInstallModal,
setInstallConfirmModal: setContentInstallConfirmModal, setModpackAlreadyInstalledModal: setContentInstallModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal, setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
} = contentInstall } = contentInstall
@@ -438,8 +443,9 @@ const {
} = serverInstall } = serverInstall
const modInstallModal = ref() const modInstallModal = ref()
const modpackAlreadyInstalledModal = ref()
const contentInstallModpackAlreadyInstalledModal = ref()
const addServerToInstanceModal = ref() const addServerToInstanceModal = ref()
const installConfirmModal = ref()
const incompatibilityWarningModal = ref() const incompatibilityWarningModal = ref()
const installToPlayModal = ref() const installToPlayModal = ref()
const updateToPlayModal = ref() const updateToPlayModal = ref()
@@ -519,8 +525,9 @@ onMounted(() => {
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value) error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
setContentIncompatibilityWarningModal(incompatibilityWarningModal.value) setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
setContentInstallConfirmModal(installConfirmModal.value)
setContentInstallModal(modInstallModal.value) setContentInstallModal(modInstallModal.value)
setContentInstallModpackAlreadyInstalledModal(contentInstallModpackAlreadyInstalledModal.value)
setModpackAlreadyInstalledModal(modpackAlreadyInstalledModal.value)
setServerAddServerToInstanceModal(addServerToInstanceModal.value) setServerAddServerToInstanceModal(addServerToInstanceModal.value)
setServerInstallToPlayModal(installToPlayModal.value) setServerInstallToPlayModal(installToPlayModal.value)
setServerUpdateToPlayModal(updateToPlayModal.value) setServerUpdateToPlayModal(updateToPlayModal.value)
@@ -1295,9 +1302,18 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@navigate="handleContentInstallNavigate" @navigate="handleContentInstallNavigate"
@cancel="handleContentInstallCancel" @cancel="handleContentInstallCancel"
/> />
<ModpackAlreadyInstalledModal
ref="modpackAlreadyInstalledModal"
@create-anyway="handleModpackDuplicateCreateAnyway"
@go-to-instance="handleModpackDuplicateGoToInstance"
/>
<AddServerToInstanceModal ref="addServerToInstanceModal" /> <AddServerToInstanceModal ref="addServerToInstanceModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" /> <IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<InstallConfirmModal ref="installConfirmModal" /> <ModpackAlreadyInstalledModal
ref="contentInstallModpackAlreadyInstalledModal"
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@go-to-instance="handleContentInstallModpackDuplicateGoToInstance"
/>
<InstallToPlayModal ref="installToPlayModal" /> <InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" /> <UpdateToPlayModal ref="updateToPlayModal" />
</template> </template>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,6 +1,6 @@
import type { ModrinthId } from '@modrinth/utils' import type { ModrinthId } from '@modrinth/utils'
type GameInstance = { export type GameInstance = {
path: string path: string
install_stage: InstallStage install_stage: InstallStage
@@ -46,7 +46,7 @@ type LinkedData = {
locked: boolean locked: boolean
} }
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = { type ContentFile = {
metadata?: { metadata?: {

View File

@@ -41,6 +41,21 @@
"app.instance.confirm-delete.header": { "app.instance.confirm-delete.header": {
"message": "Delete instance" "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": { "app.instance.mods.content-type-project": {
"message": "project" "message": "project"
}, },

View File

@@ -20,6 +20,11 @@
<ConfirmModpackUpdateModal <ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal" ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade" :downgrade="isModpackUpdateDowngrade"
:backup-tip="
[linkedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
@confirm="handleModpackUpdateConfirm" @confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel" @cancel="handleModpackUpdateCancel"
/> />
@@ -471,7 +476,7 @@ async function handleModpackContentToggle(item: ContentItem) {
} }
async function handleModpackContentBulkToggle(items: 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() { async function handleModpackContent() {
@@ -814,13 +819,12 @@ provideContentManager({
isPackLocked, isPackLocked,
isBusy: isInstanceBusy, isBusy: isInstanceBusy,
isBulkOperating, isBulkOperating,
getItemId: (item) => item.file_path ?? item.file_name,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)), contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableMod, toggleEnabled: toggleDisableMod,
bulkEnableItems: (items) => bulkEnableItems: (items) =>
Promise.all(items.map((item) => toggleDisableMod(item))).then(() => {}), Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}),
bulkDisableItems: (items) => bulkDisableItems: (items) =>
Promise.all(items.map((item) => toggleDisableMod(item))).then(() => {}), Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}),
deleteItem: removeMod, deleteItem: removeMod,
bulkDeleteItems: (items) => Promise.all(items.map((item) => removeMod(item))).then(() => {}), bulkDeleteItems: (items) => Promise.all(items.map((item) => removeMod(item))).then(() => {}),
refresh: () => initProjects('must_revalidate'), refresh: () => initProjects('must_revalidate'),
@@ -838,7 +842,7 @@ provideContentManager({
dismissContentHint, dismissContentHint,
shareItems: handleShareItems, shareItems: handleShareItems,
mapToTableItem: (item) => ({ mapToTableItem: (item) => ({
id: item.file_path ?? item.file_name, id: item.id,
project: item.project ?? { project: item.project ?? {
id: item.file_name, id: item.file_name,
slug: null, slug: null,

View File

@@ -26,6 +26,7 @@ import {
remove_project, remove_project,
} from '@/helpers/profile.js' } from '@/helpers/profile.js'
import { get_game_versions } from '@/helpers/tags' import { get_game_versions } from '@/helpers/tags'
import type { GameInstance, InstanceLoader } from '@/helpers/types'
import { import {
findPreferredVersion, findPreferredVersion,
installVersionDependencies, installVersionDependencies,
@@ -37,13 +38,8 @@ interface ModalRef {
hide: () => void hide: () => void
} }
interface InstallConfirmModalRef { interface ModpackAlreadyInstalledModalRef {
show: ( show: (instanceName: string, instancePath: string) => void
project: Labrinth.Projects.v2.Project,
version: string,
callback: (versionId?: string) => void,
createInstanceCallback: (profile: string) => void,
) => void
} }
interface IncompatibilityWarningModalRef { interface IncompatibilityWarningModalRef {
@@ -92,7 +88,9 @@ export interface ContentInstallContext {
handleNavigate: (instance: ContentInstallInstance) => void handleNavigate: (instance: ContentInstallInstance) => void
handleCancel: () => void handleCancel: () => void
setContentInstallModal: (ref: ModalRef) => void setContentInstallModal: (ref: ModalRef) => void
setInstallConfirmModal: (ref: InstallConfirmModalRef) => void setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void
handleModpackDuplicateCreateAnyway: () => Promise<void>
handleModpackDuplicateGoToInstance: (instancePath: string) => void
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
install: ( install: (
projectId: string, projectId: string,
@@ -140,12 +138,13 @@ export function createContentInstall(opts: {
) { ) {
const primaryFile = version?.files?.find((f) => f.primary) ?? version?.files?.[0] const primaryFile = version?.files?.find((f) => f.primary) ?? version?.files?.[0]
const placeholder: ContentItem = { const placeholder: ContentItem = {
id: `__installing_${project.id}`,
file_name: `__installing_${project.id}`, file_name: `__installing_${project.id}`,
project: { project: {
id: project.id, id: project.id,
slug: project.slug ?? null, slug: project.slug ?? '',
title: project.title, title: project.title,
icon_url: project.icon_url ?? null, icon_url: project.icon_url ?? undefined,
}, },
version: version version: version
? { ? {
@@ -183,18 +182,26 @@ export function createContentInstall(opts: {
} }
let modalRef: ModalRef | null = null let modalRef: ModalRef | null = null
let installConfirmModalRef: InstallConfirmModalRef | null = null let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
let currentProject: Labrinth.Projects.v2.Project | null = null let currentProject: Labrinth.Projects.v2.Project | null = null
let currentVersions: Labrinth.Versions.v2.Version[] = [] let currentVersions: Labrinth.Versions.v2.Version[] = []
let currentCallback: (versionId?: string) => void = () => {} let currentCallback: (versionId?: string) => void = () => {}
let profileMap: Record<string, GameInstance> = {} 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( async function showModInstallModal(
project: Labrinth.Projects.v2.Project, project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[], versions: Labrinth.Versions.v2.Version[],
onInstall: (versionId?: string) => void, onInstall: (versionId?: string) => void,
hints?: { preferredLoader?: string; preferredGameVersion?: string }, hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
) { ) {
currentProject = project currentProject = project
currentVersions = versions currentVersions = versions
@@ -379,10 +386,10 @@ export function createContentInstall(opts: {
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: profile.loader, loader: profile.loader,
game_version: profile.game_version, game_version: profile.game_version,
id: currentProject.id, id: currentProject!.id,
version_id: version.id, version_id: version.id,
project_type: currentProject.project_type, project_type: currentProject!.project_type,
title: currentProject.title, title: currentProject!.title,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
currentCallback(version.id) currentCallback(version.id)
@@ -433,10 +440,10 @@ export function createContentInstall(opts: {
trackEvent('ProjectInstall', { trackEvent('ProjectInstall', {
loader: data.loader, loader: data.loader,
game_version: data.gameVersion, game_version: data.gameVersion,
id: currentProject.id, id: currentProject!.id,
version_id: version.id, version_id: version.id,
project_type: currentProject.project_type, project_type: currentProject!.project_type,
title: currentProject.title, title: currentProject!.title,
source: 'ProjectInstallModal', source: 'ProjectInstallModal',
}) })
@@ -470,28 +477,28 @@ export function createContentInstall(opts: {
if (project.project_type === 'modpack') { if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1] const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list() const packs = await list()
const existingPack = packs.find((pack) => pack.linked_data?.project_id === project.id)
if ( if (existingPack) {
packs.length === 0 || pendingModpackInstall = { project, version, source, callback, createInstanceCallback }
!packs.find((pack) => pack.linked_data?.project_id === project.id) 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 {
installConfirmModalRef?.show(project, version, callback, createInstanceCallback)
} }
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) { } else if (instancePath) {
const [instanceOrNull, instanceProjects, versions] = await Promise.all([ const [instanceOrNull, instanceProjects, versions] = await Promise.all([
get(instancePath), get(instancePath),
@@ -577,8 +584,31 @@ export function createContentInstall(opts: {
setContentInstallModal(ref: ModalRef) { setContentInstallModal(ref: ModalRef) {
modalRef = ref modalRef = ref
}, },
setInstallConfirmModal(ref: InstallConfirmModalRef) { setModpackAlreadyInstalledModal(ref: ModpackAlreadyInstalledModalRef) {
installConfirmModalRef = ref 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) { setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
incompatibilityWarningModalRef = ref incompatibilityWarningModalRef = ref

View File

@@ -1,18 +1,33 @@
import type { AbstractWebNotificationManager, CreationFlowContextValue } from '@modrinth/ui' import type {
import { provide, useTemplateRef } from 'vue' 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 { useRouter } from 'vue-router'
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js' import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { import_instance } from '@/helpers/import.js' import { import_instance } from '@/helpers/import.js'
import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack' import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack'
import { create, list } from '@/helpers/profile.js' import { create, list } from '@/helpers/profile.js'
import type { InstanceLoader } from '@/helpers/types'
export function setupCreationModal(notificationManager: AbstractWebNotificationManager) { export function setupCreationModal(notificationManager: AbstractWebNotificationManager) {
const { handleError } = notificationManager const { handleError } = notificationManager
const router = useRouter() 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[]> { async function fetchExistingInstanceNames(): Promise<string[]> {
const instances = await list().catch(handleError) const instances = await list().catch(handleError)
@@ -23,10 +38,34 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
installationModal.value?.show() installationModal.value?.show()
}) })
async function handleCreate(config: CreationFlowContextValue) { async function proceedWithModpackCreation(
installationModal.value?.hide() 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 { 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) { if (config.isImportMode.value) {
for (const [launcherName, instanceSet] of Object.entries( for (const [launcherName, instanceSet] of Object.entries(
config.importSelectedInstances.value, config.importSelectedInstances.value,
@@ -43,8 +82,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
if (config.modpackSelection.value) { if (config.modpackSelection.value) {
const { projectId, versionId, name, iconUrl } = config.modpackSelection.value const { projectId, versionId, name, iconUrl } = config.modpackSelection.value
await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError) await proceedWithModpackCreation(projectId, versionId, name, iconUrl)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
return return
} }
@@ -66,26 +104,40 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
await create( await create(
name, name,
config.selectedGameVersion.value, config.selectedGameVersion.value!,
loader, loader as InstanceLoader,
loaderVersion, loaderVersion,
iconPath, iconPath,
false, false,
).catch(handleError) ).catch(handleError)
trackEvent('InstanceCreate', { trackEvent('InstanceCreate', {
profile_name: name,
game_version: config.selectedGameVersion.value,
loader,
loader_version: loaderVersion,
has_icon: !!iconPath,
source: 'CreationModal', source: 'CreationModal',
}) })
} catch (err) { } 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() { function handleBrowseModpacks() {
installationModal.value?.hide() installationModal.value?.hide()
router.push('/browse/modpack') router.push('/browse/modpack')
@@ -113,5 +165,8 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM
handleBrowseModpacks, handleBrowseModpacks,
searchModpacks, searchModpacks,
getProjectVersions, getProjectVersions,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
} }
} }

View File

@@ -75,6 +75,7 @@
"semver": "^7.5.4", "semver": "^7.5.4",
"three": "^0.172.0", "three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2", "vue-confetti-explosion": "^1.0.2",
"vue-router": "*",
"vue-typed-virtual-list": "^1.0.10", "vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.5.2", "vue3-apexcharts": "^1.5.2",

View File

@@ -35,8 +35,9 @@ pub struct ContentItem {
pub file_name: String, pub file_name: String,
/// Relative path to the file within the profile /// Relative path to the file within the profile
pub file_path: String, pub file_path: String,
/// SHA1 hash of the file /// Stable frontend identifier (SHA1 hash of file content, survives renames).
pub hash: String, /// Not a project or version ID.
pub id: String,
/// File size in bytes /// File size in bytes
pub size: u64, pub size: u64,
/// Whether the file is enabled (not .disabled) /// Whether the file is enabled (not .disabled)
@@ -542,7 +543,7 @@ async fn profile_files_to_content_items(
ContentItem { ContentItem {
file_name: file.file_name.clone(), file_name: file.file_name.clone(),
file_path: path.clone(), file_path: path.clone(),
hash: file.hash.clone(), id: file.hash.clone(),
size: file.size, size: file.size,
enabled: !file.file_name.ends_with(".disabled"), enabled: !file.file_name.ends_with(".disabled"),
project_type: file.project_type, project_type: file.project_type,
@@ -726,7 +727,7 @@ pub async fn dependencies_to_content_items(
) )
}), }),
file_path: String::new(), file_path: String::new(),
hash: String::new(), id: String::new(),
size: version size: version
.and_then(|v| v.files.first()) .and_then(|v| v.files.first())
.map(|f| f.size as u64) .map(|f| f.size as u64)

View File

@@ -19,6 +19,7 @@ import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component' import _ArrowUpIcon from './icons/arrow-up.svg?component'
import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component' import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.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 _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component' import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BadgeDollarSignIcon from './icons/badge-dollar-sign.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 ArrowUpIcon = _ArrowUpIcon
export const ArrowUpDownIcon = _ArrowUpDownIcon export const ArrowUpDownIcon = _ArrowUpDownIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon export const ArrowUpRightIcon = _ArrowUpRightIcon
export const ArrowUpZAIcon = _ArrowUpZAIcon
export const AsteriskIcon = _AsteriskIcon export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon export const BadgeCheckIcon = _BadgeCheckIcon
export const BadgeDollarSignIcon = _BadgeDollarSignIcon export const BadgeDollarSignIcon = _BadgeDollarSignIcon

View 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

View File

@@ -1,11 +1,48 @@
<script setup lang="ts"> <script setup lang="ts">
import { onUnmounted, watch } from 'vue' import { onUnmounted, ref, watch } from 'vue'
const props = defineProps<{ const props = defineProps<{
shown: boolean shown: boolean
ariaLabel?: string 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( watch(
() => props.shown, () => props.shown,
(shown) => { (shown) => {
@@ -15,6 +52,7 @@ watch(
) )
onUnmounted(() => { onUnmounted(() => {
observer?.disconnect()
document?.body.classList.remove('floating-action-bar-shown') document?.body.classList.remove('floating-action-bar-shown')
}) })
</script> </script>
@@ -27,9 +65,11 @@ onUnmounted(() => {
aria-live="polite" aria-live="polite"
> >
<div <div
ref="toolbarEl"
role="toolbar" role="toolbar"
:aria-label="ariaLabel" :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="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 /> <slot />
</div> </div>
@@ -81,4 +121,12 @@ onUnmounted(() => {
.intercom-lightweight-app-launcher { .intercom-lightweight-app-launcher {
z-index: 9 !important; z-index: 9 !important;
} }
.bar-compact .bar-label {
display: none;
}
.bar-compact .cq-show-icon {
display: block;
}
</style> </style>

View File

@@ -100,15 +100,17 @@
<InlineBackupCreator <InlineBackupCreator
v-if="ctx.flowType === 'reset-server'" v-if="ctx.flowType === 'reset-server'"
backup-name="Before reinstall" ref="backupCreator"
backup-name="Before reset server"
hide-shift-click-hint hide-shift-click-hint
@update:buttons-disabled="ctx.isBackingUp.value = $event"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets' 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' import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -125,6 +127,11 @@ import { capitalize } from '../shared'
const debug = useDebugLogger('FinalConfigStage') const debug = useDebugLogger('FinalConfigStage')
const ctx = injectCreationFlowContext() const ctx = injectCreationFlowContext()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator> | null>(null)
watch(backupCreator, (creator) => {
ctx.cancelBackup.value = creator?.cancelBackup ?? null
})
const { const {
worldName, worldName,
gamemode, gamemode,

View File

@@ -115,6 +115,10 @@ export interface CreationFlowContextValue {
// Loading state (set when finish() is called, cleared on reset) // Loading state (set when finish() is called, cleared on reset)
loading: Ref<boolean> loading: Ref<boolean>
// Backup state (set by InlineBackupCreator in reset-server flow)
isBackingUp: Ref<boolean>
cancelBackup: Ref<(() => void) | null>
// Modal // Modal
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null> modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<CreationFlowContextValue>[] stageConfigs: StageConfigInput<CreationFlowContextValue>[]
@@ -240,6 +244,8 @@ export function createCreationFlowContext(
const hardReset = ref(isInitialSetup) const hardReset = ref(isInitialSetup)
const loading = ref(false) 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) // hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows)
const hideLoaderChips = computed(() => setupType.value === 'vanilla') const hideLoaderChips = computed(() => setupType.value === 'vanilla')
@@ -292,6 +298,8 @@ export function createCreationFlowContext(
hardReset.value = isInitialSetup hardReset.value = isInitialSetup
loading.value = false loading.value = false
isBackingUp.value = false
cancelBackup.value = null
} }
function setSetupType(type: SetupType) { function setSetupType(type: SetupType) {
@@ -401,6 +409,8 @@ export function createCreationFlowContext(
importSearchQuery, importSearchQuery,
hardReset, hardReset,
loading, loading,
isBackingUp,
cancelBackup,
modal, modal,
stageConfigs: resolvedStageConfigs, stageConfigs: resolvedStageConfigs,
onBack, onBack,

View File

@@ -5,7 +5,7 @@
:context="ctx" :context="ctx"
:fade="fade" :fade="fade"
disable-progress disable-progress
@hide="$emit('hide')" @hide="handleHide"
/> />
</template> </template>
@@ -89,5 +89,10 @@ function hide() {
modal.value?.hide() modal.value?.hide()
} }
function handleHide() {
ctx.cancelBackup.value?.()
emit('hide')
}
defineExpose({ show, hide, ctx }) defineExpose({ show, hide, ctx })
</script> </script>

View File

@@ -44,7 +44,7 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
icon: isFinish ? PlusIcon : RightArrowIcon, icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const), iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined, color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
disabled: isForwardBlocked(ctx), disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value,
loading: isFinish && ctx.loading.value, loading: isFinish && ctx.loading.value,
onClick: () => { onClick: () => {
if (isFinish) { if (isFinish) {

View File

@@ -109,7 +109,7 @@ const description = computed(() => {
const messages = defineMessages({ const messages = defineMessages({
fallbackName: { fallbackName: {
id: 'servers.backups.admonition.fallback-name', id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'your backup', defaultMessage: 'Your backup',
}, },
backupQueuedTitle: { backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title', id: 'servers.backups.admonition.backup-queued.title',

View File

@@ -84,10 +84,10 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
// 1. Active WS entries (real-time progress from backupsState) // 1. Active WS entries (real-time progress from backupsState)
for (const [id, entry] of backupsState.entries()) { for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id) const backup = findBackup(id)
seenIds.add(id)
if (entry.create && entry.create.state === 'ongoing') { if (entry.create && entry.create.state === 'ongoing') {
const key = `${id}:create` const key = `${id}:create`
if (!dismissedIds.has(key)) { if (!dismissedIds.has(key)) {
seenIds.add(id)
result.push({ result.push({
key, key,
backupId: id, backupId: id,
@@ -102,7 +102,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
if (entry.restore && entry.restore.state === 'ongoing') { if (entry.restore && entry.restore.state === 'ongoing') {
const key = `${id}:restore` const key = `${id}:restore`
if (!dismissedIds.has(key)) { if (!dismissedIds.has(key)) {
seenIds.add(id)
result.push({ result.push({
key, key,
backupId: id, backupId: id,

View File

@@ -178,10 +178,10 @@ const calculateMenuPosition = () => {
top = Math.max(margin, window.innerHeight - menuHeight - margin) top = Math.max(margin, window.innerHeight - menuHeight - margin)
} }
if (triggerRect.left + menuWidth + margin <= window.innerWidth) { if (triggerRect.right - menuWidth >= margin) {
left = triggerRect.left
} else if (triggerRect.right - menuWidth - margin >= 0) {
left = triggerRect.right - menuWidth left = triggerRect.right - menuWidth
} else if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left
} else { } else {
left = Math.max(margin, window.innerWidth - menuWidth - margin) left = Math.max(margin, window.innerWidth - menuWidth - margin)
} }

View File

@@ -87,22 +87,23 @@ const deleteHovered = ref(false)
:class="{ 'opacity-50': disabled }" :class="{ 'opacity-50': disabled }"
> >
<div <div
class="flex min-w-0 items-center gap-4 transition-[filter,opacity] duration-200" class="flex min-w-0 items-center gap-4"
:class="[ :class="
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none', hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
enabled === false && !disabled ? 'grayscale opacity-50' : '', "
]"
> >
<Checkbox <Checkbox
v-if="showCheckbox" v-if="showCheckbox"
:model-value="selected ?? false" :model-value="selected ?? false"
:disabled="disabled"
:aria-label="`Select ${project.title}`" :aria-label="`Select ${project.title}`"
class="shrink-0" class="shrink-0"
@update:model-value="selected = $event" @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 <div
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined" v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
class="relative shrink-0" class="relative shrink-0"
@@ -256,7 +257,7 @@ const deleteHovered = ref(false)
:model-value="enabled" :model-value="enabled"
:disabled="disabled" :disabled="disabled"
:aria-label="project.title" :aria-label="project.title"
class="mr-2 my-auto" class="my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)" @update:model-value="(val) => emit('update:enabled', val as boolean)"
/> />

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { PowerIcon, PowerOffIcon } from '@modrinth/assets' import { PowerIcon, PowerOffIcon, XIcon } from '@modrinth/assets'
import { computed } from 'vue' import { computed } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -61,6 +61,14 @@ const messages = defineMessages({
id: 'content.selection-bar.bulk.deleting-waiting', id: 'content.selection-bar.bulk.deleting-waiting',
defaultMessage: 'Deleting {contentType}...', 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 { interface Props {
@@ -95,6 +103,7 @@ const emit = defineEmits<{
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating) const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled)) const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
const selectedCountText = computed(() => { const selectedCountText = computed(() => {
const count = props.selectedItems.length || props.bulkTotal 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" /> <div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent"> <ButtonStyled type="transparent">
<button <button
v-tooltip="formatMessage(commonMessages.clearButton)"
class="!text-primary" class="!text-primary"
:disabled="isBulkOperating" :disabled="isBulkOperating"
:class="{ 'opacity-60 pointer-events-none': isBulkOperating }" :class="{ 'opacity-60 pointer-events-none': isBulkOperating }"
@click="emit('clear')" @click="emit('clear')"
> >
{{ formatMessage(commonMessages.clearButton) }} <XIcon class="hidden cq-show-icon" />
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -148,16 +159,30 @@ const bulkProgressMessage = computed(() => {
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5"> <div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
<slot name="actions" /> <slot name="actions" />
<ButtonStyled v-if="allDisabled" type="transparent"> <ButtonStyled type="transparent">
<button :disabled="isBusy" @click="emit('enable')"> <button
v-tooltip="
allEnabled ? formatMessage(messages.allAlreadyEnabled) : formatMessage(messages.enable)
"
:disabled="isBusy || allEnabled"
@click="emit('enable')"
>
<PowerIcon /> <PowerIcon />
{{ formatMessage(messages.enable) }} <span class="bar-label">{{ formatMessage(messages.enable) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-else type="transparent"> <ButtonStyled type="transparent">
<button :disabled="isBusy" @click="emit('disable')"> <button
v-tooltip="
allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(messages.disable)
"
:disabled="isBusy || allDisabled"
@click="emit('disable')"
>
<PowerOffIcon /> <PowerOffIcon />
{{ formatMessage(messages.disable) }} <span class="bar-label">{{ formatMessage(messages.disable) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>

View File

@@ -12,7 +12,7 @@
</Admonition> </Admonition>
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
backup-name="Before bulk update" :backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>
@@ -73,6 +73,7 @@ const messages = defineMessages({
defineProps<{ defineProps<{
count: number count: number
server?: boolean server?: boolean
backupTip?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -15,7 +15,7 @@
</Admonition> </Admonition>
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
backup-name="Before deletion" :backup-name="backupTip ? `Before deletion (${backupTip})` : 'Before deletion'"
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>
@@ -78,9 +78,11 @@ withDefaults(
count: number count: number
itemType: string itemType: string
variant?: 'instance' | 'server' variant?: 'instance' | 'server'
backupTip?: string
}>(), }>(),
{ {
variant: 'instance', variant: 'instance',
backupTip: undefined,
}, },
) )

View File

@@ -17,7 +17,7 @@
</Admonition> </Admonition>
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
:backup-name="downgrade ? 'Before modpack downgrade' : 'Before modpack update'" :backup-name="backupName"
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>
@@ -45,7 +45,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets' import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue' import { computed, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue' import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.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' import InlineBackupCreator from './InlineBackupCreator.vue'
defineProps<{ const props = defineProps<{
downgrade?: boolean downgrade?: boolean
server?: boolean server?: boolean
backupTip?: string
}>() }>()
const { formatMessage } = useVIntl() 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({ const messages = defineMessages({
header: { header: {
id: 'content.confirm-modpack-update.header', id: 'content.confirm-modpack-update.header',

View File

@@ -12,7 +12,7 @@
</Admonition> </Admonition>
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
backup-name="Before reinstall" :backup-name="backupTip ? `Before reinstall (${backupTip})` : 'Before reinstall'"
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>
@@ -72,6 +72,7 @@ const messages = defineMessages({
defineProps<{ defineProps<{
server?: boolean server?: boolean
backupTip?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -12,7 +12,7 @@
</Admonition> </Admonition>
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
backup-name="Before unlink" :backup-name="backupTip ? `Before unlink (${backupTip})` : 'Before unlink'"
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>
@@ -50,6 +50,7 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
defineProps<{ defineProps<{
server?: boolean server?: boolean
backupTip?: string
}>() }>()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()

View File

@@ -3,19 +3,16 @@ import { computed, ref, watch } from 'vue'
import type { ContentItem } from '../types' import type { ContentItem } from '../types'
export function useContentSelection( export function useContentSelection(items: Ref<ContentItem[]>) {
items: Ref<ContentItem[]>,
getItemId: (item: ContentItem) => string,
) {
const selectedIds = ref<string[]>([]) const selectedIds = ref<string[]>([])
const selectedItems = computed(() => const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(getItemId(item))), items.value.filter((item) => selectedIds.value.includes(item.id)),
) )
watch(items, (newItems) => { watch(items, (newItems) => {
if (selectedIds.value.length === 0) return 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)) const pruned = selectedIds.value.filter((id) => validIds.has(id))
if (pruned.length !== selectedIds.value.length) { if (pruned.length !== selectedIds.value.length) {
selectedIds.value = pruned selectedIds.value = pruned

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
ArrowDownAZIcon, ArrowDownAZIcon,
ArrowDownZAIcon, ArrowUpZAIcon,
ClockArrowDownIcon, ClockArrowDownIcon,
ClockArrowUpIcon, ClockArrowUpIcon,
CodeIcon, CodeIcon,
@@ -171,8 +171,8 @@ const sortLabels: Record<SortMode, () => string> = {
function cycleSortMode() { function cycleSortMode() {
const modes: SortMode[] = [ const modes: SortMode[] = [
'alphabetical-asc', 'alphabetical-asc',
'date-added-newest',
'alphabetical-desc', 'alphabetical-desc',
'date-added-newest',
'date-added-oldest', 'date-added-oldest',
] ]
const idx = modes.indexOf(sortMode.value) const idx = modes.indexOf(sortMode.value)
@@ -235,7 +235,6 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection( const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
ctx.items, ctx.items,
ctx.getItemId,
) )
const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation() const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation()
@@ -292,7 +291,7 @@ const pendingDeletionItems = ref<ContentItem[]>([])
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>() const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
function handleDeleteById(id: string, event?: MouseEvent) { 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) { if (item) {
pendingDeletionItems.value = [item] pendingDeletionItems.value = [item]
if (event?.shiftKey) { if (event?.shiftKey) {
@@ -334,7 +333,7 @@ async function confirmDelete() {
if (itemsToDelete.length === 1) { if (itemsToDelete.length === 1) {
const item = itemsToDelete[0] const item = itemsToDelete[0]
const id = ctx.getItemId(item) const id = item.id
markChanging(id) markChanging(id)
await ctx.deleteItem(item) await ctx.deleteItem(item)
removeFromSelection(id) removeFromSelection(id)
@@ -347,14 +346,14 @@ async function confirmDelete() {
itemsToDelete, itemsToDelete,
async (item) => { async (item) => {
await ctx.deleteItem(item) await ctx.deleteItem(item)
removeFromSelection(ctx.getItemId(item)) removeFromSelection(item.id)
}, },
{ onComplete: clearSelection }, { onComplete: clearSelection },
) )
} }
async function handleToggleEnabledById(id: string, _value: boolean) { 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 if (!item) return
markChanging(id) markChanging(id)
try { try {
@@ -647,7 +646,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
" "
@click="cycleSortMode" @click="cycleSortMode"
> >
<ArrowDownZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon <ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'" v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon /><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'" v-else-if="sortMode === 'date-added-oldest'"
@@ -667,7 +666,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
" "
@click="cycleSortMode" @click="cycleSortMode"
> >
<ArrowDownZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon <ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'" v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon /><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'" v-else-if="sortMode === 'date-added-oldest'"
@@ -790,9 +789,13 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
color-fill="text" color-fill="text"
hover-color-fill="background" hover-color-fill="background"
> >
<button :disabled="ctx.isBusy.value" @click="promptUpdateSelected"> <button
v-tooltip="formatMessage(commonMessages.updateButton)"
:disabled="ctx.isBusy.value"
@click="promptUpdateSelected"
>
<DownloadIcon /> <DownloadIcon />
{{ formatMessage(commonMessages.updateButton) }} <span class="bar-label">{{ formatMessage(commonMessages.updateButton) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -818,7 +821,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
]" ]"
> >
<ShareIcon /> <ShareIcon />
{{ formatMessage(messages.share) }} <span class="bar-label">{{ formatMessage(messages.share) }}</span>
<DropdownIcon /> <DropdownIcon />
<template #share-names> <template #share-names>
<TextCursorInputIcon /> <TextCursorInputIcon />
@@ -849,9 +852,13 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
color-fill="text" color-fill="text"
hover-color-fill="background" hover-color-fill="background"
> >
<button :disabled="ctx.isBusy.value" @click="showBulkDeleteModal"> <button
v-tooltip="formatMessage(commonMessages.deleteLabel)"
:disabled="ctx.isBusy.value"
@click="showBulkDeleteModal"
>
<TrashIcon /> <TrashIcon />
{{ formatMessage(commonMessages.deleteLabel) }} <span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
@@ -862,6 +869,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:count="pendingDeletionItems.length" :count="pendingDeletionItems.length"
:item-type="ctx.contentTypeLabel.value" :item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'" :variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project.title).join(', ')"
@delete="confirmDelete" @delete="confirmDelete"
/> />
<ConfirmBulkUpdateModal <ConfirmBulkUpdateModal
@@ -875,6 +883,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
v-if="ctx.unlinkModpack" v-if="ctx.unlinkModpack"
ref="confirmUnlinkModal" ref="confirmUnlinkModal"
:server="ctx.deletionContext === 'server'" :server="ctx.deletionContext === 'server'"
:backup-tip="ctx.modpack.value?.project.title"
@unlink="ctx.unlinkModpack!()" @unlink="ctx.unlinkModpack!()"
/> />

View File

@@ -51,8 +51,7 @@ export interface ContentManagerContext {
disableAddContent?: Ref<boolean> | ComputedRef<boolean> disableAddContent?: Ref<boolean> | ComputedRef<boolean>
disableAddContentTooltip?: string disableAddContentTooltip?: string
// Identity & labelling // Labelling
getItemId: (item: ContentItem) => string
contentTypeLabel: Ref<string> | ComputedRef<string> contentTypeLabel: Ref<string> | ComputedRef<string>
// Core actions // Core actions

View File

@@ -44,9 +44,9 @@ export interface ContentItem extends Omit<
ContentCardTableItem, ContentCardTableItem,
'id' | 'projectLink' | 'disabled' | 'overflowOptions' 'id' | 'projectLink' | 'disabled' | 'overflowOptions'
> { > {
id: string
file_name: string file_name: string
file_path?: string file_path?: string
hash?: string
size?: number size?: number
project_type: string project_type: string
has_update: boolean has_update: boolean

View File

@@ -679,6 +679,9 @@ const messages = defineMessages({
ref="modpackUpdateModal" ref="modpackUpdateModal"
:downgrade="isUpdateDowngrade" :downgrade="isUpdateDowngrade"
:server="ctx.isServer" :server="ctx.isServer"
:backup-tip="
[ctx.modpack.value?.title, pendingUpdateVersion?.version_number].filter(Boolean).join(' ')
"
@confirm="handleModpackUpdateConfirm" @confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel" @cancel="handleModpackUpdateCancel"
/> />
@@ -686,9 +689,15 @@ const messages = defineMessages({
<ConfirmReinstallModal <ConfirmReinstallModal
ref="reinstallModal" ref="reinstallModal"
:server="ctx.isServer" :server="ctx.isServer"
:backup-tip="ctx.modpack.value?.title"
@reinstall="handleReinstall" @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 <ContentDiffModal
v-if="form.pendingPreview.value" v-if="form.pendingPreview.value"

View File

@@ -487,6 +487,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
link: `/${addon.owner.type}/${addon.owner.id}`, link: `/${addon.owner.type}/${addon.owner.id}`,
} }
: undefined, : undefined,
id: addon.id,
enabled: !addon.disabled, enabled: !addon.disabled,
file_name: addon.filename, file_name: addon.filename,
project_type: addon.kind, project_type: addon.kind,
@@ -604,8 +605,8 @@ async function handleBulkUpdate(items: ContentItem[]) {
} }
} }
async function handleUpdateItem(fileNameKey: string) { async function handleUpdateItem(id: string) {
const item = contentItems.value.find((i) => i.file_name === fileNameKey) const item = contentItems.value.find((i) => i.id === id)
if (!item?.has_update || !item.project?.id || !item.version?.id) return if (!item?.has_update || !item.project?.id || !item.version?.id) return
updatingModpack.value = false updatingModpack.value = false
@@ -872,7 +873,6 @@ provideContentManager({
}) })
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
}), }),
getItemId: (item) => item.file_path ?? item.file_name,
contentTypeLabel: type, contentTypeLabel: type,
toggleEnabled: handleToggleEnabled, toggleEnabled: handleToggleEnabled,
deleteItem: handleDeleteItem, deleteItem: handleDeleteItem,
@@ -898,7 +898,7 @@ provideContentManager({
mapToTableItem: (item) => { mapToTableItem: (item) => {
const projectType = item.project_type ?? type.value const projectType = item.project_type ?? type.value
return { return {
id: item.file_path ?? item.file_name, id: item.id,
project: item.project, project: item.project,
projectLink: item.project?.id ? `/${projectType}/${item.project.id}` : undefined, projectLink: item.project?.id ? `/${projectType}/${item.project.id}` : undefined,
version: item.version, version: item.version,
@@ -962,6 +962,11 @@ provideContentManager({
<ConfirmModpackUpdateModal <ConfirmModpackUpdateModal
ref="modpackUpdateModal" ref="modpackUpdateModal"
:downgrade="isModpackUpdateDowngrade" :downgrade="isModpackUpdateDowngrade"
:backup-tip="
[modpack?.project.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
server server
@confirm="handleModpackUpdateConfirm" @confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel" @cancel="handleModpackUpdateCancel"

View File

@@ -374,6 +374,12 @@
"content.page-layout.uploading-files": { "content.page-layout.uploading-files": {
"defaultMessage": "Uploading files ({completed}/{total})" "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": { "content.selection-bar.bulk.deleting": {
"defaultMessage": "Deleting {progress}/{total} {contentType}..." "defaultMessage": "Deleting {progress}/{total} {contentType}..."
}, },
@@ -2151,7 +2157,7 @@
"defaultMessage": "Creating backup" "defaultMessage": "Creating backup"
}, },
"servers.backups.admonition.fallback-name": { "servers.backups.admonition.fallback-name": {
"defaultMessage": "your backup" "defaultMessage": "Your backup"
}, },
"servers.backups.admonition.restore-failed.description": { "servers.backups.admonition.restore-failed.description": {
"defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues." "defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues."

13
pnpm-lock.yaml generated
View File

@@ -350,6 +350,9 @@ importers:
vue-confetti-explosion: vue-confetti-explosion:
specifier: ^1.0.2 specifier: ^1.0.2
version: 1.0.2(vue@3.5.27(typescript@5.9.3)) 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: vue-typed-virtual-list:
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10(vue@3.5.27(typescript@5.9.3)) version: 1.0.10(vue@3.5.27(typescript@5.9.3))
@@ -8689,6 +8692,7 @@ packages:
tar@7.5.7: tar@7.5.7:
resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==}
engines: {node: '>=18'} 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: terser@5.46.0:
resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==} resolution: {integrity: sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==}
@@ -8975,6 +8979,7 @@ packages:
unplugin-vue-router@0.19.2: unplugin-vue-router@0.19.2:
resolution: {integrity: sha512-u5dgLBarxE5cyDK/hzJGfpCTLIAyiTXGlo85COuD4Nssj6G7NxS+i9mhCWz/1p/ud1eMwdcUbTXehQe41jYZUA==} 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: peerDependencies:
'@vue/compiler-sfc': ^3.5.17 '@vue/compiler-sfc': ^3.5.17
vue-router: ^4.6.0 vue-router: ^4.6.0
@@ -9406,8 +9411,8 @@ packages:
vue-component-type-helpers@3.2.4: vue-component-type-helpers@3.2.4:
resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==}
vue-component-type-helpers@3.2.5: vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-tkvNr+bU8+xD/onAThIe7CHFvOJ/BO6XCOrxMzeytJq40nTfpGDJuVjyCM8ccGZKfAbGk2YfuZyDMXM56qheZQ==} resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
vue-confetti-explosion@1.0.2: vue-confetti-explosion@1.0.2:
resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==} 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) 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 type-fest: 2.19.0
vue: 3.5.27(typescript@5.9.3) 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': {} '@stripe/stripe-js@7.9.0': {}
@@ -19524,7 +19529,7 @@ snapshots:
vue-component-type-helpers@3.2.4: {} 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)): vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)):
dependencies: dependencies: