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>
|
||||
Reference in New Issue
Block a user