feat:移除了弹窗,服务器添加sls
This commit is contained in:
commit
6a295f9a7a
4082 files changed
+1322534
No files matched your search
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
signInBrowser: {
|
||||
id: 'app.auth.sign-in-browser',
|
||||
defaultMessage: 'Please sign in in the browser window that just opened to continue.',
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> {{ formatMessage(commonMessages.signInButton) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.signInBrowser) }}
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="standard" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.description, { count: instanceIds.length }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 mt-4">
|
||||
<RadioButtons v-model="selectedGroup" :items="groupOptions">
|
||||
<template #default="{ item }">
|
||||
<span class="flex items-center justify-between flex-1 leading-none">
|
||||
<span>{{ item || formatMessage(messages.noGroup) }}</span>
|
||||
<button
|
||||
v-if="item"
|
||||
class="bg-transparent border-none cursor-pointer text-secondary hover:text-red rounded flex items-center justify-center w-6 h-6"
|
||||
@click.stop="deleteGroup(item)"
|
||||
>
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<span v-else class="w-6 h-6" />
|
||||
</span>
|
||||
</template>
|
||||
</RadioButtons>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
v-model="newGroupInput"
|
||||
:placeholder="formatMessage(messages.enterGroupName)"
|
||||
class="w-full max-w-[300px]"
|
||||
@submit="addNewGroup"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button class="w-fit !shadow-none" @click="addNewGroup">
|
||||
<PlusIcon /> {{ formatMessage(messages.createGroup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="confirm">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.applyButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
RadioButtons,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { edit, list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'applied'): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instances.batch-edit-groups.header',
|
||||
defaultMessage: 'Edit groups',
|
||||
},
|
||||
description: {
|
||||
id: 'app.instances.batch-edit-groups.description',
|
||||
defaultMessage: 'Select groups to apply to {count} instance(s).',
|
||||
},
|
||||
enterGroupName: {
|
||||
id: 'app.instances.batch-edit-groups.enter-group-name',
|
||||
defaultMessage: 'Enter group name',
|
||||
},
|
||||
createGroup: {
|
||||
id: 'app.instances.batch-edit-groups.create-group',
|
||||
defaultMessage: 'Create new group',
|
||||
},
|
||||
applyButton: {
|
||||
id: 'app.instances.batch-edit-groups.apply',
|
||||
defaultMessage: 'Apply',
|
||||
},
|
||||
noGroup: {
|
||||
id: 'app.instances.group.ungrouped',
|
||||
defaultMessage: 'No group',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const selectedGroup = ref('')
|
||||
const newGroupInput = ref('')
|
||||
const allInstances = ref<GameInstance[]>([])
|
||||
|
||||
const availableGroups = computed(() => {
|
||||
const groups = new Set<string>()
|
||||
for (const instance of allInstances.value) {
|
||||
for (const group of instance.groups) {
|
||||
groups.add(group)
|
||||
}
|
||||
}
|
||||
return [...groups]
|
||||
})
|
||||
|
||||
const groupOptions = computed(() => ['', ...availableGroups.value])
|
||||
|
||||
function show() {
|
||||
selectedGroup.value = ''
|
||||
newGroupInput.value = ''
|
||||
list().then((instances) => {
|
||||
allInstances.value = instances as GameInstance[]
|
||||
})
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function addNewGroup() {
|
||||
const text = newGroupInput.value.trim()
|
||||
if (text.length > 0) {
|
||||
const groupName = text.substring(0, 32)
|
||||
allInstances.value.push({ groups: [groupName] } as GameInstance)
|
||||
selectedGroup.value = groupName
|
||||
newGroupInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(group: string) {
|
||||
for (const instance of allInstances.value) {
|
||||
if (instance.groups.includes(group)) {
|
||||
const newGroups = instance.groups.filter((g) => g !== group)
|
||||
await edit(instance.id, { groups: newGroups }).catch(() => {})
|
||||
instance.groups = newGroups
|
||||
}
|
||||
}
|
||||
if (selectedGroup.value === group) {
|
||||
selectedGroup.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (newGroupInput.value.trim().length > 0) {
|
||||
addNewGroup()
|
||||
}
|
||||
|
||||
modal.value?.hide()
|
||||
|
||||
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
|
||||
|
||||
for (const instanceId of props.instanceIds) {
|
||||
await edit(instanceId, { groups }).catch(() => {})
|
||||
}
|
||||
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(count > 1 ? messages.batchHeader : messages.header)"
|
||||
fade="danger"
|
||||
max-width="500px"
|
||||
>
|
||||
<Admonition
|
||||
v-if="!symlinkTarget && count <= 1"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="!symlinkTarget"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.batchAdmonitionBody, { count }) }}
|
||||
</Admonition>
|
||||
<Admonition v-else type="critical">
|
||||
{{ formatMessage(messages.symlinkDeleteWarning, { path: symlinkTarget }) }}
|
||||
</Admonition>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirm">
|
||||
<TrashIcon />
|
||||
{{
|
||||
formatMessage(count > 1 ? messages.batchDeleteButton : messages.deleteButton, {
|
||||
count,
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
symlinkTarget?: string | null
|
||||
count?: number
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
},
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.confirm-delete.header',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
batchHeader: {
|
||||
id: 'app.instance.confirm-delete.batch-header',
|
||||
defaultMessage: 'Delete instances',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'app.instance.confirm-delete.admonition-header',
|
||||
defaultMessage: 'This action cannot be undone',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'app.instance.confirm-delete.admonition-body',
|
||||
defaultMessage:
|
||||
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
|
||||
},
|
||||
batchAdmonitionBody: {
|
||||
id: 'app.instance.confirm-delete.batch-admonition-body',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# instance} other {# instances}} will be permanently deleted, including worlds, configs, and all installed content.',
|
||||
},
|
||||
symlinkDeleteWarning: {
|
||||
id: 'app.instance.confirm-delete.symlink-warning',
|
||||
defaultMessage:
|
||||
'This is a shared instance linked to "{path}". Only the link will be removed; the original files will not be deleted.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'app.instance.confirm-delete.delete-button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
batchDeleteButton: {
|
||||
id: 'app.instance.confirm-delete.batch-delete-button',
|
||||
defaultMessage: 'Delete {count, plural, one {# instance} other {# instances}}',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,78 @@
|
||||
<!-- @deprecated Use ConfirmModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
|
||||
<script setup lang="ts">
|
||||
import { ConfirmModal } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
defineProps({
|
||||
confirmationText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasToType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'No title defined',
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'No description defined',
|
||||
required: true,
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: undefined,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
default: 'Proceed',
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/** @deprecated No longer used — ads are handled by provideModalBehavior */
|
||||
showAdOnClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
markdown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['proceed'])
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
modal.value?.show()
|
||||
},
|
||||
hide: () => {
|
||||
modal.value?.hide()
|
||||
},
|
||||
})
|
||||
|
||||
function proceed() {
|
||||
emit('proceed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
:confirmation-text="confirmationText"
|
||||
:has-to-type="hasToType"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:proceed-icon="proceedIcon"
|
||||
:proceed-label="proceedLabel"
|
||||
:danger="danger"
|
||||
:markdown="markdown"
|
||||
@proceed="proceed"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
MinecraftFormattedText,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { autoCleanToText } from '@sfirew/minecraft-motd-parser'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface ContentToggleDependencyItem {
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
versionNumber?: string
|
||||
}
|
||||
|
||||
export interface ContentToggleDependenciesData {
|
||||
enabling: boolean
|
||||
bulk: boolean
|
||||
primaryTitle: string
|
||||
related: ContentToggleDependencyItem[]
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instance.mods.toggle-dependencies.title',
|
||||
defaultMessage: 'Confirm toggle',
|
||||
},
|
||||
singleBody: {
|
||||
id: 'app.instance.mods.toggle-dependencies.single-body',
|
||||
defaultMessage:
|
||||
'Toggling {project} will affect {count, plural, one {# other item} other {# other items}}.',
|
||||
},
|
||||
bulkBody: {
|
||||
id: 'app.instance.mods.toggle-dependencies.bulk-body',
|
||||
defaultMessage:
|
||||
'Toggling the selected content will affect {count, plural, one {# other item} other {# other items}}.',
|
||||
},
|
||||
willEnable: {
|
||||
id: 'app.instance.mods.toggle-dependencies.will-enable',
|
||||
defaultMessage: 'The following content will be enabled:',
|
||||
},
|
||||
willDisable: {
|
||||
id: 'app.instance.mods.toggle-dependencies.will-disable',
|
||||
defaultMessage: 'The following content will be disabled:',
|
||||
},
|
||||
warning: {
|
||||
id: 'app.instance.mods.toggle-dependencies.warning',
|
||||
defaultMessage:
|
||||
'Do you want to apply these related changes automatically? Ignoring them may break the game.',
|
||||
},
|
||||
apply: {
|
||||
id: 'app.instance.mods.toggle-dependencies.apply',
|
||||
defaultMessage: 'Toggle related content',
|
||||
},
|
||||
selectedOnly: {
|
||||
id: 'app.instance.mods.toggle-dependencies.selected-only',
|
||||
defaultMessage: 'Only toggle selected',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const data = ref<ContentToggleDependenciesData | null>(null)
|
||||
let settled = false
|
||||
let resolveShow: ((choice: 'apply' | 'selected' | 'cancel') => void) | null = null
|
||||
|
||||
function finish(choice: 'apply' | 'selected' | 'cancel') {
|
||||
if (settled) return
|
||||
settled = true
|
||||
const resolve = resolveShow
|
||||
resolveShow = null
|
||||
if (resolve) resolve(choice)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function show(value: ContentToggleDependenciesData): Promise<'apply' | 'selected' | 'cancel'> {
|
||||
data.value = value
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise((resolve) => {
|
||||
resolveShow = resolve
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
max-content-height="70vh"
|
||||
max-width="36rem"
|
||||
:on-hide="() => finish('cancel')"
|
||||
>
|
||||
<div v-if="data" class="flex flex-col gap-4">
|
||||
<p class="m-0 text-primary">
|
||||
<IntlFormatted
|
||||
:message-id="data.bulk ? messages.bulkBody : messages.singleBody"
|
||||
:values="{ count: data.related.length }"
|
||||
>
|
||||
<template #project>
|
||||
<MinecraftFormattedText :text="data.primaryTitle" />
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<div v-if="data.related.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(data.enabling ? messages.willEnable : messages.willDisable) }}
|
||||
</span>
|
||||
<div
|
||||
v-for="item in data.related"
|
||||
:key="item.title"
|
||||
class="flex items-center gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="item.iconUrl"
|
||||
:alt="autoCleanToText(item.title)"
|
||||
size="2.5rem"
|
||||
:tint-by="item.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<MinecraftFormattedText
|
||||
:text="item.title"
|
||||
class="truncate font-semibold text-contrast"
|
||||
/>
|
||||
<span v-if="item.versionNumber" class="truncate text-sm text-secondary">
|
||||
{{ item.versionNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.warning) }}</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="finish('cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="finish('apply')">{{ formatMessage(messages.apply) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="finish('selected')">{{ formatMessage(messages.selectedOnly) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,535 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:fade="remainingCount > 0 ? 'warning' : 'standard'"
|
||||
:on-hide="stopScanning"
|
||||
max-width="680px"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-secondary">
|
||||
{{
|
||||
installed == null
|
||||
? formatMessage(messages.existingBody, { manual: remainingCount })
|
||||
: formatMessage(messages.body, {
|
||||
installed,
|
||||
manual: remainingCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<Admonition
|
||||
:type="remainingCount === 0 ? 'success' : 'info'"
|
||||
:header="
|
||||
formatMessage(remainingCount === 0 ? messages.allImported : messages.automaticImport)
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<CheckIcon v-if="remainingCount === 0" class="size-5 shrink-0" aria-hidden="true" />
|
||||
<SpinnerIcon
|
||||
v-else-if="scanning"
|
||||
class="size-5 shrink-0 animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FolderSearchIcon v-else class="size-5 shrink-0" aria-hidden="true" />
|
||||
</template>
|
||||
<span class="min-w-0 break-words text-secondary">{{ scannerStatus }}</span>
|
||||
</Admonition>
|
||||
|
||||
<div class="max-h-72 overflow-y-auto rounded-lg border border-surface-5 bg-surface-2">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="itemKey(item)"
|
||||
class="flex min-h-16 items-center justify-between gap-3 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-4">
|
||||
<CheckIcon v-if="isImported(item)" class="size-5 text-green" aria-hidden="true" />
|
||||
<FolderSearchIcon v-else class="size-5 text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0 flex flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-contrast">{{ item.fileName }}</span>
|
||||
<span class="truncate text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(messages.projectFile, {
|
||||
projectId: item.projectId,
|
||||
fileId: item.fileId,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-sm" :class="isImported(item) ? 'text-green' : 'text-secondary'">
|
||||
{{ itemStatus(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isImported(item)" class="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="busyKeys.has(itemKey(item))" @click="openOne(item)">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.open) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="busyKeys.has(itemKey(item))" @click="chooseLocalFile(item)">
|
||||
<SpinnerIcon v-if="busyKeys.has(itemKey(item))" class="animate-spin" />
|
||||
<UploadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.chooseFile) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">
|
||||
{{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="instanceId" type="outlined">
|
||||
<button @click="goToInstance">
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="remainingCount > 0" color="orange">
|
||||
<button @click="openAll">
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.openAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ExternalIcon,
|
||||
FolderSearchIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
classifyCurseForgeManualDownloadImportError,
|
||||
type CurseForgeManualDownloadImport,
|
||||
type CurseForgeManualDownloadScanResult,
|
||||
importCurseForgeManualDownloads,
|
||||
importPendingCurseForgeManualDownloadFile,
|
||||
listPendingCurseForgeManualDownloads,
|
||||
} from '@/helpers/curseforge'
|
||||
import {
|
||||
type CurseForgeManualDownloadItem,
|
||||
getCurseForgeManualDownloadUrl,
|
||||
} from '@/helpers/curseforge-manual'
|
||||
import { getMissingContentScannerSettings } from '@/helpers/downloads-scanner'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { get_content_snapshot } from '@/helpers/instance'
|
||||
import { get_instance_worlds } from '@/helpers/worlds'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.curseforge.manual-downloads.header',
|
||||
defaultMessage: 'Complete CurseForge downloads',
|
||||
},
|
||||
body: {
|
||||
id: 'app.curseforge.manual-downloads.body',
|
||||
defaultMessage:
|
||||
'Installed {installed, number} files automatically. {manual, number} files require browser download.',
|
||||
},
|
||||
existingBody: {
|
||||
id: 'app.curseforge.manual-downloads.existing-body',
|
||||
defaultMessage:
|
||||
'{manual, number} files still require browser download. Downloaded files will be verified and imported automatically.',
|
||||
},
|
||||
projectFile: {
|
||||
id: 'app.curseforge.manual-downloads.project-file',
|
||||
defaultMessage: 'Project {projectId} / File {fileId}',
|
||||
},
|
||||
open: {
|
||||
id: 'app.curseforge.manual-downloads.open',
|
||||
defaultMessage: 'Download',
|
||||
},
|
||||
openAll: {
|
||||
id: 'app.curseforge.manual-downloads.open-all',
|
||||
defaultMessage: 'Open missing',
|
||||
},
|
||||
chooseFile: {
|
||||
id: 'app.curseforge.manual-downloads.choose-file',
|
||||
defaultMessage: 'Choose local file',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'app.curseforge.manual-downloads.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
automaticImport: {
|
||||
id: 'app.curseforge.manual-downloads.automatic-import',
|
||||
defaultMessage: 'Automatic import',
|
||||
},
|
||||
allImported: {
|
||||
id: 'app.curseforge.manual-downloads.all-imported',
|
||||
defaultMessage: 'All files imported',
|
||||
},
|
||||
checkingDownloads: {
|
||||
id: 'app.curseforge.manual-downloads.checking-downloads',
|
||||
defaultMessage: 'Checking the monitored import folder...',
|
||||
},
|
||||
watchingDownloads: {
|
||||
id: 'app.curseforge.manual-downloads.watching-downloads',
|
||||
defaultMessage: 'Watching {path}. Files are verified before import.',
|
||||
},
|
||||
downloadsUnavailable: {
|
||||
id: 'app.curseforge.manual-downloads.downloads-unavailable',
|
||||
defaultMessage: 'The monitored import folder is unavailable.',
|
||||
},
|
||||
automaticImportDisabled: {
|
||||
id: 'app.curseforge.manual-downloads.automatic-import-disabled',
|
||||
defaultMessage: 'Automatic import is disabled in Resource Management settings.',
|
||||
},
|
||||
scannerFailed: {
|
||||
id: 'app.curseforge.manual-downloads.scanner-failed',
|
||||
defaultMessage: 'Automatic import check failed; retrying.',
|
||||
},
|
||||
importComplete: {
|
||||
id: 'app.curseforge.manual-downloads.import-complete',
|
||||
defaultMessage: 'Imported into the instance',
|
||||
},
|
||||
waiting: {
|
||||
id: 'app.curseforge.manual-downloads.waiting',
|
||||
defaultMessage: 'Waiting for download',
|
||||
},
|
||||
retrying: {
|
||||
id: 'app.curseforge.manual-downloads.retrying',
|
||||
defaultMessage: 'File verification failed. Choose the required file.',
|
||||
},
|
||||
stateChanged: {
|
||||
id: 'app.curseforge.manual-downloads.state-changed',
|
||||
defaultMessage: 'Download state changed. Waiting for synchronization.',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'view-instance', instanceId: string): void
|
||||
(e: 'imported', instanceId: string, imports: CurseForgeManualDownloadImport[]): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const items = ref<CurseForgeManualDownloadItem[]>([])
|
||||
const candidateItems = ref<CurseForgeManualDownloadItem[]>([])
|
||||
const installed = ref<number | null>(null)
|
||||
const instanceId = ref<string | null>(null)
|
||||
const scanning = ref(false)
|
||||
const scanError = ref(false)
|
||||
const downloadDirectory = ref<string | null>(null)
|
||||
const scannerEnabled = ref(true)
|
||||
const scanDirectory = ref<string | null>(null)
|
||||
const importedKeys = ref(new Set<string>())
|
||||
const inconsistentKeys = ref(new Set<string>())
|
||||
const errorKeys = ref(new Set<string>())
|
||||
const busyKeys = ref(new Set<string>())
|
||||
let scannerActive = false
|
||||
let scanGeneration = 0
|
||||
let reconciliationGeneration = 0
|
||||
let scanInFlight: Promise<CurseForgeManualDownloadScanResult> | undefined
|
||||
let scanInterval: ReturnType<typeof setInterval> | null = null
|
||||
let unlistenInstances: (() => void) | null = null
|
||||
|
||||
const remainingCount = computed(
|
||||
() => items.value.filter((item) => !importedKeys.value.has(itemKey(item))).length,
|
||||
)
|
||||
const scannerStatus = computed(() => {
|
||||
if (remainingCount.value === 0) return formatMessage(messages.importComplete)
|
||||
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
|
||||
if (scanError.value) return formatMessage(messages.scannerFailed)
|
||||
if (downloadDirectory.value) {
|
||||
return formatMessage(messages.watchingDownloads, { path: downloadDirectory.value })
|
||||
}
|
||||
if (scanning.value) return formatMessage(messages.checkingDownloads)
|
||||
return formatMessage(messages.downloadsUnavailable)
|
||||
})
|
||||
|
||||
function itemKey(item: Pick<CurseForgeManualDownloadItem, 'projectId' | 'fileId'>) {
|
||||
return `${item.projectId}:${item.fileId}`
|
||||
}
|
||||
|
||||
function isImported(item: CurseForgeManualDownloadItem) {
|
||||
return importedKeys.value.has(itemKey(item))
|
||||
}
|
||||
|
||||
function itemStatus(item: CurseForgeManualDownloadItem) {
|
||||
if (isImported(item)) return formatMessage(messages.importComplete)
|
||||
if (errorKeys.value.has(itemKey(item))) return formatMessage(messages.retrying)
|
||||
if (inconsistentKeys.value.has(itemKey(item))) return formatMessage(messages.stateChanged)
|
||||
return formatMessage(messages.waiting)
|
||||
}
|
||||
|
||||
function show(payload: {
|
||||
items: CurseForgeManualDownloadItem[]
|
||||
installed?: number
|
||||
instanceId?: string | null
|
||||
}) {
|
||||
stopScanning()
|
||||
scannerActive = true
|
||||
const seededItems = [...new Map(payload.items.map((item) => [itemKey(item), item])).values()]
|
||||
candidateItems.value = seededItems
|
||||
items.value = seededItems
|
||||
installed.value = payload.installed ?? null
|
||||
instanceId.value = payload.instanceId ?? null
|
||||
downloadDirectory.value = null
|
||||
scanError.value = false
|
||||
importedKeys.value = new Set()
|
||||
inconsistentKeys.value = new Set()
|
||||
errorKeys.value = new Set()
|
||||
busyKeys.value = new Set()
|
||||
const scannerSettings = getMissingContentScannerSettings()
|
||||
scannerEnabled.value = scannerSettings.enabled
|
||||
scanDirectory.value = scannerSettings.directory
|
||||
modal.value?.show()
|
||||
void reconcileManualDownloadState().catch(handleError)
|
||||
if (scannerEnabled.value) {
|
||||
void scanDownloads()
|
||||
scanInterval = setInterval(() => {
|
||||
if (scannerActive) void scanDownloads()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileManualDownloadState() {
|
||||
const currentInstanceId = instanceId.value
|
||||
if (!scannerActive || !currentInstanceId) return
|
||||
const generation = ++reconciliationGeneration
|
||||
const hasWorldCandidates = candidateItems.value.some((item) => item.projectType === 'world')
|
||||
const [pending, snapshot, worlds] = await Promise.all([
|
||||
listPendingCurseForgeManualDownloads(currentInstanceId),
|
||||
get_content_snapshot(currentInstanceId),
|
||||
hasWorldCandidates ? get_instance_worlds(currentInstanceId) : Promise.resolve([]),
|
||||
])
|
||||
if (
|
||||
!scannerActive ||
|
||||
currentInstanceId !== instanceId.value ||
|
||||
generation !== reconciliationGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingByKey = new Map(pending.map((item) => [itemKey(item), item]))
|
||||
const candidateByKey = new Map(candidateItems.value.map((item) => [itemKey(item), item]))
|
||||
for (const [key, item] of pendingByKey) candidateByKey.set(key, item)
|
||||
const nextCandidates = [...candidateByKey.values()]
|
||||
const nextItems = nextCandidates.map((item) => pendingByKey.get(itemKey(item)) ?? item)
|
||||
const materializedByKey = new Map<string, string>()
|
||||
for (const item of snapshot.items) {
|
||||
if (
|
||||
item.provider === 'curseforge' &&
|
||||
item.providerProjectId != null &&
|
||||
item.providerReleaseId != null &&
|
||||
item.materializationState === 'present' &&
|
||||
item.content != null
|
||||
) {
|
||||
materializedByKey.set(
|
||||
`${item.providerProjectId}:${item.providerReleaseId}`,
|
||||
item.expectedRelativePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const nextImported = new Set<string>()
|
||||
const nextInconsistent = new Set<string>()
|
||||
const newlyImported: CurseForgeManualDownloadImport[] = []
|
||||
for (const item of nextItems) {
|
||||
const key = itemKey(item)
|
||||
if (pendingByKey.has(key)) continue
|
||||
if (item.projectType === 'world') {
|
||||
const worldName = item.fileName.replace(/\.zip$/i, '')
|
||||
const importedWorld = worlds.some(
|
||||
(world) => world.type === 'singleplayer' && world.path === worldName,
|
||||
)
|
||||
if (importedWorld) {
|
||||
nextImported.add(key)
|
||||
if (!importedKeys.value.has(key)) {
|
||||
newlyImported.push({
|
||||
projectId: item.projectId,
|
||||
fileId: item.fileId,
|
||||
relativePath: `saves/${worldName}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
const relativePath = materializedByKey.get(key)
|
||||
if (relativePath == null) {
|
||||
nextInconsistent.add(key)
|
||||
continue
|
||||
}
|
||||
nextImported.add(key)
|
||||
if (!importedKeys.value.has(key)) {
|
||||
newlyImported.push({ projectId: item.projectId, fileId: item.fileId, relativePath })
|
||||
}
|
||||
}
|
||||
|
||||
candidateItems.value = nextCandidates
|
||||
items.value = nextItems
|
||||
importedKeys.value = nextImported
|
||||
inconsistentKeys.value = nextInconsistent
|
||||
errorKeys.value = new Set([...errorKeys.value].filter((key) => pendingByKey.has(key)))
|
||||
if (newlyImported.length > 0) emit('imported', currentInstanceId, newlyImported)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
scannerActive = false
|
||||
scanGeneration += 1
|
||||
reconciliationGeneration += 1
|
||||
if (scanInterval != null) {
|
||||
clearInterval(scanInterval)
|
||||
scanInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async function scanDownloads(): Promise<void> {
|
||||
if (!scannerEnabled.value) return
|
||||
if (scanInFlight) {
|
||||
await scanInFlight.catch(() => undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const currentInstanceId = instanceId.value
|
||||
if (!currentInstanceId) return
|
||||
const generation = scanGeneration
|
||||
if (remainingCount.value === 0) return
|
||||
|
||||
scanning.value = true
|
||||
const operation = importCurseForgeManualDownloads(currentInstanceId, scanDirectory.value)
|
||||
scanInFlight = operation
|
||||
try {
|
||||
const result = await operation
|
||||
if (generation !== scanGeneration) return
|
||||
scanError.value = false
|
||||
downloadDirectory.value = result.downloadDirectory ?? null
|
||||
errorKeys.value = new Set(result.errors.map((item) => `${item.projectId}:${item.fileId}`))
|
||||
await reconcileManualDownloadState().catch(handleError)
|
||||
} catch {
|
||||
if (generation !== scanGeneration) return
|
||||
scanError.value = true
|
||||
errorKeys.value = new Set(
|
||||
items.value
|
||||
.filter((item) => !isImported(item) && !inconsistentKeys.value.has(itemKey(item)))
|
||||
.map((item) => itemKey(item)),
|
||||
)
|
||||
} finally {
|
||||
if (scanInFlight === operation) scanInFlight = undefined
|
||||
if (generation === scanGeneration) {
|
||||
scanning.value = false
|
||||
} else if (!scanInFlight) {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openOne(item: CurseForgeManualDownloadItem) {
|
||||
await openUrl(getCurseForgeManualDownloadUrl(item))
|
||||
}
|
||||
|
||||
async function chooseLocalFile(item: CurseForgeManualDownloadItem) {
|
||||
const currentInstanceId = instanceId.value
|
||||
const key = itemKey(item)
|
||||
if (!currentInstanceId || busyKeys.value.has(key)) return
|
||||
const selected = await open({ multiple: false })
|
||||
const sourcePath = typeof selected === 'string' ? selected : null
|
||||
if (!sourcePath) return
|
||||
busyKeys.value = new Set(busyKeys.value).add(key)
|
||||
try {
|
||||
await importPendingCurseForgeManualDownloadFile(
|
||||
currentInstanceId,
|
||||
item.projectId,
|
||||
item.fileId,
|
||||
sourcePath,
|
||||
)
|
||||
await reconcileManualDownloadState().catch(handleError)
|
||||
} catch (error) {
|
||||
const errorKind = classifyCurseForgeManualDownloadImportError(error)
|
||||
if (errorKind === 'verification_failed') {
|
||||
const nextErrors = new Set(errorKeys.value)
|
||||
nextErrors.add(key)
|
||||
errorKeys.value = nextErrors
|
||||
const nextInconsistent = new Set(inconsistentKeys.value)
|
||||
nextInconsistent.delete(key)
|
||||
inconsistentKeys.value = nextInconsistent
|
||||
} else if (errorKind === 'not_pending') {
|
||||
const nextErrors = new Set(errorKeys.value)
|
||||
nextErrors.delete(key)
|
||||
errorKeys.value = nextErrors
|
||||
await reconcileManualDownloadState().catch(handleError)
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
const nextBusy = new Set(busyKeys.value)
|
||||
nextBusy.delete(key)
|
||||
busyKeys.value = nextBusy
|
||||
}
|
||||
}
|
||||
|
||||
async function openAll() {
|
||||
for (const item of items.value) {
|
||||
if (isImported(item)) continue
|
||||
await openOne(item)
|
||||
}
|
||||
}
|
||||
|
||||
function goToInstance() {
|
||||
if (!instanceId.value) return
|
||||
hide()
|
||||
emit('view-instance', instanceId.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void instance_listener(async (event: { event: string; instance_id: string }) => {
|
||||
if (
|
||||
event.event === 'content_changed' &&
|
||||
event.instance_id === instanceId.value &&
|
||||
scannerActive
|
||||
) {
|
||||
await reconcileManualDownloadState().catch(() => {
|
||||
scanError.value = true
|
||||
})
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenInstances = unlisten
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScanning()
|
||||
unlistenInstances?.()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
|
||||
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
|
||||
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
|
||||
{{ formatMessage(messages.serverRequiresMods) }}
|
||||
</Admonition>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.requiredModpack)
|
||||
}}</span>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="openViewContents">
|
||||
<EyeIcon />
|
||||
{{ formatMessage(messages.viewContents) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
|
||||
<Avatar
|
||||
:src="requiredContentProject.icon_url"
|
||||
:alt="requiredContentProject.title"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
<template v-if="usingCustomModpack && modpackVersion">
|
||||
{{ modpackVersion.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ requiredContentProject.title }}
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
|
||||
<template v-if="modCount">
|
||||
· {{ formatMessage(messages.modCount, { count: modCount }) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDecline">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleAccept">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="project?.name ?? ''"
|
||||
:modpack-icon-url="project?.icon_url ?? undefined"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoader,
|
||||
ModpackContentModal,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const project = ref<Labrinth.Projects.v3.Project | null>(null)
|
||||
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
|
||||
const onInstallComplete = ref<() => void>(() => {})
|
||||
const { formatMessage } = useVIntl()
|
||||
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
|
||||
const usingCustomModpack = computed(() => {
|
||||
return requiredContentProject.value?.id === project.value?.id
|
||||
})
|
||||
|
||||
const loaderDisplay = computed(() => {
|
||||
const loader = requiredContentProject.value?.loaders?.[0]
|
||||
if (!loader) return ''
|
||||
return formatLoader(formatMessage, loader)
|
||||
})
|
||||
|
||||
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
|
||||
|
||||
async function fetchData(versionId: string) {
|
||||
// cache is making version null for some reason so bypassing for now
|
||||
modpackVersion.value = await get_version(versionId, 'bypass')
|
||||
|
||||
if (modpackVersion.value?.project_id) {
|
||||
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
hide()
|
||||
const serverProjectId = project.value?.id
|
||||
startInstallingServer(serverProjectId)
|
||||
try {
|
||||
await installServerProject(serverProjectId)
|
||||
onInstallComplete.value()
|
||||
} catch (error) {
|
||||
console.error('Failed to install server project from InstallToPlayModal:', error)
|
||||
} finally {
|
||||
stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
|
||||
|
||||
async function openViewContents() {
|
||||
modpackContentModal.value?.showLoading()
|
||||
try {
|
||||
// Ensure version data is available — the useQuery may not have resolved yet
|
||||
const versionId = modpackVersionId.value
|
||||
const version =
|
||||
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
|
||||
|
||||
const deps = version?.dependencies ?? []
|
||||
|
||||
const projectIds = deps
|
||||
.map((d: { project_id?: string }) => d.project_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const versionIds = deps
|
||||
.map((d: { version_id?: string }) => d.version_id)
|
||||
.filter((id: string | undefined): id is string => !!id)
|
||||
|
||||
const projects: Labrinth.Projects.v2.Project[] =
|
||||
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
|
||||
|
||||
const versions: Labrinth.Versions.v2.Version[] =
|
||||
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
|
||||
|
||||
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
|
||||
|
||||
const contentItems: ContentItem[] = deps.map(
|
||||
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
|
||||
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
|
||||
// @ts-expect-error - version_id is missing from the type for some reason
|
||||
const depVersion = dep.version_id
|
||||
? // @ts-expect-error - version_id is missing from the type for some reason
|
||||
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
|
||||
: null
|
||||
|
||||
return {
|
||||
id: dep.file_name ?? dep.project_id ?? 'unknown',
|
||||
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
|
||||
project_type: depProject?.project_type ?? 'mod',
|
||||
update: null,
|
||||
origin_provider: null,
|
||||
provider_refs: [],
|
||||
enabled: true,
|
||||
project: {
|
||||
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
|
||||
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
|
||||
title: depProject?.title ?? dep.file_name ?? 'Unknown',
|
||||
icon_url: depProject?.icon_url ?? undefined,
|
||||
},
|
||||
...(depVersion
|
||||
? {
|
||||
version: {
|
||||
id: depVersion.id,
|
||||
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
|
||||
version_number: depVersion.version_number ?? undefined,
|
||||
date_published: depVersion.date_published ?? undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
modpackContentModal.value?.show(contentItems)
|
||||
} catch (err) {
|
||||
console.error('Failed to load modpack contents:', err)
|
||||
modpackContentModal.value?.show([])
|
||||
}
|
||||
}
|
||||
|
||||
async function show(
|
||||
projectVal: Labrinth.Projects.v3.Project,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: () => void = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
project.value = projectVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
modpackVersion.value = null
|
||||
requiredContentProject.value = null
|
||||
onInstallComplete.value = callback
|
||||
|
||||
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
|
||||
|
||||
modal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
installToPlay: {
|
||||
id: 'app.modal.install-to-play.header',
|
||||
defaultMessage: 'Install to play',
|
||||
},
|
||||
sharedServerInstance: {
|
||||
id: 'app.modal.install-to-play.shared-server-instance',
|
||||
defaultMessage: 'Shared server instance',
|
||||
},
|
||||
contentRequired: {
|
||||
id: 'app.modal.install-to-play.content-required',
|
||||
defaultMessage: 'Content required',
|
||||
},
|
||||
serverRequiresMods: {
|
||||
id: 'app.modal.install-to-play.server-requires-mods',
|
||||
defaultMessage:
|
||||
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
|
||||
},
|
||||
requiredModpack: {
|
||||
id: 'app.modal.install-to-play.required-modpack',
|
||||
defaultMessage: 'Required modpack',
|
||||
},
|
||||
sharedInstance: {
|
||||
id: 'app.modal.install-to-play.shared-instance',
|
||||
defaultMessage: 'Shared instance',
|
||||
},
|
||||
modCount: {
|
||||
id: 'app.modal.install-to-play.mod-count',
|
||||
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
|
||||
},
|
||||
installButton: {
|
||||
id: 'app.modal.install-to-play.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
viewContents: {
|
||||
id: 'app.modal.install-to-play.view-contents',
|
||||
defaultMessage: 'View contents',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:on-hide="handleHide"
|
||||
width="min(760px, calc(100vw - 2rem))"
|
||||
max-width="760px"
|
||||
scrollable
|
||||
>
|
||||
<div class="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div
|
||||
class="flex aspect-square w-44 items-center justify-center overflow-hidden rounded-3xl shadow-lg"
|
||||
:style="selectedBackground.style"
|
||||
>
|
||||
<img :src="selectedIcon.url" alt="" class="h-[72%] w-[72%] object-contain" />
|
||||
</div>
|
||||
<p class="m-0 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="saving" @click="surpriseMe">
|
||||
<RefreshCwIcon />
|
||||
{{ formatMessage(messages.surpriseMe) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<section class="flex flex-col gap-2.5">
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.background) }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="background in backgrounds"
|
||||
:key="background.id"
|
||||
type="button"
|
||||
class="h-10 w-10 cursor-pointer rounded-xl border-2 border-solid transition-transform hover:scale-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
|
||||
:class="
|
||||
background.id === selectedBackgroundId
|
||||
? 'border-contrast shadow-md'
|
||||
: 'border-transparent'
|
||||
"
|
||||
:style="background.style"
|
||||
:aria-label="formatMessage(background.name)"
|
||||
:aria-pressed="background.id === selectedBackgroundId"
|
||||
:disabled="saving"
|
||||
@click="selectedBackgroundId = background.id"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-for="group in iconGroups" :key="group.id" class="flex min-w-0 flex-col gap-2.5">
|
||||
<h2 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(group.name) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-4 gap-2 sm:grid-cols-6">
|
||||
<button
|
||||
v-for="icon in group.icons"
|
||||
:key="icon.id"
|
||||
type="button"
|
||||
class="group flex min-w-0 cursor-pointer flex-col items-center gap-1.5 rounded-xl border border-solid bg-surface-2 p-2 text-secondary transition-colors hover:border-brand hover:bg-brand-highlight hover:text-contrast focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
|
||||
:class="
|
||||
icon.id === selectedIconId
|
||||
? 'border-brand bg-brand-highlight text-contrast'
|
||||
: 'border-surface-5'
|
||||
"
|
||||
:aria-label="formatMessage(icon.name)"
|
||||
:aria-pressed="icon.id === selectedIconId"
|
||||
:disabled="saving"
|
||||
@click="selectedIconId = icon.id"
|
||||
>
|
||||
<img :src="icon.url" alt="" class="aspect-square w-full object-contain" />
|
||||
<span class="w-full truncate text-center text-xs font-semibold">
|
||||
{{ formatMessage(icon.name) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="saving" @click="selectUploadedIcon">
|
||||
<UploadIcon />
|
||||
{{ formatMessage(messages.upload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="saving" @click="saveGeneratedIcon">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(saving ? messages.saving : messages.useIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RefreshCwIcon, SaveIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
type MessageDescriptor,
|
||||
NewModal,
|
||||
type PickedFile,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { cache_icon } from '@/helpers/instance'
|
||||
import { builtInInstanceIcons, modrinth3DInstanceIcons } from '@/helpers/instance-icons'
|
||||
import { pickImage } from '@/providers/setup/file-picker'
|
||||
|
||||
interface IconBackground {
|
||||
id: string
|
||||
name: MessageDescriptor
|
||||
colors: [string, string] | null
|
||||
style: Record<string, string>
|
||||
}
|
||||
|
||||
function background(
|
||||
id: string,
|
||||
name: MessageDescriptor,
|
||||
top: string,
|
||||
bottom: string,
|
||||
): IconBackground {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colors: [top, bottom],
|
||||
style: { backgroundImage: `linear-gradient(145deg, ${top}, ${bottom})` },
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundNames = defineMessages({
|
||||
transparent: {
|
||||
id: 'app.instance.icon-picker.background.transparent',
|
||||
defaultMessage: 'Transparent',
|
||||
},
|
||||
grass: { id: 'app.instance.icon-picker.background.grass', defaultMessage: 'Grass' },
|
||||
ocean: { id: 'app.instance.icon-picker.background.ocean', defaultMessage: 'Ocean' },
|
||||
amethyst: { id: 'app.instance.icon-picker.background.amethyst', defaultMessage: 'Amethyst' },
|
||||
sunset: { id: 'app.instance.icon-picker.background.sunset', defaultMessage: 'Sunset' },
|
||||
cherry: { id: 'app.instance.icon-picker.background.cherry', defaultMessage: 'Cherry' },
|
||||
nether: { id: 'app.instance.icon-picker.background.nether', defaultMessage: 'Nether' },
|
||||
slime: { id: 'app.instance.icon-picker.background.slime', defaultMessage: 'Slime' },
|
||||
deepDark: { id: 'app.instance.icon-picker.background.deep-dark', defaultMessage: 'Deep Dark' },
|
||||
stone: { id: 'app.instance.icon-picker.background.stone', defaultMessage: 'Stone' },
|
||||
midnight: { id: 'app.instance.icon-picker.background.midnight', defaultMessage: 'Midnight' },
|
||||
})
|
||||
|
||||
const backgrounds = [
|
||||
{
|
||||
id: 'transparent',
|
||||
name: backgroundNames.transparent,
|
||||
colors: null,
|
||||
style: {
|
||||
backgroundColor: '#ffffff',
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, #d7d9de 25%, transparent 25%), linear-gradient(-45deg, #d7d9de 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d7d9de 75%), linear-gradient(-45deg, transparent 75%, #d7d9de 75%)',
|
||||
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
|
||||
backgroundSize: '16px 16px',
|
||||
},
|
||||
} satisfies IconBackground,
|
||||
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
|
||||
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
|
||||
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
|
||||
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
|
||||
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
|
||||
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
|
||||
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
|
||||
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
|
||||
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
|
||||
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
|
||||
]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const iconOptions = [...builtInInstanceIcons, ...modrinth3DInstanceIcons]
|
||||
const iconGroups = [
|
||||
{
|
||||
id: 'original',
|
||||
name: defineMessage({
|
||||
id: 'app.instance.icon-picker.group.original',
|
||||
defaultMessage: 'Original icons',
|
||||
}),
|
||||
icons: builtInInstanceIcons,
|
||||
},
|
||||
{
|
||||
id: 'modrinth-3d',
|
||||
name: defineMessage({
|
||||
id: 'app.instance.icon-picker.group.modrinth-3d',
|
||||
defaultMessage: 'Modrinth 3D icons',
|
||||
}),
|
||||
icons: modrinth3DInstanceIcons,
|
||||
},
|
||||
]
|
||||
|
||||
const selectedBackgroundId = ref(backgrounds[0].id)
|
||||
const selectedIconId = ref(builtInInstanceIcons[0].id)
|
||||
const saving = ref(false)
|
||||
let resolveSelection: ((selection: PickedFile | null) => void) | null = null
|
||||
|
||||
const selectedBackground = computed(
|
||||
() => backgrounds.find((item) => item.id === selectedBackgroundId.value) ?? backgrounds[0],
|
||||
)
|
||||
const selectedIcon = computed(
|
||||
() => iconOptions.find((icon) => icon.id === selectedIconId.value) ?? builtInInstanceIcons[0],
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.instance.icon-picker.title',
|
||||
defaultMessage: 'Create an instance icon',
|
||||
},
|
||||
description: {
|
||||
id: 'app.instance.icon-picker.description',
|
||||
defaultMessage: 'Combine a background and a Minecraft element, or upload your own image.',
|
||||
},
|
||||
background: {
|
||||
id: 'app.instance.icon-picker.background',
|
||||
defaultMessage: 'Background',
|
||||
},
|
||||
surpriseMe: {
|
||||
id: 'app.instance.icon-picker.surprise-me',
|
||||
defaultMessage: 'Surprise me',
|
||||
},
|
||||
upload: {
|
||||
id: 'app.instance.icon-picker.upload',
|
||||
defaultMessage: 'Upload image',
|
||||
},
|
||||
useIcon: {
|
||||
id: 'app.instance.icon-picker.use-icon',
|
||||
defaultMessage: 'Use this icon',
|
||||
},
|
||||
saving: {
|
||||
id: 'app.instance.icon-picker.saving',
|
||||
defaultMessage: 'Saving...',
|
||||
},
|
||||
loadError: {
|
||||
id: 'app.instance.icon-picker.load-error',
|
||||
defaultMessage: 'Failed to load the bundled icon.',
|
||||
},
|
||||
})
|
||||
|
||||
function finish(selection: PickedFile | null) {
|
||||
const resolve = resolveSelection
|
||||
resolveSelection = null
|
||||
modal.value?.hide()
|
||||
resolve?.(selection)
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
saving.value = false
|
||||
if (resolveSelection) {
|
||||
resolveSelection(null)
|
||||
resolveSelection = null
|
||||
}
|
||||
}
|
||||
|
||||
function surpriseMe() {
|
||||
if (backgrounds.length > 1) {
|
||||
const candidates = backgrounds.filter((item) => item.id !== selectedBackgroundId.value)
|
||||
selectedBackgroundId.value = candidates[Math.floor(Math.random() * candidates.length)].id
|
||||
}
|
||||
if (iconOptions.length > 1) {
|
||||
const candidates = iconOptions.filter((icon) => icon.id !== selectedIconId.value)
|
||||
selectedIconId.value = candidates[Math.floor(Math.random() * candidates.length)].id
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error(formatMessage(messages.loadError)))
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error(formatMessage(messages.loadError)))
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async function renderGeneratedIcon(): Promise<Blob> {
|
||||
if (!selectedBackground.value.colors) {
|
||||
const response = await fetch(selectedIcon.value.url)
|
||||
if (!response.ok) throw new Error(formatMessage(messages.loadError))
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
const size = 512
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error(formatMessage(messages.loadError))
|
||||
|
||||
if (selectedBackground.value.colors) {
|
||||
const gradient = context.createLinearGradient(0, 0, size, size)
|
||||
gradient.addColorStop(0, selectedBackground.value.colors[0])
|
||||
gradient.addColorStop(1, selectedBackground.value.colors[1])
|
||||
context.fillStyle = gradient
|
||||
context.fillRect(0, 0, size, size)
|
||||
}
|
||||
|
||||
const symbol = await loadImage(selectedIcon.value.url)
|
||||
const maxSymbolSize = size * 0.72
|
||||
const scale = Math.min(maxSymbolSize / symbol.naturalWidth, maxSymbolSize / symbol.naturalHeight)
|
||||
const width = symbol.naturalWidth * scale
|
||||
const height = symbol.naturalHeight * scale
|
||||
context.drawImage(symbol, (size - width) / 2, (size - height) / 2, width, height)
|
||||
|
||||
return await canvasToBlob(canvas)
|
||||
}
|
||||
|
||||
async function saveGeneratedIcon() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const blob = await renderGeneratedIcon()
|
||||
const fileName = `generated-${selectedBackgroundId.value}-${selectedIconId.value}.png`
|
||||
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()))
|
||||
const path = await cache_icon(fileName, bytes)
|
||||
finish({
|
||||
file: new File([blob], fileName, { type: 'image/png' }),
|
||||
path,
|
||||
previewUrl: convertFileSrc(path),
|
||||
frameless: selectedBackgroundId.value === 'transparent',
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectUploadedIcon() {
|
||||
try {
|
||||
const selection = await pickImage()
|
||||
if (selection) finish(selection)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function show(): Promise<PickedFile | null> {
|
||||
resolveSelection?.(null)
|
||||
const modalInstance = modal.value
|
||||
if (!modalInstance) return Promise.resolve(null)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveSelection = resolve
|
||||
modalInstance.show()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
size="24px"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CodeIcon,
|
||||
CoffeeIcon,
|
||||
FileArchiveIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessage,
|
||||
TabbedModal,
|
||||
type TabbedModalTab,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { get_project_v3 } from '@/helpers/cache'
|
||||
import { get_linked_modpack_info } from '@/helpers/instance'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
offline?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
unlinked: []
|
||||
}>()
|
||||
|
||||
const isMinecraftServer = ref(false)
|
||||
const handleUnlinked = () => emit('unlinked')
|
||||
let serverMetadataGeneration = 0
|
||||
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
tabbedModal.value?.hide()
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
instance: instanceRef,
|
||||
offline: props.offline,
|
||||
isMinecraftServer,
|
||||
onUnlinked: handleUnlinked,
|
||||
closeModal: hide,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance,
|
||||
(instance) => {
|
||||
const generation = ++serverMetadataGeneration
|
||||
isMinecraftServer.value = false
|
||||
if (instance.install_stage === 'installed' && instance.link?.project_id) {
|
||||
const instanceId = instance.id
|
||||
get_project_v3(instance.link.project_id, 'must_revalidate')
|
||||
.then((project: Labrinth.Projects.v3.Project | undefined) => {
|
||||
if (
|
||||
generation === serverMetadataGeneration &&
|
||||
props.instance.id === instanceId &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
project?.minecraft_server != null
|
||||
) {
|
||||
isMinecraftServer.value = true
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const tabs = computed<TabbedModalTab[]>(() => [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.general',
|
||||
defaultMessage: 'General',
|
||||
}),
|
||||
icon: InfoIcon,
|
||||
content: GeneralSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.installation',
|
||||
defaultMessage: 'Installation',
|
||||
}),
|
||||
icon: WrenchIcon,
|
||||
content: InstallationSettings,
|
||||
},
|
||||
{
|
||||
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.core-components',
|
||||
defaultMessage: 'Core components',
|
||||
}),
|
||||
icon: FileArchiveIcon,
|
||||
content: CoreComponentsSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.window',
|
||||
defaultMessage: 'Window',
|
||||
}),
|
||||
icon: MonitorIcon,
|
||||
content: WindowSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.java',
|
||||
defaultMessage: 'Java and memory',
|
||||
}),
|
||||
icon: CoffeeIcon,
|
||||
content: JavaSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.hooks',
|
||||
defaultMessage: 'Launch preparation',
|
||||
}),
|
||||
icon: CodeIcon,
|
||||
content: HooksSettings,
|
||||
},
|
||||
])
|
||||
|
||||
function getSupportedModpackLoaders() {
|
||||
return get_loaders().then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
}
|
||||
|
||||
// Preload metadata that is not scoped to an editable Minecraft version.
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: get_game_versions,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loaders', 'modpack'],
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
enabled: computed(
|
||||
() =>
|
||||
props.instance.install_stage === 'installed' &&
|
||||
!!props.instance.link?.project_id &&
|
||||
!props.offline,
|
||||
),
|
||||
})
|
||||
|
||||
function show(tabIndex?: number) {
|
||||
if (props.instance.install_stage === 'installed' && props.instance.link?.project_id) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['linkedModpackInfo', props.instance.id],
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
})
|
||||
}
|
||||
tabbedModal.value?.show()
|
||||
if (tabIndex !== undefined) {
|
||||
nextTick(() => tabbedModal.value?.setTab(tabIndex))
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="tabbedModal"
|
||||
:tabs="tabs"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="props.instance.id"
|
||||
:loader="instance.loader"
|
||||
size="24px"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-contrast">{{
|
||||
formatMessage(commonMessages.settingsLabel)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title, { version })"
|
||||
:on-hide="handleHide"
|
||||
:disable-close="responding"
|
||||
fade="warning"
|
||||
max-width="520px"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="m-0 leading-relaxed text-contrast">
|
||||
{{ formatMessage(messages.body, { version }) }}
|
||||
</p>
|
||||
<p class="m-0 leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.laterDescription, { version }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="responding" @click="modal?.hide()">
|
||||
<ClockIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.setUpLater) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" :disabled="responding" @click="confirmDownload">
|
||||
<SpinnerIcon v-if="responding" class="animate-spin" aria-hidden="true" />
|
||||
<DownloadIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.download) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon, DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { onUnmounted, ref, useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { respond_to_java_download_confirmation } from '@/helpers/jre'
|
||||
|
||||
interface JavaDownloadConfirmationRequest {
|
||||
requestId: string
|
||||
version: number
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.java-download-confirmation.title',
|
||||
defaultMessage: 'Download Java {version}?',
|
||||
},
|
||||
body: {
|
||||
id: 'app.java-download-confirmation.body',
|
||||
defaultMessage:
|
||||
'No compatible Java {version} installation was found. You can download it now or postpone Java setup while the remaining game resources continue downloading.',
|
||||
},
|
||||
laterDescription: {
|
||||
id: 'app.java-download-confirmation.later-description',
|
||||
defaultMessage:
|
||||
'The instance will finish installing, but Java {version} must be configured before you can play.',
|
||||
},
|
||||
setUpLater: {
|
||||
id: 'app.java-download-confirmation.set-up-later',
|
||||
defaultMessage: 'Set up later',
|
||||
},
|
||||
download: {
|
||||
id: 'app.java-download-confirmation.download',
|
||||
defaultMessage: 'Download Java',
|
||||
},
|
||||
postponedTitle: {
|
||||
id: 'app.java-download-confirmation.postponed-title',
|
||||
defaultMessage: 'Java setup postponed',
|
||||
},
|
||||
postponedBody: {
|
||||
id: 'app.java-download-confirmation.postponed-body',
|
||||
defaultMessage:
|
||||
'The instance will finish installing. Configure Java {version} before launching it.',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const request = ref<JavaDownloadConfirmationRequest | null>(null)
|
||||
const version = ref(0)
|
||||
const responding = ref(false)
|
||||
let decisionSent = false
|
||||
|
||||
function show(payload: JavaDownloadConfirmationRequest) {
|
||||
request.value = payload
|
||||
version.value = payload.version
|
||||
responding.value = false
|
||||
decisionSent = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
const pendingRequest = request.value
|
||||
request.value = null
|
||||
responding.value = false
|
||||
|
||||
if (!pendingRequest || decisionSent) return
|
||||
|
||||
decisionSent = true
|
||||
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
|
||||
addNotification({
|
||||
title: formatMessage(messages.postponedTitle),
|
||||
text: formatMessage(messages.postponedBody, { version: pendingRequest.version }),
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmDownload() {
|
||||
const pendingRequest = request.value
|
||||
if (!pendingRequest || responding.value) return
|
||||
|
||||
responding.value = true
|
||||
decisionSent = true
|
||||
const response = respond_to_java_download_confirmation(pendingRequest.requestId, true)
|
||||
modal.value?.hide()
|
||||
await router.push('/downloads')
|
||||
await response
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
const pendingRequest = request.value
|
||||
if (!pendingRequest || decisionSent) return
|
||||
|
||||
decisionSent = true
|
||||
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,602 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:fade="remaining > 0 ? 'warning' : 'standard'"
|
||||
:on-hide="stopScanning"
|
||||
max-width="760px"
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition
|
||||
:type="continuing || remaining === 0 ? 'success' : 'warning'"
|
||||
:header="
|
||||
continuing
|
||||
? formatMessage(messages.continuing)
|
||||
: formatMessage(messages.remaining, { count: remaining })
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<SpinnerIcon v-if="loading || continuing" class="size-5 animate-spin" />
|
||||
<CheckIcon v-else-if="remaining === 0" class="size-5 text-green" />
|
||||
<DownloadIcon v-else class="size-5" />
|
||||
</template>
|
||||
{{ continuing ? formatMessage(messages.continuingBody) : formatMessage(messages.body) }}
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-if="!continuing && remaining > 0"
|
||||
:type="scannerPresentation.phase === 'rejected' ? 'warning' : 'info'"
|
||||
:header="scannerHeader"
|
||||
>
|
||||
<template #icon>
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
scannerPresentation.phase === 'importing' ||
|
||||
scannerPresentation.phase === 'verifying' ||
|
||||
scannerPresentation.phase === 'waiting_for_stability'
|
||||
"
|
||||
class="size-5 animate-spin"
|
||||
/>
|
||||
<FolderSearchIcon v-else class="size-5" />
|
||||
</template>
|
||||
{{ scannerStatus }}
|
||||
</Admonition>
|
||||
|
||||
<div
|
||||
v-if="files.length"
|
||||
class="flex flex-col overflow-hidden rounded-lg border border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-for="file in files"
|
||||
:key="file.itemId"
|
||||
class="flex flex-col gap-3 border-0 border-b border-solid border-surface-5 bg-surface-2 p-4 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="break-all font-medium text-contrast">{{ file.path }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-sm text-secondary">
|
||||
<span>{{
|
||||
formatMessage(messages.expectedSize, { size: formatBytes(file.expectedSize) })
|
||||
}}</span>
|
||||
<span>·</span>
|
||||
<span>{{ attemptText(file) }}</span>
|
||||
<span v-if="file.browserUrls.length > 1">·</span>
|
||||
<span v-if="file.browserUrls.length > 1">
|
||||
{{ formatMessage(messages.fallbacks, { count: file.browserUrls.length - 1 }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="file.browserUrls[0]" class="mt-1 truncate text-xs text-secondary">
|
||||
<code v-tooltip="file.browserUrls[0]">{{ file.browserUrls[0] }}</code>
|
||||
</div>
|
||||
<div v-if="file.lastError" class="mt-1 text-sm text-red">
|
||||
{{ file.lastError }}
|
||||
</div>
|
||||
<div v-if="scannerItemStatus(file.itemId)" class="mt-1 text-sm text-orange">
|
||||
{{ scannerItemStatus(file.itemId) }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge :color="statusColor(file.status)" :type="statusLabel(file.status)" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="brand" size="small">
|
||||
<button :disabled="isBusy(file.itemId)" @click="retryOne(file.itemId)">
|
||||
<SpinnerIcon v-if="isBusy(file.itemId)" class="animate-spin" />
|
||||
<RefreshCwIcon v-else />
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="file.browserUrls.length" type="outlined" size="small">
|
||||
<button :disabled="isBusy(file.itemId)" @click="openBrowser(file.browserUrls[0])">
|
||||
<ExternalIcon />{{ formatMessage(messages.browserDownload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button :disabled="isBusy(file.itemId)" @click="selectLocal(file.itemId)">
|
||||
<UploadIcon />{{ formatMessage(messages.chooseFile) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="remaining > 0" color="brand">
|
||||
<button :disabled="loading || busy.size > 0" @click="retryAll">
|
||||
<RefreshCwIcon />{{ formatMessage(messages.retryAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
FolderSearchIcon,
|
||||
RefreshCwIcon,
|
||||
SpinnerIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useFormatBytes,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
createDownloadsScanLoop,
|
||||
createDownloadsScannerPresentationState,
|
||||
getMissingContentScannerSettings,
|
||||
reduceDownloadsScannerPresentation,
|
||||
} from '@/helpers/downloads-scanner'
|
||||
import { install_job_listener } from '@/helpers/events'
|
||||
import {
|
||||
install_job_get,
|
||||
install_job_import_missing_file,
|
||||
install_job_missing_files,
|
||||
install_job_resume,
|
||||
install_job_retry_missing_file,
|
||||
install_job_scan_missing_files,
|
||||
type InstallJobSnapshot,
|
||||
type MissingModpackContentView,
|
||||
} from '@/helpers/install'
|
||||
|
||||
type MissingFile = MissingModpackContentView['files'][number]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const jobId = ref<string | null>(null)
|
||||
const content = ref<MissingModpackContentView>({ remaining: 0, files: [] })
|
||||
const loading = ref(false)
|
||||
const continuing = ref(false)
|
||||
const busy = ref(new Set<string>())
|
||||
const scannerPresentation = ref(createDownloadsScannerPresentationState())
|
||||
const scannerErrors = ref(new Map<string, string>())
|
||||
const scannerEnabled = ref(true)
|
||||
const scanDirectory = ref<string | null>(null)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
const files = computed(() => content.value.files)
|
||||
const remaining = computed(() => content.value.remaining)
|
||||
const scannerHeader = computed(() => {
|
||||
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
|
||||
if (scannerPresentation.value.phase === 'rejected') {
|
||||
return formatMessage(messages.fileMismatchTitle)
|
||||
}
|
||||
if (scannerPresentation.value.phase === 'verifying') {
|
||||
return formatMessage(messages.verifyingCandidate)
|
||||
}
|
||||
if (scannerPresentation.value.phase === 'importing') {
|
||||
return formatMessage(messages.importingCandidate)
|
||||
}
|
||||
if (
|
||||
scannerPresentation.value.phase === 'monitoring' ||
|
||||
scannerPresentation.value.phase === 'idle'
|
||||
) {
|
||||
return formatMessage(messages.watchingDownloadsTitle)
|
||||
}
|
||||
return formatMessage(messages.automaticImport)
|
||||
})
|
||||
const scannerStatus = computed(() => {
|
||||
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabledBody)
|
||||
const state = scannerPresentation.value
|
||||
if (state.phase === 'importing') return formatMessage(messages.importingCandidateBody)
|
||||
if (state.phase === 'verifying') return formatMessage(messages.verifyingCandidateBody)
|
||||
if (state.phase === 'waiting_for_stability') return formatMessage(messages.waitingForCompletion)
|
||||
if (state.phase === 'rejected') return formatMessage(messages.fileMismatchBody)
|
||||
if (state.phase === 'imported') {
|
||||
return formatMessage(messages.importedAutomatically, {
|
||||
count: state.importedCount,
|
||||
remaining: remaining.value,
|
||||
})
|
||||
}
|
||||
if (state.phase === 'error') return formatMessage(messages.scannerFailed)
|
||||
if (state.phase === 'monitoring' && state.downloadDirectory) {
|
||||
return formatMessage(messages.watchingDirectory, { path: state.downloadDirectory })
|
||||
}
|
||||
if (state.phase === 'idle') {
|
||||
return formatMessage(messages.watchingDownloadsBody)
|
||||
}
|
||||
return formatMessage(messages.downloadsUnavailable)
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'app.downloads.missing-content.header', defaultMessage: 'Complete missing files' },
|
||||
body: {
|
||||
id: 'app.downloads.missing-content.body',
|
||||
defaultMessage: 'Each local file is verified before it can replace the required instance file.',
|
||||
},
|
||||
remaining: {
|
||||
id: 'app.downloads.missing-content.remaining',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# file still needs to be completed} other {# files still need to be completed}}',
|
||||
},
|
||||
continuing: {
|
||||
id: 'app.downloads.missing-content.continuing',
|
||||
defaultMessage: 'Continuing installation',
|
||||
},
|
||||
continuingBody: {
|
||||
id: 'app.downloads.missing-content.continuing-body',
|
||||
defaultMessage:
|
||||
'All required files are ready. The launcher is verifying them again and continuing installation.',
|
||||
},
|
||||
expectedSize: {
|
||||
id: 'app.downloads.missing-content.expected-size',
|
||||
defaultMessage: 'Expected size: {size}',
|
||||
},
|
||||
fallbacks: {
|
||||
id: 'app.downloads.missing-content.fallbacks',
|
||||
defaultMessage: '{count} fallback links',
|
||||
},
|
||||
retry: { id: 'app.downloads.missing-content.retry', defaultMessage: 'Retry download' },
|
||||
retryAll: {
|
||||
id: 'app.downloads.missing-content.retry-all',
|
||||
defaultMessage: 'Retry all missing files',
|
||||
},
|
||||
browserDownload: {
|
||||
id: 'app.downloads.missing-content.browser-download',
|
||||
defaultMessage: 'Browser download',
|
||||
},
|
||||
chooseFile: {
|
||||
id: 'app.downloads.missing-content.choose-file',
|
||||
defaultMessage: 'Choose local file',
|
||||
},
|
||||
attempts: {
|
||||
id: 'app.downloads.missing-content.attempts',
|
||||
defaultMessage: 'Attempt {attempt}/{max}',
|
||||
},
|
||||
noAttempts: {
|
||||
id: 'app.downloads.missing-content.no-attempts',
|
||||
defaultMessage: 'No attempt information',
|
||||
},
|
||||
automaticImport: {
|
||||
id: 'app.downloads.missing-content.automatic-import',
|
||||
defaultMessage: 'Automatic import from monitored folder',
|
||||
},
|
||||
automaticImportDisabled: {
|
||||
id: 'app.downloads.missing-content.automatic-import-disabled',
|
||||
defaultMessage: 'Automatic import is disabled',
|
||||
},
|
||||
automaticImportDisabledBody: {
|
||||
id: 'app.downloads.missing-content.automatic-import-disabled-body',
|
||||
defaultMessage: 'Retry downloading or choose each missing file manually.',
|
||||
},
|
||||
verifyingCandidate: {
|
||||
id: 'app.downloads.missing-content.verifying-candidate',
|
||||
defaultMessage: 'Verifying downloaded file',
|
||||
},
|
||||
verifyingCandidateBody: {
|
||||
id: 'app.downloads.missing-content.verifying-candidate-body',
|
||||
defaultMessage: 'Checking that the candidate matches the file required by the modpack.',
|
||||
},
|
||||
importingCandidate: {
|
||||
id: 'app.downloads.missing-content.importing-candidate',
|
||||
defaultMessage: 'Importing verified file',
|
||||
},
|
||||
importingCandidateBody: {
|
||||
id: 'app.downloads.missing-content.importing-candidate-body',
|
||||
defaultMessage: 'Adding the verified file to the instance...',
|
||||
},
|
||||
watchingDownloadsTitle: {
|
||||
id: 'app.downloads.missing-content.watching-downloads-title',
|
||||
defaultMessage: 'Watching the import folder',
|
||||
},
|
||||
watchingDownloadsBody: {
|
||||
id: 'app.downloads.missing-content.watching-downloads-body',
|
||||
defaultMessage: 'Completed downloads will be verified and imported automatically.',
|
||||
},
|
||||
watchingDirectory: {
|
||||
id: 'app.downloads.missing-content.watching-downloads',
|
||||
defaultMessage: 'Watching {path}. Matching files are verified before import.',
|
||||
},
|
||||
waitingForCompletion: {
|
||||
id: 'app.downloads.missing-content.waiting-for-completion',
|
||||
defaultMessage: 'Waiting for a browser download to finish writing...',
|
||||
},
|
||||
downloadsUnavailable: {
|
||||
id: 'app.downloads.missing-content.downloads-unavailable',
|
||||
defaultMessage: 'The monitored folder is unavailable. Choose a local file instead.',
|
||||
},
|
||||
scannerFailed: {
|
||||
id: 'app.downloads.missing-content.scanner-failed',
|
||||
defaultMessage: 'Automatic checking failed. Manual file selection is still available.',
|
||||
},
|
||||
fileMismatch: {
|
||||
id: 'app.downloads.missing-content.file-mismatch',
|
||||
defaultMessage: 'A similarly named file was found, but it did not match this required file.',
|
||||
},
|
||||
fileMismatchTitle: {
|
||||
id: 'app.downloads.missing-content.file-mismatch-title',
|
||||
defaultMessage: 'A same-named file was found, but file verification failed',
|
||||
},
|
||||
fileMismatchBody: {
|
||||
id: 'app.downloads.missing-content.file-mismatch-body',
|
||||
defaultMessage:
|
||||
'The launcher will keep waiting for the correct file. You can also choose a local file manually.',
|
||||
},
|
||||
importedAutomatically: {
|
||||
id: 'app.downloads.missing-content.imported-automatically',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# file imported automatically} other {# files imported automatically}}. {remaining, plural, one {# file still needs to be completed} other {# files still need to be completed}}.',
|
||||
},
|
||||
})
|
||||
|
||||
const statusMessages = defineMessages({
|
||||
failed: { id: 'app.downloads.item-status.failed', defaultMessage: 'Failed' },
|
||||
worker_started: { id: 'app.downloads.item-status.worker-started', defaultMessage: 'Starting' },
|
||||
connecting: { id: 'app.downloads.item-status.connecting', defaultMessage: 'Connecting' },
|
||||
verifying: { id: 'app.downloads.item-status.verifying', defaultMessage: 'Verifying' },
|
||||
writing: { id: 'app.downloads.item-status.writing', defaultMessage: 'Writing' },
|
||||
finalizing: { id: 'app.downloads.item-status.finalizing', defaultMessage: 'Finalizing' },
|
||||
metadata: { id: 'app.downloads.item-status.metadata', defaultMessage: 'Loading metadata' },
|
||||
waiting_for_database: {
|
||||
id: 'app.downloads.item-status.waiting-for-database',
|
||||
defaultMessage: 'Waiting for database',
|
||||
},
|
||||
completed: { id: 'app.downloads.item-status.completed', defaultMessage: 'Completed' },
|
||||
downloading: { id: 'app.downloads.item-status.downloading', defaultMessage: 'Downloading' },
|
||||
waiting_for_resource: {
|
||||
id: 'app.downloads.item-status.waiting-for-resource',
|
||||
defaultMessage: 'Waiting for download resources',
|
||||
},
|
||||
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
|
||||
})
|
||||
|
||||
const scanner = createDownloadsScanLoop({
|
||||
scan: async () => {
|
||||
if (!jobId.value) throw new Error('Missing install job ID')
|
||||
return await install_job_scan_missing_files(jobId.value, scanDirectory.value)
|
||||
},
|
||||
onResult: (result) => {
|
||||
content.value = result.content
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'scan_result',
|
||||
downloadDirectory: result.downloadDirectory ?? null,
|
||||
importedItemIds: result.importedItemIds,
|
||||
rejectedItemIds: result.rejectedItemIds,
|
||||
pendingCandidates: result.pendingCandidates,
|
||||
hasErrors: result.errors.length > 0,
|
||||
items: result.content.files.map((file) => ({ id: file.itemId, status: file.status })),
|
||||
})
|
||||
scannerErrors.value = new Map(result.errors.map((error) => [error.itemId, error.message]))
|
||||
for (const itemId of result.importedItemIds) {
|
||||
scannerErrors.value.delete(itemId)
|
||||
}
|
||||
if (result.job.status !== 'waiting_for_user') {
|
||||
continuing.value = true
|
||||
stopScanning()
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'scan_failed',
|
||||
})
|
||||
},
|
||||
intervalMs: 3000,
|
||||
})
|
||||
|
||||
async function show(job: InstallJobSnapshot) {
|
||||
stopScanning()
|
||||
const scannerSettings = getMissingContentScannerSettings()
|
||||
scannerEnabled.value = scannerSettings.enabled
|
||||
scanDirectory.value = scannerSettings.directory
|
||||
jobId.value = job.job_id
|
||||
continuing.value = false
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'reset',
|
||||
})
|
||||
scannerErrors.value = new Map()
|
||||
content.value = {
|
||||
remaining: job.items.filter((item) => item.status === 'failed').length,
|
||||
files: [],
|
||||
}
|
||||
modal.value?.show()
|
||||
await refresh()
|
||||
if (!unlisten) {
|
||||
unlisten = await install_job_listener((update: InstallJobSnapshot) => {
|
||||
if (update.job_id !== jobId.value) return
|
||||
if (update.status === 'waiting_for_user') {
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'items_updated',
|
||||
items: update.items,
|
||||
})
|
||||
void refresh()
|
||||
} else if (update.status === 'queued' || update.status === 'running') {
|
||||
continuing.value = true
|
||||
stopScanning()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (scannerEnabled.value) scanner.start()
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
scanner.stop()
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'reset',
|
||||
})
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!jobId.value || continuing.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const nextContent = await install_job_missing_files(jobId.value)
|
||||
content.value = nextContent
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'items_updated',
|
||||
items: nextContent.files.map((file) => ({ id: file.itemId, status: file.status })),
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runItem(itemId: string, action: () => Promise<InstallJobSnapshot>) {
|
||||
busy.value = new Set([...busy.value, itemId])
|
||||
try {
|
||||
const job = await action()
|
||||
await applyItemResult(itemId, job)
|
||||
} catch (error) {
|
||||
const latest = jobId.value ? await install_job_get(jobId.value).catch(() => null) : null
|
||||
if (
|
||||
latest &&
|
||||
(isContinuingStatus(latest.status) ||
|
||||
latest.items.some(
|
||||
(item) => item.id === itemId && ['completed', 'skipped'].includes(item.status),
|
||||
))
|
||||
) {
|
||||
await applyItemResult(itemId, latest)
|
||||
} else {
|
||||
handleError(error)
|
||||
await refresh()
|
||||
}
|
||||
} finally {
|
||||
const next = new Set(busy.value)
|
||||
next.delete(itemId)
|
||||
busy.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function applyItemResult(itemId: string, job: InstallJobSnapshot) {
|
||||
if (
|
||||
job.status !== 'waiting_for_user' ||
|
||||
job.items.some((item) => item.id === itemId && ['completed', 'skipped'].includes(item.status))
|
||||
) {
|
||||
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
|
||||
type: 'items_resolved',
|
||||
itemIds: [itemId],
|
||||
})
|
||||
scannerErrors.value.delete(itemId)
|
||||
}
|
||||
if (job.status === 'waiting_for_user') await refresh()
|
||||
else if (isContinuingStatus(job.status)) {
|
||||
continuing.value = true
|
||||
stopScanning()
|
||||
}
|
||||
}
|
||||
|
||||
function isContinuingStatus(status: InstallJobSnapshot['status']) {
|
||||
return status === 'queued' || status === 'running' || status === 'succeeded'
|
||||
}
|
||||
|
||||
async function retryOne(itemId: string) {
|
||||
if (!jobId.value) return
|
||||
await runItem(itemId, () => install_job_retry_missing_file(jobId.value!, itemId))
|
||||
}
|
||||
|
||||
async function selectLocal(itemId: string) {
|
||||
if (!jobId.value) return
|
||||
const selected = await open({ multiple: false })
|
||||
const path = selectedPath(selected)
|
||||
if (!path) return
|
||||
await runItem(itemId, () => install_job_import_missing_file(jobId.value!, itemId, path))
|
||||
}
|
||||
|
||||
function selectedPath(selected: unknown) {
|
||||
if (typeof selected === 'string') return selected
|
||||
if (
|
||||
selected &&
|
||||
typeof selected === 'object' &&
|
||||
'path' in selected &&
|
||||
typeof selected.path === 'string'
|
||||
) {
|
||||
return selected.path
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function retryAll() {
|
||||
if (!jobId.value) return
|
||||
loading.value = true
|
||||
stopScanning()
|
||||
try {
|
||||
await install_job_resume(jobId.value)
|
||||
continuing.value = true
|
||||
} catch (error) {
|
||||
const latest = await install_job_get(jobId.value).catch(() => null)
|
||||
if (latest && isContinuingStatus(latest.status)) {
|
||||
continuing.value = true
|
||||
} else {
|
||||
handleError(error)
|
||||
if (latest?.status === 'waiting_for_user' && scannerEnabled.value) scanner.start()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openBrowser(url: string) {
|
||||
try {
|
||||
await openUrl(url)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function isBusy(itemId: string) {
|
||||
return busy.value.has(itemId)
|
||||
}
|
||||
|
||||
function scannerItemStatus(itemId: string) {
|
||||
if (scannerErrors.value.has(itemId)) return scannerErrors.value.get(itemId)
|
||||
if (scannerPresentation.value.rejectedItemIds.includes(itemId)) {
|
||||
return formatMessage(messages.fileMismatch)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function attemptText(file: MissingFile) {
|
||||
if (file.attempt == null || file.maxAttempts == null) return formatMessage(messages.noAttempts)
|
||||
return formatMessage(messages.attempts, { attempt: file.attempt, max: file.maxAttempts })
|
||||
}
|
||||
|
||||
function statusLabel(status: MissingFile['status']) {
|
||||
return status in statusMessages
|
||||
? formatMessage(statusMessages[status as keyof typeof statusMessages])
|
||||
: status
|
||||
}
|
||||
|
||||
function statusColor(status: MissingFile['status']): 'green' | 'red' | 'orange' | 'blue' | 'gray' {
|
||||
if (status === 'completed') return 'green'
|
||||
if (status === 'failed') return 'red'
|
||||
if (
|
||||
status === 'verifying' ||
|
||||
status === 'writing' ||
|
||||
status === 'finalizing' ||
|
||||
status === 'metadata' ||
|
||||
status === 'waiting_for_database'
|
||||
)
|
||||
return 'orange'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScanning()
|
||||
unlisten?.()
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!-- @deprecated Use NewModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
|
||||
<script setup lang="ts">
|
||||
import { NewModal as Modal } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hideHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onHide: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
/** @deprecated No longer used — ads are handled by provideModalBehavior */
|
||||
showAdOnClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
defineExpose({
|
||||
show: (e?: MouseEvent) => {
|
||||
modal.value?.show(e)
|
||||
},
|
||||
hide: () => {
|
||||
modal.value?.hide()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:closable="closable"
|
||||
:hide-header="hideHeader"
|
||||
:on-hide="() => props.onHide?.()"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<slot />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="handleHide"
|
||||
>
|
||||
<p class="m-0 text-secondary">
|
||||
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
|
||||
<template #bold="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleGoToInstance">
|
||||
{{ formatMessage(messages.instance) }}
|
||||
<RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="handleCreateAnyway">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
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',
|
||||
},
|
||||
body: {
|
||||
id: 'app.instance.modpack-already-installed.body',
|
||||
defaultMessage:
|
||||
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
|
||||
},
|
||||
instance: {
|
||||
id: 'app.instance.modpack-already-installed.instance',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
create: {
|
||||
id: 'app.instance.modpack-already-installed.create',
|
||||
defaultMessage: 'Create',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'go-to-instance', instanceId: string): void
|
||||
(e: 'create-anyway' | 'cancel'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instanceName = ref('')
|
||||
const instanceId = ref('')
|
||||
const accepted = ref(false)
|
||||
|
||||
function show(name: string, id: string) {
|
||||
instanceName.value = name
|
||||
instanceId.value = id
|
||||
accepted.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleGoToInstance() {
|
||||
accepted.value = true
|
||||
modal.value?.hide()
|
||||
emit('go-to-instance', instanceId.value)
|
||||
}
|
||||
|
||||
function handleCreateAnyway() {
|
||||
accepted.value = true
|
||||
modal.value?.hide()
|
||||
emit('create-anyway')
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (!accepted.value) emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { releaseColor } from '@/helpers/utils'
|
||||
|
||||
export interface ModpackInstallExistingInstance {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ModpackInstallModalData {
|
||||
project: Pick<Labrinth.Projects.v2.Project, 'id' | 'title' | 'icon_url'>
|
||||
versions: Labrinth.Versions.v2.Version[]
|
||||
initialVersionId?: string | null
|
||||
existingInstancesByVersion: Record<string, ModpackInstallExistingInstance[]>
|
||||
instancePaths: string[]
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
install: [versionId: string, name: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const data = ref<ModpackInstallModalData | null>(null)
|
||||
const selectedVersionId = ref('')
|
||||
const instanceName = ref('')
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.modpack-install.title',
|
||||
defaultMessage: 'Install modpack',
|
||||
},
|
||||
version: {
|
||||
id: 'app.modpack-install.version',
|
||||
defaultMessage: 'Version',
|
||||
},
|
||||
instanceName: {
|
||||
id: 'app.modpack-install.instance-name',
|
||||
defaultMessage: 'Instance name',
|
||||
},
|
||||
instanceNamePlaceholder: {
|
||||
id: 'app.modpack-install.instance-name-placeholder',
|
||||
defaultMessage: 'My modpack instance',
|
||||
},
|
||||
installed: {
|
||||
id: 'app.modpack-install.already-installed',
|
||||
defaultMessage: 'This version is already installed',
|
||||
},
|
||||
installedDescription: {
|
||||
id: 'app.modpack-install.already-installed-description',
|
||||
defaultMessage: 'Creating another instance will not change these existing instances: {names}',
|
||||
},
|
||||
install: {
|
||||
id: 'app.modpack-install.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
selectVersion: {
|
||||
id: 'app.modpack-install.select-version',
|
||||
defaultMessage: 'Select a version',
|
||||
},
|
||||
folderName: {
|
||||
id: 'app.modpack-install.folder-name',
|
||||
defaultMessage: 'Instance folder: {name}',
|
||||
},
|
||||
folderNameConflict: {
|
||||
id: 'app.modpack-install.folder-name-conflict',
|
||||
defaultMessage: 'A folder with this name already exists. The instance folder will be: {name}',
|
||||
},
|
||||
})
|
||||
|
||||
const selectedVersion = computed(() =>
|
||||
data.value?.versions.find((version) => version.id === selectedVersionId.value),
|
||||
)
|
||||
|
||||
const versionOptions = computed(() =>
|
||||
(data.value?.versions ?? []).map((version) => ({
|
||||
value: version.id,
|
||||
label: versionLabel(version),
|
||||
})),
|
||||
)
|
||||
|
||||
const existingInstances = computed(
|
||||
() => data.value?.existingInstancesByVersion[selectedVersionId.value] ?? [],
|
||||
)
|
||||
|
||||
const folderName = computed(() => {
|
||||
const baseName = instanceName.value.trim().replace(/[\\/?*:'"|<>!]/g, '_')
|
||||
let candidate = baseName
|
||||
let index = 1
|
||||
while (candidate && data.value?.instancePaths.includes(candidate)) {
|
||||
candidate = `${baseName} (${index++})`
|
||||
}
|
||||
return candidate
|
||||
})
|
||||
|
||||
const hasFolderNameConflict = computed(() => folderName.value !== instanceName.value.trim())
|
||||
|
||||
const canInstall = computed(
|
||||
() => !!selectedVersion.value && instanceName.value.trim().length > 0 && !submitting.value,
|
||||
)
|
||||
|
||||
function versionLabel(version: Labrinth.Versions.v2.Version) {
|
||||
return [version.version_number || version.name, version.game_versions.join(', ')].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function show(nextData: ModpackInstallModalData) {
|
||||
data.value = nextData
|
||||
selectedVersionId.value =
|
||||
nextData.versions.find((version) => version.id === nextData.initialVersionId)?.id ??
|
||||
nextData.versions[0]?.id ??
|
||||
''
|
||||
instanceName.value = nextData.project.title
|
||||
submitting.value = false
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!canInstall.value) return
|
||||
submitting.value = true
|
||||
submitted.value = true
|
||||
emit('install', selectedVersionId.value, instanceName.value.trim())
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (!submitted.value) emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(36rem, calc(95vw - 10rem))"
|
||||
max-width="36rem"
|
||||
:on-hide="handleHide"
|
||||
>
|
||||
<div v-if="data" class="flex min-w-0 flex-col gap-4">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<Avatar
|
||||
:src="data.project.icon_url"
|
||||
:alt="data.project.title"
|
||||
size="3rem"
|
||||
:tint-by="data.project.title"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate text-lg font-semibold text-contrast">{{ data.project.title }}</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.version) }}</span>
|
||||
<Combobox
|
||||
v-model="selectedVersionId"
|
||||
:options="versionOptions"
|
||||
:name="formatMessage(messages.version)"
|
||||
:display-value="selectedVersion ? versionLabel(selectedVersion) : formatMessage(messages.selectVersion)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="selectedVersion" class="flex flex-wrap items-center gap-2 text-sm text-secondary">
|
||||
<Badge :color="releaseColor(selectedVersion.version_type)" :type="selectedVersion.version_type" />
|
||||
<span v-if="selectedVersion.loaders.length">{{ selectedVersion.loaders.join(', ') }}</span>
|
||||
<span v-if="selectedVersion.game_versions.length">{{ selectedVersion.game_versions.join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.instanceName) }}</span>
|
||||
<StyledInput
|
||||
v-model="instanceName"
|
||||
:placeholder="formatMessage(messages.instanceNamePlaceholder)"
|
||||
autocomplete="off"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="folderName" class="m-0 text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(hasFolderNameConflict ? messages.folderNameConflict : messages.folderName, {
|
||||
name: folderName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="existingInstances.length"
|
||||
class="flex items-start gap-2 rounded-lg border border-warning bg-warning-bg p-3"
|
||||
>
|
||||
<CheckIcon class="mt-0.5 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 font-semibold text-contrast">{{ formatMessage(messages.installed) }}</p>
|
||||
<p class="mt-1 mb-0 text-sm text-secondary">
|
||||
{{
|
||||
formatMessage(messages.installedDescription, {
|
||||
names: existingInstances.map((instance) => instance.name).join(', '),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button :disabled="submitting" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canInstall" @click="submit">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.install) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [privacy: PrivacySettings]
|
||||
}>()
|
||||
|
||||
const CONSENT_VERSION = 1
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const telemetry = ref(true)
|
||||
const discordRpc = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.privacy-consent.title',
|
||||
defaultMessage: 'Privacy & security',
|
||||
},
|
||||
intro: {
|
||||
id: 'app.privacy-consent.intro',
|
||||
defaultMessage:
|
||||
'Choose what Axolotl may send or display. Nothing is sent until you confirm these choices.',
|
||||
},
|
||||
telemetry: {
|
||||
id: 'app.privacy-consent.telemetry',
|
||||
defaultMessage: 'Allow anonymous telemetry',
|
||||
},
|
||||
telemetryDescription: {
|
||||
id: 'app.privacy-consent.telemetry-description',
|
||||
defaultMessage:
|
||||
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
|
||||
},
|
||||
discordRpc: {
|
||||
id: 'app.privacy-consent.discord-rpc',
|
||||
defaultMessage: 'Discord Rich Presence',
|
||||
},
|
||||
discordRpcDescription: {
|
||||
id: 'app.privacy-consent.discord-rpc-description',
|
||||
defaultMessage:
|
||||
'Shows your current launcher or game activity in Discord when Discord is running.',
|
||||
},
|
||||
privacyPolicy: {
|
||||
id: 'app.privacy-consent.privacy-policy',
|
||||
defaultMessage: 'Read the privacy policy',
|
||||
},
|
||||
continue: {
|
||||
id: 'app.privacy-consent.continue',
|
||||
defaultMessage: 'Save and continue',
|
||||
},
|
||||
})
|
||||
|
||||
function show(current: PrivacySettings) {
|
||||
telemetry.value = true
|
||||
discordRpc.value = current.discord_rpc
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const privacy = await savePrivacySettings({
|
||||
telemetry: telemetry.value,
|
||||
discord_rpc: discordRpc.value,
|
||||
consent_version: CONSENT_VERSION,
|
||||
})
|
||||
modal.value?.hide()
|
||||
emit('saved', privacy)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
|
||||
<p class="m-0 leading-relaxed text-primary">
|
||||
{{ formatMessage(messages.intro) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<label for="consent-telemetry" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.telemetry) }}
|
||||
</label>
|
||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.telemetryDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-5">
|
||||
<div class="min-w-0">
|
||||
<label for="consent-discord-rpc" class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.discordRpc) }}
|
||||
</label>
|
||||
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
|
||||
{{ formatMessage(messages.discordRpcDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
|
||||
<ExternalIcon />
|
||||
{{ formatMessage(messages.privacyPolicy) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="saving" @click="save">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
{{ formatMessage(messages.continue) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!-- @deprecated Use ShareModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
|
||||
<script setup lang="ts">
|
||||
import { ShareModal } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Share',
|
||||
},
|
||||
shareTitle: {
|
||||
type: String,
|
||||
default: 'Modrinth',
|
||||
},
|
||||
shareText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
link: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openInNewTab: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref(null)
|
||||
|
||||
defineExpose({
|
||||
show: (passedContent) => {
|
||||
modal.value.show(passedContent)
|
||||
},
|
||||
hide: () => {
|
||||
modal.value.hide()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ShareModal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:share-title="shareTitle"
|
||||
:share-text="shareText"
|
||||
:link="link"
|
||||
:open-in-new-tab="openInNewTab"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
<ContentDiffModal
|
||||
ref="diffModal"
|
||||
:header="formatMessage(messages.updateToPlay)"
|
||||
:admonition-header="formatMessage(messages.updateRequired)"
|
||||
:description="
|
||||
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
|
||||
"
|
||||
:diffs="normalizedDiffs"
|
||||
:confirm-label="formatMessage(commonMessages.updateButton)"
|
||||
:confirm-icon="DownloadIcon"
|
||||
:show-report-button="true"
|
||||
@confirm="handleUpdate"
|
||||
@cancel="handleDecline"
|
||||
@report="handleReport"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
type ContentDiffItem,
|
||||
ContentDiffModal,
|
||||
defineMessages,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { wait_for_install_job } from '@/helpers/install'
|
||||
import { update_managed_modrinth_version } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
type Dependency = Labrinth.Versions.v3.Dependency
|
||||
type Version = Labrinth.Versions.v2.Version
|
||||
|
||||
interface BaseDiff {
|
||||
project_id: string
|
||||
project?: {
|
||||
title: string
|
||||
icon_url?: string
|
||||
slug: string
|
||||
}
|
||||
currentVersionId?: string
|
||||
newVersionId?: string
|
||||
currentVersion?: Version
|
||||
newVersion?: Version
|
||||
fileName?: string
|
||||
}
|
||||
interface AddedDiff extends BaseDiff {
|
||||
type: 'added'
|
||||
newVersionId: string
|
||||
}
|
||||
interface RemovedDiff extends BaseDiff {
|
||||
type: 'removed'
|
||||
}
|
||||
interface UpdatedDiff extends BaseDiff {
|
||||
type: 'updated'
|
||||
currentVersionId: string
|
||||
newVersionId: string
|
||||
}
|
||||
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
|
||||
|
||||
type ProjectInfo = {
|
||||
id: string
|
||||
title: string
|
||||
icon_url?: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
|
||||
type UpdateCompleteCallback = () => void | Promise<void>
|
||||
|
||||
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
|
||||
const instance = ref<GameInstance | null>(null)
|
||||
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
|
||||
const diffs = ref<DependencyDiff[]>([])
|
||||
const modpackVersionId = ref<string | null>(null)
|
||||
const modpackVersion = ref<Version | null>(null)
|
||||
|
||||
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
|
||||
diffs.value.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.project?.title,
|
||||
fileName: diff.fileName,
|
||||
currentVersionName: diff.currentVersion?.version_number,
|
||||
newVersionName: diff.newVersion?.version_number,
|
||||
})),
|
||||
)
|
||||
|
||||
async function computeDependencyDiffs(
|
||||
currentDeps: Dependency[],
|
||||
latestDeps: Dependency[],
|
||||
): Promise<DependencyDiff[]> {
|
||||
console.log('Computing dependency diffs', { currentDeps, latestDeps })
|
||||
|
||||
// Separate deps with project_id from file_name-only deps
|
||||
const currentWithProject = currentDeps.filter((d) => d.project_id)
|
||||
const latestWithProject = latestDeps.filter((d) => d.project_id)
|
||||
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
|
||||
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
|
||||
|
||||
const currentByProject = new Map<string, Dependency>(
|
||||
currentWithProject.map((d) => [d.project_id!, d]),
|
||||
)
|
||||
const latestByProject = new Map<string, Dependency>(
|
||||
latestWithProject.map((d) => [d.project_id!, d]),
|
||||
)
|
||||
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
|
||||
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
|
||||
|
||||
const diffs: DependencyDiff[] = []
|
||||
|
||||
// Find added and updated dependencies (by project_id)
|
||||
latestByProject.forEach((latestDep, projectId) => {
|
||||
const currentDep = currentByProject.get(projectId)
|
||||
if (!currentDep && latestDep.version_id) {
|
||||
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
|
||||
} else if (
|
||||
currentDep?.version_id &&
|
||||
latestDep?.version_id &&
|
||||
currentDep?.version_id !== latestDep.version_id
|
||||
) {
|
||||
diffs.push({
|
||||
type: 'updated',
|
||||
project_id: projectId,
|
||||
currentVersionId: currentDep.version_id,
|
||||
newVersionId: latestDep.version_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Find removed dependencies (by project_id)
|
||||
currentByProject.forEach((currentDep, projectId) => {
|
||||
if (!latestByProject.has(projectId)) {
|
||||
diffs.push({
|
||||
type: 'removed',
|
||||
project_id: projectId,
|
||||
currentVersionId: currentDep.version_id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Find added/removed file_name-only dependencies
|
||||
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
|
||||
for (const fileName of latestFilenames) {
|
||||
if (!currentFilenames.has(fileName)) {
|
||||
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
|
||||
}
|
||||
}
|
||||
for (const fileName of currentFilenames) {
|
||||
if (!latestFilenames.has(fileName)) {
|
||||
diffs.push({ type: 'removed', project_id: '', fileName })
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch projects and versions of diffs
|
||||
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
|
||||
const allVersionIds = [
|
||||
...new Set(
|
||||
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
|
||||
Boolean,
|
||||
),
|
||||
),
|
||||
] as string[]
|
||||
const [projects, versions] = await Promise.all([
|
||||
get_project_many(allProjectIds, 'bypass'),
|
||||
get_version_many(allVersionIds, 'bypass'),
|
||||
])
|
||||
|
||||
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
|
||||
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
|
||||
|
||||
const mappedDiffs = diffs
|
||||
.map((diff) => {
|
||||
const project = projectMap.get(diff.project_id)
|
||||
return {
|
||||
...diff,
|
||||
project: project
|
||||
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
|
||||
: undefined,
|
||||
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
|
||||
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const typeOrder = { added: 0, updated: 1, removed: 2 }
|
||||
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
|
||||
if (typeCompare !== 0) return typeCompare
|
||||
|
||||
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
|
||||
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
|
||||
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
|
||||
})
|
||||
.filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
|
||||
return mappedDiffs
|
||||
}
|
||||
|
||||
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
||||
if (!inst.link) return null
|
||||
if (!modpackVersionId.value || !inst.link.version_id) return null
|
||||
|
||||
try {
|
||||
// For server projects, link.project_id is the server project but
|
||||
// link.version_id references a content modpack version from a different project.
|
||||
// Detect this by comparing the version's project_id with link.project_id.
|
||||
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
|
||||
const instanceModpackVersion = await get_version(inst.link.version_id, 'bypass')
|
||||
|
||||
// Compute dependency diffs between current and latest version
|
||||
if (instanceModpackVersion && modpackVersion.value) {
|
||||
return await computeDependencyDiffs(
|
||||
instanceModpackVersion.dependencies || [],
|
||||
modpackVersion.value.dependencies || [],
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => instance.value,
|
||||
async (newInstance) => {
|
||||
if (!newInstance) return
|
||||
const result = await checkUpdateAvailable(newInstance)
|
||||
diffs.value = result || []
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
async function handleUpdate() {
|
||||
hide()
|
||||
const serverProjectId = instance.value?.link?.project_id
|
||||
if (serverProjectId) startInstallingServer(serverProjectId)
|
||||
try {
|
||||
if (modpackVersionId.value && instance.value) {
|
||||
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
|
||||
await wait_for_install_job(job.job_id)
|
||||
await onUpdateComplete.value()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating instance:', error)
|
||||
} finally {
|
||||
if (serverProjectId) stopInstallingServer(serverProjectId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReport() {
|
||||
if (instance.value?.link?.project_id) {
|
||||
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDecline() {
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(
|
||||
instanceVal: GameInstance,
|
||||
modpackVersionIdVal: string | null = null,
|
||||
callback: UpdateCompleteCallback = () => {},
|
||||
e?: MouseEvent,
|
||||
) {
|
||||
instance.value = instanceVal
|
||||
modpackVersionId.value = modpackVersionIdVal
|
||||
onUpdateComplete.value = callback
|
||||
diffModal.value?.show(e)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
diffModal.value?.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
updateToPlay: {
|
||||
id: 'app.modal.update-to-play.header',
|
||||
defaultMessage: 'Update to play',
|
||||
},
|
||||
updateRequired: {
|
||||
id: 'app.modal.update-to-play.update-required',
|
||||
defaultMessage: 'Update required',
|
||||
},
|
||||
updateRequiredDescription: {
|
||||
id: 'app.modal.update-to-play.update-required-description',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to the latest version to launch the game.',
|
||||
},
|
||||
})
|
||||
|
||||
const hasUpdate = computed(() => {
|
||||
if (!instance.value?.link) return false
|
||||
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
|
||||
})
|
||||
|
||||
defineExpose({ show, hide, hasUpdate })
|
||||
</script>
|
||||
Reference in new issue
Block a user