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

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

No files matched your search

@@ -0,0 +1,607 @@
<script setup lang="ts">
import { EditIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
IconButton,
injectNotificationManager,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onScopeDispose, ref } from 'vue'
import { gameSettingsKeys } from '@/helpers/game-options'
import {
type GlobalSyncedOptions,
isSyncedOptionAvailable,
set_global_synced_option,
type SyncedOption,
} from '@/helpers/instance'
import {
canSourceMultiplayerServers,
gameOptionsSyncSourcesQueryOptions,
globalSyncedOptionsQueryOptions,
initializedSyncedOptionsQueryOptions,
syncedOptionsKeys,
syncedServersQueryOptions,
} from '@/helpers/synced-options'
import { syncedPackKeys, syncedPackQueryOptions } from '@/helpers/synced-packs'
import { instanceKeys, instanceListQueryOptions } from '@/pages/instance/query-options'
import SettingsRow from '../settings/SettingsRow.vue'
import SettingsSection from '../settings/SettingsSection.vue'
import CommandHistoryModal from '../settings/instances/command-history-modal.vue'
import SyncedServersModal from '../settings/instances/servers-modal.vue'
import SyncedPacksModal from '../settings/instances/SyncedPacksModal.vue'
import SyncSourceModal from '../settings/instances/SyncSourceModal.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const syncedPacksModal = ref<InstanceType<typeof SyncedPacksModal>>()
const commandHistoryModal = ref<InstanceType<typeof CommandHistoryModal>>()
const syncedServersModal = ref<InstanceType<typeof SyncedServersModal>>()
const messages = defineMessages({
globalOptionsTitle: {
id: 'app.settings.synced-options.title',
defaultMessage: 'Options synced across all instances',
},
resourcePacks: {
id: 'app.settings.synced-options.resource-packs',
defaultMessage: 'Sync resource packs',
},
resourcePacksDescription: {
id: 'app.settings.synced-options.resource-packs.description',
defaultMessage: 'Use the same resource packs across your instances.',
},
dataPacks: { id: 'app.settings.synced-options.data-packs', defaultMessage: 'Sync data packs' },
dataPacksDescription: {
id: 'app.settings.synced-options.data-packs.description',
defaultMessage: 'Use the same datapacks across your instances',
},
packsDisabled: {
id: 'app.settings.synced-options.packs.disabled',
defaultMessage: 'Enable syncing before editing shared packs.',
},
gameSettings: {
id: 'app.settings.synced-options.game-settings',
defaultMessage: 'Sync game options',
},
gameSettingsDescription: {
id: 'app.settings.synced-options.game-settings.description',
defaultMessage: 'Use the same game options across your instances.',
},
gameSettingsButton: {
id: 'app.settings.synced-options.game-settings.button',
defaultMessage: 'Edit game settings',
},
gameSettingsDisabledTooltip: {
id: 'app.settings.synced-options.game-settings.disabled-tooltip',
defaultMessage: 'Enable game settings sync before choosing individual settings.',
},
noGameOptionsToEdit: {
id: 'app.settings.synced-options.game-settings.no-options-to-edit',
defaultMessage: "You haven't got any options yet to edit",
},
multiplayerServers: {
id: 'app.settings.synced-options.multiplayer-servers',
defaultMessage: 'Sync multiplayer servers',
},
multiplayerServersDescription: {
id: 'app.settings.synced-options.multiplayer-servers.description',
defaultMessage: 'Use the same multiplayer servers across your instances.',
},
commandHistory: {
id: 'app.settings.synced-options.command-history',
defaultMessage: 'Sync command history',
},
commandHistoryDescription: {
id: 'app.settings.synced-options.command-history.description',
defaultMessage: 'Use the same command history across your instances.',
},
creativeHotbars: {
id: 'app.settings.synced-options.creative-hotbars',
defaultMessage: 'Sync saved creative hotbars',
},
creativeHotbarsDescription: {
id: 'app.settings.synced-options.creative-hotbars.description',
defaultMessage: 'Use the same saved creative hotbars across your instances.',
},
gameSettingsSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.game-settings-title',
defaultMessage: 'Choose game settings source',
},
multiplayerServersSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-description',
defaultMessage:
'Choose which instance to copy your multiplayer servers from. These servers are only used for the initial sync, and you can edit them from any instance afterward.',
},
multiplayerServersSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.multiplayer-servers-title',
defaultMessage: 'Choose multiplayer servers source',
},
commandHistorySyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.command-history-description',
defaultMessage:
'Choose which instance to copy your command history from. This history is only used for the initial sync, and you can edit it from any instance afterward.',
},
commandHistorySyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.command-history-title',
defaultMessage: 'Choose command history source',
},
creativeHotbarsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-description',
defaultMessage:
'Choose which instance to copy your saved creative hotbars from. These hotbars are only used for the initial sync, and you can edit them from any instance afterward.',
},
creativeHotbarsSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.creative-hotbars-title',
defaultMessage: 'Choose creative hotbars source',
},
gameSettingsSyncSourceDescription: {
id: 'app.settings.synced-options.choose-sync-source.game-settings-description',
defaultMessage:
'Choose which instance to copy your game settings from. These settings are only used for the initial sync, and you can edit them from any instance afterward.',
},
resourcePacksSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.resource-packs-title',
defaultMessage: 'Choose resource packs source',
},
resourcePacksSyncSourceDescription: {
id: 'app.sync-instances-update.choose-source.resource-packs',
defaultMessage:
'Choose which instance to copy your resource packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
},
dataPacksSyncSourceTitle: {
id: 'app.settings.synced-options.choose-sync-source.data-packs-title',
defaultMessage: 'Choose data packs source',
},
dataPacksSyncSourceDescription: {
id: 'app.sync-instances-update.choose-source.data-packs',
defaultMessage:
'Choose which instance to copy your data packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward.',
},
noServersSyncedYet: {
id: 'app.settings.synced-options.multiplayer-servers.none-synced-yet',
defaultMessage: "You haven't synced any servers yet",
},
noResourcePacksSyncedYet: {
id: 'app.settings.synced-options.resource-packs.none-synced-yet',
defaultMessage: "You haven't synced any resource packs yet",
},
noDataPacksSyncedYet: {
id: 'app.settings.synced-options.data-packs.none-synced-yet',
defaultMessage: "You haven't synced any data packs yet",
},
noInstancesToEdit: {
id: 'app.settings.synced-options.edit.no-instances',
defaultMessage: 'Add an instance before editing synced data.',
},
noSyncedDataToEdit: {
id: 'app.settings.synced-options.edit.no-synced-data',
defaultMessage: 'Choose a sync source before editing this setting.',
},
})
const globalRows: Array<{
option: SyncedOption
title: keyof typeof messages
description?: keyof typeof messages
editable?: 'game-settings' | 'servers' | 'commands' | 'resourcepack' | 'datapack'
}> = [
{
option: 'game_options',
title: 'gameSettings',
description: 'gameSettingsDescription',
editable: 'game-settings',
},
{
option: 'multiplayer_servers',
title: 'multiplayerServers',
description: 'multiplayerServersDescription',
editable: 'servers',
},
{
option: 'resource_packs',
title: 'resourcePacks',
description: 'resourcePacksDescription',
editable: 'resourcepack',
},
{
option: 'data_packs',
title: 'dataPacks',
description: 'dataPacksDescription',
editable: 'datapack',
},
{
option: 'command_history',
title: 'commandHistory',
description: 'commandHistoryDescription',
editable: 'commands',
},
{
option: 'creative_hotbars',
title: 'creativeHotbars',
description: 'creativeHotbarsDescription',
},
]
const availableGlobalRows = globalRows.filter((row) => isSyncedOptionAvailable(row.option))
const defaultGlobalOptions: GlobalSyncedOptions = {
resource_packs: false,
data_packs: false,
game_options: false,
command_history: false,
multiplayer_servers: false,
creative_hotbars: false,
screenshots: false,
}
const globalOptionsQuery = useQuery(globalSyncedOptionsQueryOptions())
const initializedOptionsQuery = useQuery(initializedSyncedOptionsQueryOptions())
const gameOptionSourcesQuery = useQuery(gameOptionsSyncSourcesQueryOptions())
const resourcePacksQuery = useQuery(syncedPackQueryOptions('resourcepack'))
const dataPacksQuery = useQuery(syncedPackQueryOptions('datapack'))
const globalOptions = computed(() => globalOptionsQuery.data.value ?? defaultGlobalOptions)
const initializedOptions = computed(
() => initializedOptionsQuery.data.value ?? defaultGlobalOptions,
)
const gameOptionSources = computed(() => gameOptionSourcesQuery.data.value ?? [])
const instancesQuery = useQuery(instanceListQueryOptions())
const instances = computed(() => instancesQuery.data.value ?? [])
const serversQuery = useQuery(syncedServersQueryOptions())
const syncedServers = computed(() => serversQuery.data.value ?? [])
const eligibleGameOptionSourceIds = computed(
() =>
new Set(
gameOptionSources.value.filter((source) => source.eligible).map((source) => source.source_id),
),
)
const hasGameOptionsToEdit = computed(
() =>
initializedOptions.value.game_options ||
instances.value.some(
(instance) =>
instance?.synced_options?.game_options &&
eligibleGameOptionSourceIds.value.has(instance.id),
),
)
const baseOption = ref<SyncedOption | null>(null)
const baseInstanceId = ref('')
const baseModal = ref<InstanceType<typeof SyncSourceModal> | null>(null)
const baseSourcesLoading = computed(() =>
baseOption.value === null
? false
: baseOption.value === 'game_options'
? gameOptionSourcesQuery.isPending.value
: instancesQuery.isPending.value,
)
const baseSourcesError = computed(() =>
baseOption.value === 'game_options'
? gameOptionSourcesQuery.isError.value && !gameOptionSourcesQuery.data.value
: instancesQuery.isError.value && !instancesQuery.data.value,
)
let baseSourceGeneration = 0
const baseInstanceTitle = computed(() => {
switch (baseOption.value) {
case 'game_options':
return formatMessage(messages.gameSettingsSyncSourceTitle)
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceTitle)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceTitle)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceTitle)
case 'resource_packs':
return formatMessage(messages.resourcePacksSyncSourceTitle)
case 'data_packs':
return formatMessage(messages.dataPacksSyncSourceTitle)
default:
return ''
}
})
const baseInstanceDescription = computed(() => {
switch (baseOption.value) {
case 'game_options':
return formatMessage(messages.gameSettingsSyncSourceDescription)
case 'multiplayer_servers':
return formatMessage(messages.multiplayerServersSyncSourceDescription)
case 'command_history':
return formatMessage(messages.commandHistorySyncSourceDescription)
case 'creative_hotbars':
return formatMessage(messages.creativeHotbarsSyncSourceDescription)
case 'resource_packs':
return formatMessage(messages.resourcePacksSyncSourceDescription)
case 'data_packs':
return formatMessage(messages.dataPacksSyncSourceDescription)
default:
return ''
}
})
const baseInstances = computed(() =>
baseOption.value === 'game_options'
? gameOptionSources.value.map((source) => ({
id: source.source_id,
name: source.name,
icon_path: source.icon_path,
eligible: source.eligible,
}))
: instances.value.map((instance) => ({
id: instance.id,
name: instance.name,
icon_path: instance.icon_path,
eligible:
!instance.quarantined &&
(baseOption.value !== 'multiplayer_servers' || canSourceMultiplayerServers(instance)),
})),
)
async function invalidateSyncedOptions() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.gameSources }),
queryClient.invalidateQueries({ queryKey: ['worlds'] }),
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
])
}
function emptySyncedContentMessage(row: (typeof globalRows)[number]) {
if (
row.editable === 'servers' &&
(instances.value.length === 0 || syncedServers.value.length === 0)
) {
return messages.noServersSyncedYet
}
if (row.editable === 'resourcepack' && !resourcePacksQuery.data.value?.length) {
return messages.noResourcePacksSyncedYet
}
if (row.editable === 'datapack' && !dataPacksQuery.data.value?.length) {
return messages.noDataPacksSyncedYet
}
}
function canEditGlobalOption(row: (typeof globalRows)[number]): boolean {
if (!row.editable || !globalOptions.value[row.option]) return false
if (emptySyncedContentMessage(row)) return false
if (row.editable === 'resourcepack' || row.editable === 'datapack') return true
if (row.editable === 'game-settings') return hasGameOptionsToEdit.value
return instances.value.length > 0 && initializedOptions.value[row.option]
}
function editGlobalOptionTooltip(row: (typeof globalRows)[number]): string {
const emptyMessage = emptySyncedContentMessage(row)
if (emptyMessage) return formatMessage(emptyMessage)
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
return formatMessage(
globalOptions.value[row.option] ? commonMessages.editButton : messages.packsDisabled,
)
}
if (row.editable === 'game-settings') {
if (!hasGameOptionsToEdit.value) return formatMessage(messages.noGameOptionsToEdit)
if (!globalOptions.value[row.option]) {
return formatMessage(messages.gameSettingsDisabledTooltip)
}
return formatMessage(messages.gameSettingsButton)
}
if (instances.value.length === 0) {
return formatMessage(messages.noInstancesToEdit)
}
if (!initializedOptions.value[row.option]) {
return formatMessage(messages.noSyncedDataToEdit)
}
return formatMessage(commonMessages.editButton)
}
type GlobalOptionMutationVariables = {
option: SyncedOption
enabled: boolean
baseInstanceId?: string
}
const globalOptionMutation = useMutation({
mutationKey: syncedOptionsKeys.set,
mutationFn: ({ option, enabled, baseInstanceId }: GlobalOptionMutationVariables) =>
set_global_synced_option(option, enabled, baseInstanceId),
onError: handleError,
onSuccess: async (options, { option, enabled }) => {
queryClient.setQueryData(syncedOptionsKeys.global, options)
if (option === 'game_options') {
await queryClient.invalidateQueries({ queryKey: gameSettingsKeys.synced })
}
if (enabled && option === 'multiplayer_servers') {
await queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.servers })
}
},
onSettled: async (_data, _error, { option }) => {
if (queryClient.isMutating({ mutationKey: syncedOptionsKeys.set }) === 1) {
if (option === 'resource_packs' || option === 'data_packs') {
void Promise.all([
queryClient.invalidateQueries({ queryKey: instanceKeys.all }),
queryClient.invalidateQueries({ queryKey: ['instance-synced-options'] }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.global }),
queryClient.invalidateQueries({ queryKey: syncedOptionsKeys.initialized }),
queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
])
return
}
await invalidateSyncedOptions()
}
},
})
const canToggleGlobalOptions = computed(
() =>
!!globalOptionsQuery.data.value &&
!globalOptionMutation.isPending.value &&
!baseSourcesLoading.value,
)
async function chooseBaseInstance(option: SyncedOption) {
const generation = ++baseSourceGeneration
baseOption.value = option
baseInstanceId.value = ''
try {
if (option === 'game_options') {
await queryClient.fetchQuery(gameOptionsSyncSourcesQueryOptions())
} else {
await queryClient.fetchQuery({ ...instanceListQueryOptions(), staleTime: 0 })
}
} catch (error) {
if (generation !== baseSourceGeneration) return
handleError(error)
baseModal.value?.show()
return
}
if (generation !== baseSourceGeneration) return
const eligibleSources = baseInstances.value.filter((source) => source.eligible)
baseInstanceId.value = eligibleSources[0]?.id ?? ''
if (eligibleSources.length > 1) {
baseModal.value?.show()
return
}
try {
await globalOptionMutation.mutateAsync({
option,
enabled: true,
baseInstanceId: eligibleSources[0]?.id,
})
await nextTick()
if (!baseModal.value?.hide()) clearBaseSource()
} catch {
return
}
}
function toggleGlobalOption(option: SyncedOption, enabled: boolean) {
if (!isSyncedOptionAvailable(option) || !canToggleGlobalOptions.value) return
if (enabled && option !== 'screenshots') {
void chooseBaseInstance(option)
return
}
globalOptionMutation.mutate({ option, enabled })
}
async function confirmBaseInstance() {
if (!baseOption.value || !baseInstanceId.value) return
try {
await globalOptionMutation.mutateAsync({
option: baseOption.value,
enabled: true,
baseInstanceId: baseInstanceId.value,
})
await nextTick()
baseModal.value?.hide()
} catch {
return
}
}
function editGlobalOption(row: (typeof globalRows)[number]) {
if (!isSyncedOptionAvailable(row.option)) return
if (row.editable === 'resourcepack' || row.editable === 'datapack') {
void syncedPacksModal.value?.show(row.editable)
} else if (row.editable === 'game-settings') {
// Game settings modal is ported in a later pass.
return
} else if (row.editable === 'commands') {
void commandHistoryModal.value?.show()
} else {
void syncedServersModal.value?.show()
}
}
function clearBaseSource() {
baseSourceGeneration++
baseOption.value = null
}
onScopeDispose(clearBaseSource)
</script>
<template>
<div class="flex flex-col gap-6">
<SyncedPacksModal ref="syncedPacksModal" />
<CommandHistoryModal ref="commandHistoryModal" />
<SyncedServersModal ref="syncedServersModal" />
<SyncSourceModal
ref="baseModal"
v-model="baseInstanceId"
:title="baseInstanceTitle"
:description="baseInstanceDescription"
:sources="baseInstances"
:loading="baseSourcesLoading"
:error="baseSourcesError"
:pending="globalOptionMutation.isPending.value"
@confirm="confirmBaseInstance"
@close="clearBaseSource"
@retry="baseOption && chooseBaseInstance(baseOption)"
/>
<SettingsSection>
<template #header>
<h2
id="settings-target-instance-sync-options"
tabindex="-1"
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ formatMessage(messages.globalOptionsTitle) }}
</h2>
</template>
<SettingsRow v-for="row in availableGlobalRows" :key="row.option">
<template #label>
<span :id="`settings-target-instance-sync-${row.option}`" tabindex="-1">
{{ formatMessage(messages[row.title]) }}
</span>
</template>
<template v-if="row.description" #description>
{{ formatMessage(messages[row.description]) }}
</template>
<template #control>
<span v-if="row.editable" v-tooltip="editGlobalOptionTooltip(row)" class="flex">
<IconButton
type="outlined"
circular
:disabled="
!canEditGlobalOption(row) ||
initializedOptionsQuery.isPending.value ||
globalOptionMutation.isPending.value
"
:label="
formatMessage(
row.editable === 'game-settings'
? messages.gameSettingsButton
: commonMessages.editButton,
)
"
@click="editGlobalOption(row)"
>
<EditIcon aria-hidden="true" />
</IconButton>
</span>
<Toggle
:id="`global-sync-${row.option}`"
:model-value="globalOptions[row.option]"
:disabled="!canToggleGlobalOptions"
:aria-label="formatMessage(messages[row.title])"
@update:model-value="(enabled) => toggleGlobalOption(row.option, enabled)"
/>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>
@@ -7,6 +7,7 @@ import {
FileArchiveIcon,
InfoIcon,
MonitorIcon,
RefreshCwIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
@@ -23,6 +24,7 @@ import { computed, nextTick, ref, watch } from 'vue'
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import SyncSettings from '@/components/ui/instance_settings/SyncSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
@@ -147,6 +149,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: CodeIcon,
content: HooksSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.sync',
defaultMessage: 'Sync',
}),
icon: RefreshCwIcon,
content: SyncSettings,
},
])
function getSupportedModpackLoaders() {
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { RefreshCwIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
CheckCircleButton,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef } from 'vue'
import { getInstanceIconUrl } from '@/helpers/instance'
const props = defineProps<{
title: string
description: string
sources: {
id: string
name: string
icon_path?: string | null
eligible: boolean
}[]
loading?: boolean
pending?: boolean
error?: boolean
}>()
const emit = defineEmits<{
confirm: []
close: []
retry: []
}>()
const selectedInstanceId = defineModel<string>({ required: true })
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const search = ref('')
const { formatMessage } = useVIntl()
const messages = defineMessages({
ineligible: {
id: 'app.settings.synced-options.choose-sync-source.ineligible',
defaultMessage: 'You cannot choose this instance as a sync source',
},
search: {
id: 'app.settings.synced-options.choose-sync-source.search-placeholder',
defaultMessage: 'Search instance',
},
empty: {
id: 'app.settings.synced-options.choose-sync-source.no-instances-found',
defaultMessage: 'No instances found',
},
sync: {
id: 'app.settings.synced-options.choose-sync-source.sync',
defaultMessage: 'Sync',
},
loadError: {
id: 'app.settings.synced-options.choose-sync-source.load-error',
defaultMessage: 'Could not load sync sources. Please try again.',
},
retry: {
id: 'app.settings.synced-options.choose-sync-source.retry',
defaultMessage: 'Try again',
},
})
const filteredSources = computed(() => {
const query = search.value.trim().toLocaleLowerCase()
return props.sources.filter((source) => source.name.toLocaleLowerCase().includes(query))
})
const canConfirm = computed(
() =>
!props.loading &&
!props.pending &&
!props.error &&
props.sources.some((source) => source.id === selectedInstanceId.value && source.eligible),
)
function show() {
search.value = ''
modal.value?.show()
}
function hide() {
return modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="title"
no-padding
actions-divider
max-width="560px"
width="560px"
:disable-close="pending"
:on-after-hide="() => emit('close')"
>
<p
class="m-0 border-0 border-b border-solid border-surface-5 p-6 text-[var(--color-text-default)]"
>
{{ description }}
</p>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 px-6 py-4">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="search"
autocomplete="off"
:placeholder="formatMessage(messages.search)"
:aria-label="formatMessage(messages.search)"
class="shrink-0"
/>
<div v-if="loading" class="flex flex-1 items-center justify-center" aria-busy="true">
<SpinnerIcon class="size-5 animate-spin text-[var(--color-text-tertiary)]" />
</div>
<div v-else-if="error" class="flex flex-1 flex-col items-center justify-center gap-3">
<p role="alert" class="m-0 text-center text-[var(--color-text-default)]">
{{ formatMessage(messages.loadError) }}
</p>
<Button @click="emit('retry')">{{ formatMessage(messages.retry) }}</Button>
</div>
<div
v-else-if="filteredSources.length === 0"
class="flex flex-1 items-center justify-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else role="radiogroup" :aria-label="title" class="flex flex-col gap-1">
<div
v-for="source in filteredSources"
:key="source.id"
v-tooltip="!source.eligible ? formatMessage(messages.ineligible) : undefined"
>
<CheckCircleButton
:checked="selectedInstanceId === source.id"
:disabled="pending || !source.eligible"
class="min-h-10"
@click="selectedInstanceId = source.id"
>
<span class="size-5 shrink-0 overflow-hidden rounded-[6px]">
<Avatar
:src="getInstanceIconUrl(source.icon_path)"
:alt="source.name"
:tint-by="source.id"
size="1.25rem"
no-shadow
/>
</span>
<span class="min-w-0 flex-1 truncate">{{ source.name }}</span>
</CheckCircleButton>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2 p-2">
<Button type="outlined" :disabled="pending" @click="hide">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!canConfirm"
:loading="pending"
@click="canConfirm && emit('confirm')"
>
<RefreshCwIcon :class="{ 'animate-spin': pending }" aria-hidden="true" />
{{ formatMessage(messages.sync) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { Button, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import {
remove_synced_pack,
set_synced_pack_enabled,
syncedPackKeys,
syncedPackQueryOptions,
type SyncedPackType,
} from '@/helpers/synced-packs'
const modal = ref<InstanceType<typeof NewModal>>()
const type = ref<SyncedPackType>('resourcepack')
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const messages = defineMessages({
resourcePacks: {
id: 'app.settings.synced-packs.resource-packs',
defaultMessage: 'Resource packs',
},
dataPacks: { id: 'app.settings.synced-packs.data-packs', defaultMessage: 'Data packs' },
enable: { id: 'app.settings.synced-packs.enable', defaultMessage: 'Enable' },
disable: { id: 'app.settings.synced-packs.disable', defaultMessage: 'Disable' },
remove: { id: 'app.settings.synced-packs.remove', defaultMessage: 'Remove' },
empty: { id: 'app.settings.synced-packs.empty', defaultMessage: 'No synced packs' },
})
const query = useQuery(computed(() => syncedPackQueryOptions(type.value)))
const packs = computed(() => query.value.data.value ?? [])
const mutation = useMutation({
mutationFn: async (request: { id: string; enabled?: boolean; remove?: boolean }) => {
if (request.remove) await remove_synced_pack(request.id)
else await set_synced_pack_enabled(request.id, request.enabled ?? true)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: syncedPackKeys.all }),
})
async function show(nextType: SyncedPackType) {
type.value = nextType
modal.value?.show()
await queryClient.invalidateQueries({ queryKey: syncedPackKeys.list(nextType) })
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(type === 'resourcepack' ? messages.resourcePacks : messages.dataPacks)"
max-width="42rem"
>
<div v-if="packs.length" class="flex flex-col gap-2">
<div
v-for="pack in packs"
:key="pack.id"
class="flex items-center gap-3 border-b border-solid border-surface-4 py-2"
>
<span class="min-w-0 flex-1 truncate">{{ pack.file_name }}</span>
<Button
:disabled="mutation.isPending.value"
@click="mutation.mutate({ id: pack.id, enabled: !pack.enabled })"
>{{ formatMessage(pack.enabled ? messages.disable : messages.enable) }}
</Button>
<Button
type="colored"
color="red"
:disabled="mutation.isPending.value"
@click="mutation.mutate({ id: pack.id, remove: true })"
>{{ formatMessage(messages.remove) }}
</Button>
</div>
</div>
<p v-else class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.empty) }}</p>
</NewModal>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import {
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { nextTick, ref } from 'vue'
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
import { set_command_history } from '@/helpers/instance'
import { commandHistoryQueryOptions, syncedOptionsKeys } from '@/helpers/synced-options'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const commandHistory = ref('')
const historyQuery = useQuery({ ...commandHistoryQueryOptions(), enabled: false })
const saveMutation = useMutation({
mutationFn: set_command_history,
onSuccess: (history) => {
queryClient.setQueryData(syncedOptionsKeys.commandHistory, history)
},
onError: handleError,
})
const messages = defineMessages({
commandHistoryEditorTitle: {
id: 'app.settings.synced-options.command-history.editor-title',
defaultMessage: 'Edit command history',
},
})
async function show() {
const result = await historyQuery.refetch()
if (result.isError) {
handleError(result.error)
return
}
if (!result.isSuccess) return
commandHistory.value = result.data
modal.value?.show()
}
async function saveCommandHistory() {
if (saveMutation.isPending.value) return
try {
await saveMutation.mutateAsync(commandHistory.value)
await nextTick()
modal.value?.hide()
} catch {
return
}
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.commandHistoryEditorTitle)"
:disable-close="saveMutation.isPending.value"
no-padding
actions-divider
max-width="700px"
width="700px"
>
<StudioEditor
v-model:content="commandHistory"
file-path="command_history.txt"
language="plaintext"
class="h-[420px] text-sm"
/>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" :loading="saveMutation.isPending.value" @click="saveCommandHistory">
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,476 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
MoreVerticalIcon,
NoSignalIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SignalIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
Button,
type ButtonMenuOption,
commonMessages,
defineMessages,
FilterPills,
injectNotificationManager,
NewModal,
StyledInput,
Table,
type TableColumn,
TeleportOverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import { remove_synced_server, type SyncedServer, update_synced_server } from '@/helpers/instance'
import { syncedOptionsKeys, syncedServersQueryOptions } from '@/helpers/synced-options'
import { copyToClipboard } from '@/helpers/utils'
import {
refreshServerData,
refreshServers,
type ServerData,
type ServerWorld,
} from '@/helpers/worlds'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const serverEditorModal = ref<InstanceType<typeof NewModal> | null>(null)
const editServerModal = ref<InstanceType<typeof NewModal> | null>(null)
const editedServer = ref<SyncedServer | null>(null)
const serversQuery = useQuery(syncedServersQueryOptions())
const syncedServers = computed(() => serversQuery.data.value ?? [])
const serverData = ref<Record<string, ServerData>>({})
const serverSearch = ref('')
const selectedServerFilters = ref<string[]>([])
const messages = defineMessages({
serverEditorTitle: {
id: 'app.settings.synced-options.multiplayer-servers.editor-title',
defaultMessage: 'Edit synced servers',
},
editServerTitle: {
id: 'instance.edit-server.title',
defaultMessage: 'Edit server',
},
serverName: {
id: 'app.settings.synced-options.multiplayer-servers.name',
defaultMessage: 'Server name',
},
serverAddress: {
id: 'app.settings.synced-options.multiplayer-servers.address',
defaultMessage: 'Server address',
},
searchServers: {
id: 'app.settings.synced-options.multiplayer-servers.search',
defaultMessage:
'Search {count, number} {count, plural, one {synced server} other {synced servers}}...',
},
serverStatus: {
id: 'app.settings.synced-options.multiplayer-servers.status',
defaultMessage: 'Status',
},
onlineFilter: {
id: 'app.settings.synced-options.multiplayer-servers.filter-online',
defaultMessage: 'Online',
},
offlineFilter: {
id: 'app.settings.synced-options.multiplayer-servers.filter-offline',
defaultMessage: 'Offline',
},
playersOnline: {
id: 'app.settings.synced-options.multiplayer-servers.players-online',
defaultMessage: '{count, number} online',
},
noMatchingServers: {
id: 'app.settings.synced-options.multiplayer-servers.no-results',
defaultMessage: 'No synced servers match your search or filters.',
},
serverCount: {
id: 'app.settings.synced-options.multiplayer-servers.count',
defaultMessage: '{count, plural, one {# server} other {# servers}}',
},
moreServerOptions: {
id: 'app.settings.synced-options.multiplayer-servers.more-options',
defaultMessage: 'More server options',
},
copyServerAddress: {
id: 'app.settings.synced-options.multiplayer-servers.copy-address',
defaultMessage: 'Copy address',
},
noSyncedServers: {
id: 'app.settings.synced-options.multiplayer-servers.empty',
defaultMessage: 'No user-added servers are currently synced.',
},
})
const serverWorlds = computed<ServerWorld[]>(() =>
syncedServers.value.map((server, index) => ({
name: server.name,
type: 'server',
index,
server_id: server.id,
address: server.address,
pack_status:
server.accept_textures === true
? 'enabled'
: server.accept_textures === false
? 'disabled'
: 'prompt',
display_status: 'normal',
})),
)
type SyncedServerTableColumn = 'server' | 'status' | 'version' | 'actions'
type SyncedServerTableRow = SyncedServer & {
server: string
status: 'online' | 'offline'
version: string
actions: null
}
const syncedServerColumns = computed<TableColumn<SyncedServerTableColumn>[]>(() => [
{
key: 'server',
label: formatMessage(commonMessages.serverLabel),
width: '45%',
cellClass: '!h-16',
},
{
key: 'status',
label: formatMessage(messages.serverStatus),
width: '25%',
cellClass: '!h-16',
},
{
key: 'version',
label: formatMessage(commonMessages.versionLabel),
width: '22%',
cellClass: '!h-16',
},
{
key: 'actions',
label: formatMessage(commonMessages.actionsLabel),
align: 'right',
width: '6rem',
cellClass: '!h-16',
},
])
const syncedServerFilterOptions = computed(() => [
{ id: 'online', label: formatMessage(messages.onlineFilter) },
{ id: 'offline', label: formatMessage(messages.offlineFilter) },
])
const syncedServerRows = computed<SyncedServerTableRow[]>(() =>
syncedServers.value.map((server) => ({
...server,
server: server.name,
status: serverData.value[server.address]?.status ? 'online' : 'offline',
version: serverData.value[server.address]?.status?.version?.name ?? '',
actions: null,
})),
)
const statusFilteredSyncedServerRows = computed(() => {
if (selectedServerFilters.value.length === 0) return syncedServerRows.value
return syncedServerRows.value.filter((server) =>
selectedServerFilters.value.includes(server.status),
)
})
const filteredSyncedServerRows = computed(() => {
const search = serverSearch.value.trim().toLocaleLowerCase()
if (!search) return statusFilteredSyncedServerRows.value
return statusFilteredSyncedServerRows.value.filter(
(server) =>
server.name.toLocaleLowerCase().includes(search) ||
server.address.toLocaleLowerCase().includes(search),
)
})
const refreshMutation = useMutation({
mutationFn: () => refreshServers(serverWorlds.value, serverData.value, null),
onError: handleError,
})
const refreshingAllSyncedServers = refreshMutation.isPending
const saveMutation = useMutation({
mutationFn: update_synced_server,
onSuccess: async (_, server) => {
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
servers?.map((current) => (current.id === server.id ? { ...server } : current)),
)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
void refreshSyncedServer(server.address).catch(handleError)
},
onError: handleError,
})
const removeMutation = useMutation({
mutationFn: remove_synced_server,
onSuccess: async (_, serverId) => {
queryClient.setQueryData<SyncedServer[]>(syncedOptionsKeys.servers, (servers) =>
servers?.filter((server) => server.id !== serverId),
)
await queryClient.invalidateQueries({ queryKey: ['worlds'] })
},
onError: handleError,
})
async function show() {
const result = await serversQuery.refetch()
if (result.isError) {
handleError(result.error)
return
}
serverSearch.value = ''
selectedServerFilters.value = []
serverData.value = {}
serverEditorModal.value?.show()
refreshAllSyncedServers()
}
function openSyncedServerEditor(server: SyncedServer) {
editedServer.value = { ...server }
editServerModal.value?.show()
}
async function saveSyncedServer() {
if (!editedServer.value || saveMutation.isPending.value) return
try {
await saveMutation.mutateAsync({ ...editedServer.value })
await nextTick()
editServerModal.value?.hide()
} catch {
return
}
}
async function refreshSyncedServer(address: string) {
serverData.value[address] ??= { refreshing: true }
await refreshServerData(serverData.value[address], null, address)
}
function refreshAllSyncedServers() {
if (!refreshingAllSyncedServers.value) refreshMutation.mutate()
}
function syncedServerMenuOptions(server: SyncedServer): ButtonMenuOption[] {
return [
{
id: 'refresh',
label: formatMessage(commonMessages.refreshButton),
icon: RefreshCwIcon,
action: () => refreshSyncedServer(server.address).catch(handleError),
},
{
id: 'copy-address',
label: formatMessage(messages.copyServerAddress),
icon: ClipboardCopyIcon,
action: () => copyToClipboard(server.address),
},
{
id: 'edit',
label: formatMessage(commonMessages.editButton),
icon: EditIcon,
action: () => openSyncedServerEditor(server),
},
{ type: 'divider' },
{
id: 'remove',
label: formatMessage(commonMessages.removeButton),
icon: TrashIcon,
tone: 'red',
disabled: removeMutation.isPending.value,
action: () => removeMutation.mutate(server.id),
},
]
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="serverEditorModal"
:header="formatMessage(messages.serverEditorTitle)"
no-padding
max-width="min(928px, calc(100vw - 2rem))"
width="min(928px, calc(100vw - 2rem))"
>
<div class="flex max-h-[min(600px,calc(95vh-10rem))] flex-col">
<div
class="flex shrink-0 flex-col gap-4 border-0 border-b border-solid border-surface-4 px-6 py-4"
>
<StyledInput
v-model="serverSearch"
:icon="SearchIcon"
type="search"
autocomplete="off"
:spellcheck="false"
:placeholder="formatMessage(messages.searchServers, { count: syncedServers.length })"
size="medium"
:aria-label="formatMessage(messages.searchServers, { count: syncedServers.length })"
:clear-label="formatMessage(commonMessages.clearButton)"
clearable
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<FilterPills v-model="selectedServerFilters" :options="syncedServerFilterOptions">
<template #all>
{{ formatMessage(commonMessages.allProjectType) }}
</template>
</FilterPills>
<Button
type="quiet"
:disabled="refreshingAllSyncedServers || syncedServers.length === 0"
interaction="none"
@click="refreshAllSyncedServers"
>
<RefreshCwIcon
:class="{ 'animate-spin': refreshingAllSyncedServers }"
aria-hidden="true"
/>
{{ formatMessage(commonMessages.refreshButton) }}
</Button>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
v-if="syncedServers.length === 0"
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noSyncedServers) }}
</div>
<div
v-else-if="filteredSyncedServerRows.length === 0"
class="flex flex-1 items-center justify-center p-8 text-center text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noMatchingServers) }}
</div>
<div v-else class="min-h-0 flex-1 overflow-y-auto">
<Table
:columns="syncedServerColumns"
:data="filteredSyncedServerRows"
row-key="id"
table-min-width="44rem"
class="!rounded-none !border-0"
>
<template #cell-server="{ row }">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="serverData[row.address]?.status?.favicon"
:alt="row.name"
:tint-by="row.address"
size="32px"
no-shadow
class="shrink-0 !rounded-lg"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
row.name
}}</span>
<span class="truncate text-sm text-[var(--color-text-tertiary)]">{{
row.address
}}</span>
</div>
</div>
</template>
<template #cell-status="{ row }">
<span class="inline-flex items-center gap-1.5 font-medium">
<RefreshCwIcon
v-if="serverData[row.address]?.refreshing"
class="size-4 shrink-0 animate-spin"
aria-hidden="true"
/>
<SignalIcon
v-else-if="serverData[row.address]?.status"
class="size-4 shrink-0 text-green"
aria-hidden="true"
/>
<NoSignalIcon v-else class="size-4 shrink-0" aria-hidden="true" />
<template v-if="serverData[row.address]?.refreshing">
{{ formatMessage(commonMessages.loadingLabel) }}
</template>
<template v-else-if="serverData[row.address]?.status">
{{
formatMessage(messages.playersOnline, {
count: serverData[row.address]?.status?.players?.online ?? 0,
})
}}
</template>
<template v-else>{{ formatMessage(messages.offlineFilter) }}</template>
</span>
</template>
<template #cell-version="{ row }">
<span class="block truncate">{{ row.version || '—' }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<TeleportOverflowMenu
type="quiet"
:label="formatMessage(messages.moreServerOptions)"
:options="syncedServerMenuOptions(row)"
>
<MoreVerticalIcon aria-hidden="true" />
</TeleportOverflowMenu>
</div>
</template>
</Table>
</div>
</div>
<div
class="flex shrink-0 items-center border-0 border-t border-solid border-surface-4 px-6 py-4"
>
<span class="font-medium text-[var(--color-text-default)]">
{{ formatMessage(messages.serverCount, { count: syncedServers.length }) }}
</span>
</div>
</div>
</NewModal>
<NewModal
ref="editServerModal"
:header="formatMessage(messages.editServerTitle)"
:disable-close="saveMutation.isPending.value"
max-width="500px"
width="500px"
>
<div v-if="editedServer" class="flex flex-col gap-4">
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.serverName) }}
<StyledInput v-model="editedServer.name" autocomplete="off" wrapper-class="w-full" />
</label>
<label class="flex flex-col gap-2 font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.serverAddress) }}
<StyledInput v-model="editedServer.address" autocomplete="off" wrapper-class="w-full" />
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="editServerModal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="!editedServer?.address"
:loading="saveMutation.isPending.value"
@click="saveSyncedServer"
>
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveChangesButton) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -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,
})
}
+98
View File
@@ -108,6 +108,104 @@ export async function get_many(instanceIds: string[]): Promise<GameInstance[]> {
return await invoke('plugin:instance|instance_get_many', { instanceIds })
}
export function adaptContentItems(items: ContentItem[]): ContentItem[] {
return items.map((item) => {
const embeddedMetadata = item.embedded_metadata
if (!embeddedMetadata?.icon_path) return item
return {
...item,
embedded_metadata: {
...embeddedMetadata,
icon_url: convertFileSrc(embeddedMetadata.icon_path),
},
}
})
}
export type SyncedOption =
| 'game_options'
| 'command_history'
| 'multiplayer_servers'
| 'creative_hotbars'
| 'screenshots'
| 'resource_packs'
| 'data_packs'
export type GlobalSyncedOptions = Record<SyncedOption, boolean>
export type SyncedOptionCapability = {
option: SyncedOption
supported: boolean
disabled_reason: string | null
}
export type SyncedOptionJoinPreview = { action: string }
export type SyncedOptionsOverview = {
global_options: GlobalSyncedOptions
capabilities: SyncedOptionCapability[]
}
export async function get_global_synced_options(): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_get_synced_options')
}
export async function get_initialized_synced_options(): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_get_initialized_synced_options')
}
/**
* Every synced option is currently available on every instance. The parameter
* stays so callers keep passing the option they are asking about, which is what
* a real implementation will need.
*/
export function isSyncedOptionAvailable(_option: SyncedOption): boolean {
return true
}
export async function set_global_synced_option(
option: SyncedOption,
enabled: boolean,
baseInstanceId?: string | null,
): Promise<GlobalSyncedOptions> {
return await invoke('plugin:instance|instance_set_synced_option', {
option,
enabled,
baseInstanceId,
})
}
export async function set_instance_synced_option(
instanceId: string,
option: SyncedOption,
enabled: boolean,
resolution?: string | null,
): Promise<GameInstance> {
return await invoke('plugin:instance|instance_set_instance_synced_option', {
instanceId,
option,
enabled,
resolution,
})
}
export async function get_synced_options_overview(
instanceId: string,
): Promise<SyncedOptionsOverview> {
return await invoke('plugin:instance|instance_get_synced_options_overview', { instanceId })
}
export async function get_command_history(): Promise<string> {
return await invoke('plugin:instance|instance_get_synced_command_history')
}
export async function set_command_history(contents: string): Promise<string> {
return await invoke('plugin:instance|instance_set_synced_command_history', { contents })
}
export type SyncedServer = {
id: string
name: string
address: string
accept_textures: boolean | null
}
export async function list_synced_servers(): Promise<SyncedServer[]> {
return await invoke('plugin:instance|instance_list_synced_servers')
}
export async function update_synced_server(server: SyncedServer): Promise<void> {
await invoke('plugin:instance|instance_update_synced_server', { server })
}
export async function remove_synced_server(id: string): Promise<void> {
await invoke('plugin:instance|instance_remove_synced_server', { id })
}
export async function get_projects(
instanceId: string,
cacheBehaviour?: CacheBehaviour,
@@ -0,0 +1,61 @@
import { queryOptions } from '@tanstack/vue-query'
import { list_game_options_sync_sources } from './game-options'
import {
get_command_history,
get_global_synced_options,
get_initialized_synced_options,
list_synced_servers,
} from './instance'
import type { GameInstance } from './types'
export const syncedOptionsKeys = {
global: ['global-synced-options'] as const,
initialized: ['initialized-synced-options'] as const,
gameSources: ['game-options-sync-sources'] as const,
set: ['global-synced-options', 'set'] as const,
servers: ['synced-servers', 'list'] as const,
commandHistory: ['synced-options', 'command-history'] as const,
}
export function globalSyncedOptionsQueryOptions() {
return queryOptions({
queryKey: syncedOptionsKeys.global,
queryFn: get_global_synced_options,
})
}
export function initializedSyncedOptionsQueryOptions() {
return queryOptions({
queryKey: syncedOptionsKeys.initialized,
queryFn: get_initialized_synced_options,
})
}
export function gameOptionsSyncSourcesQueryOptions() {
return queryOptions({
queryKey: syncedOptionsKeys.gameSources,
queryFn: list_game_options_sync_sources,
staleTime: 0,
})
}
export function syncedServersQueryOptions() {
return queryOptions({
queryKey: syncedOptionsKeys.servers,
queryFn: list_synced_servers,
})
}
export function commandHistoryQueryOptions() {
return queryOptions({
queryKey: syncedOptionsKeys.commandHistory,
queryFn: get_command_history,
})
}
export function canSourceMultiplayerServers(instance: GameInstance): boolean {
return !['server_project', 'server_project_modpack', 'modrinth_hosting'].includes(
instance.link?.type ?? '',
)
}
@@ -0,0 +1,83 @@
import type { ContentItem } from '@modrinth/ui'
import { queryOptions } from '@tanstack/vue-query'
import { invoke } from '@tauri-apps/api/core'
import { adaptContentItems, isSyncedOptionAvailable } from './instance'
export type SyncedPackType = 'resourcepack' | 'datapack'
export type SyncedPackAction = 'enable' | 'disable'
export type DesyncServerMode = 'keep_in_other_instances' | 'remove_from_other_instances'
export function isSyncedPackTypeAvailable(projectType: ContentItem['project_type']): boolean {
return (
['resourcepack', 'datapack'].includes(projectType) &&
isSyncedOptionAvailable(projectType === 'resourcepack' ? 'resource_packs' : 'data_packs')
)
}
export interface PackSyncPreview {
pack: ContentItem
instances: {
instance_id: string
name: string
game_version: string
compatible: boolean
participating: boolean
}[]
}
export const syncedPackKeys = {
all: ['synced-packs'] as const,
list: (type: SyncedPackType) => ['synced-packs', 'list', type] as const,
}
export function syncedPackQueryOptions(type: SyncedPackType) {
return queryOptions({
queryKey: syncedPackKeys.list(type),
queryFn: () => list_synced_packs(type),
enabled: isSyncedPackTypeAvailable(type),
})
}
export async function list_synced_packs(projectType: SyncedPackType): Promise<ContentItem[]> {
const items = await invoke<ContentItem[]>('plugin:instance|instance_list_synced_packs', {
projectType,
})
return adaptContentItems(items).map((item) => ({ ...item, external: !item.project }))
}
export function get_pack_sync_preview(
instanceId: string,
projectPath: string,
): Promise<PackSyncPreview> {
return invoke('plugin:instance|instance_get_pack_sync_preview', { instanceId, projectPath })
}
export function sync_pack(instanceId: string, projectPath: string): Promise<void> {
return invoke('plugin:instance|instance_sync_pack', { instanceId, projectPath })
}
export function desync_pack(
instanceId: string,
packId: string,
mode: DesyncServerMode,
): Promise<void> {
return invoke('plugin:instance|instance_desync_pack', { instanceId, packId, mode })
}
export function upload_synced_pack(
path: string,
projectType: SyncedPackType,
gameVersions: string[],
): Promise<void> {
return invoke('plugin:instance|instance_upload_synced_pack', { path, projectType, gameVersions })
}
export function set_synced_pack_enabled(packId: string, enabled: boolean): Promise<void> {
return invoke('plugin:instance|instance_set_synced_pack_enabled', { packId, enabled })
}
export function remove_synced_pack(packId: string): Promise<void> {
return invoke('plugin:instance|instance_remove_synced_pack', { packId })
}
@@ -10165,5 +10165,191 @@
},
"settings.backups.repository.usage-description": {
"message": "{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}."
},
"app.settings.synced-options.choose-sync-source.command-history-description": {
"message": "Choose which instance to copy your command history from. This history is only used for the initial sync, and you can edit it from any instance afterward."
},
"app.settings.synced-options.choose-sync-source.command-history-title": {
"message": "Choose command history source"
},
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": {
"message": "Choose which instance to copy your saved creative hotbars from. These hotbars are only used for the initial sync, and you can edit them from any instance afterward."
},
"app.settings.synced-options.choose-sync-source.creative-hotbars-title": {
"message": "Choose creative hotbars source"
},
"app.settings.synced-options.choose-sync-source.data-packs-title": {
"message": "Choose data packs source"
},
"app.settings.synced-options.choose-sync-source.game-settings-description": {
"message": "Choose which instance to copy your game settings from. These settings are only used for the initial sync, and you can edit them from any instance afterward."
},
"app.settings.synced-options.choose-sync-source.game-settings-title": {
"message": "Choose game settings source"
},
"app.settings.synced-options.choose-sync-source.ineligible": {
"message": "You cannot choose this instance as a sync source"
},
"app.settings.synced-options.choose-sync-source.load-error": {
"message": "Could not load sync sources. Please try again."
},
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": {
"message": "Choose which instance to copy your multiplayer servers from. These servers are only used for the initial sync, and you can edit them from any instance afterward."
},
"app.settings.synced-options.choose-sync-source.multiplayer-servers-title": {
"message": "Choose multiplayer servers source"
},
"app.settings.synced-options.choose-sync-source.no-instances-found": {
"message": "No instances found"
},
"app.settings.synced-options.choose-sync-source.resource-packs-title": {
"message": "Choose resource packs source"
},
"app.settings.synced-options.choose-sync-source.retry": {
"message": "Try again"
},
"app.settings.synced-options.choose-sync-source.search-placeholder": {
"message": "Search instance"
},
"app.settings.synced-options.choose-sync-source.sync": {
"message": "Sync"
},
"app.settings.synced-options.command-history": {
"message": "Sync command history"
},
"app.settings.synced-options.command-history.description": {
"message": "Use the same command history across your instances."
},
"app.settings.synced-options.command-history.editor-title": {
"message": "Edit command history"
},
"app.settings.synced-options.creative-hotbars": {
"message": "Sync saved creative hotbars"
},
"app.settings.synced-options.creative-hotbars.description": {
"message": "Use the same saved creative hotbars across your instances."
},
"app.settings.synced-options.data-packs": {
"message": "Sync data packs"
},
"app.settings.synced-options.data-packs.description": {
"message": "Use the same datapacks across your instances"
},
"app.settings.synced-options.data-packs.none-synced-yet": {
"message": "You haven't synced any data packs yet"
},
"app.settings.synced-options.edit.no-instances": {
"message": "Add an instance before editing synced data."
},
"app.settings.synced-options.edit.no-synced-data": {
"message": "Choose a sync source before editing this setting."
},
"app.settings.synced-options.game-settings": {
"message": "Sync game options"
},
"app.settings.synced-options.game-settings.button": {
"message": "Edit game settings"
},
"app.settings.synced-options.game-settings.description": {
"message": "Use the same game options across your instances."
},
"app.settings.synced-options.game-settings.disabled-tooltip": {
"message": "Enable game settings sync before choosing individual settings."
},
"app.settings.synced-options.game-settings.no-options-to-edit": {
"message": "You haven't got any options yet to edit"
},
"app.settings.synced-options.multiplayer-servers": {
"message": "Sync multiplayer servers"
},
"app.settings.synced-options.multiplayer-servers.address": {
"message": "Server address"
},
"app.settings.synced-options.multiplayer-servers.copy-address": {
"message": "Copy address"
},
"app.settings.synced-options.multiplayer-servers.count": {
"message": "{count, plural, one {# server} other {# servers}}"
},
"app.settings.synced-options.multiplayer-servers.description": {
"message": "Use the same multiplayer servers across your instances."
},
"app.settings.synced-options.multiplayer-servers.editor-title": {
"message": "Edit synced servers"
},
"app.settings.synced-options.multiplayer-servers.empty": {
"message": "No user-added servers are currently synced."
},
"app.settings.synced-options.multiplayer-servers.filter-offline": {
"message": "Offline"
},
"app.settings.synced-options.multiplayer-servers.filter-online": {
"message": "Online"
},
"app.settings.synced-options.multiplayer-servers.more-options": {
"message": "More server options"
},
"app.settings.synced-options.multiplayer-servers.name": {
"message": "Server name"
},
"app.settings.synced-options.multiplayer-servers.no-results": {
"message": "No synced servers match your search or filters."
},
"app.settings.synced-options.multiplayer-servers.none-synced-yet": {
"message": "You haven't synced any servers yet"
},
"app.settings.synced-options.multiplayer-servers.players-online": {
"message": "{count, number} online"
},
"app.settings.synced-options.multiplayer-servers.search": {
"message": "Search {count, number} {count, plural, one {synced server} other {synced servers}}..."
},
"app.settings.synced-options.multiplayer-servers.status": {
"message": "Status"
},
"app.settings.synced-options.packs.disabled": {
"message": "Enable syncing before editing shared packs."
},
"app.settings.synced-options.resource-packs": {
"message": "Sync resource packs"
},
"app.settings.synced-options.resource-packs.description": {
"message": "Use the same resource packs across your instances."
},
"app.settings.synced-options.resource-packs.none-synced-yet": {
"message": "You haven't synced any resource packs yet"
},
"app.settings.synced-options.title": {
"message": "Options synced across all instances"
},
"app.settings.synced-packs.data-packs": {
"message": "Data packs"
},
"app.settings.synced-packs.disable": {
"message": "Disable"
},
"app.settings.synced-packs.empty": {
"message": "No synced packs"
},
"app.settings.synced-packs.enable": {
"message": "Enable"
},
"app.settings.synced-packs.remove": {
"message": "Remove"
},
"app.settings.synced-packs.resource-packs": {
"message": "Resource packs"
},
"app.sync-instances-update.choose-source.data-packs": {
"message": "Choose which instance to copy your data packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward."
},
"app.sync-instances-update.choose-source.resource-packs": {
"message": "Choose which instance to copy your resource packs from. These packs are only used for the initial sync, and you can edit them from any instance afterward."
},
"instance.settings.tabs.backups": {
"message": "Backups"
},
"instance.settings.tabs.sync": {
"message": "Synchronization"
}
}
+63 -1
View File
@@ -10929,5 +10929,67 @@
},
"settings.backups.repository.usage-description": {
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
}
},
"app.settings.synced-options.choose-sync-source.command-history-description": { "message": "选择要从哪个实例复制命令历史记录。这些记录仅用于初始同步,之后可从任意实例编辑。" },
"app.settings.synced-options.choose-sync-source.command-history-title": { "message": "选择命令历史记录来源" },
"app.settings.synced-options.choose-sync-source.creative-hotbars-description": { "message": "选择要从哪个实例复制保存的创造模式快捷栏。这些快捷栏仅用于初始同步,之后可从任意实例编辑。" },
"app.settings.synced-options.choose-sync-source.creative-hotbars-title": { "message": "选择创造模式快捷栏来源" },
"app.settings.synced-options.choose-sync-source.data-packs-title": { "message": "选择数据包来源" },
"app.settings.synced-options.choose-sync-source.game-settings-description": { "message": "选择要从哪个实例复制游戏设置。这些设置仅用于初始同步,之后可从任意实例编辑。" },
"app.settings.synced-options.choose-sync-source.game-settings-title": { "message": "选择游戏设置来源" },
"app.settings.synced-options.choose-sync-source.ineligible": { "message": "无法选择此实例作为同步来源" },
"app.settings.synced-options.choose-sync-source.load-error": { "message": "无法加载同步来源,请重试。" },
"app.settings.synced-options.choose-sync-source.multiplayer-servers-description": { "message": "选择要从哪个实例复制多人服务器列表。这些服务器仅用于初始同步,之后可从任意实例编辑。" },
"app.settings.synced-options.choose-sync-source.multiplayer-servers-title": { "message": "选择多人服务器来源" },
"app.settings.synced-options.choose-sync-source.no-instances-found": { "message": "没有找到实例" },
"app.settings.synced-options.choose-sync-source.resource-packs-title": { "message": "选择资源包来源" },
"app.settings.synced-options.choose-sync-source.retry": { "message": "重试" },
"app.settings.synced-options.choose-sync-source.search-placeholder": { "message": "搜索实例" },
"app.settings.synced-options.choose-sync-source.sync": { "message": "同步" },
"app.settings.synced-options.command-history": { "message": "同步命令记录" },
"app.settings.synced-options.command-history.description": { "message": "在不同的实例之间同步命令记录。" },
"app.settings.synced-options.command-history.editor-title": { "message": "编辑指令历史记录" },
"app.settings.synced-options.creative-hotbars": { "message": "同步保存的创造模式物品栏" },
"app.settings.synced-options.creative-hotbars.description": { "message": "在不同的实例之间同步创造模式物品栏。" },
"app.settings.synced-options.data-packs": { "message": "同步数据包" },
"app.settings.synced-options.data-packs.description": { "message": "在所有实例间使用相同的数据包" },
"app.settings.synced-options.data-packs.none-synced-yet": { "message": "尚未同步任何数据包" },
"app.settings.synced-options.edit.no-instances": { "message": "请先添加实例,再编辑同步数据。" },
"app.settings.synced-options.edit.no-synced-data": { "message": "请先选择同步来源,再编辑此设置。" },
"app.settings.synced-options.game-settings": { "message": "同步游戏设置" },
"app.settings.synced-options.game-settings.button": { "message": "编辑游戏设置" },
"app.settings.synced-options.game-settings.description": { "message": "在所有实例间使用相同的游戏设置。" },
"app.settings.synced-options.game-settings.disabled-tooltip": { "message": "请先启用游戏设置同步,再选择单个设置。" },
"app.settings.synced-options.game-settings.no-options-to-edit": { "message": "目前没有可编辑的设置" },
"app.settings.synced-options.multiplayer-servers": { "message": "同步多人模式服务器列表" },
"app.settings.synced-options.multiplayer-servers.address": { "message": "服务器地址" },
"app.settings.synced-options.multiplayer-servers.copy-address": { "message": "复制地址" },
"app.settings.synced-options.multiplayer-servers.count": { "message": "{count, number} 个服务器" },
"app.settings.synced-options.multiplayer-servers.description": { "message": "在不同的实例直接同步多人模式服务器列表。" },
"app.settings.synced-options.multiplayer-servers.editor-title": { "message": "编辑同步的服务器" },
"app.settings.synced-options.multiplayer-servers.empty": { "message": "当前未同步任何用户添加的服务器。" },
"app.settings.synced-options.multiplayer-servers.filter-offline": { "message": "离线" },
"app.settings.synced-options.multiplayer-servers.filter-online": { "message": "在线" },
"app.settings.synced-options.multiplayer-servers.more-options": { "message": "更多服务器选项" },
"app.settings.synced-options.multiplayer-servers.name": { "message": "服务器名称" },
"app.settings.synced-options.multiplayer-servers.no-results": { "message": "没有匹配搜索或筛选条件的已同步服务器。" },
"app.settings.synced-options.multiplayer-servers.none-synced-yet": { "message": "暂无已同步的服务器" },
"app.settings.synced-options.multiplayer-servers.players-online": { "message": "{count, number} 人在线" },
"app.settings.synced-options.multiplayer-servers.search": { "message": "搜索 {count, number} 个已同步服务器……" },
"app.settings.synced-options.multiplayer-servers.status": { "message": "状态" },
"app.settings.synced-options.packs.disabled": { "message": "请先启用同步,再编辑共享包。" },
"app.settings.synced-options.resource-packs": { "message": "同步资源包" },
"app.settings.synced-options.resource-packs.description": { "message": "在所有实例间使用相同的资源包" },
"app.settings.synced-options.resource-packs.none-synced-yet": { "message": "尚未同步任何资源包" },
"app.settings.synced-options.title": { "message": "跨所有实例同步的选项" },
"app.settings.synced-packs.data-packs": { "message": "数据包" },
"app.settings.synced-packs.disable": { "message": "禁用" },
"app.settings.synced-packs.empty": { "message": "没有已同步的包" },
"app.settings.synced-packs.enable": { "message": "启用" },
"app.settings.synced-packs.remove": { "message": "移除" },
"app.settings.synced-packs.resource-packs": { "message": "资源包" },
"app.sync-instances-update.choose-source.data-packs": { "message": "选择要从哪个实例复制数据包。这些包仅用于初始化同步,之后可从任意实例进行编辑。" },
"app.sync-instances-update.choose-source.resource-packs": { "message": "选择要从哪个实例复制资源包。这些包仅用于初始化同步,之后可从任意实例进行编辑。" },
"instance.settings.tabs.backups": { "message": "备份" },
"instance.settings.tabs.sync": { "message": "同步" }
}
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { CheckIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import ButtonFrame from './ButtonFrame.vue'
import type { ButtonNativeType } from './types'
withDefaults(
defineProps<{
checked: boolean
disabled?: boolean
nativeType?: ButtonNativeType
}>(),
{
disabled: false,
nativeType: 'button',
},
)
const frame = ref<InstanceType<typeof ButtonFrame> | null>(null)
const element = computed(() => frame.value?.element ?? null)
defineExpose({ element })
</script>
<template>
<ButtonFrame
ref="frame"
as="button"
type="quiet"
size="lg"
interaction="none"
:disabled="disabled"
:native-type="nativeType"
role="radio"
:aria-checked="checked"
class="w-full !justify-between !gap-4 !whitespace-normal !border !border-solid !px-2 !text-left !transition-colors"
:class="
checked
? '!border-brand !bg-brand-highlight !text-[var(--color-text-primary)]'
: '!border-transparent !bg-transparent !text-[var(--color-text-primary)] enabled:hover:!bg-surface-3'
"
>
<span class="flex min-w-0 flex-1 items-center gap-2"><slot /></span>
<span
v-if="checked"
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-brand text-brand-inverted"
>
<CheckIcon class="size-4" aria-hidden="true" />
</span>
<span v-else class="size-6 shrink-0 rounded-full border border-solid border-surface-5" />
</ButtonFrame>
</template>
+2
View File
@@ -11,6 +11,7 @@ export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as NewButton } from './buttons/Button.vue'
export { default as IconButton } from './buttons/IconButton.vue'
export { default as CheckCircleButton } from './buttons/CheckCircleButton.vue'
export type {
ButtonColor,
ButtonElementHandle,
@@ -78,6 +79,7 @@ export { default as NavTabs } from './NavTabs.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as Pagination } from './Pagination.vue'
export { default as PopoutMenu } from './PopoutMenu.vue'