新增同步 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:
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>
|
||||
@@ -0,0 +1,244 @@
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export type GameOptionCanonicalValue =
|
||||
| { type: 'bool'; value: boolean }
|
||||
| { type: 'integer'; value: number }
|
||||
| { type: 'decimal'; value: string }
|
||||
| { type: 'enum'; value: string }
|
||||
| { type: 'text'; value: string }
|
||||
| { type: 'string_list'; value: string[] }
|
||||
| { type: 'key_binding'; value: string }
|
||||
| { type: 'external_raw'; value: string }
|
||||
|
||||
export type GameOptionValueState = 'canonical' | 'uniform_local' | 'mixed' | 'unset' | 'invalid'
|
||||
|
||||
export type GameOptionCompatibilityStatus =
|
||||
| 'ready'
|
||||
| 'deferred'
|
||||
| 'waiting_for_file'
|
||||
| 'waiting_for_base'
|
||||
| 'not_available'
|
||||
| 'unsupported_value'
|
||||
| 'unmappable'
|
||||
| 'catalog_uncovered'
|
||||
| 'controlled'
|
||||
| 'degraded'
|
||||
|
||||
export type GameOptionMappingKind = 'direct' | 'legacy' | 'migrated' | 'lossless' | 'lossy'
|
||||
|
||||
export type GameOptionCompatibilityReason =
|
||||
| 'launcher_controlled'
|
||||
| 'catalog_uncovered'
|
||||
| 'inspection_failed'
|
||||
| 'waiting_for_options_file'
|
||||
| 'unsupported_value'
|
||||
| 'migrates_on_write'
|
||||
| 'waiting_for_compatible_base'
|
||||
| 'missing_setting'
|
||||
|
||||
export type GameOptionCompatibilityBucket = {
|
||||
instance_count: number
|
||||
write_keys: string[]
|
||||
eventual_keys: string[]
|
||||
game_versions: string[]
|
||||
status: GameOptionCompatibilityStatus
|
||||
mapping?: GameOptionMappingKind | null
|
||||
reason?: GameOptionCompatibilityReason | null
|
||||
}
|
||||
|
||||
export type GameOptionCompatibilitySummary = {
|
||||
total_participating: number
|
||||
will_receive: number
|
||||
write_now: number
|
||||
left_local: number
|
||||
buckets: GameOptionCompatibilityBucket[]
|
||||
}
|
||||
|
||||
export type GameOptionEditorChoice = {
|
||||
value: string
|
||||
}
|
||||
|
||||
export type GameOptionEditor = {
|
||||
type:
|
||||
| 'bool'
|
||||
| 'integer'
|
||||
| 'decimal'
|
||||
| 'enum'
|
||||
| 'text'
|
||||
| 'string_list'
|
||||
| 'key_binding'
|
||||
| 'external_raw'
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
step?: number | null
|
||||
unit?: string | null
|
||||
choices?: GameOptionEditorChoice[]
|
||||
}
|
||||
|
||||
export type GameSettingCategory = {
|
||||
id: string
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
export type GameOptionValidationError =
|
||||
'missing_value' | 'no_compatible_instances' | 'invalid_value' | 'changed_since_opened'
|
||||
|
||||
export type EditableGameSetting = {
|
||||
option_id: string
|
||||
category_id: string
|
||||
kind: 'vanilla' | 'external'
|
||||
raw_key?: string | null
|
||||
sync_enabled: boolean
|
||||
canonical_value?: GameOptionCanonicalValue | null
|
||||
value_state: GameOptionValueState
|
||||
option_revision: number
|
||||
editor: GameOptionEditor
|
||||
compatibility: GameOptionCompatibilitySummary
|
||||
validation_error?: GameOptionValidationError | null
|
||||
controlled?: boolean
|
||||
}
|
||||
|
||||
export type GameSettingsEditorState = {
|
||||
summary_revision: string
|
||||
canonical_revision: number
|
||||
catalog_revision: number
|
||||
total_participating: number
|
||||
categories: GameSettingCategory[]
|
||||
settings: EditableGameSetting[]
|
||||
}
|
||||
|
||||
export type GameSettingChange = {
|
||||
option_id: string
|
||||
base_option_revision: number
|
||||
sync_enabled?: boolean
|
||||
canonical_value?: GameOptionCanonicalValue | null
|
||||
}
|
||||
|
||||
export type UpdateGameSettingsRequest = {
|
||||
expected_summary_revision: string
|
||||
expected_canonical_revision: number
|
||||
expected_catalog_revision: number
|
||||
changes: GameSettingChange[]
|
||||
}
|
||||
|
||||
export type GameOptionsSourceCandidate = {
|
||||
source_id: string
|
||||
instance_id: string
|
||||
name: string
|
||||
icon_path?: string | null
|
||||
game_version?: string | null
|
||||
eligible: boolean
|
||||
disabled_reason?: GameOptionsSourceDisabledReason | null
|
||||
recognized_setting_count: number
|
||||
custom_setting_count: number
|
||||
}
|
||||
|
||||
export type GameOptionsSourceDisabledReason =
|
||||
| 'installing_or_updating'
|
||||
| 'unsupported_version'
|
||||
| 'missing_options_file'
|
||||
| 'no_syncable_settings'
|
||||
| 'unreadable_options_file'
|
||||
|
||||
export type SaveGameSettingsResult = {
|
||||
state?: GameSettingsEditorState
|
||||
applied: number
|
||||
migrated: number
|
||||
deferred: number
|
||||
unsupported: number
|
||||
failed: number
|
||||
conflicts?: string[]
|
||||
}
|
||||
|
||||
export type GameSettingLocaleLabel = {
|
||||
label: string
|
||||
choices: Record<string, string>
|
||||
source?: GameSettingSource | null
|
||||
}
|
||||
|
||||
export type GameSettingSource = {
|
||||
instance_id: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
project: {
|
||||
id: string
|
||||
title: string
|
||||
icon_url: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export async function get_game_setting_locale_labels(
|
||||
instanceId: string | undefined,
|
||||
locale: string,
|
||||
optionIds: string[],
|
||||
refreshSources = false,
|
||||
): Promise<{ settings: Record<string, GameSettingLocaleLabel> }> {
|
||||
return await invoke('plugin:instance|instance_get_game_setting_locale_labels', {
|
||||
instanceId,
|
||||
locale,
|
||||
optionIds,
|
||||
refreshSources,
|
||||
})
|
||||
}
|
||||
|
||||
export async function list_game_options_sync_sources(): Promise<GameOptionsSourceCandidate[]> {
|
||||
return await invoke('plugin:instance|instance_list_game_options_sync_sources')
|
||||
}
|
||||
|
||||
export async function get_synced_game_options_config(): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_get_synced_game_options_config')
|
||||
}
|
||||
|
||||
export const gameSettingsKeys = {
|
||||
synced: ['game-settings', 'synced'] as const,
|
||||
local: (instanceId: string) => ['game-settings', 'local', instanceId] as const,
|
||||
}
|
||||
|
||||
export function gameSettingsQueryOptions(instanceId?: string) {
|
||||
return queryOptions({
|
||||
queryKey: instanceId ? gameSettingsKeys.local(instanceId) : gameSettingsKeys.synced,
|
||||
queryFn: () =>
|
||||
instanceId ? get_local_game_options_config(instanceId) : get_synced_game_options_config(),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export async function preview_synced_game_option_changes(
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_preview_synced_game_option_changes', { request })
|
||||
}
|
||||
|
||||
export async function save_synced_game_option_changes(
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<SaveGameSettingsResult> {
|
||||
return await invoke('plugin:instance|instance_save_synced_game_option_changes', { request })
|
||||
}
|
||||
|
||||
export async function get_local_game_options_config(
|
||||
instanceId: string,
|
||||
): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_get_local_game_options_config', { instanceId })
|
||||
}
|
||||
|
||||
export async function preview_local_game_option_changes(
|
||||
instanceId: string,
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<GameSettingsEditorState> {
|
||||
return await invoke('plugin:instance|instance_preview_local_game_option_changes', {
|
||||
instanceId,
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
export async function save_local_game_option_changes(
|
||||
instanceId: string,
|
||||
request: UpdateGameSettingsRequest,
|
||||
): Promise<SaveGameSettingsResult> {
|
||||
return await invoke('plugin:instance|instance_save_local_game_option_changes', {
|
||||
instanceId,
|
||||
request,
|
||||
})
|
||||
}
|
||||
@@ -108,6 +108,104 @@ export async function get_many(instanceIds: string[]): Promise<GameInstance[]> {
|
||||
return await invoke('plugin:instance|instance_get_many', { instanceIds })
|
||||
}
|
||||
|
||||
export function adaptContentItems(items: ContentItem[]): ContentItem[] {
|
||||
return items.map((item) => {
|
||||
const embeddedMetadata = item.embedded_metadata
|
||||
if (!embeddedMetadata?.icon_path) return item
|
||||
return {
|
||||
...item,
|
||||
embedded_metadata: {
|
||||
...embeddedMetadata,
|
||||
icon_url: convertFileSrc(embeddedMetadata.icon_path),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type SyncedOption =
|
||||
| 'game_options'
|
||||
| 'command_history'
|
||||
| 'multiplayer_servers'
|
||||
| 'creative_hotbars'
|
||||
| 'screenshots'
|
||||
| 'resource_packs'
|
||||
| 'data_packs'
|
||||
export type GlobalSyncedOptions = Record<SyncedOption, boolean>
|
||||
export type SyncedOptionCapability = {
|
||||
option: SyncedOption
|
||||
supported: boolean
|
||||
disabled_reason: string | null
|
||||
}
|
||||
export type SyncedOptionJoinPreview = { action: string }
|
||||
export type SyncedOptionsOverview = {
|
||||
global_options: GlobalSyncedOptions
|
||||
capabilities: SyncedOptionCapability[]
|
||||
}
|
||||
export async function get_global_synced_options(): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_get_synced_options')
|
||||
}
|
||||
export async function get_initialized_synced_options(): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_get_initialized_synced_options')
|
||||
}
|
||||
/**
|
||||
* Every synced option is currently available on every instance. The parameter
|
||||
* stays so callers keep passing the option they are asking about, which is what
|
||||
* a real implementation will need.
|
||||
*/
|
||||
export function isSyncedOptionAvailable(_option: SyncedOption): boolean {
|
||||
return true
|
||||
}
|
||||
export async function set_global_synced_option(
|
||||
option: SyncedOption,
|
||||
enabled: boolean,
|
||||
baseInstanceId?: string | null,
|
||||
): Promise<GlobalSyncedOptions> {
|
||||
return await invoke('plugin:instance|instance_set_synced_option', {
|
||||
option,
|
||||
enabled,
|
||||
baseInstanceId,
|
||||
})
|
||||
}
|
||||
export async function set_instance_synced_option(
|
||||
instanceId: string,
|
||||
option: SyncedOption,
|
||||
enabled: boolean,
|
||||
resolution?: string | null,
|
||||
): Promise<GameInstance> {
|
||||
return await invoke('plugin:instance|instance_set_instance_synced_option', {
|
||||
instanceId,
|
||||
option,
|
||||
enabled,
|
||||
resolution,
|
||||
})
|
||||
}
|
||||
export async function get_synced_options_overview(
|
||||
instanceId: string,
|
||||
): Promise<SyncedOptionsOverview> {
|
||||
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
|
||||
}
|
||||
export async function get_command_history(): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_get_synced_command_history')
|
||||
}
|
||||
export async function set_command_history(contents: string): Promise<string> {
|
||||
return await invoke('plugin:instance|instance_set_synced_command_history', { contents })
|
||||
}
|
||||
export type SyncedServer = {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
accept_textures: boolean | null
|
||||
}
|
||||
export async function list_synced_servers(): Promise<SyncedServer[]> {
|
||||
return await invoke('plugin:instance|instance_list_synced_servers')
|
||||
}
|
||||
export async function update_synced_server(server: SyncedServer): Promise<void> {
|
||||
await invoke('plugin:instance|instance_update_synced_server', { server })
|
||||
}
|
||||
export async function remove_synced_server(id: string): Promise<void> {
|
||||
await invoke('plugin:instance|instance_remove_synced_server', { id })
|
||||
}
|
||||
|
||||
export async function get_projects(
|
||||
instanceId: string,
|
||||
cacheBehaviour?: CacheBehaviour,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
|
||||
import { list_game_options_sync_sources } from './game-options'
|
||||
import {
|
||||
get_command_history,
|
||||
get_global_synced_options,
|
||||
get_initialized_synced_options,
|
||||
list_synced_servers,
|
||||
} from './instance'
|
||||
import type { GameInstance } from './types'
|
||||
|
||||
export const syncedOptionsKeys = {
|
||||
global: ['global-synced-options'] as const,
|
||||
initialized: ['initialized-synced-options'] as const,
|
||||
gameSources: ['game-options-sync-sources'] as const,
|
||||
set: ['global-synced-options', 'set'] as const,
|
||||
servers: ['synced-servers', 'list'] as const,
|
||||
commandHistory: ['synced-options', 'command-history'] as const,
|
||||
}
|
||||
|
||||
export function globalSyncedOptionsQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.global,
|
||||
queryFn: get_global_synced_options,
|
||||
})
|
||||
}
|
||||
|
||||
export function initializedSyncedOptionsQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.initialized,
|
||||
queryFn: get_initialized_synced_options,
|
||||
})
|
||||
}
|
||||
|
||||
export function gameOptionsSyncSourcesQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.gameSources,
|
||||
queryFn: list_game_options_sync_sources,
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function syncedServersQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.servers,
|
||||
queryFn: list_synced_servers,
|
||||
})
|
||||
}
|
||||
|
||||
export function commandHistoryQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: syncedOptionsKeys.commandHistory,
|
||||
queryFn: get_command_history,
|
||||
})
|
||||
}
|
||||
|
||||
export function canSourceMultiplayerServers(instance: GameInstance): boolean {
|
||||
return !['server_project', 'server_project_modpack', 'modrinth_hosting'].includes(
|
||||
instance.link?.type ?? '',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import { adaptContentItems, isSyncedOptionAvailable } from './instance'
|
||||
|
||||
export type SyncedPackType = 'resourcepack' | 'datapack'
|
||||
export type SyncedPackAction = 'enable' | 'disable'
|
||||
|
||||
export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances'
|
||||
|
||||
export function isSyncedPackTypeAvailable(projectType: ContentItem['project_type']): boolean {
|
||||
return (
|
||||
['resourcepack', 'datapack'].includes(projectType) &&
|
||||
isSyncedOptionAvailable(projectType === 'resourcepack' ? 'resource_packs' : 'data_packs')
|
||||
)
|
||||
}
|
||||
|
||||
export interface PackSyncPreview {
|
||||
pack: ContentItem
|
||||
instances: {
|
||||
instance_id: string
|
||||
name: string
|
||||
game_version: string
|
||||
compatible: boolean
|
||||
participating: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export const syncedPackKeys = {
|
||||
all: ['synced-packs'] as const,
|
||||
list: (type: SyncedPackType) => ['synced-packs', 'list', type] as const,
|
||||
}
|
||||
|
||||
export function syncedPackQueryOptions(type: SyncedPackType) {
|
||||
return queryOptions({
|
||||
queryKey: syncedPackKeys.list(type),
|
||||
queryFn: () => list_synced_packs(type),
|
||||
enabled: isSyncedPackTypeAvailable(type),
|
||||
})
|
||||
}
|
||||
|
||||
export async function list_synced_packs(projectType: SyncedPackType): Promise<ContentItem[]> {
|
||||
const items = await invoke<ContentItem[]>('plugin:instance|instance_list_synced_packs', {
|
||||
projectType,
|
||||
})
|
||||
return adaptContentItems(items).map((item) => ({ ...item, external: !item.project }))
|
||||
}
|
||||
|
||||
export function get_pack_sync_preview(
|
||||
instanceId: string,
|
||||
projectPath: string,
|
||||
): Promise<PackSyncPreview> {
|
||||
return invoke('plugin:instance|instance_get_pack_sync_preview', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
export function sync_pack(instanceId: string, projectPath: string): Promise<void> {
|
||||
return invoke('plugin:instance|instance_sync_pack', { instanceId, projectPath })
|
||||
}
|
||||
|
||||
export function desync_pack(
|
||||
instanceId: string,
|
||||
packId: string,
|
||||
mode: DesyncServerMode,
|
||||
): Promise<void> {
|
||||
return invoke('plugin:instance|instance_desync_pack', { instanceId, packId, mode })
|
||||
}
|
||||
|
||||
export function upload_synced_pack(
|
||||
path: string,
|
||||
projectType: SyncedPackType,
|
||||
gameVersions: string[],
|
||||
): Promise<void> {
|
||||
return invoke('plugin:instance|instance_upload_synced_pack', { path, projectType, gameVersions })
|
||||
}
|
||||
|
||||
export function set_synced_pack_enabled(packId: string, enabled: boolean): Promise<void> {
|
||||
return invoke('plugin:instance|instance_set_synced_pack_enabled', { packId, enabled })
|
||||
}
|
||||
|
||||
export function remove_synced_pack(packId: string): Promise<void> {
|
||||
return invoke('plugin:instance|instance_remove_synced_pack', { packId })
|
||||
}
|
||||
@@ -10165,5 +10165,191 @@
|
||||
},
|
||||
"settings.backups.repository.usage-description": {
|
||||
"message": "{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}."
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.command-history-description": {
|
||||
"message": "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."
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.command-history-title": {
|
||||
"message": "Choose command history source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": {
|
||||
"message": "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."
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.creative-hotbars-title": {
|
||||
"message": "Choose creative hotbars source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.data-packs-title": {
|
||||
"message": "Choose data packs source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.game-settings-description": {
|
||||
"message": "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."
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.game-settings-title": {
|
||||
"message": "Choose game settings source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.ineligible": {
|
||||
"message": "You cannot choose this instance as a sync source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.load-error": {
|
||||
"message": "Could not load sync sources. Please try again."
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": {
|
||||
"message": "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."
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.multiplayer-servers-title": {
|
||||
"message": "Choose multiplayer servers source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.no-instances-found": {
|
||||
"message": "No instances found"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.resource-packs-title": {
|
||||
"message": "Choose resource packs source"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.retry": {
|
||||
"message": "Try again"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.search-placeholder": {
|
||||
"message": "Search instance"
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.sync": {
|
||||
"message": "Sync"
|
||||
},
|
||||
"app.settings.synced-options.command-history": {
|
||||
"message": "Sync command history"
|
||||
},
|
||||
"app.settings.synced-options.command-history.description": {
|
||||
"message": "Use the same command history across your instances."
|
||||
},
|
||||
"app.settings.synced-options.command-history.editor-title": {
|
||||
"message": "Edit command history"
|
||||
},
|
||||
"app.settings.synced-options.creative-hotbars": {
|
||||
"message": "Sync saved creative hotbars"
|
||||
},
|
||||
"app.settings.synced-options.creative-hotbars.description": {
|
||||
"message": "Use the same saved creative hotbars across your instances."
|
||||
},
|
||||
"app.settings.synced-options.data-packs": {
|
||||
"message": "Sync data packs"
|
||||
},
|
||||
"app.settings.synced-options.data-packs.description": {
|
||||
"message": "Use the same datapacks across your instances"
|
||||
},
|
||||
"app.settings.synced-options.data-packs.none-synced-yet": {
|
||||
"message": "You haven't synced any data packs yet"
|
||||
},
|
||||
"app.settings.synced-options.edit.no-instances": {
|
||||
"message": "Add an instance before editing synced data."
|
||||
},
|
||||
"app.settings.synced-options.edit.no-synced-data": {
|
||||
"message": "Choose a sync source before editing this setting."
|
||||
},
|
||||
"app.settings.synced-options.game-settings": {
|
||||
"message": "Sync game options"
|
||||
},
|
||||
"app.settings.synced-options.game-settings.button": {
|
||||
"message": "Edit game settings"
|
||||
},
|
||||
"app.settings.synced-options.game-settings.description": {
|
||||
"message": "Use the same game options across your instances."
|
||||
},
|
||||
"app.settings.synced-options.game-settings.disabled-tooltip": {
|
||||
"message": "Enable game settings sync before choosing individual settings."
|
||||
},
|
||||
"app.settings.synced-options.game-settings.no-options-to-edit": {
|
||||
"message": "You haven't got any options yet to edit"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers": {
|
||||
"message": "Sync multiplayer servers"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.address": {
|
||||
"message": "Server address"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.copy-address": {
|
||||
"message": "Copy address"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.count": {
|
||||
"message": "{count, plural, one {# server} other {# servers}}"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.description": {
|
||||
"message": "Use the same multiplayer servers across your instances."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.editor-title": {
|
||||
"message": "Edit synced servers"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.empty": {
|
||||
"message": "No user-added servers are currently synced."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.filter-offline": {
|
||||
"message": "Offline"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.filter-online": {
|
||||
"message": "Online"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.more-options": {
|
||||
"message": "More server options"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.name": {
|
||||
"message": "Server name"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.no-results": {
|
||||
"message": "No synced servers match your search or filters."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.none-synced-yet": {
|
||||
"message": "You haven't synced any servers yet"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.players-online": {
|
||||
"message": "{count, number} online"
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.search": {
|
||||
"message": "Search {count, number} {count, plural, one {synced server} other {synced servers}}..."
|
||||
},
|
||||
"app.settings.synced-options.multiplayer-servers.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"app.settings.synced-options.packs.disabled": {
|
||||
"message": "Enable syncing before editing shared packs."
|
||||
},
|
||||
"app.settings.synced-options.resource-packs": {
|
||||
"message": "Sync resource packs"
|
||||
},
|
||||
"app.settings.synced-options.resource-packs.description": {
|
||||
"message": "Use the same resource packs across your instances."
|
||||
},
|
||||
"app.settings.synced-options.resource-packs.none-synced-yet": {
|
||||
"message": "You haven't synced any resource packs yet"
|
||||
},
|
||||
"app.settings.synced-options.title": {
|
||||
"message": "Options synced across all instances"
|
||||
},
|
||||
"app.settings.synced-packs.data-packs": {
|
||||
"message": "Data packs"
|
||||
},
|
||||
"app.settings.synced-packs.disable": {
|
||||
"message": "Disable"
|
||||
},
|
||||
"app.settings.synced-packs.empty": {
|
||||
"message": "No synced packs"
|
||||
},
|
||||
"app.settings.synced-packs.enable": {
|
||||
"message": "Enable"
|
||||
},
|
||||
"app.settings.synced-packs.remove": {
|
||||
"message": "Remove"
|
||||
},
|
||||
"app.settings.synced-packs.resource-packs": {
|
||||
"message": "Resource packs"
|
||||
},
|
||||
"app.sync-instances-update.choose-source.data-packs": {
|
||||
"message": "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."
|
||||
},
|
||||
"app.sync-instances-update.choose-source.resource-packs": {
|
||||
"message": "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."
|
||||
},
|
||||
"instance.settings.tabs.backups": {
|
||||
"message": "Backups"
|
||||
},
|
||||
"instance.settings.tabs.sync": {
|
||||
"message": "Synchronization"
|
||||
}
|
||||
}
|
||||
@@ -10929,5 +10929,67 @@
|
||||
},
|
||||
"settings.backups.repository.usage-description": {
|
||||
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
|
||||
}
|
||||
},
|
||||
"app.settings.synced-options.choose-sync-source.command-history-description": { "message": "选择要从哪个实例复制命令历史记录。这些记录仅用于初始同步,之后可从任意实例编辑。" },
|
||||
"app.settings.synced-options.choose-sync-source.command-history-title": { "message": "选择命令历史记录来源" },
|
||||
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": { "message": "选择要从哪个实例复制保存的创造模式快捷栏。这些快捷栏仅用于初始同步,之后可从任意实例编辑。" },
|
||||
"app.settings.synced-options.choose-sync-source.creative-hotbars-title": { "message": "选择创造模式快捷栏来源" },
|
||||
"app.settings.synced-options.choose-sync-source.data-packs-title": { "message": "选择数据包来源" },
|
||||
"app.settings.synced-options.choose-sync-source.game-settings-description": { "message": "选择要从哪个实例复制游戏设置。这些设置仅用于初始同步,之后可从任意实例编辑。" },
|
||||
"app.settings.synced-options.choose-sync-source.game-settings-title": { "message": "选择游戏设置来源" },
|
||||
"app.settings.synced-options.choose-sync-source.ineligible": { "message": "无法选择此实例作为同步来源" },
|
||||
"app.settings.synced-options.choose-sync-source.load-error": { "message": "无法加载同步来源,请重试。" },
|
||||
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": { "message": "选择要从哪个实例复制多人服务器列表。这些服务器仅用于初始同步,之后可从任意实例编辑。" },
|
||||
"app.settings.synced-options.choose-sync-source.multiplayer-servers-title": { "message": "选择多人服务器来源" },
|
||||
"app.settings.synced-options.choose-sync-source.no-instances-found": { "message": "没有找到实例" },
|
||||
"app.settings.synced-options.choose-sync-source.resource-packs-title": { "message": "选择资源包来源" },
|
||||
"app.settings.synced-options.choose-sync-source.retry": { "message": "重试" },
|
||||
"app.settings.synced-options.choose-sync-source.search-placeholder": { "message": "搜索实例" },
|
||||
"app.settings.synced-options.choose-sync-source.sync": { "message": "同步" },
|
||||
"app.settings.synced-options.command-history": { "message": "同步命令记录" },
|
||||
"app.settings.synced-options.command-history.description": { "message": "在不同的实例之间同步命令记录。" },
|
||||
"app.settings.synced-options.command-history.editor-title": { "message": "编辑指令历史记录" },
|
||||
"app.settings.synced-options.creative-hotbars": { "message": "同步保存的创造模式物品栏" },
|
||||
"app.settings.synced-options.creative-hotbars.description": { "message": "在不同的实例之间同步创造模式物品栏。" },
|
||||
"app.settings.synced-options.data-packs": { "message": "同步数据包" },
|
||||
"app.settings.synced-options.data-packs.description": { "message": "在所有实例间使用相同的数据包" },
|
||||
"app.settings.synced-options.data-packs.none-synced-yet": { "message": "尚未同步任何数据包" },
|
||||
"app.settings.synced-options.edit.no-instances": { "message": "请先添加实例,再编辑同步数据。" },
|
||||
"app.settings.synced-options.edit.no-synced-data": { "message": "请先选择同步来源,再编辑此设置。" },
|
||||
"app.settings.synced-options.game-settings": { "message": "同步游戏设置" },
|
||||
"app.settings.synced-options.game-settings.button": { "message": "编辑游戏设置" },
|
||||
"app.settings.synced-options.game-settings.description": { "message": "在所有实例间使用相同的游戏设置。" },
|
||||
"app.settings.synced-options.game-settings.disabled-tooltip": { "message": "请先启用游戏设置同步,再选择单个设置。" },
|
||||
"app.settings.synced-options.game-settings.no-options-to-edit": { "message": "目前没有可编辑的设置" },
|
||||
"app.settings.synced-options.multiplayer-servers": { "message": "同步多人模式服务器列表" },
|
||||
"app.settings.synced-options.multiplayer-servers.address": { "message": "服务器地址" },
|
||||
"app.settings.synced-options.multiplayer-servers.copy-address": { "message": "复制地址" },
|
||||
"app.settings.synced-options.multiplayer-servers.count": { "message": "{count, number} 个服务器" },
|
||||
"app.settings.synced-options.multiplayer-servers.description": { "message": "在不同的实例直接同步多人模式服务器列表。" },
|
||||
"app.settings.synced-options.multiplayer-servers.editor-title": { "message": "编辑同步的服务器" },
|
||||
"app.settings.synced-options.multiplayer-servers.empty": { "message": "当前未同步任何用户添加的服务器。" },
|
||||
"app.settings.synced-options.multiplayer-servers.filter-offline": { "message": "离线" },
|
||||
"app.settings.synced-options.multiplayer-servers.filter-online": { "message": "在线" },
|
||||
"app.settings.synced-options.multiplayer-servers.more-options": { "message": "更多服务器选项" },
|
||||
"app.settings.synced-options.multiplayer-servers.name": { "message": "服务器名称" },
|
||||
"app.settings.synced-options.multiplayer-servers.no-results": { "message": "没有匹配搜索或筛选条件的已同步服务器。" },
|
||||
"app.settings.synced-options.multiplayer-servers.none-synced-yet": { "message": "暂无已同步的服务器" },
|
||||
"app.settings.synced-options.multiplayer-servers.players-online": { "message": "{count, number} 人在线" },
|
||||
"app.settings.synced-options.multiplayer-servers.search": { "message": "搜索 {count, number} 个已同步服务器……" },
|
||||
"app.settings.synced-options.multiplayer-servers.status": { "message": "状态" },
|
||||
"app.settings.synced-options.packs.disabled": { "message": "请先启用同步,再编辑共享包。" },
|
||||
"app.settings.synced-options.resource-packs": { "message": "同步资源包" },
|
||||
"app.settings.synced-options.resource-packs.description": { "message": "在所有实例间使用相同的资源包" },
|
||||
"app.settings.synced-options.resource-packs.none-synced-yet": { "message": "尚未同步任何资源包" },
|
||||
"app.settings.synced-options.title": { "message": "跨所有实例同步的选项" },
|
||||
"app.settings.synced-packs.data-packs": { "message": "数据包" },
|
||||
"app.settings.synced-packs.disable": { "message": "禁用" },
|
||||
"app.settings.synced-packs.empty": { "message": "没有已同步的包" },
|
||||
"app.settings.synced-packs.enable": { "message": "启用" },
|
||||
"app.settings.synced-packs.remove": { "message": "移除" },
|
||||
"app.settings.synced-packs.resource-packs": { "message": "资源包" },
|
||||
"app.sync-instances-update.choose-source.data-packs": { "message": "选择要从哪个实例复制数据包。这些包仅用于初始化同步,之后可从任意实例进行编辑。" },
|
||||
"app.sync-instances-update.choose-source.resource-packs": { "message": "选择要从哪个实例复制资源包。这些包仅用于初始化同步,之后可从任意实例进行编辑。" },
|
||||
"instance.settings.tabs.backups": { "message": "备份" },
|
||||
"instance.settings.tabs.sync": { "message": "同步" }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonFrame from './ButtonFrame.vue'
|
||||
import type { ButtonNativeType } from './types'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
nativeType?: ButtonNativeType
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
nativeType: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
|
||||
const element = computed(() => frame.value?.element ?? null)
|
||||
|
||||
defineExpose({ element })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonFrame
|
||||
ref="frame"
|
||||
as="button"
|
||||
type="quiet"
|
||||
size="lg"
|
||||
interaction="none"
|
||||
:disabled="disabled"
|
||||
:native-type="nativeType"
|
||||
role="radio"
|
||||
:aria-checked="checked"
|
||||
class="w-full !justify-between !gap-4 !whitespace-normal !border !border-solid !px-2 !text-left !transition-colors"
|
||||
:class="
|
||||
checked
|
||||
? '!border-brand !bg-brand-highlight !text-[var(--color-text-primary)]'
|
||||
: '!border-transparent !bg-transparent !text-[var(--color-text-primary)] enabled:hover:!bg-surface-3'
|
||||
"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2"><slot /></span>
|
||||
<span
|
||||
v-if="checked"
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-brand text-brand-inverted"
|
||||
>
|
||||
<CheckIcon class="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span v-else class="size-6 shrink-0 rounded-full border border-solid border-surface-5" />
|
||||
</ButtonFrame>
|
||||
</template>
|
||||
@@ -11,6 +11,7 @@ export { default as BulletDivider } from './BulletDivider.vue'
|
||||
export { default as Button } from './Button.vue'
|
||||
export { default as NewButton } from './buttons/Button.vue'
|
||||
export { default as IconButton } from './buttons/IconButton.vue'
|
||||
export { default as CheckCircleButton } from './buttons/CheckCircleButton.vue'
|
||||
export type {
|
||||
ButtonColor,
|
||||
ButtonElementHandle,
|
||||
@@ -78,6 +79,7 @@ export { default as NavTabs } from './NavTabs.vue'
|
||||
export { default as OptionGroup } from './OptionGroup.vue'
|
||||
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
||||
export { default as OverflowMenu } from './OverflowMenu.vue'
|
||||
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
|
||||
export { default as Page } from './Page.vue'
|
||||
export { default as Pagination } from './Pagination.vue'
|
||||
export { default as PopoutMenu } from './PopoutMenu.vue'
|
||||
|
||||
Reference in new issue
Block a user