feat:移除了弹窗,服务器添加sls
This commit is contained in:
775
apps/app-frontend/src/pages/instance/WorldEditor.vue
Normal file
775
apps/app-frontend/src/pages/instance/WorldEditor.vue
Normal file
@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<ModalWrapper ref="unsavedModal">
|
||||
<template #title>
|
||||
<span class="font-extrabold text-lg text-contrast">
|
||||
{{ formatMessage(messages.unsavedTitle) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="w-[400px] max-w-full">
|
||||
<p class="m-0">{{ formatMessage(messages.unsavedBody) }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirmLeave">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.leaveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="unsavedModal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.stayButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<EmptyState
|
||||
v-if="loadError"
|
||||
type="error"
|
||||
:heading="formatMessage(messages.loadErrorHeading)"
|
||||
:description="loadError"
|
||||
/>
|
||||
<div v-else-if="data" class="flex flex-col gap-6 pb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="group relative">
|
||||
<Avatar :src="form.removeIcon ? undefined : data.icon" size="64px" />
|
||||
<Tooltip>
|
||||
<button
|
||||
v-if="data.icon && !form.removeIcon && !readonly"
|
||||
class="absolute inset-0 hidden cursor-pointer items-center justify-center rounded-xl border-none bg-black/60 text-white group-hover:flex"
|
||||
@click="form.removeIcon = true"
|
||||
>
|
||||
<UndoIcon class="size-5" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.resetIcon) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<h1 class="m-0 truncate text-2xl font-extrabold text-contrast">{{ data.name }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-secondary">
|
||||
<span v-if="data.version_name" class="rounded-full bg-button-bg px-2 py-0.5 font-medium">
|
||||
{{ data.version_name }}
|
||||
</span>
|
||||
<span v-if="data.modded" class="rounded-full bg-button-bg px-2 py-0.5 font-medium">
|
||||
{{ formatMessage(messages.moddedBadge) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="data.hardcore"
|
||||
class="rounded-full bg-bg-red px-2 py-0.5 font-medium text-red"
|
||||
>
|
||||
{{ formatMessage(messages.hardcoreBadge) }}
|
||||
</span>
|
||||
<span v-if="data.last_played">
|
||||
{{
|
||||
formatMessage(messages.lastPlayed, {
|
||||
ago: formatRelativeTime(dayjs(data.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Admonition v-if="readonly" type="warning" :header="formatMessage(messages.lockedHeading)">
|
||||
{{ formatMessage(messages.lockedBody) }}
|
||||
</Admonition>
|
||||
<SymlinkInstanceWarning
|
||||
v-if="instance?.symlink_target"
|
||||
:symlink-target="instance.symlink_target"
|
||||
/>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.basicSection) }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="font-semibold text-contrast" for="world-name">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="world-name"
|
||||
v-model="form.name"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
autocomplete="off"
|
||||
:disabled="readonly"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span v-if="nameError" class="text-sm text-red">
|
||||
{{ formatMessage(messages.nameRequired) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameModeLabel) }}
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.gameMode"
|
||||
name="world-game-mode"
|
||||
:options="GAME_MODE_OPTIONS"
|
||||
:display-name="gameModeLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.difficulty" class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.difficultyLabel) }}
|
||||
<span v-if="data.difficulty_locked" class="font-normal text-secondary">
|
||||
{{ formatMessage(messages.difficultyLockedHint) }}
|
||||
</span>
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.difficulty"
|
||||
name="world-difficulty"
|
||||
:options="DIFFICULTY_OPTIONS"
|
||||
:display-name="difficultyLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="form.allowCommands !== undefined" class="flex flex-col gap-1.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.allowCommandsLabel) }}
|
||||
</span>
|
||||
<DropdownSelect
|
||||
v-model="form.allowCommands"
|
||||
name="world-allow-commands"
|
||||
:options="BOOLEAN_OPTIONS"
|
||||
:display-name="booleanLabel"
|
||||
:disabled="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="form.seed !== undefined" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.seedSection) }}
|
||||
</h2>
|
||||
<Admonition type="warning" :header="formatMessage(messages.seedWarningHeading)">
|
||||
{{ formatMessage(messages.seedWarningBody) }}
|
||||
</Admonition>
|
||||
<div class="flex max-w-md flex-col gap-1.5">
|
||||
<StyledInput
|
||||
v-model="form.seed"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
input-class="font-mono"
|
||||
:disabled="readonly"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span v-if="seedError" class="text-sm text-red">
|
||||
{{ formatMessage(messages.seedInvalid) }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="data.game_rules.length > 0" class="flex flex-col gap-3">
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.gameRulesSection) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
v-model="ruleSearch"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
clearable
|
||||
:placeholder="
|
||||
formatMessage(messages.searchRulesPlaceholder, { count: data.game_rules.length })
|
||||
"
|
||||
wrapper-class="max-w-md"
|
||||
/>
|
||||
<div v-if="ruleGroups.length === 0" class="text-secondary">
|
||||
{{ formatMessage(messages.noRulesFound) }}
|
||||
</div>
|
||||
<div v-for="group in ruleGroups" :key="group.category" class="flex flex-col gap-2">
|
||||
<Accordion
|
||||
:open-by-default="false"
|
||||
:force-open="ruleSearchActive"
|
||||
class="min-w-0 overflow-hidden rounded-xl border border-solid border-surface-4 bg-bg-raised"
|
||||
button-class="group flex w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-4 py-3 text-left"
|
||||
>
|
||||
<template #title>
|
||||
<h3 class="m-0 text-base font-bold text-contrast">{{ group.label }}</h3>
|
||||
</template>
|
||||
<div class="border-0 border-t border-solid border-surface-4">
|
||||
<div
|
||||
v-for="rule in group.rules"
|
||||
:key="rule.key"
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-b border-solid border-surface-4 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<span
|
||||
v-if="rule.modifiedFromDefault"
|
||||
class="size-2 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.modifiedFromDefault) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<span class="truncate font-medium text-contrast" :title="rule.key">
|
||||
{{ rule.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ButtonStyled v-if="rule.canResetToDefault" type="transparent" size="small">
|
||||
<Tooltip>
|
||||
<button
|
||||
:disabled="readonly"
|
||||
@click="resetRuleToDefault(rule.key, rule.defaultValue)"
|
||||
>
|
||||
<UndoIcon />
|
||||
</button>
|
||||
<template #popper>
|
||||
<span>{{ formatMessage(messages.resetRuleToDefault) }}</span>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</ButtonStyled>
|
||||
<DropdownSelect
|
||||
v-if="rule.widget === 'boolean'"
|
||||
v-model="form.rules[rule.key]"
|
||||
:name="`gamerule-${rule.key}`"
|
||||
class="!w-36"
|
||||
:options="BOOLEAN_OPTIONS"
|
||||
:display-name="booleanLabel"
|
||||
:disabled="readonly"
|
||||
render-up
|
||||
/>
|
||||
<StyledInput
|
||||
v-else
|
||||
v-model="form.rules[rule.key]"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
:disabled="readonly"
|
||||
input-class="font-mono !h-9"
|
||||
wrapper-class="w-36"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="invalidRules.includes(rule.key)" class="w-full text-sm text-red">
|
||||
{{ formatMessage(messages.ruleInvalidInteger) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="dirty && !readonly"
|
||||
class="sticky bottom-0 z-10 -mx-2 flex flex-wrap items-center gap-3 rounded-t-xl border border-b-0 border-solid border-button-border bg-bg-raised px-4 py-3 shadow-lg"
|
||||
>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.unsavedChangesLabel) }}
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!canSave" @click="save">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="saving" @click="discard">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.discardButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, SearchIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
Admonition,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
EmptyState,
|
||||
GAME_MODES,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, type RouteLocationNormalized, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
|
||||
import {
|
||||
gameRuleCategoryMessages,
|
||||
getGameRuleMetadata,
|
||||
resolveGameRuleType,
|
||||
} from '@/components/ui/world/gameRuleRegistry.ts'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
get_world_level_data,
|
||||
reset_world_icon,
|
||||
type SingleplayerGameMode,
|
||||
update_world_settings,
|
||||
type WorldDifficulty,
|
||||
type WorldLevelData,
|
||||
type WorldSettingsPatch,
|
||||
} from '@/helpers/worlds.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options?: unknown
|
||||
offline?: boolean
|
||||
playing?: boolean
|
||||
installed?: boolean
|
||||
}>()
|
||||
|
||||
const worldPath = computed(() => decodeURIComponent(String(route.params.world ?? '')))
|
||||
|
||||
const GAME_MODE_OPTIONS: SingleplayerGameMode[] = ['survival', 'creative', 'adventure', 'spectator']
|
||||
const DIFFICULTY_OPTIONS: WorldDifficulty[] = ['peaceful', 'easy', 'normal', 'hard']
|
||||
const BOOLEAN_OPTIONS = ['true', 'false']
|
||||
|
||||
const RULE_CATEGORY_ORDER = [
|
||||
'player',
|
||||
'mobs',
|
||||
'drops',
|
||||
'world',
|
||||
'chat',
|
||||
'commands',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
gameMode: SingleplayerGameMode
|
||||
difficulty?: WorldDifficulty
|
||||
allowCommands?: string
|
||||
seed?: string
|
||||
rules: Record<string, string>
|
||||
removeIcon: boolean
|
||||
}
|
||||
|
||||
const data = ref<WorldLevelData>()
|
||||
const form = ref<FormState>(emptyForm())
|
||||
const savedState = ref<FormState>(emptyForm())
|
||||
const loadError = ref<string>()
|
||||
const saving = ref(false)
|
||||
const ruleSearch = ref('')
|
||||
|
||||
const ruleSearchActive = computed(() => ruleSearch.value.trim().length > 0)
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { name: '', gameMode: 'survival', rules: {}, removeIcon: false }
|
||||
}
|
||||
|
||||
function snapshotForm(level: WorldLevelData): FormState {
|
||||
return {
|
||||
name: level.name,
|
||||
gameMode: level.game_mode,
|
||||
difficulty: level.difficulty,
|
||||
allowCommands: level.allow_commands === undefined ? undefined : String(level.allow_commands),
|
||||
seed: level.seed,
|
||||
rules: Object.fromEntries(level.game_rules.map((rule) => [rule.key, rule.value])),
|
||||
removeIcon: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const level = await get_world_level_data(props.instance.id, worldPath.value)
|
||||
data.value = level
|
||||
form.value = snapshotForm(level)
|
||||
savedState.value = snapshotForm(level)
|
||||
loadError.value = undefined
|
||||
} catch (err) {
|
||||
loadError.value = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
await load()
|
||||
|
||||
watch(
|
||||
() => props.playing,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
setTimeout(() => {
|
||||
if (!dirty.value) {
|
||||
load()
|
||||
} else if (data.value) {
|
||||
reloadLockedStateOnly()
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function reloadLockedStateOnly() {
|
||||
try {
|
||||
const level = await get_world_level_data(props.instance.id, worldPath.value)
|
||||
if (data.value) {
|
||||
data.value.locked = level.locked
|
||||
}
|
||||
} catch {
|
||||
// Keep the current editor state when only the lock probe fails
|
||||
}
|
||||
}
|
||||
|
||||
const readonly = computed(() => data.value?.locked ?? true)
|
||||
|
||||
const dirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(savedState.value))
|
||||
|
||||
const nameError = computed(() => form.value.name.trim().length === 0)
|
||||
|
||||
const I64_MIN = -(2n ** 63n)
|
||||
const I64_MAX = 2n ** 63n - 1n
|
||||
|
||||
const seedError = computed(() => {
|
||||
if (form.value.seed === undefined || form.value.seed === savedState.value.seed) {
|
||||
return false
|
||||
}
|
||||
const trimmed = form.value.seed.trim()
|
||||
if (!/^[+-]?\d+$/.test(trimmed)) {
|
||||
return true
|
||||
}
|
||||
const value = BigInt(trimmed)
|
||||
return value < I64_MIN || value > I64_MAX
|
||||
})
|
||||
|
||||
const invalidRules = computed(() =>
|
||||
Object.entries(form.value.rules)
|
||||
.filter(([key, value]) => {
|
||||
if (value === savedState.value.rules[key]) {
|
||||
return false
|
||||
}
|
||||
const widget = resolveGameRuleType(savedState.value.rules[key] ?? value)
|
||||
return widget === 'integer' && !/^[+-]?\d+$/.test(value.trim())
|
||||
})
|
||||
.map(([key]) => key),
|
||||
)
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
dirty.value &&
|
||||
!saving.value &&
|
||||
!nameError.value &&
|
||||
!seedError.value &&
|
||||
invalidRules.value.length === 0,
|
||||
)
|
||||
|
||||
type RuleRow = {
|
||||
key: string
|
||||
label: string
|
||||
widget: 'boolean' | 'integer' | 'text'
|
||||
modifiedFromDefault: boolean
|
||||
canResetToDefault: boolean
|
||||
defaultValue?: string
|
||||
}
|
||||
|
||||
const ruleGroups = computed(() => {
|
||||
if (!data.value) {
|
||||
return []
|
||||
}
|
||||
const query = ruleSearch.value.trim().toLowerCase()
|
||||
const grouped = new Map<string, RuleRow[]>()
|
||||
|
||||
for (const entry of data.value.game_rules) {
|
||||
const meta = getGameRuleMetadata(entry.key)
|
||||
const label = meta ? formatMessage(meta.name) : entry.key
|
||||
if (query && !label.toLowerCase().includes(query) && !entry.key.toLowerCase().includes(query)) {
|
||||
continue
|
||||
}
|
||||
const currentValue = form.value.rules[entry.key] ?? entry.value
|
||||
const category = meta?.category ?? 'other'
|
||||
const row: RuleRow = {
|
||||
key: entry.key,
|
||||
label,
|
||||
widget: resolveGameRuleType(entry.value),
|
||||
modifiedFromDefault: meta?.defaultValue !== undefined && currentValue !== meta.defaultValue,
|
||||
canResetToDefault: meta?.defaultValue !== undefined && currentValue !== meta.defaultValue,
|
||||
defaultValue: meta?.defaultValue,
|
||||
}
|
||||
const rows = grouped.get(category)
|
||||
if (rows) {
|
||||
rows.push(row)
|
||||
} else {
|
||||
grouped.set(category, [row])
|
||||
}
|
||||
}
|
||||
|
||||
return RULE_CATEGORY_ORDER.filter((category) => grouped.has(category)).map((category) => ({
|
||||
category,
|
||||
label: formatMessage(gameRuleCategoryMessages[category]),
|
||||
rules: grouped.get(category)!,
|
||||
}))
|
||||
})
|
||||
|
||||
function gameModeLabel(mode: SingleplayerGameMode) {
|
||||
return formatMessage(GAME_MODES[mode].message)
|
||||
}
|
||||
|
||||
function difficultyLabel(difficulty: WorldDifficulty) {
|
||||
return formatMessage(messages[`difficulty_${difficulty}`])
|
||||
}
|
||||
|
||||
function booleanLabel(value: string) {
|
||||
return formatMessage(value === 'true' ? messages.ruleEnabled : messages.ruleDisabled)
|
||||
}
|
||||
|
||||
function buildPatch(): WorldSettingsPatch {
|
||||
const patch: WorldSettingsPatch = {}
|
||||
const current = form.value
|
||||
const saved = savedState.value
|
||||
|
||||
if (current.name.trim() !== saved.name) {
|
||||
patch.name = current.name.trim()
|
||||
}
|
||||
if (current.gameMode !== saved.gameMode) {
|
||||
patch.game_mode = current.gameMode
|
||||
}
|
||||
if (current.difficulty && current.difficulty !== saved.difficulty) {
|
||||
patch.difficulty = current.difficulty
|
||||
}
|
||||
if (current.allowCommands !== undefined && current.allowCommands !== saved.allowCommands) {
|
||||
patch.allow_commands = current.allowCommands === 'true'
|
||||
}
|
||||
if (current.seed !== undefined && current.seed.trim() !== saved.seed) {
|
||||
patch.seed = current.seed.trim()
|
||||
}
|
||||
const changedRules = Object.entries(current.rules)
|
||||
.filter(([key, value]) => value !== saved.rules[key])
|
||||
.map(([key, value]) => ({ key, value: value.trim() }))
|
||||
if (changedRules.length > 0) {
|
||||
patch.game_rules = changedRules
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value || !data.value) {
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const patch = buildPatch()
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await update_world_settings(props.instance.id, worldPath.value, patch)
|
||||
}
|
||||
if (form.value.removeIcon && data.value.icon) {
|
||||
await reset_world_icon(props.instance.id, worldPath.value)
|
||||
}
|
||||
await load()
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', props.instance.id] })
|
||||
addNotification({
|
||||
title: formatMessage(messages.savedNotification),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
form.value = JSON.parse(JSON.stringify(savedState.value))
|
||||
}
|
||||
|
||||
function resetRuleToDefault(key: string, defaultValue?: string) {
|
||||
if (defaultValue !== undefined) {
|
||||
form.value.rules[key] = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
const unsavedModal = ref<InstanceType<typeof ModalWrapper>>()
|
||||
let allowLeave = false
|
||||
let pendingNavigation: RouteLocationNormalized | null = null
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (!dirty.value || readonly.value || allowLeave) {
|
||||
return true
|
||||
}
|
||||
pendingNavigation = to
|
||||
unsavedModal.value?.show()
|
||||
return false
|
||||
})
|
||||
|
||||
function confirmLeave() {
|
||||
allowLeave = true
|
||||
unsavedModal.value?.hide()
|
||||
if (pendingNavigation) {
|
||||
router.push(pendingNavigation.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
loadErrorHeading: {
|
||||
id: 'app.world-editor.load-error',
|
||||
defaultMessage: 'Failed to load world',
|
||||
},
|
||||
moddedBadge: {
|
||||
id: 'app.world-editor.badge.modded',
|
||||
defaultMessage: 'Modded',
|
||||
},
|
||||
hardcoreBadge: {
|
||||
id: 'app.world-editor.badge.hardcore',
|
||||
defaultMessage: 'Hardcore',
|
||||
},
|
||||
lastPlayed: {
|
||||
id: 'app.world-editor.last-played',
|
||||
defaultMessage: 'Last played {ago}',
|
||||
},
|
||||
lockedHeading: {
|
||||
id: 'app.world-editor.locked.heading',
|
||||
defaultMessage: 'World is in use',
|
||||
},
|
||||
lockedBody: {
|
||||
id: 'app.world-editor.locked.body',
|
||||
defaultMessage:
|
||||
'This world is currently open in Minecraft. Close the world before editing it — the editor is read-only until then.',
|
||||
},
|
||||
basicSection: {
|
||||
id: 'app.world-editor.section.basic',
|
||||
defaultMessage: 'Basic settings',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'app.world-editor.name.label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'app.world-editor.name.placeholder',
|
||||
defaultMessage: 'Minecraft World',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'app.world-editor.name.required',
|
||||
defaultMessage: 'The world name cannot be empty',
|
||||
},
|
||||
gameModeLabel: {
|
||||
id: 'app.world-editor.game-mode.label',
|
||||
defaultMessage: 'Game mode',
|
||||
},
|
||||
difficultyLabel: {
|
||||
id: 'app.world-editor.difficulty.label',
|
||||
defaultMessage: 'Difficulty',
|
||||
},
|
||||
difficultyLockedHint: {
|
||||
id: 'app.world-editor.difficulty.locked-hint',
|
||||
defaultMessage: '(locked in game)',
|
||||
},
|
||||
difficulty_peaceful: {
|
||||
id: 'app.world-editor.difficulty.peaceful',
|
||||
defaultMessage: 'Peaceful',
|
||||
},
|
||||
difficulty_easy: {
|
||||
id: 'app.world-editor.difficulty.easy',
|
||||
defaultMessage: 'Easy',
|
||||
},
|
||||
difficulty_normal: {
|
||||
id: 'app.world-editor.difficulty.normal',
|
||||
defaultMessage: 'Normal',
|
||||
},
|
||||
difficulty_hard: {
|
||||
id: 'app.world-editor.difficulty.hard',
|
||||
defaultMessage: 'Hard',
|
||||
},
|
||||
allowCommandsLabel: {
|
||||
id: 'app.world-editor.allow-commands.label',
|
||||
defaultMessage: 'Allow cheats',
|
||||
},
|
||||
seedSection: {
|
||||
id: 'app.world-editor.section.seed',
|
||||
defaultMessage: 'World seed',
|
||||
},
|
||||
seedWarningHeading: {
|
||||
id: 'app.world-editor.seed.warning-heading',
|
||||
defaultMessage: 'Changing the seed only affects new terrain',
|
||||
},
|
||||
seedWarningBody: {
|
||||
id: 'app.world-editor.seed.warning-body',
|
||||
defaultMessage:
|
||||
'Chunks that have already been generated will not be regenerated, which can create visible borders between old and new terrain.',
|
||||
},
|
||||
seedInvalid: {
|
||||
id: 'app.world-editor.seed.invalid',
|
||||
defaultMessage: 'The seed must be a whole number in the 64-bit integer range',
|
||||
},
|
||||
gameRulesSection: {
|
||||
id: 'app.world-editor.section.game-rules',
|
||||
defaultMessage: 'Game rules',
|
||||
},
|
||||
searchRulesPlaceholder: {
|
||||
id: 'app.world-editor.game-rules.search-placeholder',
|
||||
defaultMessage: 'Search {count} game rules...',
|
||||
},
|
||||
noRulesFound: {
|
||||
id: 'app.world-editor.game-rules.no-results',
|
||||
defaultMessage: 'No game rules match your search',
|
||||
},
|
||||
modifiedFromDefault: {
|
||||
id: 'app.world-editor.game-rules.modified',
|
||||
defaultMessage: 'Differs from the vanilla default',
|
||||
},
|
||||
resetRuleToDefault: {
|
||||
id: 'app.world-editor.game-rules.reset-to-default',
|
||||
defaultMessage: 'Reset to default',
|
||||
},
|
||||
ruleEnabled: {
|
||||
id: 'app.world-editor.game-rules.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
ruleDisabled: {
|
||||
id: 'app.world-editor.game-rules.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
ruleInvalidInteger: {
|
||||
id: 'app.world-editor.game-rules.invalid-integer',
|
||||
defaultMessage: 'This rule requires a whole number',
|
||||
},
|
||||
resetIcon: {
|
||||
id: 'app.world-editor.reset-icon',
|
||||
defaultMessage: 'Reset icon',
|
||||
},
|
||||
unsavedChangesLabel: {
|
||||
id: 'app.world-editor.unsaved-changes',
|
||||
defaultMessage: 'You have unsaved changes',
|
||||
},
|
||||
discardButton: {
|
||||
id: 'app.world-editor.discard',
|
||||
defaultMessage: 'Discard changes',
|
||||
},
|
||||
savedNotification: {
|
||||
id: 'app.world-editor.saved',
|
||||
defaultMessage: 'World settings saved',
|
||||
},
|
||||
unsavedTitle: {
|
||||
id: 'app.world-editor.unsaved-modal.title',
|
||||
defaultMessage: 'Discard unsaved changes?',
|
||||
},
|
||||
unsavedBody: {
|
||||
id: 'app.world-editor.unsaved-modal.body',
|
||||
defaultMessage: 'Your changes to this world have not been saved and will be lost if you leave.',
|
||||
},
|
||||
leaveButton: {
|
||||
id: 'app.world-editor.unsaved-modal.leave',
|
||||
defaultMessage: 'Discard and leave',
|
||||
},
|
||||
stayButton: {
|
||||
id: 'app.world-editor.unsaved-modal.stay',
|
||||
defaultMessage: 'Keep editing',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user