feat: 跨实例同步移植第8轮(前端 helpers + 同步设置 UI)
Sync source to CNB / Sync Git ref (push) Has been cancelled

新增同步 helpers(synced-options / synced-packs / game-options),在 instance helper 中补充同步类型与 API。新增实例设置「同步」标签页(SyncSettings)及 SyncSource / SyncedPacks / servers / command-history 四个弹窗,并把 CheckCircleButton、TeleportOverflowMenu 从 ui 包导出。同步相关的 en-US / zh-CN 文案已补齐(各 62 条)。game-settings-modal 留待下一轮。
This commit is contained in:
AxTps committed 2026-10-04 08:51:45 +08:00
1 parent f7380f1590
commit 83ee962c16
14 files changed
+2238 -1

No files matched your search

@@ -0,0 +1,607 @@
<script setup lang="ts">
import { EditIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
IconButton,
injectNotificationManager,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onScopeDispose, ref } from 'vue'
import { gameSettingsKeys } from '@/helpers/game-options'
import {
type GlobalSyncedOptions,
isSyncedOptionAvailable,
set_global_synced_option,
type SyncedOption,
} from '@/helpers/instance'
import {
canSourceMultiplayerServers,
gameOptionsSyncSourcesQueryOptions,
globalSyncedOptionsQueryOptions,
initializedSyncedOptionsQueryOptions,
syncedOptionsKeys,
syncedServersQueryOptions,
} from '@/helpers/synced-options'
import { syncedPackKeys, syncedPackQueryOptions } from '@/helpers/synced-packs'
import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options'
import SettingsRow from '../settings/SettingsRow.vue'
import SettingsSection from '../settings/SettingsSection.vue'
import CommandHistoryModal from '../settings/instances/command-history-modal.vue'
import SyncedServersModal from '../settings/instances/servers-modal.vue'
import SyncedPacksModal from '../settings/instances/SyncedPacksModal.vue'
import SyncSourceModal from '../settings/instances/SyncSourceModal.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const syncedPacksModal = ref<InstanceType<typeof SyncedPacksModal>>()
const commandHistoryModal = ref<InstanceType<typeof CommandHistoryModal>>()
const syncedServersModal = ref<InstanceType<typeof SyncedServersModal>>()
const messages = defineMessages({
globalOptionsTitle: {
id: 'app.settings.synced-options.title',
defaultMessage: 'Options synced across all instances',
},
resourcePacks: {
id: 'app.settings.synced-options.resource-packs',
defaultMessage: 'Sync resource packs',
},
resourcePacksDescription: {
id: 'app.settings.synced-options.resource-packs.description',
defaultMessage: 'Use the same resource packs across your instances.',
},
dataPacks: { id: 'app.settings.synced-options.data-packs', defaultMessage: 'Sync data packs' },
dataPacksDescription: {
id: 'app.settings.synced-options.data-packs.description',
defaultMessage: 'Use the same datapacks across your instances',
},
packsDisabled: {
id: 'app.settings.synced-options.packs.disabled',
defaultMessage: 'Enable syncing before editing shared packs.',
},
gameSettings: {
id: 'app.settings.synced-options.game-settings',
defaultMessage: 'Sync game options',
},
gameSettingsDescription: {
id: 'app.settings.synced-options.game-settings.description',
defaultMessage: 'Use the same game options across your instances.',
},
gameSettingsButton: {
id: 'app.settings.synced-options.game-settings.button',
defaultMessage: 'Edit game settings',
},
gameSettingsDisabledTooltip: {
id: 'app.settings.synced-options.game-settings.disabled-tooltip',
defaultMessage: 'Enable game settings sync before choosing individual settings.',
},
noGameOptionsToEdit: {
id: 'app.settings.synced-options.game-settings.no-options-to-edit',
defaultMessage: "You haven't got any options yet to edit",
},
multiplayerServers: {
id: 'app.settings.synced-options.multiplayer-servers',
defaultMessage: 'Sync multiplayer servers',
},
multiplayerServersDescription: {
id: 'app.settings.synced-options.multiplayer-servers.description',
defaultMessage: 'Use the same multiplayer servers across your instances.',
},
commandHistory: {
id: 'app.settings.synced-options.command-history',
defaultMessage: 'Sync command history',
},
commandHistoryDescription: {
id: 'app.settings.synced-options.command-history.description',
defaultMessage: 'Use the same command history across your instances.',
},
creativeHotbars: {
id: 'app.settings.synced-options.creative-hotbars',
defaultMessage: 'Sync saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Use the same saved creative hotbars across your instances.',
},
gameSettingsSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.game-settings-title',
defaultMessage: 'Choose game settings source',
},
multiplayerServersSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
defaultMessage:
'Choose which instance to copy your multiplayer servers from. These servers are only used for the initial sync, and you can edit them from any instance afterward.',
},
multiplayerServersSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-title',
defaultMessage: 'Choose multiplayer servers source',
},
commandHistorySyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
defaultMessage:
'Choose which instance to copy your command history from. This history is only used for the initial sync, and you can edit it from any instance afterward.',
},
commandHistorySyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.command-history-title',
defaultMessage: 'Choose command history source',
},
creativeHotbarsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
defaultMessage:
'Choose which instance to copy your saved creative hotbars from. These hotbars are only used for the initial sync, and you can edit them from any instance afterward.',
},
creativeHotbarsSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-title',
defaultMessage: 'Choose creative hotbars source',
},
gameSettingsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.game-settings-description',
defaultMessage:
'Choose which instance to copy your game settings from. These settings are only used for the initial sync, and you can edit them from any instance afterward.',
},
resourcePacksSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.resource-packs-title',
defaultMessage: 'Choose resource packs source',
},
resourcePacksSyncSourceDescription: {
id: 'app.sync-instances-update.choose-source.resource-packs',
defaultMessage:
'Choose which instance to copy your resource packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
},
dataPacksSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.data-packs-title',
defaultMessage: 'Choose data packs source',
},
dataPacksSyncSourceDescription: {
id: 'app.sync-instances-update.choose-source.data-packs',
defaultMessage:
'Choose which instance to copy your data packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
},
noServersSyncedYet: {
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
defaultMessage: "You haven't synced any servers yet",
},
noResourcePacksSyncedYet: {
id: 'app.settings.synced-options.resource-packs.none-synced-yet',
defaultMessage: "You haven't synced any resource packs yet",
},
noDataPacksSyncedYet: {
id: 'app.settings.synced-options.data-packs.none-synced-yet',
defaultMessage: "You haven't synced any data packs yet",
},
noInstancesToEdit: {
id: 'app.settings.synced-options.edit.no-instances',
defaultMessage: 'Add an instance before editing synced data.',
},
noSyncedDataToEdit: {
id: 'app.settings.synced-options.edit.no-synced-data',
defaultMessage: 'Choose a sync source before editing this setting.',
},
})
const globalRows: Array<{
option: SyncedOption
title: keyof typeof messages
description?: keyof typeof messages
editable?: 'game-settings' | 'servers' | 'commands' | 'resourcepack' | 'datapack'
}> = [
{
option: 'game_options',
title: 'gameSettings',
description: 'gameSettingsDescription',
editable: 'game-settings',
},
{
option: 'multiplayer_servers',
title: 'multiplayerServers',
description: 'multiplayerServersDescription',
editable: 'servers',
},
{
option: 'resource_packs',
title: 'resourcePacks',
description: 'resourcePacksDescription',
editable: 'resourcepack',
},
{
option: 'data_packs',
title: 'dataPacks',
description: 'dataPacksDescription',
editable: 'datapack',
},
{
option: 'command_history',
title: 'commandHistory',
description: 'commandHistoryDescription',
editable: 'commands',
},
{
option: 'creative_hotbars',
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
]
const availableGlobalRows = globalRows.filter((row) => isSyncedOptionAvailable(row.option))
const defaultGlobalOptions: GlobalSyncedOptions = {
resource_packs: false,
data_packs: false,
game_options: false,
command_history: false,
multiplayer_servers: false,
creative_hotbars: false,
screenshots: false,
}
const globalOptionsQuery = useQuery(globalSyncedOptionsQueryOptions())
const initializedOptionsQuery = useQuery(initializedSyncedOptionsQueryOptions())
const gameOptionSourcesQuery = useQuery(gameOptionsSyncSourcesQueryOptions())
const resourcePacksQuery = useQuery(syncedPackQueryOptions('resourcepack'))
const dataPacksQuery = useQuery(syncedPackQueryOptions('datapack'))
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
const initializedOptions = computed(
() => initializedOptionsQuery.data.value ?? defaultGlobalOptions,
)
const gameOptionSources = computed(() => gameOptionSourcesQuery.data.value ?? [])
const instancesQuery = useQuery(instanceListQueryOptions())
const instances = computed(() => instancesQuery.data.value ?? [])
const serversQuery = useQuery(syncedServersQueryOptions())
const syncedServers = computed(() => serversQuery.data.value ?? [])
const eligibleGameOptionSourceIds = computed(
() =>
new Set(
gameOptionSources.value.filter((source) => source.eligible).map((source) => source.source_id),
),
)
const hasGameOptionsToEdit = computed(
() =>
initializedOptions.value.game_options ||
instances.value.some(
(instance) =>
instance?.synced_options?.game_options &&
eligibleGameOptionSourceIds.value.has(instance.id),
),
)
const baseOption = ref<SyncedOption | null>(null)
const baseInstanceId = ref('')
const baseModal = ref<InstanceType<typeof SyncSourceModal> | null>(null)
const baseSourcesLoading = computed(() =>
baseOption.value === null
? false
: baseOption.value === 'game_options'
? gameOptionSourcesQuery.isPending.value
: instancesQuery.isPending.value,
)
const baseSourcesError = computed(() =>
baseOption.value === 'game_options'
? gameOptionSourcesQuery.isError.value && !gameOptionSourcesQuery.data.value
: instancesQuery.isError.value && !instancesQuery.data.value,
)
let baseSourceGeneration = 0
const baseInstanceTitle = computed(() => {
switch (baseOption.value) {
case 'game_options':
return formatMessage(messages.gameSettingsSyncSourceTitle)
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceTitle)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceTitle)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceTitle)
case 'resource_packs':
return formatMessage(messages.resourcePacksSyncSourceTitle)
case 'data_packs':
return formatMessage(messages.dataPacksSyncSourceTitle)
default:
return ''
}
})
const baseInstanceDescription = computed(() => {
switch (baseOption.value) {
case 'game_options':
return formatMessage(messages.gameSettingsSyncSourceDescription)
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceDescription)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceDescription)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
case 'resource_packs':
return formatMessage(messages.resourcePacksSyncSourceDescription)
case 'data_packs':
return formatMessage(messages.dataPacksSyncSourceDescription)
default:
return ''
}
})
const baseInstances = computed(() =>
baseOption.value === 'game_options'
? gameOptionSources.value.map((source) => ({
id: source.source_id,
name: source.name,
icon_path: source.icon_path,
eligible: source.eligible,
}))
: instances.value.map((instance) => ({
id: instance.id,
name: instance.name,
icon_path: instance.icon_path,
eligible:
!instance.quarantined &&
(baseOption.value !== 'multiplayer_servers' || canSourceMultiplayerServers(instance)),
})),
)
async function invalidateSyncedOptions() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }),
queryClient.invalidateQueries({ queryKey: ['worlds'] }),
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
])
}
function emptySyncedContentMessage(row: (typeof globalRows)[number]) {
if (
row.editable === 'servers' &&
(instances.value.length === 0 || syncedServers.value.length === 0)
) {
return messages.noServersSyncedYet
}
if (row.editable === 'resourcepack' && !resourcePacksQuery.data.value?.length) {
return messages.noResourcePacksSyncedYet
}
if (row.editable === 'datapack' && !dataPacksQuery.data.value?.length) {
return messages.noDataPacksSyncedYet
}
}
function canEditGlobalOption(row: (typeof globalRows)[number]): boolean {
if (!row.editable || !globalOptions.value[row.option]) return false
if (emptySyncedContentMessage(row)) return false
if (row.editable === 'resourcepack' || row.editable === 'datapack') return true
if (row.editable === 'game-settings') return hasGameOptionsToEdit.value
return instances.value.length > 0 && initializedOptions.value[row.option]
}
function editGlobalOptionTooltip(row: (typeof globalRows)[number]): string {
const emptyMessage = emptySyncedContentMessage(row)
if (emptyMessage) return formatMessage(emptyMessage)
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
return formatMessage(
globalOptions.value[row.option] ? commonMessages.editButton : messages.packsDisabled,
)
}
if (row.editable === 'game-settings') {
if (!hasGameOptionsToEdit.value) return formatMessage(messages.noGameOptionsToEdit)
if (!globalOptions.value[row.option]) {
return formatMessage(messages.gameSettingsDisabledTooltip)
}
return formatMessage(messages.gameSettingsButton)
}
if (instances.value.length === 0) {
return formatMessage(messages.noInstancesToEdit)
}
if (!initializedOptions.value[row.option]) {
return formatMessage(messages.noSyncedDataToEdit)
}
return formatMessage(commonMessages.editButton)
}
type GlobalOptionMutationVariables = {
option: SyncedOption
enabled: boolean
baseInstanceId?: string
}
const globalOptionMutation = useMutation({
mutationKey: syncedOptionsKeys.set,
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled, baseInstanceId),
onError: handleError,
onSuccess: async (options, { option, enabled }) => {
queryClient.setQueryData(syncedOptionsKeys.global, options)
if (option === 'game_options') {
await queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
}
if (enabled && option === 'multiplayer_servers') {
await queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.servers })
}
},
onSettled: async (_data, _error, { option }) => {
if (queryClient.isMutating({ mutationKey: syncedOptionsKeys.set }) === 1) {
if (option === 'resource_packs' || option === 'data_packs') {
void Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
])
return
}
await invalidateSyncedOptions()
}
},
})
const canToggleGlobalOptions = computed(
() =>
!!globalOptionsQuery.data.value &&
!globalOptionMutation.isPending.value &&
!baseSourcesLoading.value,
)
async function chooseBaseInstance(option: SyncedOption) {
const generation = ++baseSourceGeneration
baseOption.value = option
baseInstanceId.value = ''
try {
if (option === 'game_options') {
await queryClient.fetchQuery(gameOptionsSyncSourcesQueryOptions())
} else {
await queryClient.fetchQuery({ ...instanceListQueryOptions(), staleTime: 0 })
}
} catch (error) {
if (generation !== baseSourceGeneration) return
handleError(error)
baseModal.value?.show()
return
}
if (generation !== baseSourceGeneration) return
const eligibleSources = baseInstances.value.filter((source) => source.eligible)
baseInstanceId.value = eligibleSources[0]?.id ?? ''
if (eligibleSources.length > 1) {
baseModal.value?.show()
return
}
try {
await globalOptionMutation.mutateAsync({
option,
enabled: true,
baseInstanceId: eligibleSources[0]?.id,
})
await nextTick()
if (!baseModal.value?.hide()) clearBaseSource()
} catch {
return
}
}
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
if (!isSyncedOptionAvailable(option) || !canToggleGlobalOptions.value) return
if (enabled && option !== 'screenshots') {
void chooseBaseInstance(option)
return
}
globalOptionMutation.mutate({ option, enabled })
}
async function confirmBaseInstance() {
if (!baseOption.value || !baseInstanceId.value) return
try {
await globalOptionMutation.mutateAsync({
option: baseOption.value,
enabled: true,
baseInstanceId: baseInstanceId.value,
})
await nextTick()
baseModal.value?.hide()
} catch {
return
}
}
function editGlobalOption(row: (typeof globalRows)[number]) {
if (!isSyncedOptionAvailable(row.option)) return
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
void syncedPacksModal.value?.show(row.editable)
} else if (row.editable === 'game-settings') {
// Game settings modal is ported in a later pass.
return
} else if (row.editable === 'commands') {
void commandHistoryModal.value?.show()
} else {
void syncedServersModal.value?.show()
}
}
function clearBaseSource() {
baseSourceGeneration++
baseOption.value = null
}
onScopeDispose(clearBaseSource)
</script>
<template>
<div class="flex flex-col gap-6">
<SyncedPacksModal ref="syncedPacksModal" />
<CommandHistoryModal ref="commandHistoryModal" />
<SyncedServersModal ref="syncedServersModal" />
<SyncSourceModal
ref="baseModal"
v-model="baseInstanceId"
:title="baseInstanceTitle"
:description="baseInstanceDescription"
:sources="baseInstances"
:loading="baseSourcesLoading"
:error="baseSourcesError"
:pending="globalOptionMutation.isPending.value"
@confirm="confirmBaseInstance"
@close="clearBaseSource"
@retry="baseOption && chooseBaseInstance(baseOption)"
/>
<SettingsSection>
<template #header>
<h2
id="settings-target-instance-sync-options"
tabindex="-1"
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ formatMessage(messages.globalOptionsTitle) }}
</h2>
</template>
<SettingsRow v-for="row in availableGlobalRows" :key="row.option">
<template #label>
<span :id="`settings-target-instance-sync-${row.option}`" tabindex="-1">
{{ formatMessage(messages[row.title]) }}
</span>
</template>
<template v-if="row.description" #description>
{{ formatMessage(messages[row.description]) }}
</template>
<template #control>
<span v-if="row.editable" v-tooltip="editGlobalOptionTooltip(row)" class="flex">
<IconButton
type="outlined"
circular
:disabled="
!canEditGlobalOption(row) ||
initializedOptionsQuery.isPending.value ||
globalOptionMutation.isPending.value
"
:label="
formatMessage(
row.editable === 'game-settings'
? messages.gameSettingsButton
: commonMessages.editButton,
)
"
@click="editGlobalOption(row)"
>
<EditIcon aria-hidden="true" />
</IconButton>
</span>
<Toggle
:id="`global-sync-${row.option}`"
:model-value="globalOptions[row.option]"
:disabled="!canToggleGlobalOptions"
:aria-label="formatMessage(messages[row.title])"
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
/>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>
@@ -7,6 +7,7 @@ import {
FileArchiveIcon,
InfoIcon,
MonitorIcon,
RefreshCwIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -23,6 +24,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import SyncSettings from '@/components/ui/instance_settings/SyncSettings.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'
@@ -147,6 +149,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: CodeIcon,
content: HooksSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sync',
defaultMessage: 'Sync',
}),
icon: RefreshCwIcon,
content: SyncSettings,
},
])
function getSupportedModpackLoaders() {
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { RefreshCwIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
CheckCircleButton,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
const props = defineProps<{
title: string
description: string
sources: {
id: string
name: string
icon_path?: string | null
eligible: boolean
}[]
loading?: boolean
pending?: boolean
error?: boolean
}>()
const emit = defineEmits<{
confirm: []
close: []
retry: []
}>()
const selectedInstanceId = defineModel<string>({ required: true })
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const search = ref('')
const { formatMessage } = useVIntl()
const messages = defineMessages({
ineligible: {
id: 'app.settings.synced-options.choose-sync-source.ineligible',
defaultMessage: 'You cannot choose this instance as a sync source',
},
search: {
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
defaultMessage: 'Search instance',
},
empty: {
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
defaultMessage: 'No instances found',
},
sync: {
id: 'app.settings.synced-options.choose-sync-source.sync',
defaultMessage: 'Sync',
},
loadError: {
id: 'app.settings.synced-options.choose-sync-source.load-error',
defaultMessage: 'Could not load sync sources. Please try again.',
},
retry: {
id: 'app.settings.synced-options.choose-sync-source.retry',
defaultMessage: 'Try again',
},
})
const filteredSources = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.sources.filter((source) => source.name.toLocaleLowerCase().includes(query))
})
const canConfirm = computed(
() =>
!props.loading &&
!props.pending &&
!props.error &&
props.sources.some((source) => source.id === selectedInstanceId.value && source.eligible),
)
function show() {
search.value = ''
modal.value?.show()
}
function hide() {
return modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="title"
no-padding
actions-divider
max-width="560px"
width="560px"
:disable-close="pending"
:on-after-hide="() => emit('close')"
>
<p
class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-[var(--color-text-default)]"
>
{{ description }}
</p>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.search)"
:aria-label="formatMessage(messages.search)"
class="shrink-0"
/>
<div v-if="loading" class="flex flex-1 items-center justify-center" aria-busy="true">
<SpinnerIcon class="size-5 animate-spin text-[var(--color-text-tertiary)]" />
</div>
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3">
<p role="alert" class="m-0 text-center text-[var(--color-text-default)]">
{{ formatMessage(messages.loadError) }}
</p>
<Button @click="emit('retry')">{{ formatMessage(messages.retry) }}</Button>
</div>
<div
v-else-if="filteredSources.length === 0"
class="flex flex-1 items-center justify-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else role="radiogroup" :aria-label="title" class="flex flex-col gap-1">
<div
v-for="source in filteredSources"
:key="source.id"
v-tooltip="!source.eligible ? formatMessage(messages.ineligible) : undefined"
>
<CheckCircleButton
:checked="selectedInstanceId === source.id"
:disabled="pending || !source.eligible"
class="min-h-10"
@click="selectedInstanceId = source.id"
>
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
<Avatar
:src="getInstanceIconUrl(source.icon_path)"
:alt="source.name"
:tint-by="source.id"
size="1.25rem"
no-shadow
/>
</span>
<span class="min-w-0 flex-1 truncate">{{ source.name }}</span>
</CheckCircleButton>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2 p-2">
<Button type="outlined" :disabled="pending" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!canConfirm"
:loading="pending"
@click="canConfirm && emit('confirm')"
>
<RefreshCwIcon :class="{ 'animate-spin': pending }" aria-hidden="true" />
{{ formatMessage(messages.sync) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Button, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import {
remove_synced_pack,
set_synced_pack_enabled,
syncedPackKeys,
syncedPackQueryOptions,
type SyncedPackType,
} from '@/helpers/synced-packs'
const modal = ref<InstanceType<typeof NewModal>>()
const type = ref<SyncedPackType>('resourcepack')
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const messages = defineMessages({
resourcePacks: {
id: 'app.settings.synced-packs.resource-packs',
defaultMessage: 'Resource packs',
},
dataPacks: { id: 'app.settings.synced-packs.data-packs', defaultMessage: 'Data packs' },
enable: { id: 'app.settings.synced-packs.enable', defaultMessage: 'Enable' },
disable: { id: 'app.settings.synced-packs.disable', defaultMessage: 'Disable' },
remove: { id: 'app.settings.synced-packs.remove', defaultMessage: 'Remove' },
empty: { id: 'app.settings.synced-packs.empty', defaultMessage: 'No synced packs' },
})
const query = useQuery(computed(() => syncedPackQueryOptions(type.value)))
const packs = computed(() => query.value.data.value ?? [])
const mutation = useMutation({
mutationFn: async (request: { id: string; enabled?: boolean; remove?: boolean }) => {
if (request.remove) await remove_synced_pack(request.id)
else await set_synced_pack_enabled(request.id, request.enabled ?? true)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
})
async function show(nextType: SyncedPackType) {
type.value = nextType
modal.value?.show()
await queryClient.invalidateQueries({ queryKey: syncedPackKeys.list(nextType) })
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(type === 'resourcepack' ? messages.resourcePacks : messages.dataPacks)"
max-width="42rem"
>
<div v-if="packs.length" class="flex flex-col gap-2">
<div
v-for="pack in packs"
:key="pack.id"
class="flex items-center gap-3 border-b border-solid border-surface-4 py-2"
>
<span class="min-w-0 flex-1 truncate">{{ pack.file_name }}</span>
<Button
:disabled="mutation.isPending.value"
@click="mutation.mutate({ id: pack.id, enabled: !pack.enabled })"
>{{ formatMessage(pack.enabled ? messages.disable : messages.enable) }}
</Button>
<Button
type="colored"
color="red"
:disabled="mutation.isPending.value"
@click="mutation.mutate({ id: pack.id, remove: true })"
>{{ formatMessage(messages.remove) }}
</Button>
</div>
</div>
<p v-else class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.empty) }}</p>
</NewModal>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { nextTick, ref } from 'vue'
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
import { set_command_history } from '@/helpers/instance'
import { commandHistoryQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const commandHistory = ref('')
const historyQuery = useQuery({ ...commandHistoryQueryOptions(), enabled: false })
const saveMutation = useMutation({
mutationFn: set_command_history,
onSuccess: (history) => {
queryClient.setQueryData(syncedOptionsKeys.commandHistory, history)
},
onError: handleError,
})
const messages = defineMessages({
commandHistoryEditorTitle: {
id: 'app.settings.synced-options.command-history.editor-title',
defaultMessage: 'Edit command history',
},
})
async function show() {
const result = await historyQuery.refetch()
if (result.isError) {
handleError(result.error)
return
}
if (!result.isSuccess) return
commandHistory.value = result.data
modal.value?.show()
}
async function saveCommandHistory() {
if (saveMutation.isPending.value) return
try {
await saveMutation.mutateAsync(commandHistory.value)
await nextTick()
modal.value?.hide()
} catch {
return
}
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.commandHistoryEditorTitle)"
:disable-close="saveMutation.isPending.value"
no-padding
actions-divider
max-width="700px"
width="700px"
>
<StudioEditor
v-model:content="commandHistory"
file-path="command_history.txt"
language="plaintext"
class="h-[420px] text-sm"
/>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" :loading="saveMutation.isPending.value" @click="saveCommandHistory">
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,476 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
MoreVerticalIcon,
NoSignalIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SignalIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
type ButtonMenuOption,
commonMessages,
defineMessages,
FilterPills,
injectNotificationManager,
NewModal,
StyledInput,
Table,
type TableColumn,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { remove_synced_server, type SyncedServer, update_synced_server } from '@/helpers/instance'
import { syncedOptionsKeys, syncedServersQueryOptions } from '@/helpers/synced-options'
import { copyToClipboard } from '@/helpers/utils'
import {
refreshServerData,
refreshServers,
type ServerData,
type ServerWorld,
} from '@/helpers/worlds'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
const editedServer = ref<SyncedServer | null>(null)
const serversQuery = useQuery(syncedServersQueryOptions())
const syncedServers = computed(() => serversQuery.data.value ?? [])
const serverData = ref<Record<string, ServerData>>({})
const serverSearch = ref('')
const selectedServerFilters = ref<string[]>([])
const messages = defineMessages({
serverEditorTitle: {
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
defaultMessage: 'Edit synced servers',
},
editServerTitle: {
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
},
serverName: {
id: 'app.settings.synced-options.multiplayer-servers.name',
defaultMessage: 'Server name',
},
serverAddress: {
id: 'app.settings.synced-options.multiplayer-servers.address',
defaultMessage: 'Server address',
},
searchServers: {
id: 'app.settings.synced-options.multiplayer-servers.search',
defaultMessage:
'Search {count, number} {count, plural, one {synced server} other {synced servers}}...',
},
serverStatus: {
id: 'app.settings.synced-options.multiplayer-servers.status',
defaultMessage: 'Status',
},
onlineFilter: {
id: 'app.settings.synced-options.multiplayer-servers.filter-online',
defaultMessage: 'Online',
},
offlineFilter: {
id: 'app.settings.synced-options.multiplayer-servers.filter-offline',
defaultMessage: 'Offline',
},
playersOnline: {
id: 'app.settings.synced-options.multiplayer-servers.players-online',
defaultMessage: '{count, number} online',
},
noMatchingServers: {
id: 'app.settings.synced-options.multiplayer-servers.no-results',
defaultMessage: 'No synced servers match your search or filters.',
},
serverCount: {
id: 'app.settings.synced-options.multiplayer-servers.count',
defaultMessage: '{count, plural, one {# server} other {# servers}}',
},
moreServerOptions: {
id: 'app.settings.synced-options.multiplayer-servers.more-options',
defaultMessage: 'More server options',
},
copyServerAddress: {
id: 'app.settings.synced-options.multiplayer-servers.copy-address',
defaultMessage: 'Copy address',
},
noSyncedServers: {
id: 'app.settings.synced-options.multiplayer-servers.empty',
defaultMessage: 'No user-added servers are currently synced.',
},
})
const serverWorlds = computed<ServerWorld[]>(() =>
syncedServers.value.map((server, index) => ({
name: server.name,
type: 'server',
index,
server_id: server.id,
address: server.address,
pack_status:
server.accept_textures === true
? 'enabled'
: server.accept_textures === false
? 'disabled'
: 'prompt',
display_status: 'normal',
})),
)
type SyncedServerTableColumn = 'server' | 'status' | 'version' | 'actions'
type SyncedServerTableRow = SyncedServer & {
server: string
status: 'online' | 'offline'
version: string
actions: null
}
const syncedServerColumns = computed<TableColumn<SyncedServerTableColumn>[]>(() => [
{
key: 'server',
label: formatMessage(commonMessages.serverLabel),
width: '45%',
cellClass: '!h-16',
},
{
key: 'status',
label: formatMessage(messages.serverStatus),
width: '25%',
cellClass: '!h-16',
},
{
key: 'version',
label: formatMessage(commonMessages.versionLabel),
width: '22%',
cellClass: '!h-16',
},
{
key: 'actions',
label: formatMessage(commonMessages.actionsLabel),
align: 'right',
width: '6rem',
cellClass: '!h-16',
},
])
const syncedServerFilterOptions = computed(() => [
{ id: 'online', label: formatMessage(messages.onlineFilter) },
{ id: 'offline', label: formatMessage(messages.offlineFilter) },
])
const syncedServerRows = computed<SyncedServerTableRow[]>(() =>
syncedServers.value.map((server) => ({
...server,
server: server.name,
status: serverData.value[server.address]?.status ? 'online' : 'offline',
version: serverData.value[server.address]?.status?.version?.name ?? '',
actions: null,
})),
)
const statusFilteredSyncedServerRows = computed(() => {
if (selectedServerFilters.value.length === 0) return syncedServerRows.value
return syncedServerRows.value.filter((server) =>
selectedServerFilters.value.includes(server.status),
)
})
const filteredSyncedServerRows = computed(() => {
const search = serverSearch.value.trim().toLocaleLowerCase()
if (!search) return statusFilteredSyncedServerRows.value
return statusFilteredSyncedServerRows.value.filter(
(server) =>
server.name.toLocaleLowerCase().includes(search) ||
server.address.toLocaleLowerCase().includes(search),
)
})
const refreshMutation = useMutation({
mutationFn: () => refreshServers(serverWorlds.value, serverData.value, null),
onError: handleError,
})
const refreshingAllSyncedServers = refreshMutation.isPending
const saveMutation = useMutation({
mutationFn: update_synced_server,
onSuccess: async (_, server) => {
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
servers?.map((current) => (current.id === server.id ? { ...server } : current)),
)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
void refreshSyncedServer(server.address).catch(handleError)
},
onError: handleError,
})
const removeMutation = useMutation({
mutationFn: remove_synced_server,
onSuccess: async (_, serverId) => {
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
servers?.filter((server) => server.id !== serverId),
)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
},
onError: handleError,
})
async function show() {
const result = await serversQuery.refetch()
if (result.isError) {
handleError(result.error)
return
}
serverSearch.value = ''
selectedServerFilters.value = []
serverData.value = {}
serverEditorModal.value?.show()
refreshAllSyncedServers()
}
function openSyncedServerEditor(server: SyncedServer) {
editedServer.value = { ...server }
editServerModal.value?.show()
}
async function saveSyncedServer() {
if (!editedServer.value || saveMutation.isPending.value) return
try {
await saveMutation.mutateAsync({ ...editedServer.value })
await nextTick()
editServerModal.value?.hide()
} catch {
return
}
}
async function refreshSyncedServer(address: string) {
serverData.value[address] ??= { refreshing: true }
await refreshServerData(serverData.value[address], null, address)
}
function refreshAllSyncedServers() {
if (!refreshingAllSyncedServers.value) refreshMutation.mutate()
}
function syncedServerMenuOptions(server: SyncedServer): ButtonMenuOption[] {
return [
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
icon: RefreshCwIcon,
action: () => refreshSyncedServer(server.address).catch(handleError),
},
{
id: 'copy-address',
label: formatMessage(messages.copyServerAddress),
icon: ClipboardCopyIcon,
action: () => copyToClipboard(server.address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
icon: EditIcon,
action: () => openSyncedServerEditor(server),
},
{ type: 'divider' },
{
id: 'remove',
label: formatMessage(commonMessages.removeButton),
icon: TrashIcon,
tone: 'red',
disabled: removeMutation.isPending.value,
action: () => removeMutation.mutate(server.id),
},
]
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="serverEditorModal"
:header="formatMessage(messages.serverEditorTitle)"
no-padding
max-width="min(928px, calc(100vw - 2rem))"
width="min(928px, calc(100vw - 2rem))"
>
<div class="flex max-h-[min(600px,calc(95vh-10rem))] flex-col">
<div
class="flex shrink-0 flex-col gap-4 border-0 border-b border-solid border-surface-4 px-6 py-4"
>
<StyledInput
v-model="serverSearch"
:icon="SearchIcon"
type="search"
autocomplete="off"
:spellcheck="false"
:placeholder="formatMessage(messages.searchServers, { count: syncedServers.length })"
size="medium"
:aria-label="formatMessage(messages.searchServers, { count: syncedServers.length })"
:clear-label="formatMessage(commonMessages.clearButton)"
clearable
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<FilterPills v-model="selectedServerFilters" :options="syncedServerFilterOptions">
<template #all>
{{ formatMessage(commonMessages.allProjectType) }}
</template>
</FilterPills>
<Button
type="quiet"
:disabled="refreshingAllSyncedServers || syncedServers.length === 0"
interaction="none"
@click="refreshAllSyncedServers"
>
<RefreshCwIcon
:class="{ 'animate-spin': refreshingAllSyncedServers }"
aria-hidden="true"
/>
{{ formatMessage(commonMessages.refreshButton) }}
</Button>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
v-if="syncedServers.length === 0"
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noSyncedServers) }}
</div>
<div
v-else-if="filteredSyncedServerRows.length === 0"
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noMatchingServers) }}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<Table
:columns="syncedServerColumns"
:data="filteredSyncedServerRows"
row-key="id"
table-min-width="44rem"
class="!rounded-none !border-0"
>
<template #cell-server="{ row }">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="serverData[row.address]?.status?.favicon"
:alt="row.name"
:tint-by="row.address"
size="32px"
no-shadow
class="shrink-0 !rounded-lg"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
row.name
}}</span>
<span class="truncate text-sm text-[var(--color-text-tertiary)]">{{
row.address
}}</span>
</div>
</div>
</template>
<template #cell-status="{ row }">
<span class="inline-flex items-center gap-1.5 font-medium">
<RefreshCwIcon
v-if="serverData[row.address]?.refreshing"
class="size-4 shrink-0 animate-spin"
aria-hidden="true"
/>
<SignalIcon
v-else-if="serverData[row.address]?.status"
class="size-4 shrink-0 text-green"
aria-hidden="true"
/>
<NoSignalIcon v-else class="size-4 shrink-0" aria-hidden="true" />
<template v-if="serverData[row.address]?.refreshing">
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<template v-else-if="serverData[row.address]?.status">
{{
formatMessage(messages.playersOnline, {
count: serverData[row.address]?.status?.players?.online ?? 0,
})
}}
</template>
<template v-else>{{ formatMessage(messages.offlineFilter) }}</template>
</span>
</template>
<template #cell-version="{ row }">
<span class="block truncate">{{ row.version || '—' }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreServerOptions)"
:options="syncedServerMenuOptions(row)"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
</div>
</template>
</Table>
</div>
</div>
<div
class="flex shrink-0 items-center border-0 border-t border-solid border-surface-4 px-6 py-4"
>
<span class="font-medium text-[var(--color-text-default)]">
{{ formatMessage(messages.serverCount, { count: syncedServers.length }) }}
</span>
</div>
</div>
</NewModal>
<NewModal
ref="editServerModal"
:header="formatMessage(messages.editServerTitle)"
:disable-close="saveMutation.isPending.value"
max-width="500px"
width="500px"
>
<div v-if="editedServer" class="flex flex-col gap-4">
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.serverName) }}
<StyledInput v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
</label>
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.serverAddress) }}
<StyledInput v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="editServerModal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!editedServer?.address"
:loading="saveMutation.isPending.value"
@click="saveSyncedServer"
>
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
</template>