新增同步 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,
|
FileArchiveIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import { computed, nextTick, ref, watch } from 'vue'
|
|||||||
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
|
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
|
||||||
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
|
||||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.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 HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||||
@@ -147,6 +149,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
|
|||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
content: HooksSettings,
|
content: HooksSettings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: defineMessage({
|
||||||
|
id: 'instance.settings.tabs.sync',
|
||||||
|
defaultMessage: 'Sync',
|
||||||
|
}),
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
content: SyncSettings,
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function getSupportedModpackLoaders() {
|
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 })
|
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(
|
export async function get_projects(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
cacheBehaviour?: CacheBehaviour,
|
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": {
|
"settings.backups.repository.usage-description": {
|
||||||
"message": "{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}."
|
"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": {
|
"settings.backups.repository.usage-description": {
|
||||||
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
|
"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 Button } from './Button.vue'
|
||||||
export { default as NewButton } from './buttons/Button.vue'
|
export { default as NewButton } from './buttons/Button.vue'
|
||||||
export { default as IconButton } from './buttons/IconButton.vue'
|
export { default as IconButton } from './buttons/IconButton.vue'
|
||||||
|
export { default as CheckCircleButton } from './buttons/CheckCircleButton.vue'
|
||||||
export type {
|
export type {
|
||||||
ButtonColor,
|
ButtonColor,
|
||||||
ButtonElementHandle,
|
ButtonElementHandle,
|
||||||
@@ -78,6 +79,7 @@ export { default as NavTabs } from './NavTabs.vue'
|
|||||||
export { default as OptionGroup } from './OptionGroup.vue'
|
export { default as OptionGroup } from './OptionGroup.vue'
|
||||||
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
||||||
export { default as OverflowMenu } 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 Page } from './Page.vue'
|
||||||
export { default as Pagination } from './Pagination.vue'
|
export { default as Pagination } from './Pagination.vue'
|
||||||
export { default as PopoutMenu } from './PopoutMenu.vue'
|
export { default as PopoutMenu } from './PopoutMenu.vue'
|
||||||
|
|||||||
Reference in new issue
Block a user