feat: 添加获批整合包同步与 StarLight 实例模式
This commit is contained in:
214
apps/app-frontend/src/components/instance/HostedModpacks.vue
Normal file
214
apps/app-frontend/src/components/instance/HostedModpacks.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
|
||||
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
|
||||
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<ButtonStyled
|
||||
><button type="button" @click="router.push('/browse/modpack')">
|
||||
{{ formatMessage(messages.browse) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
><button type="button" @click="importLocal">
|
||||
{{ formatMessage(messages.importLocal) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="modeQuery.data.value === 'starlight'">
|
||||
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
|
||||
<p>{{ formatMessage(messages.description) }}</p>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
><button type="button" :disabled="loading || syncing" @click="load">
|
||||
{{ formatMessage(messages.refresh) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
|
||||
<p v-if="binding">
|
||||
{{
|
||||
formatMessage(messages.bound, {
|
||||
name: binding.publication.manifest.name,
|
||||
version: binding.publication.manifest.version,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="syncing" role="status">{{ formatMessage(messages.syncing) }}</p>
|
||||
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
|
||||
<p>
|
||||
{{
|
||||
formatMessage(messages.complete, {
|
||||
version: result.version,
|
||||
count: result.changedFiles,
|
||||
size: (result.downloadedBytes / 1048576).toFixed(2),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<details v-if="result.preservedFiles.length">
|
||||
<summary>
|
||||
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<p v-if="!loading && ready && !catalog.length">{{ formatMessage(messages.empty) }}</p>
|
||||
<article
|
||||
v-for="pack in catalog"
|
||||
:key="pack.packId"
|
||||
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
|
||||
>
|
||||
<div>
|
||||
<h3 class="m-0">{{ pack.manifest.name }}</h3>
|
||||
<p class="mb-0">
|
||||
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
|
||||
{{ pack.manifest.runtime.loader }}
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled color="brand"
|
||||
><button
|
||||
type="button"
|
||||
:disabled="
|
||||
!ready ||
|
||||
syncing ||
|
||||
loading ||
|
||||
(!!binding && binding.publication.packId !== pack.packId)
|
||||
"
|
||||
@click="sync(pack.packId)"
|
||||
>
|
||||
{{ formatMessage(binding ? messages.update : messages.install) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { inject, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
|
||||
import { useInstanceMode } from '@/composables/useInstanceMode'
|
||||
|
||||
import {
|
||||
type HostedBinding,
|
||||
hostedBinding,
|
||||
hostedCatalog,
|
||||
type HostedPublication,
|
||||
hostedSync,
|
||||
type HostedSyncResult,
|
||||
} from '@/helpers/hosted-packs'
|
||||
const props = defineProps<{ instanceId: string }>()
|
||||
const modeQuery = useInstanceMode(() => props.instanceId)
|
||||
const router = useRouter()
|
||||
const showCreation = inject<
|
||||
(options: { skipSetupType: boolean; initialMode: 'import'; instanceMode: 'local' }) => void
|
||||
>('showCreationModalWithOptions')
|
||||
function importLocal() {
|
||||
showCreation?.({ skipSetupType: true, initialMode: 'import', instanceMode: 'local' })
|
||||
}
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
|
||||
localPacks: {
|
||||
id: 'app.instance-mode.local-packs',
|
||||
defaultMessage:
|
||||
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
|
||||
},
|
||||
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
|
||||
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
|
||||
title: { id: 'app.hosted-packs.title', defaultMessage: 'Published modpacks' },
|
||||
description: {
|
||||
id: 'app.hosted-packs.description',
|
||||
defaultMessage:
|
||||
'Install an approved modpack into this instance. Future online launches automatically download changed files. Use a separate empty instance for each pack; saves and personal settings are preserved.',
|
||||
},
|
||||
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
|
||||
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
|
||||
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
|
||||
syncing: {
|
||||
id: 'app.hosted-packs.syncing',
|
||||
defaultMessage: 'Comparing files and synchronizing changes…',
|
||||
},
|
||||
complete: {
|
||||
id: 'app.hosted-packs.complete',
|
||||
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
|
||||
},
|
||||
preserved: {
|
||||
id: 'app.hosted-packs.preserved',
|
||||
defaultMessage: 'Preserved {count} locally modified or personal files',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.hosted-packs.empty',
|
||||
defaultMessage: 'No modpacks have been approved for publication yet.',
|
||||
},
|
||||
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
|
||||
install: { id: 'app.hosted-packs.install', defaultMessage: 'Install and enable automatic sync' },
|
||||
})
|
||||
const catalog = ref<HostedPublication[]>([])
|
||||
const binding = ref<HostedBinding | null>(null)
|
||||
const result = ref<HostedSyncResult | null>(null)
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const ready = ref(false)
|
||||
const error = ref('')
|
||||
let generation = 0
|
||||
async function load() {
|
||||
if (modeQuery.data.value !== 'starlight') return
|
||||
const current = ++generation
|
||||
const instanceId = props.instanceId
|
||||
loading.value = true
|
||||
ready.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
const [packs, installed] = await Promise.all([hostedCatalog(), hostedBinding(instanceId)])
|
||||
if (current !== generation) return
|
||||
catalog.value = packs
|
||||
binding.value = installed
|
||||
ready.value = true
|
||||
} catch (cause) {
|
||||
if (current === generation) error.value = String(cause)
|
||||
} finally {
|
||||
if (current === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
async function sync(packId: string) {
|
||||
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
|
||||
const instanceId = props.instanceId
|
||||
syncing.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const completed = await hostedSync(instanceId, packId)
|
||||
if (props.instanceId !== instanceId) return
|
||||
result.value = completed
|
||||
await load()
|
||||
} catch (cause) {
|
||||
if (props.instanceId === instanceId) error.value = String(cause)
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => [props.instanceId, modeQuery.data.value] as const,
|
||||
() => {
|
||||
generation++
|
||||
loading.value = false
|
||||
ready.value = false
|
||||
error.value = ''
|
||||
catalog.value = []
|
||||
binding.value = null
|
||||
result.value = null
|
||||
void load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { useId } from 'vue'
|
||||
|
||||
import type { InstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
|
||||
const group = useId()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
|
||||
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
|
||||
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
|
||||
starlightDescription: {
|
||||
id: 'app.instance-mode.starlight-description',
|
||||
defaultMessage:
|
||||
'Automatically syncs changes published by the StarLight server. Required for playing on StarLight. Select and install the official modpack in Mod management before your first launch.',
|
||||
},
|
||||
localDescription: {
|
||||
id: 'app.instance-mode.local-description',
|
||||
defaultMessage:
|
||||
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
|
||||
<legend class="mb-3 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</legend>
|
||||
<label
|
||||
v-for="mode in ['starlight', 'local'] as const"
|
||||
:key="mode"
|
||||
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
|
||||
:class="[
|
||||
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
|
||||
disabled ? 'opacity-60' : 'cursor-pointer',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
class="mt-1"
|
||||
:name="group"
|
||||
:value="mode"
|
||||
:checked="modelValue === mode"
|
||||
@change="emit('update:modelValue', mode)"
|
||||
/>
|
||||
<span class="flex flex-col gap-1"
|
||||
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
|
||||
><span class="text-sm text-secondary">{{
|
||||
formatMessage(
|
||||
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
|
||||
)
|
||||
}}</span></span
|
||||
>
|
||||
</label>
|
||||
</fieldset>
|
||||
</template>
|
||||
@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
|
||||
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
|
||||
import type { InstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
|
||||
const query = useInstanceMode(() => props.instanceId)
|
||||
const save = useSetInstanceMode()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
saving: { id: 'app.instance-mode.saving', defaultMessage: 'Saving instance type…' },
|
||||
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
|
||||
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
|
||||
})
|
||||
const saving = computed(
|
||||
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
|
||||
)
|
||||
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
|
||||
const failure = computed(
|
||||
() =>
|
||||
query.error.value ??
|
||||
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
|
||||
)
|
||||
function select(mode: InstanceMode) {
|
||||
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
|
||||
save.mutate({ instanceId: props.instanceId, mode })
|
||||
}
|
||||
function retry() {
|
||||
if (props.disabled || saving.value) return
|
||||
if (query.error.value) void query.refetch()
|
||||
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
|
||||
}
|
||||
watch(
|
||||
() => props.instanceId,
|
||||
() => save.reset(),
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<InstanceModeOptions
|
||||
:model-value="selection"
|
||||
:disabled="disabled || !query.data.value || query.isPending.value || saving"
|
||||
@update:model-value="select"
|
||||
/>
|
||||
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
|
||||
{{ formatMessage(saving ? messages.saving : messages.loading) }}
|
||||
</p>
|
||||
<div v-if="failure" role="alert" class="flex items-center gap-3">
|
||||
<span>{{ String(failure) }}</span
|
||||
><ButtonStyled
|
||||
><button
|
||||
type="button"
|
||||
:disabled="disabled || query.isFetching.value || saving"
|
||||
@click="retry"
|
||||
>
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div
|
||||
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
|
||||
:data-packs="selected === 'packs'"
|
||||
role="group"
|
||||
:aria-label="formatMessage(messages.label)"
|
||||
>
|
||||
<span
|
||||
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
|
||||
type="button"
|
||||
:aria-pressed="selected === 'mods'"
|
||||
@click="select('mods')"
|
||||
>
|
||||
{{ formatMessage(messages.mods) }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
|
||||
type="button"
|
||||
:aria-pressed="selected === 'packs'"
|
||||
@click="select('packs')"
|
||||
>
|
||||
{{ formatMessage(messages.packs) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="selected === 'mods'"><slot /></div>
|
||||
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
|
||||
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
|
||||
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
|
||||
})
|
||||
const key = 'starlight:instance:mod-management-tab'
|
||||
function initial(): 'mods' | 'packs' {
|
||||
try {
|
||||
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
|
||||
} catch {
|
||||
return 'mods'
|
||||
}
|
||||
}
|
||||
const selected = ref(initial())
|
||||
const openedPacks = ref(selected.value === 'packs')
|
||||
function select(value: 'mods' | 'packs') {
|
||||
selected.value = value
|
||||
if (value === 'packs') openedPacks.value = true
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* Selection remains usable without storage. */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.selection {
|
||||
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
[data-packs='true'] .selection {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.selection {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -17,6 +17,7 @@ import { computed, type Ref, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||
@ -379,6 +380,7 @@ const messages = defineMessages({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="deleteConfirmModal"
|
||||
:symlink-target="instance.symlink_target"
|
||||
|
||||
@ -207,7 +207,8 @@ export const onboardingMessages = defineMessages({
|
||||
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
|
||||
updatesDescription: {
|
||||
id: 'app.onboarding.updates.description',
|
||||
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
|
||||
defaultMessage:
|
||||
'Choose when Starlight checks for updates and whether it installs them for you.',
|
||||
},
|
||||
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
|
||||
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
|
||||
@ -239,6 +240,15 @@ export const onboardingMessages = defineMessages({
|
||||
defaultMessage: 'Click Create new instance to continue',
|
||||
},
|
||||
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
|
||||
instanceModeTitle: {
|
||||
id: 'app.onboarding.instance-mode.title',
|
||||
defaultMessage: 'Choose your instance type',
|
||||
},
|
||||
instanceModeDescription: {
|
||||
id: 'app.onboarding.instance-mode.description',
|
||||
defaultMessage:
|
||||
'Choose StarLight to play on the StarLight server and automatically sync its approved updates. Local instances skip StarLight sync, start faster, and let you use your own modpacks for other servers or single-player. You can change this later in instance settings.',
|
||||
},
|
||||
creationDescription: {
|
||||
id: 'app.onboarding.creation.description',
|
||||
defaultMessage:
|
||||
@ -681,6 +691,12 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
|
||||
),
|
||||
control('create-instance', '/create'),
|
||||
),
|
||||
inspect(
|
||||
'creation-instance-mode',
|
||||
'creation-instance-mode',
|
||||
onboardingMessages.instanceModeTitle,
|
||||
onboardingMessages.instanceModeDescription,
|
||||
),
|
||||
step(
|
||||
'creation-flow',
|
||||
'activate',
|
||||
|
||||
27
apps/app-frontend/src/composables/useInstanceMode.ts
Normal file
27
apps/app-frontend/src/composables/useInstanceMode.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
|
||||
|
||||
import { getInstanceMode, type InstanceMode, setInstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
export const instanceModeKey = (instanceId: string) => ['instance-mode', instanceId] as const
|
||||
|
||||
export function useInstanceMode(instanceId: MaybeRefOrGetter<string>) {
|
||||
return useQuery({
|
||||
queryKey: computed(() => instanceModeKey(toValue(instanceId))),
|
||||
queryFn: () => getInstanceMode(toValue(instanceId)),
|
||||
enabled: computed(() => !!toValue(instanceId)),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetInstanceMode() {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ instanceId, mode }: { instanceId: string; mode: InstanceMode }) =>
|
||||
setInstanceMode(instanceId, mode),
|
||||
onMutate: ({ instanceId }) => client.cancelQueries({ queryKey: instanceModeKey(instanceId) }),
|
||||
onSuccess: (_, { instanceId, mode }) => client.setQueryData(instanceModeKey(instanceId), mode),
|
||||
onError: (_, { instanceId }) =>
|
||||
client.invalidateQueries({ queryKey: instanceModeKey(instanceId) }),
|
||||
})
|
||||
}
|
||||
33
apps/app-frontend/src/helpers/hosted-packs.ts
Normal file
33
apps/app-frontend/src/helpers/hosted-packs.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type InstanceMode = 'starlight' | 'local'
|
||||
export const getInstanceMode = (instanceId: string) =>
|
||||
invoke<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
|
||||
export const setInstanceMode = (instanceId: string, mode: InstanceMode) =>
|
||||
invoke<void>('plugin:install|hosted_set_instance_mode', { instanceId, mode })
|
||||
|
||||
export interface HostedPublication {
|
||||
packId: string
|
||||
releaseId: number
|
||||
manifest: {
|
||||
name: string
|
||||
version: string
|
||||
format: string
|
||||
runtime: { gameVersion: string; loader: string; loaderVersion: string | null }
|
||||
files: { path: string; size: number; sha256: string }[]
|
||||
}
|
||||
}
|
||||
export interface HostedBinding {
|
||||
publication: HostedPublication
|
||||
}
|
||||
export interface HostedSyncResult {
|
||||
version: string
|
||||
downloadedBytes: number
|
||||
changedFiles: number
|
||||
preservedFiles: string[]
|
||||
}
|
||||
export const hostedCatalog = () => invoke<HostedPublication[]>('plugin:install|hosted_catalog')
|
||||
export const hostedBinding = (instanceId: string) =>
|
||||
invoke<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||
export const hostedSync = (instanceId: string, packId: string) =>
|
||||
invoke<HostedSyncResult>('plugin:install|hosted_sync', { instanceId, packId })
|
||||
@ -32,6 +32,7 @@ export interface InstallModpackPreview {
|
||||
}
|
||||
|
||||
export interface InstallCreateInstanceRequest {
|
||||
instanceMode?: 'starlight' | 'local'
|
||||
name: string
|
||||
gameVersion: string
|
||||
loader: InstanceLoader
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
{
|
||||
"app.onboarding.instance-mode.title": { "message": "Choose your instance type" },
|
||||
"app.onboarding.instance-mode.description": { "message": "Choose StarLight to play on the StarLight server and automatically sync its approved updates. Local instances skip StarLight sync, start faster, and let you use your own modpacks for other servers or single-player. You can change this later in instance settings." },
|
||||
"app.instance-mode.title": { "message": "Instance type" },
|
||||
"app.instance-mode.starlight": { "message": "StarLight instance" },
|
||||
"app.instance-mode.local": { "message": "Local instance" },
|
||||
"app.instance-mode.starlight-description": { "message": "Automatically syncs changes published by the StarLight server. Required for playing on StarLight. Select and install the official modpack in Mod management before your first launch." },
|
||||
"app.instance-mode.local-description": { "message": "Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds." },
|
||||
"app.instance-mode.saving": { "message": "Saving instance type…" },
|
||||
"app.instance-mode.loading": { "message": "Loading instance type…" },
|
||||
"app.instance-mode.retry": { "message": "Retry" },
|
||||
"app.instance-mode.local-packs-title": { "message": "Local modpacks" },
|
||||
"app.instance-mode.local-packs": { "message": "Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized." },
|
||||
"app.instance-mode.browse": { "message": "Browse modpacks" },
|
||||
"app.instance-mode.import": { "message": "Import as a local instance" },
|
||||
"app.hosted-packs.title": {"message": "Published modpacks"},
|
||||
"app.hosted-packs.description": {"message": "Install an approved modpack into this instance. Future online launches automatically download changed files. Use a separate empty instance for each pack; saves and personal settings are preserved."},
|
||||
"app.hosted-packs.refresh": {"message": "Refresh"},
|
||||
"app.hosted-packs.loading": {"message": "Loading published modpacks…"},
|
||||
"app.hosted-packs.bound": {"message": "Installed: {name} · {version}"},
|
||||
"app.hosted-packs.syncing": {"message": "Comparing files and synchronizing changes…"},
|
||||
"app.hosted-packs.complete": {"message": "Synced to {version}. Changed {count} files; downloaded {size} MiB."},
|
||||
"app.hosted-packs.preserved": {"message": "Preserved {count} locally modified or personal files"},
|
||||
"app.hosted-packs.empty": {"message": "No modpacks have been approved for publication yet."},
|
||||
"app.hosted-packs.update": {"message": "Synchronize now"},
|
||||
"app.hosted-packs.install": {"message": "Install and enable automatic sync"},
|
||||
"app.hosted-packs.management": {"message": "Content management"},
|
||||
"app.hosted-packs.mods": {"message": "Manage mods"},
|
||||
"app.hosted-packs.packs": {"message": "Manage modpacks"},
|
||||
"app.home.luck.title": { "message": "Daily luck index" },
|
||||
"app.home.luck.get": { "message": "Get with one click" },
|
||||
"app.home.luck.login-to-get": { "message": "Sign in to get it" },
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
{
|
||||
"app.onboarding.instance-mode.title": { "message": "选择实例类型" },
|
||||
"app.onboarding.instance-mode.description": { "message": "游玩 StarLight 服务器请选择 StarLight 实例,它会自动同步管理员批准的更新。本地实例跳过 StarLight 同步,启动更快,可自选整合包,适合第三方服务器与单人游玩。之后也可以在实例设置中切换。" },
|
||||
"app.instance-mode.title": { "message": "实例类型" },
|
||||
"app.instance-mode.starlight": { "message": "StarLight 实例" },
|
||||
"app.instance-mode.local": { "message": "本地实例" },
|
||||
"app.instance-mode.starlight-description": { "message": "自动同步 StarLight 服务器推送的变更,游玩 StarLight 服务器必选。首次启动前,请在 Mod 管理中选择并安装官方整合包。" },
|
||||
"app.instance-mode.local-description": { "message": "不会同步 StarLight 服务器的变更。跳过同步检查,启动更快,可自选整合包安装,更适合第三方服务器与本地个人游玩。" },
|
||||
"app.instance-mode.saving": { "message": "正在保存实例类型…" },
|
||||
"app.instance-mode.loading": { "message": "正在读取实例类型…" },
|
||||
"app.instance-mode.retry": { "message": "重试" },
|
||||
"app.instance-mode.local-packs-title": { "message": "本地整合包" },
|
||||
"app.instance-mode.local-packs": { "message": "可以自选整合包并安装为本地实例。切换为本地后保留现有游戏文件和存档,之后不再同步 StarLight 的变更。" },
|
||||
"app.instance-mode.browse": { "message": "浏览整合包" },
|
||||
"app.instance-mode.import": { "message": "导入为本地实例" },
|
||||
"app.hosted-packs.management": {"message": "内容管理"},
|
||||
"app.hosted-packs.mods": {"message": "管理 Mod"},
|
||||
"app.hosted-packs.packs": {"message": "管理整合包"},
|
||||
"app.hosted-packs.title": {"message": "已发布整合包"},
|
||||
"app.hosted-packs.description": {"message": "将审核通过的整合包安装到当前实例。此后联网启动时自动下载变动文件。每个整合包请使用单独的空实例;存档和个人设置会保留。"},
|
||||
"app.hosted-packs.refresh": {"message": "刷新"},
|
||||
"app.hosted-packs.loading": {"message": "正在获取已发布整合包…"},
|
||||
"app.hosted-packs.bound": {"message": "已安装:{name} · {version}"},
|
||||
"app.hosted-packs.syncing": {"message": "正在比对文件并同步变动…"},
|
||||
"app.hosted-packs.complete": {"message": "已同步至 {version},变更 {count} 个文件,下载 {size} MiB。"},
|
||||
"app.hosted-packs.preserved": {"message": "保留了 {count} 个本地修改或个人文件"},
|
||||
"app.hosted-packs.empty": {"message": "暂无审核通过的整合包。"},
|
||||
"app.hosted-packs.update": {"message": "立即同步"},
|
||||
"app.hosted-packs.install": {"message": "安装并启用自动同步"},
|
||||
"app.home.luck.title": { "message": "每日幸运指数" },
|
||||
"app.home.luck.get": { "message": "一键获取" },
|
||||
"app.home.luck.login-to-get": { "message": "登录后获取" },
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
{
|
||||
"app.onboarding.instance-mode.title": { "message": "選擇實例類型" },
|
||||
"app.onboarding.instance-mode.description": { "message": "遊玩 StarLight 伺服器請選擇 StarLight 實例,它會自動同步管理員核准的更新。本地實例跳過 StarLight 同步,啟動更快,可自行選擇整合包,適合第三方伺服器與單人遊玩。之後也可以在實例設定中切換。" },
|
||||
"app.instance-mode.title": { "message": "實例類型" },
|
||||
"app.instance-mode.starlight": { "message": "StarLight 實例" },
|
||||
"app.instance-mode.local": { "message": "本機實例" },
|
||||
"app.instance-mode.starlight-description": { "message": "自動同步 StarLight 伺服器推送的變更,遊玩 StarLight 伺服器必選。首次啟動前,請在 Mod 管理中選擇並安裝官方整合包。" },
|
||||
"app.instance-mode.local-description": { "message": "不會同步 StarLight 伺服器的變更。略過同步檢查,啟動更快,可自行選擇整合包安裝,更適合第三方伺服器與本機個人遊玩。" },
|
||||
"app.instance-mode.saving": { "message": "正在儲存實例類型…" },
|
||||
"app.instance-mode.loading": { "message": "正在讀取實例類型…" },
|
||||
"app.instance-mode.retry": { "message": "重試" },
|
||||
"app.instance-mode.local-packs-title": { "message": "本機整合包" },
|
||||
"app.instance-mode.local-packs": { "message": "可以自行選擇整合包並安裝為本機實例。切換為本機後保留現有遊戲檔案和存檔,之後不再同步 StarLight 的變更。" },
|
||||
"app.instance-mode.browse": { "message": "瀏覽整合包" },
|
||||
"app.instance-mode.import": { "message": "匯入為本機實例" },
|
||||
"app.hosted-packs.management": {"message": "內容管理"},
|
||||
"app.hosted-packs.mods": {"message": "管理 Mod"},
|
||||
"app.hosted-packs.packs": {"message": "管理整合包"},
|
||||
"app.hosted-packs.title": {"message": "已發布整合包"},
|
||||
"app.hosted-packs.description": {"message": "將審核通過的整合包安裝到目前實例。之後連線啟動時自動下載變動檔案。每個整合包請使用獨立的空實例;存檔和個人設定會保留。"},
|
||||
"app.hosted-packs.refresh": {"message": "重新整理"},
|
||||
"app.hosted-packs.loading": {"message": "正在取得已發布整合包…"},
|
||||
"app.hosted-packs.bound": {"message": "已安裝:{name} · {version}"},
|
||||
"app.hosted-packs.syncing": {"message": "正在比對檔案並同步變動…"},
|
||||
"app.hosted-packs.complete": {"message": "已同步至 {version},變更 {count} 個檔案,下載 {size} MiB。"},
|
||||
"app.hosted-packs.preserved": {"message": "保留了 {count} 個本機修改或個人檔案"},
|
||||
"app.hosted-packs.empty": {"message": "尚無審核通過的整合包。"},
|
||||
"app.hosted-packs.update": {"message": "立即同步"},
|
||||
"app.hosted-packs.install": {"message": "安裝並啟用自動同步"},
|
||||
"app.home.luck.title": { "message": "每日幸運指數" },
|
||||
"app.home.luck.get": { "message": "一鍵獲取" },
|
||||
"app.home.luck.login-to-get": { "message": "登入後獲取" },
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { inject } from 'vue'
|
||||
import { inject, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
|
||||
import type { InstanceMode } from '@/helpers/hosted-packs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const instanceMode = ref<InstanceMode>('local')
|
||||
|
||||
const showModal = inject<
|
||||
(options?: {
|
||||
skipSetupType?: boolean
|
||||
initialMode?: 'custom' | 'import'
|
||||
instanceMode?: InstanceMode
|
||||
onBack?: () => void
|
||||
}) => void
|
||||
>('showCreationModalWithOptions')
|
||||
@ -60,6 +64,7 @@ function handleStartFresh() {
|
||||
showModal?.({
|
||||
skipSetupType: true,
|
||||
initialMode: 'custom',
|
||||
instanceMode: instanceMode.value,
|
||||
onBack: () => router.push('/create'),
|
||||
})
|
||||
}
|
||||
@ -68,14 +73,15 @@ function handleImportExisting() {
|
||||
showModal?.({
|
||||
skipSetupType: true,
|
||||
initialMode: 'import',
|
||||
instanceMode: 'local',
|
||||
onBack: () => router.push('/create'),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col items-center justify-center p-6">
|
||||
<div class="flex w-full max-w-2xl flex-col gap-6">
|
||||
<div class="flex h-full w-full flex-col items-center overflow-y-auto p-6">
|
||||
<div class="my-auto flex w-full max-w-2xl shrink-0 flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="m-0 text-2xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
@ -85,6 +91,7 @@ function handleImportExisting() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<InstanceModeOptions v-model="instanceMode" data-onboarding-id="creation-instance-mode" />
|
||||
<div data-onboarding-id="creation-methods" class="flex flex-col gap-4 sm:flex-row">
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-custom"
|
||||
@ -97,6 +104,7 @@ function handleImportExisting() {
|
||||
|
||||
<BigOptionButton
|
||||
data-onboarding-id="creation-method-import"
|
||||
v-if="instanceMode === 'local'"
|
||||
:icon="FolderOpenIcon"
|
||||
:title="formatMessage(messages.importTitle)"
|
||||
:description="formatMessage(messages.importDescription)"
|
||||
|
||||
@ -1,215 +1,220 @@
|
||||
<template>
|
||||
<ReadyTransition :pending="loading">
|
||||
<template #pending>
|
||||
<LoadingIndicator class="pt-4" />
|
||||
</template>
|
||||
<CollapsibleAdmonition
|
||||
v-if="postUpgradeNotice?.warnings.length"
|
||||
v-model="postUpgradeNoticeExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ formatMessage(messages.postUpgradeNoticeTitle) }}
|
||||
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">{{
|
||||
postUpgradeNotice.warnings.length
|
||||
}}</span>
|
||||
</span>
|
||||
<ModManagementSwitch>
|
||||
<template #packs><HostedModpacks :instance-id="instance.id" /></template>
|
||||
<ReadyTransition :pending="loading">
|
||||
<template #pending>
|
||||
<LoadingIndicator class="pt-4" />
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
|
||||
<p class="m-0">{{ formatMessage(messages.postUpgradeNoticeBody) }}</p>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<ButtonStyled color="orange" size="small">
|
||||
<button type="button" @click="dismissPostUpgradeNotice">
|
||||
{{ formatMessage(messages.ignoreAllPostUpgradeWarnings) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<CollapsibleAdmonition
|
||||
v-if="skippedManualDownloads.length > 0"
|
||||
v-model="manualWarningExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ formatMessage(messages.skippedFilesWarningTitle) }}
|
||||
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
|
||||
{{ skippedManualDownloads.length }}
|
||||
<CollapsibleAdmonition
|
||||
v-if="postUpgradeNotice?.warnings.length"
|
||||
v-model="postUpgradeNoticeExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ formatMessage(messages.postUpgradeNoticeTitle) }}
|
||||
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">{{
|
||||
postUpgradeNotice.warnings.length
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
|
||||
<p class="m-0">{{ skippedFilesWarningBody }}</p>
|
||||
<ul class="mb-0 mt-2 flex list-none flex-col gap-1 p-0">
|
||||
<li
|
||||
v-for="item in visibleSkippedManualDownloads"
|
||||
:key="`${item.projectId}:${item.fileId}`"
|
||||
class="min-w-0"
|
||||
>
|
||||
<button
|
||||
class="inline-flex max-w-full cursor-pointer items-center gap-1 text-left font-semibold text-brand hover:underline"
|
||||
@click="openManualCurseForgeDownload(item)"
|
||||
>
|
||||
<span class="truncate">{{ item.fileName }}</span>
|
||||
<ExternalIcon class="size-4 shrink-0" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="hiddenSkippedManualDownloadCount > 0" class="mb-0 mt-2 text-secondary">
|
||||
{{
|
||||
formatMessage(messages.skippedFilesWarningMore, {
|
||||
count: hiddenSkippedManualDownloadCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<ButtonStyled color="orange" size="small">
|
||||
<button @click="openManualCurseForgeResolver">
|
||||
<FolderSearchIcon />
|
||||
{{ formatMessage(messages.completeSkippedFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<CollapsibleAdmonition
|
||||
v-if="missingPackMembers.length > 0"
|
||||
v-model="missingWarningExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ formatMessage(messages.missingFilesWarningTitle) }}
|
||||
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
|
||||
{{ missingPackMembers.length }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="bg-bg-orange px-4 pb-4 pt-3">
|
||||
<p class="m-0 text-sm leading-6 text-secondary">
|
||||
{{ formatMessage(messages.missingFilesWarningBody) }}
|
||||
</p>
|
||||
<ul class="m-0 mt-2 flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0">
|
||||
<li
|
||||
v-for="item in missingPackMembers"
|
||||
:key="item.memberId ?? item.expectedRelativePath"
|
||||
class="flex min-h-14 min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-surface-5/40"
|
||||
>
|
||||
<FileIcon class="size-5 shrink-0 text-brand-orange" aria-hidden="true" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-contrast" :title="item.expectedRelativePath">
|
||||
{{ fileNameFromPath(item.expectedRelativePath) }}
|
||||
</span>
|
||||
<code class="truncate text-xs text-secondary" :title="item.expectedRelativePath">
|
||||
{{ item.expectedRelativePath }}
|
||||
</code>
|
||||
</span>
|
||||
<ButtonStyled size="small" type="highlight-colored-text" color="orange">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!item.memberId || isInstanceBusy || isRestoringMissingPackMember(item)"
|
||||
@click="restoreMissingPackMember(item)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="isRestoringMissingPackMember(item)"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<UndoIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.restoreMissingFile) }}
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
|
||||
<p class="m-0">{{ formatMessage(messages.postUpgradeNoticeBody) }}</p>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<ButtonStyled color="orange" size="small">
|
||||
<button type="button" @click="dismissPostUpgradeNotice">
|
||||
{{ formatMessage(messages.ignoreAllPostUpgradeWarnings) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<CollapsibleAdmonition
|
||||
v-if="contentWarnings.length > 0"
|
||||
v-model="contentWarningExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>{{ formatMessage(messages.contentRefreshWarningTitle) }}</template>
|
||||
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
|
||||
<p class="m-0">{{ formatMessage(messages.contentRefreshWarningBody) }}</p>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<ContentPageLayout @visible-items="handleVisibleItems">
|
||||
<template #modals>
|
||||
<ContentToggleDependenciesModal ref="toggleDependenciesModal" />
|
||||
<DependencyGraphModal
|
||||
ref="dependencyGraphModal"
|
||||
:instance-id="props.instance.id"
|
||||
:instance-name="props.instance.name"
|
||||
:instance-icon-url="localContentIconUrl(props.instance.icon_path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<CollapsibleAdmonition
|
||||
v-if="skippedManualDownloads.length > 0"
|
||||
v-model="manualWarningExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ formatMessage(messages.skippedFilesWarningTitle) }}
|
||||
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
|
||||
{{ skippedManualDownloads.length }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
|
||||
<p class="m-0">{{ skippedFilesWarningBody }}</p>
|
||||
<ul class="mb-0 mt-2 flex list-none flex-col gap-1 p-0">
|
||||
<li
|
||||
v-for="item in visibleSkippedManualDownloads"
|
||||
:key="`${item.projectId}:${item.fileId}`"
|
||||
class="min-w-0"
|
||||
>
|
||||
<button
|
||||
class="inline-flex max-w-full cursor-pointer items-center gap-1 text-left font-semibold text-brand hover:underline"
|
||||
@click="openManualCurseForgeDownload(item)"
|
||||
>
|
||||
<span class="truncate">{{ item.fileName }}</span>
|
||||
<ExternalIcon class="size-4 shrink-0" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="hiddenSkippedManualDownloadCount > 0" class="mb-0 mt-2 text-secondary">
|
||||
{{
|
||||
formatMessage(messages.skippedFilesWarningMore, {
|
||||
count: hiddenSkippedManualDownloadCount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<ButtonStyled color="orange" size="small">
|
||||
<button @click="openManualCurseForgeResolver">
|
||||
<FolderSearchIcon />
|
||||
{{ formatMessage(messages.completeSkippedFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<CollapsibleAdmonition
|
||||
v-if="missingPackMembers.length > 0"
|
||||
v-model="missingWarningExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
{{ formatMessage(messages.missingFilesWarningTitle) }}
|
||||
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
|
||||
{{ missingPackMembers.length }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="bg-bg-orange px-4 pb-4 pt-3">
|
||||
<p class="m-0 text-sm leading-6 text-secondary">
|
||||
{{ formatMessage(messages.missingFilesWarningBody) }}
|
||||
</p>
|
||||
<ul class="m-0 mt-2 flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0">
|
||||
<li
|
||||
v-for="item in missingPackMembers"
|
||||
:key="item.memberId ?? item.expectedRelativePath"
|
||||
class="flex min-h-14 min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-surface-5/40"
|
||||
>
|
||||
<FileIcon class="size-5 shrink-0 text-brand-orange" aria-hidden="true" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-contrast" :title="item.expectedRelativePath">
|
||||
{{ fileNameFromPath(item.expectedRelativePath) }}
|
||||
</span>
|
||||
<code class="truncate text-xs text-secondary" :title="item.expectedRelativePath">
|
||||
{{ item.expectedRelativePath }}
|
||||
</code>
|
||||
</span>
|
||||
<ButtonStyled size="small" type="highlight-colored-text" color="orange">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!item.memberId || isInstanceBusy || isRestoringMissingPackMember(item)"
|
||||
@click="restoreMissingPackMember(item)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="isRestoringMissingPackMember(item)"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<UndoIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.restoreMissingFile) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<CollapsibleAdmonition
|
||||
v-if="contentWarnings.length > 0"
|
||||
v-model="contentWarningExpanded"
|
||||
type="warning"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #header>{{ formatMessage(messages.contentRefreshWarningTitle) }}</template>
|
||||
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
|
||||
<p class="m-0">{{ formatMessage(messages.contentRefreshWarningBody) }}</p>
|
||||
</div>
|
||||
</CollapsibleAdmonition>
|
||||
<ContentPageLayout @visible-items="handleVisibleItems">
|
||||
<template #modals>
|
||||
<ContentToggleDependenciesModal ref="toggleDependenciesModal" />
|
||||
<DependencyGraphModal
|
||||
ref="dependencyGraphModal"
|
||||
:instance-id="props.instance.id"
|
||||
:instance-name="props.instance.name"
|
||||
:instance-icon-url="localContentIconUrl(props.instance.icon_path)"
|
||||
/>
|
||||
|
||||
<ShareModalWrapper
|
||||
ref="shareModal"
|
||||
:share-title="formatMessage(messages.shareTitle)"
|
||||
:share-text="formatMessage(messages.shareText)"
|
||||
:open-in-new-tab="false"
|
||||
/>
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="displayedModpackProject?.title"
|
||||
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
|
||||
:enable-toggle="!props.isServerInstance"
|
||||
:busy="isBulkOperating"
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
|
||||
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
|
||||
/>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateConfirmModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
:backup-tip="
|
||||
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
"
|
||||
:symlink-target="props.instance.symlink_target"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ContentUpdaterModal
|
||||
v-if="updatingProject || updatingModpack"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="updatingProjectVersions"
|
||||
:current-game-version="instance.game_version"
|
||||
:current-loader="instance.loader"
|
||||
:current-version-id="
|
||||
updatingModpack
|
||||
? (instance.link?.version_id ?? '')
|
||||
: (updatingProject?.version?.id ?? '')
|
||||
"
|
||||
:is-app="true"
|
||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||
:project-icon-url="
|
||||
updatingModpack ? displayedModpackProject?.icon_url : updatingProject?.project?.icon_url
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
|
||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
@version-hover="handleVersionHover"
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
</ReadyTransition>
|
||||
<ShareModalWrapper
|
||||
ref="shareModal"
|
||||
:share-title="formatMessage(messages.shareTitle)"
|
||||
:share-text="formatMessage(messages.shareText)"
|
||||
:open-in-new-tab="false"
|
||||
/>
|
||||
<ModpackContentModal
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="displayedModpackProject?.title"
|
||||
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
|
||||
:enable-toggle="!props.isServerInstance"
|
||||
:busy="isBulkOperating"
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
|
||||
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
|
||||
/>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateConfirmModal"
|
||||
:downgrade="isModpackUpdateDowngrade"
|
||||
:backup-tip="
|
||||
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
"
|
||||
:symlink-target="props.instance.symlink_target"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ContentUpdaterModal
|
||||
v-if="updatingProject || updatingModpack"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="updatingProjectVersions"
|
||||
:current-game-version="instance.game_version"
|
||||
:current-loader="instance.loader"
|
||||
:current-version-id="
|
||||
updatingModpack
|
||||
? (instance.link?.version_id ?? '')
|
||||
: (updatingProject?.version?.id ?? '')
|
||||
"
|
||||
:is-app="true"
|
||||
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
|
||||
:project-icon-url="
|
||||
updatingModpack
|
||||
? displayedModpackProject?.icon_url
|
||||
: updatingProject?.project?.icon_url
|
||||
"
|
||||
:project-name="
|
||||
updatingModpack
|
||||
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
|
||||
: (updatingProject?.project?.title ?? updatingProject?.file_name)
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
@version-hover="handleVersionHover"
|
||||
/>
|
||||
</template>
|
||||
</ContentPageLayout>
|
||||
</ReadyTransition>
|
||||
</ModManagementSwitch>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@ -259,6 +264,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import DependencyGraphModal from '@/components/instance/dependencies/DependencyGraphModal.vue'
|
||||
import HostedModpacks from '@/components/instance/HostedModpacks.vue'
|
||||
import ModManagementSwitch from '@/components/instance/ModManagementSwitch.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleDependenciesModal.vue'
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
@ -1401,8 +1408,7 @@ async function getUpdaterProjectVersions(
|
||||
|
||||
if (!versions) {
|
||||
versions = (await get_project_versions(projectId).catch(() => null)) as
|
||||
| Labrinth.Versions.v2.Version[]
|
||||
| null
|
||||
Labrinth.Versions.v2.Version[] | null
|
||||
}
|
||||
|
||||
if (!versions && fetchError) {
|
||||
@ -1636,7 +1642,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
|
||||
file_name: newFileName,
|
||||
enabled: actualEnabled,
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
|
||||
file_path: originalFilePath,
|
||||
@ -1805,7 +1810,6 @@ async function removeMod(mod: ContentItem) {
|
||||
await remove_content_entry(props.instance.id, contentId)
|
||||
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
@ -1966,7 +1970,6 @@ async function updateProject(mod: ContentItem) {
|
||||
|
||||
try {
|
||||
await update_content_entry(props.instance.id, contentId)
|
||||
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
throw err
|
||||
@ -1984,7 +1987,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
||||
|
||||
try {
|
||||
await switch_content_entry_version(props.instance.id, contentId, version.id)
|
||||
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
@ -2750,8 +2752,8 @@ provideContentManager({
|
||||
const instanceLink = props.instance.link
|
||||
const projectPath =
|
||||
instanceLink?.type === 'curseforge_modpack'
|
||||
? `/project/curseforge/${instanceLink.project_id}`
|
||||
: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`
|
||||
? `/project/curseforge/${instanceLink.project_id}`
|
||||
: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`
|
||||
|
||||
return {
|
||||
project: displayedModpackProject.value ?? linkedModpackProject.value,
|
||||
|
||||
@ -112,16 +112,20 @@ export function setupCreationModal(
|
||||
}
|
||||
|
||||
provide('showCreationModal', () => {
|
||||
creationInstanceMode.value = 'local'
|
||||
installationModal.value?.show()
|
||||
})
|
||||
const creationInstanceMode = ref<'starlight' | 'local'>('local')
|
||||
|
||||
provide(
|
||||
'showCreationModalWithOptions',
|
||||
(options?: {
|
||||
skipSetupType?: boolean
|
||||
initialMode?: 'custom' | 'import'
|
||||
instanceMode?: 'starlight' | 'local'
|
||||
onBack?: () => void
|
||||
}) => {
|
||||
creationInstanceMode.value = options?.instanceMode ?? 'local'
|
||||
installationModal.value?.show(options)
|
||||
},
|
||||
)
|
||||
@ -145,6 +149,7 @@ export function setupCreationModal(
|
||||
}
|
||||
|
||||
async function handleCreate(config: CreationFlowContextValue) {
|
||||
const instanceMode = creationInstanceMode.value
|
||||
try {
|
||||
installationModal.value?.hide()
|
||||
|
||||
@ -262,7 +267,8 @@ export function setupCreationModal(
|
||||
: gameRoot
|
||||
: null
|
||||
|
||||
await install_create_instance({
|
||||
const job = await install_create_instance({
|
||||
instanceMode,
|
||||
name,
|
||||
gameVersion: config.selectedGameVersion.value!,
|
||||
loader: loader as InstanceLoader,
|
||||
@ -276,7 +282,14 @@ export function setupCreationModal(
|
||||
iconPath,
|
||||
gameDirOverride,
|
||||
}).catch(handleError)
|
||||
|
||||
if (instanceMode === 'starlight' && job?.instance_id) {
|
||||
try {
|
||||
localStorage.setItem('starlight:instance:mod-management-tab', 'packs')
|
||||
} catch {
|
||||
/* Optional navigation preference. */
|
||||
}
|
||||
await router.push(`/instance/${encodeURIComponent(job.instance_id)}/`)
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user