feat:移除了弹窗,服务器添加sls
This commit is contained in:
557
apps/app-frontend/src/components/GridDisplay.vue
Normal file
557
apps/app-frontend/src/components/GridDisplay.vue
Normal file
@ -0,0 +1,557 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
CollectionIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GridIcon,
|
||||
MoreVerticalIcon,
|
||||
PinIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
FloatingActionBar,
|
||||
formatLoader,
|
||||
injectNotificationManager,
|
||||
PopoutMenu,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import BatchEditGroupsModal from '@/components/ui/modal/BatchEditGroupsModal.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { UNGROUPED_GROUP_KEY, useGridGrouping } from '@/composables/useGridGrouping'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { remove, set_pinned } from '@/helpers/instance'
|
||||
import {
|
||||
getLastLibraryDisplayMode,
|
||||
setLastLibraryDisplayMode,
|
||||
} from '@/helpers/library-display-mode'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
search: { id: 'app.instances.search', defaultMessage: 'Search' },
|
||||
select: { id: 'app.instances.select', defaultMessage: 'Select...' },
|
||||
groupBy: { id: 'app.instances.group-by', defaultMessage: 'Group by:' },
|
||||
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
|
||||
duplicateInstance: {
|
||||
id: 'app.instances.duplicate-instance',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
copyPath: { id: 'app.instances.copy-path', defaultMessage: 'Copy path' },
|
||||
pinToHome: { id: 'app.instances.pin-to-home', defaultMessage: 'Pin to Home' },
|
||||
unpinFromHome: { id: 'app.instances.unpin-from-home', defaultMessage: 'Unpin from Home' },
|
||||
name: { id: 'app.instances.sort.name', defaultMessage: 'Name' },
|
||||
lastPlayed: { id: 'app.instances.sort.last-played', defaultMessage: 'Last played' },
|
||||
dateCreated: { id: 'app.instances.sort.date-created', defaultMessage: 'Date created' },
|
||||
dateModified: { id: 'app.instances.sort.date-modified', defaultMessage: 'Date modified' },
|
||||
gameVersion: { id: 'app.instances.group.game-version', defaultMessage: 'Game version' },
|
||||
group: { id: 'app.instances.group.group', defaultMessage: 'Group' },
|
||||
loader: { id: 'app.instances.group.loader', defaultMessage: 'Loader' },
|
||||
none: { id: 'app.instances.group.none', defaultMessage: 'None' },
|
||||
ungrouped: { id: 'app.instances.group.ungrouped', defaultMessage: 'No group' },
|
||||
editGroups: { id: 'app.instances.edit-groups', defaultMessage: 'Edit groups' },
|
||||
selectAll: { id: 'app.instances.select-all', defaultMessage: 'Select all' },
|
||||
deselectAll: { id: 'app.instances.deselect-all', defaultMessage: 'Deselect all' },
|
||||
selectedCount: {
|
||||
id: 'app.instances.selected-count',
|
||||
defaultMessage: '{count, plural, one {# selected} other {# selected}}',
|
||||
},
|
||||
view: { id: 'app.library.view', defaultMessage: 'View' },
|
||||
standardView: { id: 'app.library.view.standard', defaultMessage: 'Standard grid' },
|
||||
cardsView: { id: 'app.library.view.cards', defaultMessage: 'Library cards' },
|
||||
})
|
||||
|
||||
const optionMessages = {
|
||||
Name: messages.name,
|
||||
'Last played': messages.lastPlayed,
|
||||
'Date created': messages.dateCreated,
|
||||
'Date modified': messages.dateModified,
|
||||
'Game version': messages.gameVersion,
|
||||
Group: messages.group,
|
||||
Loader: messages.loader,
|
||||
None: messages.none,
|
||||
}
|
||||
|
||||
const formatOption = (option) =>
|
||||
optionMessages[option] ? formatMessage(optionMessages[option]) : option
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const instanceOptions = ref(null)
|
||||
const instanceComponents = ref(null)
|
||||
const currentDeleteInstance = ref(null)
|
||||
const batchDeleteCount = ref(0)
|
||||
const confirmModal = ref(null)
|
||||
const search = ref('')
|
||||
const displayMode = ref(getLastLibraryDisplayMode())
|
||||
|
||||
const displayModeOptions = computed(() => [
|
||||
{ id: 'standard', label: formatMessage(messages.standardView), icon: GridIcon },
|
||||
{ id: 'cards', label: formatMessage(messages.cardsView), icon: CollectionIcon },
|
||||
])
|
||||
|
||||
const currentDisplayMode = computed(() =>
|
||||
displayModeOptions.value.find((option) => option.id === displayMode.value),
|
||||
)
|
||||
|
||||
function setDisplayMode(mode) {
|
||||
displayMode.value = mode
|
||||
setLastLibraryDisplayMode(mode)
|
||||
}
|
||||
|
||||
const filteredInstances = computed(() =>
|
||||
props.instances.filter((instance) =>
|
||||
instance.name.toLowerCase().includes(search.value.toLowerCase()),
|
||||
),
|
||||
)
|
||||
|
||||
const { state, grouping, filteredResults, isSectionCollapsed, setSectionCollapsed } =
|
||||
useGridGrouping(props.label, filteredInstances, {
|
||||
formatLoader: (loader) => formatLoader(formatMessage, loader),
|
||||
})
|
||||
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter(
|
||||
(x) => x.instance.id !== currentDeleteInstance.value.id,
|
||||
)
|
||||
await remove(currentDeleteInstance.value.id).catch(handleError)
|
||||
}
|
||||
batchDeleteCount.value = 0
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleRightClick = (event, instanceId) => {
|
||||
const item = instanceComponents.value.find((x) => x.instance.id === instanceId)
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: item.instance.pinned_at ? 'unpin' : 'pin' },
|
||||
{ name: 'open' },
|
||||
{ name: 'copy' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'delete',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
instanceOptions.value.showMenu(
|
||||
event,
|
||||
item,
|
||||
item.playing
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
args.item.play(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'stop':
|
||||
args.item.stop(null, 'InstanceGridContextMenu')
|
||||
break
|
||||
case 'add_content':
|
||||
await args.item.addContent()
|
||||
break
|
||||
case 'edit':
|
||||
await args.item.seeInstance()
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.instance.install_stage == 'installed')
|
||||
await duplicateInstance(args.item.instance.id)
|
||||
break
|
||||
case 'pin':
|
||||
await set_pinned(args.item.instance.id, true).catch(handleError)
|
||||
break
|
||||
case 'unpin':
|
||||
await set_pinned(args.item.instance.id, false).catch(handleError)
|
||||
break
|
||||
case 'open':
|
||||
await args.item.openFolder()
|
||||
break
|
||||
case 'copy':
|
||||
await navigator.clipboard.writeText(args.item.instance.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item.instance
|
||||
confirmModal.value.show()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Selection mode
|
||||
const selectMode = ref(false)
|
||||
const selectedInstanceIds = ref(new Set())
|
||||
const batchEditModal = ref(null)
|
||||
|
||||
let longPressTimer = null
|
||||
let longPressTriggered = false
|
||||
|
||||
function startLongPress(instanceId) {
|
||||
longPressTriggered = false
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTriggered = true
|
||||
if (!selectMode.value) {
|
||||
selectMode.value = true
|
||||
}
|
||||
toggleInstanceSelection(instanceId)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function cancelLongPress() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer)
|
||||
longPressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(instanceId, _event) {
|
||||
if (longPressTriggered) {
|
||||
longPressTriggered = false
|
||||
return
|
||||
}
|
||||
if (selectMode.value) {
|
||||
toggleInstanceSelection(instanceId)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectMode() {
|
||||
selectMode.value = !selectMode.value
|
||||
if (!selectMode.value) {
|
||||
selectedInstanceIds.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInstanceSelection(instanceId) {
|
||||
const newSet = new Set(selectedInstanceIds.value)
|
||||
if (newSet.has(instanceId)) {
|
||||
newSet.delete(instanceId)
|
||||
} else {
|
||||
newSet.add(instanceId)
|
||||
}
|
||||
selectedInstanceIds.value = newSet
|
||||
if (newSet.size === 0) {
|
||||
selectMode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckboxClick(instanceId) {
|
||||
if (!selectMode.value) {
|
||||
selectMode.value = true
|
||||
}
|
||||
toggleInstanceSelection(instanceId)
|
||||
}
|
||||
|
||||
function openBatchEdit() {
|
||||
batchEditModal.value?.show()
|
||||
}
|
||||
|
||||
const batchDeleteConfirmModal = ref(null)
|
||||
|
||||
function openBatchDelete() {
|
||||
batchDeleteCount.value = selectedInstanceIds.value.size
|
||||
batchDeleteConfirmModal.value?.show()
|
||||
}
|
||||
|
||||
async function batchDeleteInstances() {
|
||||
for (const id of selectedInstanceIds.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter((x) => x.instance.id !== id)
|
||||
await remove(id).catch(handleError)
|
||||
}
|
||||
selectedInstanceIds.value.clear()
|
||||
selectMode.value = false
|
||||
}
|
||||
|
||||
const visibleInstanceIds = computed(() => {
|
||||
const ids = []
|
||||
for (const section of Array.from(filteredResults.value, ([, value]) => value)) {
|
||||
for (const instance of section) {
|
||||
ids.push(instance.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
const visibleIds = visibleInstanceIds.value
|
||||
return visibleIds.length > 0 && visibleIds.every((id) => selectedInstanceIds.value.has(id))
|
||||
})
|
||||
|
||||
function toggleSelectAll() {
|
||||
const visibleIds = visibleInstanceIds.value
|
||||
if (isAllSelected.value) {
|
||||
const newSet = new Set(selectedInstanceIds.value)
|
||||
for (const id of visibleIds) {
|
||||
newSet.delete(id)
|
||||
}
|
||||
selectedInstanceIds.value = newSet
|
||||
} else {
|
||||
const newSet = new Set(selectedInstanceIds.value)
|
||||
for (const id of visibleIds) {
|
||||
newSet.add(id)
|
||||
}
|
||||
selectedInstanceIds.value = newSet
|
||||
}
|
||||
}
|
||||
|
||||
function onBatchEditApplied() {
|
||||
selectedInstanceIds.value.clear()
|
||||
selectMode.value = false
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex gap-2">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
clearable
|
||||
wrapper-class="flex-1"
|
||||
/>
|
||||
<PopoutMenu :tooltip="formatMessage(messages.view)" placement="bottom-end">
|
||||
<ButtonStyled circular>
|
||||
<button :aria-label="formatMessage(messages.view)">
|
||||
<component :is="currentDisplayMode?.icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #menu>
|
||||
<div class="flex w-44 flex-col gap-1 p-1">
|
||||
<ButtonStyled
|
||||
v-for="option in displayModeOptions"
|
||||
:key="option.id"
|
||||
:type="displayMode === option.id ? 'filled' : 'transparent'"
|
||||
>
|
||||
<button
|
||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||||
:aria-pressed="displayMode === option.id"
|
||||
@click="setDisplayMode(option.id)"
|
||||
>
|
||||
<component :is="option.icon" class="size-4" />
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="state.sortBy"
|
||||
name="Sort Dropdown"
|
||||
class="max-w-[16rem]"
|
||||
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
|
||||
:display-name="formatOption"
|
||||
:placeholder="formatMessage(messages.select)"
|
||||
>
|
||||
<span class="font-semibold text-primary">{{
|
||||
formatMessage(commonMessages.sortByLabel)
|
||||
}}</span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
<DropdownSelect
|
||||
v-slot="{ selected }"
|
||||
v-model="state.group"
|
||||
class="max-w-[16rem]"
|
||||
name="Group Dropdown"
|
||||
:options="['Group', 'Loader', 'Game version', 'None']"
|
||||
:display-name="formatOption"
|
||||
:placeholder="formatMessage(messages.select)"
|
||||
>
|
||||
<span class="font-semibold text-primary">{{ formatMessage(messages.groupBy) }} </span>
|
||||
<span class="font-semibold text-secondary">{{ selected }}</span>
|
||||
</DropdownSelect>
|
||||
</div>
|
||||
<Accordion
|
||||
v-for="instanceSection in Array.from(filteredResults, ([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}))"
|
||||
:key="instanceSection.key"
|
||||
:divider="grouping === 'Group' || instanceSection.key !== UNGROUPED_GROUP_KEY"
|
||||
:open-by-default="!isSectionCollapsed(instanceSection.key)"
|
||||
class="w-full"
|
||||
@on-open="setSectionCollapsed(instanceSection.key, false)"
|
||||
@on-close="setSectionCollapsed(instanceSection.key, true)"
|
||||
>
|
||||
<template v-if="grouping === 'Group' || instanceSection.key !== UNGROUPED_GROUP_KEY" #title>
|
||||
<span class="text-base">{{
|
||||
instanceSection.key === UNGROUPED_GROUP_KEY
|
||||
? formatMessage(messages.ungrouped)
|
||||
: instanceSection.key
|
||||
}}</span>
|
||||
</template>
|
||||
<section
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full gap-3 mr-auto scroll-smooth overflow-y-auto"
|
||||
:class="{
|
||||
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="instance in instanceSection.value"
|
||||
:key="instance.id + instance.install_stage"
|
||||
class="group relative"
|
||||
>
|
||||
<div
|
||||
class="relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]"
|
||||
@click="handleCardClick(instance.id)"
|
||||
@mousedown="!selectMode && startLongPress(instance.id)"
|
||||
@mouseup="cancelLongPress"
|
||||
@mouseleave="cancelLongPress"
|
||||
@touchstart="!selectMode && startLongPress(instance.id)"
|
||||
@touchend="cancelLongPress"
|
||||
@touchcancel="cancelLongPress"
|
||||
>
|
||||
<div :class="{ 'pointer-events-none': selectMode }">
|
||||
<Instance
|
||||
ref="instanceComponents"
|
||||
:instance="instance"
|
||||
:disabled="selectMode"
|
||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
||||
:class="{ 'opacity-50': selectMode && !selectedInstanceIds.has(instance.id) }"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute right-2 bottom-2 z-10 transition-opacity"
|
||||
:class="
|
||||
selectMode && selectedInstanceIds.has(instance.id)
|
||||
? ''
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
"
|
||||
@click.stop="handleCheckboxClick(instance.id)"
|
||||
>
|
||||
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!selectMode"
|
||||
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click.stop="(event) => handleRightClick(event, instance.id)"
|
||||
>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button type="button">
|
||||
<MoreVerticalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Accordion>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="confirmModal"
|
||||
:symlink-target="currentDeleteInstance?.symlink_target"
|
||||
:count="batchDeleteCount"
|
||||
@delete="batchDeleteCount > 0 ? batchDeleteInstances() : deleteInstance()"
|
||||
/>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="batchDeleteConfirmModal"
|
||||
:count="selectedInstanceIds.size"
|
||||
@delete="batchDeleteInstances"
|
||||
/>
|
||||
<BatchEditGroupsModal
|
||||
ref="batchEditModal"
|
||||
:instance-ids="[...selectedInstanceIds]"
|
||||
@applied="onBatchEditApplied"
|
||||
/>
|
||||
<FloatingActionBar :shown="selectMode" position="top" aria-label="Instance selection">
|
||||
<span class="px-3 py-2 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedInstanceIds.size }) }}
|
||||
</span>
|
||||
<div class="mx-0.5 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" @click="toggleSelectAll">
|
||||
<span>{{
|
||||
isAllSelected ? formatMessage(messages.deselectAll) : formatMessage(messages.selectAll)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" @click="openBatchEdit">
|
||||
<span>{{ formatMessage(messages.editGroups) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="transparent">
|
||||
<button type="button" @click="openBatchDelete">
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="ml-auto" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button class="!text-primary" type="button" @click="toggleSelectMode">
|
||||
<XIcon class="hidden cq-show-icon" />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</FloatingActionBar>
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #duplicate>
|
||||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||||
</template>
|
||||
<template #pin> <PinIcon /> {{ formatMessage(messages.pinToHome) }} </template>
|
||||
<template #unpin>
|
||||
<PinIcon class="rotate-45" /> {{ formatMessage(messages.unpinFromHome) }}
|
||||
</template>
|
||||
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
|
||||
<template #open>
|
||||
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
|
||||
</template>
|
||||
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
||||
354
apps/app-frontend/src/components/RowDisplay.vue
Normal file
354
apps/app-frontend/src/components/RowDisplay.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<script setup>
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
HeadingLink,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { kill, remove, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process.js'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { offline } = useNetworkStatus()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
|
||||
const messages = defineMessages({
|
||||
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
|
||||
duplicateInstance: {
|
||||
id: 'app.instances.duplicate-instance',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
copyPath: { id: 'app.instances.copy-path', defaultMessage: 'Copy path' },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
instances: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
canPaginate: Boolean,
|
||||
})
|
||||
|
||||
const actualInstances = computed(() =>
|
||||
props.instances.filter(
|
||||
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
|
||||
),
|
||||
)
|
||||
|
||||
const modsRow = ref(null)
|
||||
const instanceOptions = ref(null)
|
||||
const instanceComponents = ref(null)
|
||||
const rows = ref(null)
|
||||
const deleteConfirmModal = ref(null)
|
||||
|
||||
const currentDeleteInstance = ref(null)
|
||||
|
||||
async function deleteInstance() {
|
||||
if (currentDeleteInstance.value) {
|
||||
await remove(currentDeleteInstance.value.id).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateInstance(p) {
|
||||
await install_duplicate_instance(p).catch(handleError)
|
||||
}
|
||||
|
||||
const handleInstanceRightClick = async (event, passedInstance) => {
|
||||
const baseOptions = [
|
||||
...(!offline.value ? [{ name: 'add_content' }, { type: 'divider' }] : []),
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open_folder' },
|
||||
{ name: 'copy_path' },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'delete',
|
||||
color: 'danger',
|
||||
},
|
||||
]
|
||||
|
||||
const runningProcesses = await get_by_instance_id(passedInstance.id).catch(handleError)
|
||||
|
||||
const options =
|
||||
runningProcesses.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'stop',
|
||||
color: 'danger',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
: [
|
||||
...(offline.value && passedInstance.install_stage !== 'installed'
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'play',
|
||||
color: 'primary',
|
||||
},
|
||||
]),
|
||||
...baseOptions,
|
||||
]
|
||||
|
||||
instanceOptions.value.showMenu(event, passedInstance, options)
|
||||
}
|
||||
|
||||
const handleProjectClick = (event, passedInstance) => {
|
||||
instanceOptions.value.showMenu(event, passedInstance, [
|
||||
{
|
||||
name: 'install',
|
||||
color: 'primary',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
name: 'open_link',
|
||||
},
|
||||
{
|
||||
name: 'copy_link',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await run(args.item.id).catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: args.item.id,
|
||||
instance_name: args.item.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: args.item.id })
|
||||
})
|
||||
trackEvent('InstanceStart', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'stop':
|
||||
await kill(args.item.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: args.item.loader,
|
||||
game_version: args.item.game_version,
|
||||
})
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${args.item.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: args.item.id },
|
||||
})
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(args.item.id)}`,
|
||||
})
|
||||
break
|
||||
case 'duplicate':
|
||||
if (args.item.install_stage == 'installed') await duplicateInstance(args.item.id)
|
||||
break
|
||||
case 'delete':
|
||||
currentDeleteInstance.value = args.item
|
||||
deleteConfirmModal.value.show()
|
||||
break
|
||||
case 'open_folder':
|
||||
await showInstanceInFolder(args.item.id)
|
||||
break
|
||||
case 'copy_path':
|
||||
await navigator.clipboard.writeText(args.item.id)
|
||||
break
|
||||
case 'install': {
|
||||
await installVersion(
|
||||
args.item.project_id,
|
||||
null,
|
||||
null,
|
||||
'ProjectCardContextMenu',
|
||||
() => {},
|
||||
() => {},
|
||||
).catch(handleError)
|
||||
|
||||
break
|
||||
}
|
||||
case 'open_link':
|
||||
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
|
||||
break
|
||||
case 'copy_link':
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const maxInstancesPerCompactRow = ref(1)
|
||||
const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
if (rows.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
const containerWidthInRem =
|
||||
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
|
||||
maxInstancesPerCompactRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 0.75) / 20.75)
|
||||
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 0.75) / 18.75)
|
||||
|
||||
if (maxInstancesPerRow.value < 5) {
|
||||
maxInstancesPerRow.value *= 2
|
||||
}
|
||||
if (maxInstancesPerCompactRow.value < 5) {
|
||||
maxInstancesPerCompactRow.value *= 2
|
||||
}
|
||||
if (maxProjectsPerRow.value < 3) {
|
||||
maxProjectsPerRow.value *= 2
|
||||
}
|
||||
}
|
||||
|
||||
const rowContainer = ref(null)
|
||||
const resizeObserver = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
calculateCardsPerRow()
|
||||
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
}
|
||||
window.addEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateCardsPerRow)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="deleteConfirmModal"
|
||||
:symlink-target="currentDeleteInstance?.symlink_target"
|
||||
@delete="deleteInstance"
|
||||
/>
|
||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row flex flex-col items-start overflow-hidden w-full min-w-full">
|
||||
<HeadingLink class="mt-1" :to="row.route">
|
||||
{{ row.label }}
|
||||
</HeadingLink>
|
||||
<section
|
||||
v-if="row.instance"
|
||||
ref="modsRow"
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(20rem,1fr))] gap-3 w-full"
|
||||
:class="{
|
||||
'grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] gap-3': row.compact,
|
||||
}"
|
||||
>
|
||||
<Instance
|
||||
v-for="(instance, instanceIndex) in row.instances.slice(
|
||||
0,
|
||||
row.compact ? maxInstancesPerCompactRow : maxInstancesPerRow,
|
||||
)"
|
||||
:key="row.label + instance.id"
|
||||
:instance="instance"
|
||||
:compact="row.compact"
|
||||
:first="instanceIndex === 0"
|
||||
@contextmenu.prevent.stop="(event) => handleInstanceRightClick(event, instance)"
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
v-else
|
||||
ref="modsRow"
|
||||
class="projects grid w-full grid-cols-[repeat(auto-fill,minmax(18rem,1fr))] gap-3"
|
||||
>
|
||||
<LegacyProjectCard
|
||||
v-for="project in row.instances.slice(0, maxProjectsPerRow)"
|
||||
:key="project?.project_id"
|
||||
ref="instanceComponents"
|
||||
class="item"
|
||||
:project="project"
|
||||
@contextmenu.prevent.stop="(event) => handleProjectClick(event, project)"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
|
||||
<template #open_folder>
|
||||
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
|
||||
</template>
|
||||
<template #duplicate>
|
||||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||||
</template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
||||
<template #install>
|
||||
<DownloadIcon /> {{ formatMessage(commonMessages.installButton) }}
|
||||
</template>
|
||||
<template #open_link>
|
||||
<GlobeIcon /> {{ formatMessage(commonMessages.openInModrinthButton) }} <ExternalIcon />
|
||||
</template>
|
||||
<template #copy_link>
|
||||
<ClipboardCopyIcon /> {{ formatMessage(commonMessages.copyLinkButton) }}
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
&:nth-child(even) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.projects {
|
||||
.item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import InstancePickerList from '@/components/ui/instance/InstancePickerList.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
selectedInstance: GameInstance | null
|
||||
selectedCount: number
|
||||
installCurrent: () => Promise<boolean>
|
||||
clearCurrent: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [instance: GameInstance]
|
||||
cancelSwitch: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const pickerModal = ref<InstanceType<typeof NewModal>>()
|
||||
const switchModal = ref<InstanceType<typeof NewModal>>()
|
||||
const instancePicker = ref<InstanceType<typeof InstancePickerList>>()
|
||||
const pendingInstance = ref<GameInstance | null>(null)
|
||||
const installingCurrent = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.browse.instance-selector.title',
|
||||
defaultMessage: 'Choose an instance',
|
||||
},
|
||||
search: {
|
||||
id: 'app.browse.instance-selector.search',
|
||||
defaultMessage: 'Search instances',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'app.browse.instance-selector.no-instances',
|
||||
defaultMessage: 'Create an instance before installing content',
|
||||
},
|
||||
noResults: {
|
||||
id: 'app.browse.instance-selector.no-results',
|
||||
defaultMessage: 'No matching instances',
|
||||
},
|
||||
select: {
|
||||
id: 'app.browse.instance-selector.select',
|
||||
defaultMessage: 'Choose {name}',
|
||||
},
|
||||
create: {
|
||||
id: 'app.browse.instance-selector.create',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
switchTitle: {
|
||||
id: 'app.browse.instance-selector.switch-title',
|
||||
defaultMessage: 'Switch installation target?',
|
||||
},
|
||||
switchDescription: {
|
||||
id: 'app.browse.instance-selector.switch-description',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# selected project is} other {# selected projects are}} resolved for the current target. Switching will discard the resolved versions.',
|
||||
},
|
||||
currentTarget: {
|
||||
id: 'app.browse.instance-selector.current-target',
|
||||
defaultMessage: 'Current target',
|
||||
},
|
||||
newTarget: {
|
||||
id: 'app.browse.instance-selector.new-target',
|
||||
defaultMessage: 'New target',
|
||||
},
|
||||
installCurrent: {
|
||||
id: 'app.browse.instance-selector.install-current',
|
||||
defaultMessage: 'Install to current instance',
|
||||
},
|
||||
installingCurrent: {
|
||||
id: 'app.browse.instance-selector.installing-current',
|
||||
defaultMessage: 'Preparing installation…',
|
||||
},
|
||||
clearAndSwitch: {
|
||||
id: 'app.browse.instance-selector.clear-and-switch',
|
||||
defaultMessage: 'Clear and switch',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.browse.instance-selector.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
instancePicker.value?.reset()
|
||||
pickerModal.value?.show()
|
||||
void nextTick(() => instancePicker.value?.focus())
|
||||
}
|
||||
|
||||
function choose(instance: GameInstance) {
|
||||
if (instance.id === props.selectedInstance?.id) {
|
||||
pickerModal.value?.hide()
|
||||
return
|
||||
}
|
||||
if (props.selectedCount > 0 && props.selectedInstance) {
|
||||
pendingInstance.value = instance
|
||||
pickerModal.value?.hide()
|
||||
void nextTick(() => switchModal.value?.show())
|
||||
return
|
||||
}
|
||||
emit('select', instance)
|
||||
pickerModal.value?.hide()
|
||||
}
|
||||
|
||||
function requestSwitch(instance: GameInstance) {
|
||||
choose(instance)
|
||||
}
|
||||
|
||||
async function installAndSwitch() {
|
||||
if (!pendingInstance.value || installingCurrent.value) return
|
||||
installingCurrent.value = true
|
||||
try {
|
||||
if (!(await props.installCurrent())) return
|
||||
const next = pendingInstance.value
|
||||
pendingInstance.value = null
|
||||
installingCurrent.value = false
|
||||
switchModal.value?.hide()
|
||||
emit('select', next)
|
||||
} finally {
|
||||
installingCurrent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearAndSwitch() {
|
||||
if (!pendingInstance.value) return
|
||||
props.clearCurrent()
|
||||
const next = pendingInstance.value
|
||||
pendingInstance.value = null
|
||||
switchModal.value?.hide()
|
||||
emit('select', next)
|
||||
}
|
||||
|
||||
function createInstance() {
|
||||
pickerModal.value?.hide()
|
||||
void router.push('/create')
|
||||
}
|
||||
|
||||
function cancelSwitch() {
|
||||
pendingInstance.value = null
|
||||
switchModal.value?.hide()
|
||||
emit('cancelSwitch')
|
||||
}
|
||||
|
||||
defineExpose({ show, requestSwitch })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="pickerModal"
|
||||
:header="formatMessage(messages.title)"
|
||||
max-width="560px"
|
||||
width="min(560px, calc(100vw - 2rem))"
|
||||
scrollable
|
||||
max-content-height="min(28rem, calc(100dvh - 18rem))"
|
||||
actions-divider
|
||||
>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<InstancePickerList
|
||||
ref="instancePicker"
|
||||
:instances="instances"
|
||||
:search-placeholder="formatMessage(messages.search)"
|
||||
:no-instances-message="formatMessage(messages.noInstances)"
|
||||
:no-matches-message="formatMessage(messages.noResults)"
|
||||
:select-label="(instance) => formatMessage(messages.select, { name: instance.name })"
|
||||
@select="choose"
|
||||
>
|
||||
<template #action="{ instance }">
|
||||
<CheckIcon
|
||||
v-if="instance.id === selectedInstance?.id"
|
||||
class="size-5 shrink-0 text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</InstancePickerList>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-start">
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" @click="createInstance">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="switchModal"
|
||||
:header="formatMessage(messages.switchTitle)"
|
||||
width="min(600px, calc(100vw - 2rem))"
|
||||
max-width="600px"
|
||||
scrollable
|
||||
max-content-height="min(32rem, 70vh)"
|
||||
actions-divider
|
||||
:disable-close="installingCurrent"
|
||||
>
|
||||
<div v-if="pendingInstance && selectedInstance" class="flex min-w-0 flex-col gap-5">
|
||||
<div
|
||||
class="grid min-w-0 gap-3 sm:grid-cols-[minmax(0,1fr)_1px_minmax(0,1fr)] sm:items-stretch"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-2 px-1 py-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.currentTarget) }}
|
||||
</span>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="selectedInstance.icon_path"
|
||||
:instance-id="selectedInstance.id"
|
||||
:loader="selectedInstance.loader"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ selectedInstance.name }}</span>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ selectedInstance.loader }} {{ selectedInstance.game_version }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-hidden="true" class="h-px w-full bg-surface-4 sm:h-auto sm:w-px" />
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2 px-1 py-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.newTarget) }}
|
||||
</span>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="pendingInstance.icon_path"
|
||||
:instance-id="pendingInstance.id"
|
||||
:loader="pendingInstance.loader"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ pendingInstance.name }}</span>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ pendingInstance.loader }} {{ pendingInstance.game_version }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.switchDescription, { count: selectedCount }) }}
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="installingCurrent"
|
||||
@click="cancelSwitch"
|
||||
>
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="installingCurrent"
|
||||
@click="clearAndSwitch"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.clearAndSwitch) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full sm:w-auto"
|
||||
:disabled="installingCurrent"
|
||||
@click="installAndSwitch"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
installingCurrent ? messages.installingCurrent : messages.installCurrent,
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
487
apps/app-frontend/src/components/home/HomeCalendar.vue
Normal file
487
apps/app-frontend/src/components/home/HomeCalendar.vue
Normal file
@ -0,0 +1,487 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PlayIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
type DailyPlaytime,
|
||||
type DailyPlaytimeEntry,
|
||||
get_daily_playtime,
|
||||
get_daily_playtime_details,
|
||||
kill,
|
||||
run,
|
||||
} from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
import type { HomeWidgetSize } from './home-dashboard'
|
||||
import {
|
||||
buildHeatmapDays,
|
||||
dateFromKey,
|
||||
endOfPeriod,
|
||||
getPlaytimeLevel,
|
||||
shiftPeriod,
|
||||
startOfPeriod,
|
||||
toDateKey,
|
||||
} from './home-utils'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
dashboardSize?: HomeWidgetSize | null
|
||||
}>()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const { instanceRevision, runningInstanceIds } = useHomeDashboardRuntime()
|
||||
const formatPeriod = useFormatDateTime({ month: 'long', year: 'numeric' })
|
||||
const formatWeekday = useFormatDateTime({ weekday: 'narrow' })
|
||||
const formatDetailDate = useFormatDateTime({ month: 'long', day: 'numeric' })
|
||||
const formatFullDate = useFormatDateTime({ dateStyle: 'full' })
|
||||
|
||||
const messages = defineMessages({
|
||||
calendar: { id: 'app.home.calendar.title', defaultMessage: 'Calendar' },
|
||||
thisMonth: { id: 'app.home.calendar.this-month', defaultMessage: 'This month' },
|
||||
previousMonth: { id: 'app.home.calendar.previous', defaultMessage: 'Previous month' },
|
||||
nextMonth: { id: 'app.home.calendar.next', defaultMessage: 'Next month' },
|
||||
playedOn: { id: 'app.home.calendar.played-on', defaultMessage: 'On {date} you played:' },
|
||||
noActivity: {
|
||||
id: 'app.home.calendar.no-activity',
|
||||
defaultMessage: 'No playtime recorded on this day.',
|
||||
},
|
||||
playInstance: { id: 'app.home.calendar.play', defaultMessage: 'Play' },
|
||||
stopInstance: { id: 'app.home.calendar.stop', defaultMessage: 'Stop' },
|
||||
minutes: { id: 'app.home.playtime.minutes', defaultMessage: '{minutes}m' },
|
||||
hoursMinutes: { id: 'app.home.playtime.hours-minutes', defaultMessage: '{hours}h {minutes}m' },
|
||||
seconds: { id: 'app.home.playtime.seconds', defaultMessage: '{seconds}s' },
|
||||
sessions: {
|
||||
id: 'app.home.playtime.sessions',
|
||||
defaultMessage: '{count, plural, one {# successful launch} other {# successful launches}}',
|
||||
},
|
||||
mostPlayed: { id: 'app.home.playtime.most-played', defaultMessage: 'Most played: {name}' },
|
||||
})
|
||||
|
||||
const todayKey = toDateKey(new Date())
|
||||
const anchor = ref(new Date())
|
||||
const selectedKey = ref(todayKey)
|
||||
const dailyPlaytime = ref<DailyPlaytime[]>([])
|
||||
const dayDetails = ref<DailyPlaytimeEntry[]>([])
|
||||
const activeTooltip = ref<{
|
||||
dateKey: string
|
||||
lines: string[]
|
||||
left: number
|
||||
top: number
|
||||
} | null>(null)
|
||||
|
||||
const periodStart = computed(() => startOfPeriod(anchor.value, 'month'))
|
||||
const periodEnd = computed(() => endOfPeriod(anchor.value, 'month'))
|
||||
const periodLabel = computed(() => formatPeriod(periodStart.value))
|
||||
const days = computed(() => buildHeatmapDays(anchor.value, 'month'))
|
||||
const weekdayLabels = computed(() =>
|
||||
Array.from({ length: 7 }, (_, index) => formatWeekday(new Date(2024, 0, index + 1, 12))),
|
||||
)
|
||||
const dailyByDate = computed(() => new Map(dailyPlaytime.value.map((entry) => [entry.date, entry])))
|
||||
|
||||
function heatmapLevelClass(dateKey: string): string {
|
||||
const level = getPlaytimeLevel(dailyByDate.value.get(dateKey)?.played_seconds ?? 0)
|
||||
return level === 0 ? 'bg-surface-4' : `home-calendar-level-${level}`
|
||||
}
|
||||
const canGoForward = computed(() => toDateKey(periodEnd.value) < todayKey)
|
||||
const instanceById = computed(
|
||||
() => new Map(props.instances.map((instance) => [instance.id, instance])),
|
||||
)
|
||||
const selectedDateLabel = computed(() => formatDetailDate(dateFromKey(selectedKey.value)))
|
||||
const detailRows = computed(() =>
|
||||
dayDetails.value.map((entry) => ({
|
||||
entry,
|
||||
instance: instanceById.value.get(entry.instance_id),
|
||||
})),
|
||||
)
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const roundedSeconds = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(roundedSeconds / 3600)
|
||||
const minutes = Math.floor((roundedSeconds % 3600) / 60)
|
||||
if (hours > 0) return formatMessage(messages.hoursMinutes, { hours, minutes })
|
||||
if (minutes > 0) return formatMessage(messages.minutes, { minutes })
|
||||
return formatMessage(messages.seconds, { seconds: roundedSeconds })
|
||||
}
|
||||
|
||||
function tooltipLinesFor(dateKey: string): string[] {
|
||||
const entry = dailyByDate.value.get(dateKey)
|
||||
const lines = [formatFullDate(dateFromKey(dateKey))]
|
||||
if (entry && entry.played_seconds > 0) {
|
||||
lines.push(
|
||||
formatDuration(entry.played_seconds),
|
||||
formatMessage(messages.sessions, { count: entry.session_count }),
|
||||
)
|
||||
if (entry.top_instance_name) {
|
||||
lines.push(formatMessage(messages.mostPlayed, { name: entry.top_instance_name }))
|
||||
}
|
||||
} else {
|
||||
lines.push(formatMessage(messages.noActivity))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function showTooltip(event: PointerEvent | FocusEvent) {
|
||||
const target =
|
||||
event.target instanceof Element ? event.target.closest<HTMLElement>('[data-date-key]') : null
|
||||
const dateKey = target?.dataset.dateKey
|
||||
if (!target || !dateKey) {
|
||||
activeTooltip.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
const halfWidth = Math.min(144, Math.max(0, (window.innerWidth - 24) / 2))
|
||||
activeTooltip.value = {
|
||||
dateKey,
|
||||
lines: tooltipLinesFor(dateKey),
|
||||
left: Math.min(
|
||||
Math.max(rect.left + rect.width / 2, 12 + halfWidth),
|
||||
window.innerWidth - 12 - halfWidth,
|
||||
),
|
||||
top: rect.top - 8,
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPlaytime() {
|
||||
dailyPlaytime.value = await get_daily_playtime(
|
||||
toDateKey(periodStart.value),
|
||||
toDateKey(periodEnd.value),
|
||||
).catch((error): DailyPlaytime[] => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshDayDetails() {
|
||||
dayDetails.value = await get_daily_playtime_details(selectedKey.value).catch(
|
||||
(error): DailyPlaytimeEntry[] => {
|
||||
handleError(error)
|
||||
return []
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function movePeriod(amount: number) {
|
||||
activeTooltip.value = null
|
||||
anchor.value = shiftPeriod(anchor.value, 'month', amount)
|
||||
}
|
||||
|
||||
function goToThisMonth() {
|
||||
activeTooltip.value = null
|
||||
anchor.value = new Date()
|
||||
selectedKey.value = todayKey
|
||||
}
|
||||
|
||||
function selectDay(dateKey: string) {
|
||||
if (dateKey > todayKey) return
|
||||
selectedKey.value = dateKey
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeCalendar',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeCalendar',
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
|
||||
watch(selectedKey, refreshDayDetails, { immediate: true })
|
||||
watch(instanceRevision, async () => {
|
||||
await refreshPlaytime()
|
||||
await refreshDayDetails()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex min-w-0 min-h-0 h-full flex-col gap-2.5 overflow-hidden p-2">
|
||||
<header class="flex min-w-0 h-8 flex-none items-center gap-2">
|
||||
<div class="home-calendar-title flex min-w-0 items-center gap-2">
|
||||
<CalendarIcon class="size-5 shrink-0 text-brand" aria-hidden="true" />
|
||||
<h2>{{ formatMessage(messages.calendar) }}</h2>
|
||||
</div>
|
||||
<div class="ml-auto flex min-w-0 items-center gap-0.5">
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.previousMonth)" @click="movePeriod(-1)">
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" size="small" class="home-calendar-period min-w-0">
|
||||
<button v-tooltip="formatMessage(messages.thisMonth)" @click="goToThisMonth">
|
||||
{{ periodLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.nextMonth)"
|
||||
:disabled="!canGoForward"
|
||||
@click="movePeriod(1)"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="flex-none"
|
||||
@pointerover="showTooltip"
|
||||
@pointerleave="activeTooltip = null"
|
||||
@focusin="showTooltip"
|
||||
@focusout="activeTooltip = null"
|
||||
>
|
||||
<div
|
||||
class="mb-1 grid grid-cols-7 gap-[0.1875rem] text-center text-xs font-semibold text-secondary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span v-for="(weekday, index) in weekdayLabels" :key="index">{{ weekday }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-7 gap-[0.1875rem]" role="grid" :aria-label="formatMessage(messages.calendar)">
|
||||
<button
|
||||
v-for="day in days"
|
||||
:key="day.dateKey"
|
||||
type="button"
|
||||
class="home-calendar-cell h-[1.4rem] border border-solid rounded-[var(--radius-sm)] text-[0.6875rem] font-semibold outline-none p-0"
|
||||
:class="{
|
||||
'text-contrast': !(day.inPeriod && day.dateKey > todayKey),
|
||||
'text-secondary opacity-50 cursor-default':
|
||||
day.inPeriod && day.dateKey > todayKey,
|
||||
'cursor-default': !day.inPeriod,
|
||||
'cursor-pointer': day.inPeriod && day.dateKey <= todayKey,
|
||||
'border-transparent': !(day.inPeriod && day.dateKey === todayKey),
|
||||
'border-brand': day.inPeriod && day.dateKey === todayKey,
|
||||
'bg-transparent': !(day.inPeriod && day.dateKey <= todayKey),
|
||||
'home-calendar-cell-selected': day.inPeriod && day.dateKey === selectedKey,
|
||||
[heatmapLevelClass(day.dateKey)]: day.inPeriod && day.dateKey <= todayKey,
|
||||
}"
|
||||
:tabindex="day.inPeriod && day.dateKey <= todayKey ? 0 : -1"
|
||||
:disabled="!day.inPeriod || day.dateKey > todayKey"
|
||||
:data-date-key="day.inPeriod && day.dateKey <= todayKey ? day.dateKey : undefined"
|
||||
:aria-label="day.inPeriod ? day.dateKey : undefined"
|
||||
:aria-pressed="day.inPeriod ? day.dateKey === selectedKey : undefined"
|
||||
:aria-describedby="
|
||||
activeTooltip?.dateKey === day.dateKey ? 'home-calendar-tooltip' : undefined
|
||||
"
|
||||
role="gridcell"
|
||||
@click="selectDay(day.dateKey)"
|
||||
>
|
||||
<span v-if="day.inPeriod" aria-hidden="true">{{ day.date.getDate() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 min-h-[3.75rem] flex-1 flex-col gap-1.5 overflow-y-auto border-t border-divider pt-2.5"
|
||||
>
|
||||
<h3 class="m-0 text-sm font-bold text-contrast">
|
||||
{{ formatMessage(messages.playedOn, { date: selectedDateLabel }) }}
|
||||
</h3>
|
||||
<p v-if="dayDetails.length === 0" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.noActivity) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col p-0">
|
||||
<li
|
||||
v-for="row in detailRows"
|
||||
:key="row.entry.instance_id"
|
||||
class="group flex min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1.5 transition-colors hover:bg-button-bg"
|
||||
>
|
||||
<InstanceIcon
|
||||
:icon-path="row.instance?.icon_path"
|
||||
:instance-id="row.entry.instance_id"
|
||||
:loader="row.instance?.loader"
|
||||
size="36px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate text-sm font-semibold text-contrast">
|
||||
{{ row.instance?.name ?? row.entry.instance_name }}
|
||||
</span>
|
||||
<span class="truncate text-xs text-secondary">
|
||||
{{ formatDuration(row.entry.played_seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.instance" class="ml-auto shrink-0">
|
||||
<ButtonStyled
|
||||
v-if="runningInstanceIds.includes(row.instance.id)"
|
||||
circular
|
||||
size="small"
|
||||
type="transparent"
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="!text-red"
|
||||
@click="stopInstance(row.instance)"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.playInstance)"
|
||||
class="!text-brand opacity-60 transition-opacity group-hover:opacity-100"
|
||||
@click="playInstance(row.instance)"
|
||||
>
|
||||
<PlayIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<Teleport to="body">
|
||||
<Transition name="home-calendar-tooltip">
|
||||
<div
|
||||
v-if="activeTooltip"
|
||||
id="home-calendar-tooltip"
|
||||
class="home-calendar-tooltip"
|
||||
role="tooltip"
|
||||
:style="{ left: `${activeTooltip.left}px`, top: `${activeTooltip.top}px` }"
|
||||
>
|
||||
<strong>{{ activeTooltip.lines[0] }}</strong>
|
||||
<span v-for="line in activeTooltip.lines.slice(1)" :key="line">{{ line }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-calendar-title h2 {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-calendar-period :deep(button) {
|
||||
max-width: 7.5rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-calendar-cell {
|
||||
transition:
|
||||
box-shadow 100ms ease,
|
||||
background-color 100ms ease;
|
||||
}
|
||||
|
||||
.home-calendar-cell:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--color-brand);
|
||||
}
|
||||
|
||||
.home-calendar-cell-selected {
|
||||
box-shadow: 0 0 0 2px var(--color-brand);
|
||||
}
|
||||
|
||||
.home-calendar-level-1 {
|
||||
background: color-mix(in oklab, var(--color-brand) 28%, var(--surface-4));
|
||||
}
|
||||
.home-calendar-level-2 {
|
||||
background: color-mix(in oklab, var(--color-brand) 48%, var(--surface-4));
|
||||
}
|
||||
.home-calendar-level-3 {
|
||||
background: color-mix(in oklab, var(--color-brand) 70%, var(--surface-4));
|
||||
}
|
||||
.home-calendar-level-4 {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.home-calendar-tooltip {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
max-width: 18rem;
|
||||
transform: translate(-50%, -100%);
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
pointer-events: none;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-tooltip-bg);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
color: var(--color-tooltip-text);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.home-calendar-tooltip::after {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
border-right: 1px solid var(--surface-5);
|
||||
border-bottom: 1px solid var(--surface-5);
|
||||
background: var(--color-tooltip-bg);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.home-calendar-tooltip strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home-calendar-tooltip-enter-active,
|
||||
.home-calendar-tooltip-leave-active {
|
||||
transition:
|
||||
opacity 100ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.home-calendar-tooltip-enter-from,
|
||||
.home-calendar-tooltip-leave-to {
|
||||
transform: translate(-50%, calc(-100% + 0.25rem));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-calendar-tooltip-enter-active,
|
||||
.home-calendar-tooltip-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
85
apps/app-frontend/src/components/home/HomeDailyChallenge.vue
Normal file
85
apps/app-frontend/src/components/home/HomeDailyChallenge.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { SparklesIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { type ChallengeDifficulty, dailyChallenges } from '@/data/daily-challenges'
|
||||
|
||||
import { stableGreetingIndex, toDateKey } from './home-utils'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
dailyChallenge: { id: 'app.home.challenge.title', defaultMessage: 'Daily challenge' },
|
||||
shuffle: { id: 'app.home.challenge.shuffle', defaultMessage: 'Try another' },
|
||||
easy: { id: 'app.home.challenge.easy', defaultMessage: 'Easy' },
|
||||
medium: { id: 'app.home.challenge.medium', defaultMessage: 'Medium' },
|
||||
hard: { id: 'app.home.challenge.hard', defaultMessage: 'Hard' },
|
||||
})
|
||||
|
||||
const difficultyMessages = {
|
||||
easy: messages.easy,
|
||||
medium: messages.medium,
|
||||
hard: messages.hard,
|
||||
} as const
|
||||
|
||||
const dailyIndex = stableGreetingIndex(
|
||||
`daily-challenge:${toDateKey(new Date())}`,
|
||||
dailyChallenges.length,
|
||||
)
|
||||
const challengeIndex = ref(dailyIndex)
|
||||
|
||||
const challenge = computed(() => dailyChallenges[challengeIndex.value])
|
||||
const challengeText = computed(() => {
|
||||
const lowerLocale = locale.value.toLowerCase()
|
||||
if (lowerLocale == 'zh-tw') {
|
||||
return challenge.value.text['zh-TW']
|
||||
} else {
|
||||
return lowerLocale.startsWith('zh')
|
||||
? challenge.value.text['zh-CN']
|
||||
: challenge.value.text['en-US']
|
||||
}
|
||||
})
|
||||
|
||||
const difficultyDotClass: Record<ChallengeDifficulty, string> = {
|
||||
easy: 'bg-brand-green',
|
||||
medium: 'bg-orange',
|
||||
hard: 'bg-red',
|
||||
}
|
||||
|
||||
function shuffleChallenge() {
|
||||
if (dailyChallenges.length < 2) return
|
||||
let next = challengeIndex.value
|
||||
while (next === challengeIndex.value) {
|
||||
next = Math.floor(Math.random() * dailyChallenges.length)
|
||||
}
|
||||
challengeIndex.value = next
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<SparklesIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 truncate text-lg">
|
||||
{{ formatMessage(messages.dailyChallenge) }}
|
||||
</h2>
|
||||
<ButtonStyled circular size="small" type="transparent" class="ml-auto">
|
||||
<button v-tooltip="formatMessage(messages.shuffle)" @click="shuffleChallenge">
|
||||
<UpdatedIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-sm leading-relaxed text-primary">{{ challengeText }}</p>
|
||||
<div class="flex items-center gap-1.5 text-xs text-secondary">
|
||||
<span
|
||||
class="size-2 rounded-full"
|
||||
:class="difficultyDotClass[challenge.difficulty]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ formatMessage(difficultyMessages[challenge.difficulty]) }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
952
apps/app-frontend/src/components/home/HomeDashboard.vue
Normal file
952
apps/app-frontend/src/components/home/HomeDashboard.vue
Normal file
@ -0,0 +1,952 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ExpandIcon,
|
||||
GripVerticalIcon,
|
||||
ListIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import {
|
||||
addHomeWidget,
|
||||
enableFreeHomeDashboard,
|
||||
findNearestFreeHomeWidgetPosition,
|
||||
getHomeGridColumnCount,
|
||||
getHomeWidgetDimensions,
|
||||
getHomeWidgetSpan,
|
||||
HOME_RECENT_DEFAULT_LIMIT,
|
||||
HOME_RECENT_LIMIT_OPTIONS,
|
||||
HOME_WIDGET_GRID_GAP,
|
||||
HOME_WIDGET_GRID_ROW_HEIGHT,
|
||||
HOME_WIDGET_SIZE_OPTIONS,
|
||||
type HomeDashboardConfig,
|
||||
type HomeGreetingFont,
|
||||
type HomeGreetingMode,
|
||||
type HomeWidgetLayout,
|
||||
type HomeWidgetPlacement,
|
||||
type HomeWidgetPosition,
|
||||
type HomeWidgetSize,
|
||||
moveHomeWidget,
|
||||
packHomeWidgets,
|
||||
removeHomeWidget,
|
||||
replaceHomeDashboardWidgets,
|
||||
resizeHomeWidget,
|
||||
setHomeDashboardLayout,
|
||||
setHomeGreetingOptions,
|
||||
setHomeRecentLimit,
|
||||
setHomeWidgetPosition,
|
||||
} from '@/components/home/home-dashboard'
|
||||
import { provideHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import HomeCalendar from '@/components/home/HomeCalendar.vue'
|
||||
import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
||||
import HomeGreetingSettingsModal from '@/components/home/HomeGreetingSettingsModal.vue'
|
||||
import HomePinnedInstances from '@/components/home/HomePinnedInstances.vue'
|
||||
import HomePinnedServers from '@/components/home/HomePinnedServers.vue'
|
||||
import HomePinnedWorlds from '@/components/home/HomePinnedWorlds.vue'
|
||||
import HomeRecentWorlds from '@/components/home/HomeRecentWorlds.vue'
|
||||
import HomeShortcutWidget from '@/components/home/HomeShortcutWidget.vue'
|
||||
import HomeWidgetPickerModal from '@/components/home/HomeWidgetPickerModal.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
config: HomeDashboardConfig
|
||||
instances: GameInstance[]
|
||||
playerName: string | null
|
||||
editing: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [config: HomeDashboardConfig]
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
provideHomeDashboardRuntime(handleError)
|
||||
const editing = computed(() => props.editing)
|
||||
const isFreeLayout = computed(() => props.config.layout === 'free')
|
||||
const gridContainer = ref<HTMLElement>()
|
||||
const widgetPicker = ref<InstanceType<typeof HomeWidgetPickerModal>>()
|
||||
const greetingSettings = ref<InstanceType<typeof HomeGreetingSettingsModal>>()
|
||||
const replacingWidgetId = ref<string | null>(null)
|
||||
const dragging = ref(false)
|
||||
const draggableWidgets = ref<HomeWidgetPlacement[]>([])
|
||||
const previewPositions = ref<Record<string, HomeWidgetPosition>>({})
|
||||
const { width } = useElementSize(gridContainer)
|
||||
const columnCount = computed(() => getHomeGridColumnCount(width.value))
|
||||
const widgetsForPacking = computed(() =>
|
||||
editing.value ? draggableWidgets.value : props.config.widgets,
|
||||
)
|
||||
const packedWidgets = computed(() => packHomeWidgets(widgetsForPacking.value, columnCount.value))
|
||||
const packedById = computed(() => new Map(packedWidgets.value.map((widget) => [widget.id, widget])))
|
||||
const freeDrag = shallowRef<{
|
||||
id: string
|
||||
pointerId: number
|
||||
startClientX: number
|
||||
startClientY: number
|
||||
startPosition: HomeWidgetPosition
|
||||
target: HTMLElement
|
||||
article: HTMLElement
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
frame: number | null
|
||||
} | null>(null)
|
||||
|
||||
const freeGridColumnPitch = computed(
|
||||
() => getHomeWidgetDimensions('1x1', columnCount.value, width.value).width + HOME_WIDGET_GRID_GAP,
|
||||
)
|
||||
const freeGridRowPitch = HOME_WIDGET_GRID_ROW_HEIGHT + HOME_WIDGET_GRID_GAP
|
||||
const resolvedFreePositions = computed(() => {
|
||||
const positions: Record<string, HomeWidgetPosition> = {}
|
||||
const positioned: HomeWidgetPlacement[] = []
|
||||
const activeId = freeDrag.value?.id
|
||||
const orderedWidgets = activeId
|
||||
? [
|
||||
...props.config.widgets.filter((widget) => widget.id !== activeId),
|
||||
...props.config.widgets.filter((widget) => widget.id === activeId),
|
||||
]
|
||||
: props.config.widgets
|
||||
|
||||
for (const widget of orderedWidgets) {
|
||||
const position = findNearestFreeHomeWidgetPosition(
|
||||
positioned,
|
||||
widget,
|
||||
rawFreeWidgetPosition(widget),
|
||||
columnCount.value,
|
||||
)
|
||||
positions[widget.id] = position
|
||||
positioned.push({ ...widget, position })
|
||||
}
|
||||
return positions
|
||||
})
|
||||
const freeContentRows = computed(() =>
|
||||
props.config.widgets.reduce((lastRow, widget) => {
|
||||
const position = freeWidgetPosition(widget)
|
||||
return Math.max(lastRow, position.row + getHomeWidgetSpan(widget.size, columnCount.value).rows)
|
||||
}, 0),
|
||||
)
|
||||
const freeCanvasHeight = computed(() => {
|
||||
if (!props.config.widgets.length) return 0
|
||||
return Math.max(480, freeContentRows.value * freeGridRowPitch)
|
||||
})
|
||||
const dashboardGridStyle = computed(() =>
|
||||
isFreeLayout.value
|
||||
? {
|
||||
height: `${freeCanvasHeight.value}px`,
|
||||
'--home-free-grid-column-pitch': `${freeGridColumnPitch.value}px`,
|
||||
'--home-free-grid-row-pitch': `${freeGridRowPitch}px`,
|
||||
}
|
||||
: { gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))` },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
add: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
|
||||
options: { id: 'app.home.widgets.options', defaultMessage: 'Widget options' },
|
||||
moveEarlier: { id: 'app.home.widgets.move-earlier', defaultMessage: 'Move earlier' },
|
||||
moveLater: { id: 'app.home.widgets.move-later', defaultMessage: 'Move later' },
|
||||
remove: { id: 'app.home.widgets.remove', defaultMessage: 'Remove widget' },
|
||||
drag: { id: 'app.home.widgets.drag', defaultMessage: 'Drag to move widget' },
|
||||
replace: { id: 'app.home.widgets.replace', defaultMessage: 'Replace target' },
|
||||
empty: { id: 'app.home.widgets.empty', defaultMessage: 'Add a widget to build your Home.' },
|
||||
size: { id: 'app.home.widgets.size', defaultMessage: 'Size {size}' },
|
||||
recentItems: {
|
||||
id: 'app.home.widgets.recent-items',
|
||||
defaultMessage: 'Show {count} recent items',
|
||||
},
|
||||
greetingSettings: {
|
||||
id: 'app.home.greeting.settings.title',
|
||||
defaultMessage: 'Customize greeting',
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.config.widgets,
|
||||
(widgets) => {
|
||||
if (!dragging.value) {
|
||||
draggableWidgets.value = [...widgets]
|
||||
previewPositions.value = Object.fromEntries(
|
||||
widgets.flatMap((widget) =>
|
||||
widget.position ? [[widget.id, widget.position] as const] : [],
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
function widgetStyle(widget: HomeWidgetPlacement) {
|
||||
if (isFreeLayout.value) {
|
||||
const position = freeWidgetPosition(widget)
|
||||
const dimensions = getWidgetDimensions(widget)
|
||||
return {
|
||||
left: `${position.column * freeGridColumnPitch.value}px`,
|
||||
top: `${position.row * freeGridRowPitch}px`,
|
||||
width: `${dimensions.width}px`,
|
||||
height: `${dimensions.height}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const packed = packedById.value.get(widget.id)
|
||||
if (!packed) return undefined
|
||||
if (editing.value && dragging.value) {
|
||||
return {
|
||||
gridColumn: `span ${packed.effectiveColumns}`,
|
||||
gridRow: `span ${packed.effectiveRows}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
gridColumn: `${packed.column} / span ${packed.effectiveColumns}`,
|
||||
gridRow: `${packed.row} / span ${packed.effectiveRows}`,
|
||||
}
|
||||
}
|
||||
|
||||
function getWidgetDimensions(widget: HomeWidgetPlacement) {
|
||||
return getHomeWidgetDimensions(widget.size, columnCount.value, width.value)
|
||||
}
|
||||
|
||||
function defaultFreeWidgetPosition(widget: HomeWidgetPlacement): HomeWidgetPosition {
|
||||
const packed = packedById.value.get(widget.id)
|
||||
if (!packed) return { column: 0, row: 0 }
|
||||
return {
|
||||
column: packed.column - 1,
|
||||
row: packed.row - 1,
|
||||
}
|
||||
}
|
||||
|
||||
function rawFreeWidgetPosition(widget: HomeWidgetPlacement): HomeWidgetPosition {
|
||||
const position =
|
||||
previewPositions.value[widget.id] ?? widget.position ?? defaultFreeWidgetPosition(widget)
|
||||
const span = getHomeWidgetSpan(widget.size, columnCount.value)
|
||||
return {
|
||||
column: Math.min(
|
||||
Math.max(0, Math.round(position.column)),
|
||||
Math.max(0, columnCount.value - span.columns),
|
||||
),
|
||||
row: Math.max(0, Math.round(position.row)),
|
||||
}
|
||||
}
|
||||
|
||||
function freeWidgetPosition(widget: HomeWidgetPlacement): HomeWidgetPosition {
|
||||
return resolvedFreePositions.value[widget.id] ?? rawFreeWidgetPosition(widget)
|
||||
}
|
||||
|
||||
function startFreeWidgetDrag(event: PointerEvent, widget: HomeWidgetPlacement) {
|
||||
if (!editing.value || !isFreeLayout.value || event.button !== 0) return
|
||||
event.preventDefault()
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const article = target.closest<HTMLElement>('.home-widget')
|
||||
if (!article) return
|
||||
const position = freeWidgetPosition(widget)
|
||||
target.setPointerCapture(event.pointerId)
|
||||
freeDrag.value = {
|
||||
id: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startPosition: position,
|
||||
target,
|
||||
article,
|
||||
deltaX: 0,
|
||||
deltaY: 0,
|
||||
frame: null,
|
||||
}
|
||||
dragging.value = true
|
||||
}
|
||||
|
||||
function updateFreeWidgetDrag(event: PointerEvent, widget: HomeWidgetPlacement) {
|
||||
const current = freeDrag.value
|
||||
if (!current || current.id !== widget.id || current.pointerId !== event.pointerId) return
|
||||
const dimensions = getWidgetDimensions(widget)
|
||||
const startLeft = current.startPosition.column * freeGridColumnPitch.value
|
||||
const startTop = current.startPosition.row * freeGridRowPitch
|
||||
current.deltaX = Math.min(
|
||||
Math.max(event.clientX - current.startClientX, -startLeft),
|
||||
Math.max(-startLeft, width.value - dimensions.width - startLeft),
|
||||
)
|
||||
current.deltaY = Math.max(event.clientY - current.startClientY, -startTop)
|
||||
if (current.frame !== null) return
|
||||
|
||||
current.frame = window.requestAnimationFrame(() => {
|
||||
current.frame = null
|
||||
if (freeDrag.value !== current) return
|
||||
current.article.style.transform = `translate3d(${current.deltaX}px, ${current.deltaY}px, 0)`
|
||||
})
|
||||
}
|
||||
|
||||
function finishFreeWidgetDrag(event: PointerEvent, widget: HomeWidgetPlacement) {
|
||||
const current = freeDrag.value
|
||||
if (!current || current.id !== widget.id || current.pointerId !== event.pointerId) return
|
||||
if (current.target.hasPointerCapture(event.pointerId)) {
|
||||
current.target.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
if (current.frame !== null) window.cancelAnimationFrame(current.frame)
|
||||
current.article.style.transform = ''
|
||||
const position = findNearestFreeHomeWidgetPosition(
|
||||
props.config.widgets.map((candidate) => ({
|
||||
...candidate,
|
||||
position: freeWidgetPosition(candidate),
|
||||
})),
|
||||
widget,
|
||||
{
|
||||
column: current.startPosition.column + Math.round(current.deltaX / freeGridColumnPitch.value),
|
||||
row: current.startPosition.row + Math.round(current.deltaY / freeGridRowPitch),
|
||||
},
|
||||
columnCount.value,
|
||||
)
|
||||
previewPositions.value = { ...previewPositions.value, [widget.id]: position }
|
||||
freeDrag.value = null
|
||||
dragging.value = false
|
||||
emit('change', setHomeWidgetPosition(props.config, widget.id, position))
|
||||
}
|
||||
|
||||
function moveFreeWidgetWithKeyboard(event: KeyboardEvent, widget: HomeWidgetPlacement) {
|
||||
if (!editing.value || !isFreeLayout.value) return
|
||||
const movement = {
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
}[event.key]
|
||||
if (!movement) return
|
||||
|
||||
event.preventDefault()
|
||||
const current = freeWidgetPosition(widget)
|
||||
const position = findNearestFreeHomeWidgetPosition(
|
||||
props.config.widgets.map((candidate) => ({
|
||||
...candidate,
|
||||
position: freeWidgetPosition(candidate),
|
||||
})),
|
||||
widget,
|
||||
{
|
||||
column: current.column + movement[0],
|
||||
row: current.row + movement[1],
|
||||
},
|
||||
columnCount.value,
|
||||
)
|
||||
previewPositions.value = { ...previewPositions.value, [widget.id]: position }
|
||||
emit('change', setHomeWidgetPosition(props.config, widget.id, position))
|
||||
}
|
||||
|
||||
function startWidgetDrag() {
|
||||
dragging.value = true
|
||||
}
|
||||
|
||||
function finishWidgetDrag() {
|
||||
dragging.value = false
|
||||
const reordered = [...draggableWidgets.value]
|
||||
const unchanged = reordered.every(
|
||||
(widget, index) => widget.id === props.config.widgets[index]?.id,
|
||||
)
|
||||
if (!unchanged) emit('change', replaceHomeDashboardWidgets(props.config, reordered))
|
||||
}
|
||||
|
||||
function effectiveSize(widget: HomeWidgetPlacement): HomeWidgetSize {
|
||||
const packed = packedById.value.get(widget.id)
|
||||
return packed
|
||||
? (`${packed.effectiveColumns}x${packed.effectiveRows}` as HomeWidgetSize)
|
||||
: widget.size
|
||||
}
|
||||
|
||||
function openWidgetPicker() {
|
||||
replacingWidgetId.value = null
|
||||
widgetPicker.value?.show()
|
||||
}
|
||||
|
||||
function addWidget(widget: HomeWidgetPlacement) {
|
||||
const replacingId = replacingWidgetId.value
|
||||
replacingWidgetId.value = null
|
||||
if (!replacingId) {
|
||||
const placement = isFreeLayout.value
|
||||
? { ...widget, position: { column: 0, row: freeContentRows.value } }
|
||||
: widget
|
||||
emit('change', addHomeWidget(props.config, placement))
|
||||
return
|
||||
}
|
||||
|
||||
emit(
|
||||
'change',
|
||||
replaceHomeDashboardWidgets(
|
||||
props.config,
|
||||
props.config.widgets.map((current) =>
|
||||
current.id === replacingId
|
||||
? {
|
||||
...widget,
|
||||
id: current.id,
|
||||
size: current.size,
|
||||
...(current.position ? { position: current.position } : {}),
|
||||
}
|
||||
: current,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function replaceWidgetTarget(widget: HomeWidgetPlacement) {
|
||||
replacingWidgetId.value = widget.id
|
||||
widgetPicker.value?.show(widget.kind)
|
||||
}
|
||||
|
||||
function removeWidget(id: string) {
|
||||
emit('change', removeHomeWidget(props.config, id))
|
||||
}
|
||||
|
||||
function resizeWidget(id: string, size: HomeWidgetSize) {
|
||||
let config = resizeHomeWidget(props.config, id, size)
|
||||
if (isFreeLayout.value) {
|
||||
const widget = config.widgets.find((candidate) => candidate.id === id)
|
||||
if (widget) {
|
||||
const position = findNearestFreeHomeWidgetPosition(
|
||||
config.widgets.map((candidate) => ({
|
||||
...candidate,
|
||||
position: freeWidgetPosition(candidate),
|
||||
})),
|
||||
widget,
|
||||
freeWidgetPosition(widget),
|
||||
columnCount.value,
|
||||
)
|
||||
config = setHomeWidgetPosition(config, id, position)
|
||||
}
|
||||
}
|
||||
emit('change', config)
|
||||
}
|
||||
|
||||
function setRecentLimit(id: string, limit: (typeof HOME_RECENT_LIMIT_OPTIONS)[number]) {
|
||||
emit('change', setHomeRecentLimit(props.config, id, limit))
|
||||
}
|
||||
|
||||
function openGreetingSettings(widget: HomeWidgetPlacement) {
|
||||
greetingSettings.value?.show(widget)
|
||||
}
|
||||
|
||||
function saveGreetingSettings(
|
||||
id: string,
|
||||
mode: HomeGreetingMode,
|
||||
text: string,
|
||||
font: HomeGreetingFont,
|
||||
fontSize: number,
|
||||
) {
|
||||
emit('change', setHomeGreetingOptions(props.config, id, mode, text, font, fontSize))
|
||||
}
|
||||
|
||||
function moveWidget(index: number, direction: -1 | 1) {
|
||||
emit('change', moveHomeWidget(props.config, index, direction))
|
||||
}
|
||||
|
||||
function widgetOptions(widget: HomeWidgetPlacement, index: number) {
|
||||
const sizeOptions = HOME_WIDGET_SIZE_OPTIONS[widget.kind]
|
||||
return [
|
||||
...(widget.kind === 'greeting'
|
||||
? [
|
||||
{
|
||||
id: 'greeting-settings',
|
||||
icon: PencilIcon,
|
||||
action: () => openGreetingSettings(widget),
|
||||
},
|
||||
{ divider: true },
|
||||
]
|
||||
: []),
|
||||
...(widget.kind === 'recent'
|
||||
? [
|
||||
...HOME_RECENT_LIMIT_OPTIONS.map((limit) => ({
|
||||
id: `recent-limit-${limit}`,
|
||||
icon: ListIcon,
|
||||
disabled: (widget.options?.recentLimit ?? HOME_RECENT_DEFAULT_LIMIT) === limit,
|
||||
action: () => setRecentLimit(widget.id, limit),
|
||||
})),
|
||||
{ divider: true },
|
||||
]
|
||||
: []),
|
||||
...(sizeOptions.length > 1
|
||||
? [
|
||||
...sizeOptions.map((size) => ({
|
||||
id: `size-${size}`,
|
||||
icon: ExpandIcon,
|
||||
disabled: widget.size === size,
|
||||
action: () => resizeWidget(widget.id, size),
|
||||
})),
|
||||
{ divider: true },
|
||||
]
|
||||
: []),
|
||||
...(widget.target
|
||||
? [
|
||||
{
|
||||
id: 'replace',
|
||||
icon: RefreshCwIcon,
|
||||
action: () => replaceWidgetTarget(widget),
|
||||
},
|
||||
{ divider: true },
|
||||
]
|
||||
: []),
|
||||
...(!isFreeLayout.value
|
||||
? [
|
||||
{
|
||||
id: 'move-earlier',
|
||||
icon: ChevronUpIcon,
|
||||
disabled: index === 0,
|
||||
action: () => moveWidget(index, -1),
|
||||
},
|
||||
{
|
||||
id: 'move-later',
|
||||
icon: ChevronDownIcon,
|
||||
disabled: index === props.config.widgets.length - 1,
|
||||
action: () => moveWidget(index, 1),
|
||||
},
|
||||
{ divider: true },
|
||||
]
|
||||
: []),
|
||||
{ id: 'remove', icon: TrashIcon, color: 'red' as const, action: () => removeWidget(widget.id) },
|
||||
]
|
||||
}
|
||||
|
||||
function setLayout(layout: HomeWidgetLayout) {
|
||||
if (layout === props.config.layout) return
|
||||
emit(
|
||||
'change',
|
||||
layout === 'free'
|
||||
? enableFreeHomeDashboard(props.config, columnCount.value)
|
||||
: setHomeDashboardLayout(props.config, 'grid'),
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({ openWidgetPicker, setLayout })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomeWidgetPickerModal ref="widgetPicker" :instances="instances" @add="addWidget" />
|
||||
<HomeGreetingSettingsModal
|
||||
ref="greetingSettings"
|
||||
:player-name="playerName"
|
||||
@save="saveGreetingSettings"
|
||||
/>
|
||||
<section class="home-dashboard p-6 pb-20" :class="{ 'is-dragging': dragging }">
|
||||
<div ref="gridContainer" class="mx-auto w-full max-w-[96rem]">
|
||||
<Draggable
|
||||
:list="draggableWidgets"
|
||||
item-key="id"
|
||||
tag="div"
|
||||
class="home-dashboard-grid"
|
||||
:class="{
|
||||
'is-editing': editing,
|
||||
'is-dragging': dragging,
|
||||
'is-free': isFreeLayout,
|
||||
'has-widgets': config.widgets.length > 0,
|
||||
}"
|
||||
:style="dashboardGridStyle"
|
||||
handle=".home-widget-drag-handle"
|
||||
:disabled="!editing || isFreeLayout"
|
||||
:animation="80"
|
||||
:swap-threshold="0.2"
|
||||
:invert-swap="true"
|
||||
:inverted-swap-threshold="0.65"
|
||||
:empty-insert-threshold="12"
|
||||
:force-fallback="true"
|
||||
:fallback-on-body="false"
|
||||
:fallback-tolerance="0"
|
||||
:scroll="true"
|
||||
:scroll-sensitivity="96"
|
||||
:scroll-speed="24"
|
||||
:bubble-scroll="true"
|
||||
ghost-class="home-widget-ghost !border-2 !border-dashed !border-brand !bg-brand-highlight !shadow-none opacity-[0.45]"
|
||||
chosen-class="home-widget-chosen"
|
||||
drag-class="home-widget-drag"
|
||||
fallback-class="home-widget-fallback"
|
||||
data-onboarding-id="home-widget-grid"
|
||||
@start="startWidgetDrag"
|
||||
@end="finishWidgetDrag"
|
||||
>
|
||||
<template #item="{ element: widget, index }">
|
||||
<article
|
||||
class="home-widget"
|
||||
:class="{ 'is-free-dragging': freeDrag?.id === widget.id }"
|
||||
:data-widget-kind="widget.kind"
|
||||
:style="widgetStyle(widget)"
|
||||
>
|
||||
<div v-if="editing" class="home-widget-edit-bar">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.drag)"
|
||||
type="button"
|
||||
class="home-widget-drag-handle"
|
||||
@pointerdown="startFreeWidgetDrag($event, widget)"
|
||||
@pointermove="updateFreeWidgetDrag($event, widget)"
|
||||
@pointerup="finishFreeWidgetDrag($event, widget)"
|
||||
@pointercancel="finishFreeWidgetDrag($event, widget)"
|
||||
@keydown="moveFreeWidgetWithKeyboard($event, widget)"
|
||||
>
|
||||
<GripVerticalIcon />
|
||||
</button>
|
||||
<span class="home-widget-size-label">{{ widget.size }}</span>
|
||||
<div class="home-widget-options">
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<OverflowMenu
|
||||
:options="widgetOptions(widget, index)"
|
||||
:tooltip="formatMessage(messages.options)"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #greeting-settings>
|
||||
<PencilIcon /> {{ formatMessage(messages.greetingSettings) }}
|
||||
</template>
|
||||
<template
|
||||
v-for="limit in HOME_RECENT_LIMIT_OPTIONS"
|
||||
#[`recent-limit-${limit}`]
|
||||
:key="`recent-limit-${limit}`"
|
||||
>
|
||||
<ListIcon />
|
||||
{{ formatMessage(messages.recentItems, { count: limit }) }}
|
||||
</template>
|
||||
<template
|
||||
v-for="size in HOME_WIDGET_SIZE_OPTIONS[widget.kind]"
|
||||
#[`size-${size}`]
|
||||
:key="size"
|
||||
>
|
||||
<ExpandIcon /> {{ formatMessage(messages.size, { size }) }}
|
||||
</template>
|
||||
<template #move-earlier>
|
||||
<ChevronUpIcon /> {{ formatMessage(messages.moveEarlier) }}
|
||||
</template>
|
||||
<template #move-later>
|
||||
<ChevronDownIcon /> {{ formatMessage(messages.moveLater) }}
|
||||
</template>
|
||||
<template #replace>
|
||||
<RefreshCwIcon /> {{ formatMessage(messages.replace) }}
|
||||
</template>
|
||||
<template #remove>
|
||||
<TrashIcon /> {{ formatMessage(messages.remove) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="home-widget-content min-w-0 min-h-0 flex-1 overflow-hidden p-4">
|
||||
<HomeGreeting
|
||||
v-if="widget.kind === 'greeting'"
|
||||
:player-name="playerName"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
:greeting-mode="widget.options?.greetingMode"
|
||||
:greeting-text="widget.options?.greetingText"
|
||||
:greeting-font="widget.options?.greetingFont"
|
||||
:greeting-font-size="widget.options?.greetingFontSize"
|
||||
/>
|
||||
<HomeRecentWorlds
|
||||
v-else-if="widget.kind === 'recent'"
|
||||
:instances="instances"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
:limit="widget.options?.recentLimit"
|
||||
dashboard
|
||||
/>
|
||||
<HomeCalendar
|
||||
v-else-if="widget.kind === 'calendar'"
|
||||
:instances="instances"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
/>
|
||||
<HomePinnedInstances
|
||||
v-else-if="widget.kind === 'pinned-instances'"
|
||||
:instances="instances"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
dashboard
|
||||
/>
|
||||
<HomePinnedWorlds
|
||||
v-else-if="widget.kind === 'pinned-worlds'"
|
||||
:instances="instances"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
dashboard
|
||||
/>
|
||||
<HomePinnedServers
|
||||
v-else-if="widget.kind === 'pinned-servers'"
|
||||
:instances="instances"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
dashboard
|
||||
/>
|
||||
<HomeShortcutWidget
|
||||
v-else
|
||||
:placement="widget"
|
||||
:instances="instances"
|
||||
:dashboard-size="effectiveSize(widget)"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div
|
||||
v-if="config.widgets.length === 0"
|
||||
class="flex min-h-64 flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-divider text-center"
|
||||
>
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.empty) }}</p>
|
||||
<ButtonStyled>
|
||||
<button @click="openWidgetPicker"><PlusIcon /> {{ formatMessage(messages.add) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-dashboard {
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.home-dashboard-grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 10rem;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-editing.is-dragging {
|
||||
grid-auto-flow: dense;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-free {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-free.is-editing::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
border: 1px solid color-mix(in srgb, var(--color-divider) 55%, transparent);
|
||||
border-radius: var(--radius-lg);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--color-divider) 45%, transparent) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-divider) 45%, transparent) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size:
|
||||
var(--home-free-grid-column-pitch) var(--home-free-grid-row-pitch),
|
||||
var(--home-free-grid-column-pitch) var(--home-free-grid-row-pitch);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-free.has-widgets {
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-free .home-widget {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.home-widget {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
box-shadow 120ms ease,
|
||||
filter 120ms ease;
|
||||
}
|
||||
|
||||
.home-widget[data-widget-kind='greeting'] {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.home-dashboard-grid:not(.is-editing)
|
||||
.home-widget:is(
|
||||
[data-widget-kind='instance'],
|
||||
[data-widget-kind='world'],
|
||||
[data-widget-kind='server']
|
||||
):hover {
|
||||
filter: brightness(var(--hover-brightness));
|
||||
}
|
||||
|
||||
.home-widget-edit-bar {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 12;
|
||||
display: flex;
|
||||
max-width: calc(100% - 1rem);
|
||||
height: 2.25rem;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0.125rem;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-button);
|
||||
overflow: hidden;
|
||||
opacity: 0.9;
|
||||
transition:
|
||||
box-shadow 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.home-widget-drag-handle {
|
||||
display: inline-flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
color 100ms ease;
|
||||
}
|
||||
|
||||
.home-widget-size-label {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
width 120ms ease,
|
||||
margin 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.home-widget-options {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 0;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
width 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.home-widget:hover .home-widget-edit-bar,
|
||||
.home-widget:focus-within .home-widget-edit-bar {
|
||||
box-shadow: var(--shadow-card);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-widget:hover .home-widget-size-label,
|
||||
.home-widget:focus-within .home-widget-size-label {
|
||||
width: 2.25rem;
|
||||
margin-left: 0.25rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.home-widget:hover .home-widget-options,
|
||||
.home-widget:focus-within .home-widget-options {
|
||||
width: 2rem;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.home-widget-drag-handle:hover,
|
||||
.home-widget-drag-handle:focus-visible {
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.home-widget-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.home-widget[data-widget-kind='instance'] .home-widget-content,
|
||||
.home-widget[data-widget-kind='world'] .home-widget-content,
|
||||
.home-widget[data-widget-kind='server'] .home-widget-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.home-widget-content > :deep(*) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.home-widget-ghost > * {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.home-widget-chosen {
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
||||
}
|
||||
|
||||
.home-widget-drag,
|
||||
.home-widget-fallback,
|
||||
.home-widget.is-free-dragging {
|
||||
z-index: 1000 !important;
|
||||
border-color: var(--color-brand);
|
||||
box-shadow: var(--shadow-card);
|
||||
cursor: grabbing;
|
||||
opacity: 0.98;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-editing .home-widget {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-editing .home-widget[data-widget-kind='greeting'] {
|
||||
border-color: var(--color-divider);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-editing .home-widget-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.home-dashboard-grid.is-editing
|
||||
.home-widget:is(
|
||||
[data-widget-kind='instance'],
|
||||
[data-widget-kind='world'],
|
||||
[data-widget-kind='server']
|
||||
):hover,
|
||||
.home-dashboard-grid.is-editing
|
||||
.home-widget:is(
|
||||
[data-widget-kind='instance'],
|
||||
[data-widget-kind='world'],
|
||||
[data-widget-kind='server']
|
||||
):focus-within {
|
||||
border-color: var(--color-divider);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.home-dashboard.is-dragging,
|
||||
.home-dashboard.is-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-dashboard-grid {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
219
apps/app-frontend/src/components/home/HomeGreeting.vue
Normal file
219
apps/app-frontend/src/components/home/HomeGreeting.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
HOME_GREETING_DEFAULT_FONT,
|
||||
HOME_GREETING_DEFAULT_FONT_SIZE,
|
||||
HOME_GREETING_DEFAULT_MODE,
|
||||
type HomeGreetingFont,
|
||||
type HomeGreetingMode,
|
||||
type HomeWidgetSize,
|
||||
} from './home-dashboard'
|
||||
import { getTimeBucket, stableGreetingIndex } from './home-utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
playerName?: string | null
|
||||
variant?: 'standard' | 'minimal'
|
||||
dashboardSize?: HomeWidgetSize | null
|
||||
greetingMode?: HomeGreetingMode
|
||||
greetingText?: string
|
||||
greetingFont?: HomeGreetingFont
|
||||
greetingFontSize?: number
|
||||
}>(),
|
||||
{
|
||||
playerName: null,
|
||||
variant: 'standard',
|
||||
dashboardSize: null,
|
||||
greetingMode: HOME_GREETING_DEFAULT_MODE,
|
||||
greetingText: '',
|
||||
greetingFont: HOME_GREETING_DEFAULT_FONT,
|
||||
greetingFontSize: HOME_GREETING_DEFAULT_FONT_SIZE,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const now = ref(new Date())
|
||||
const messages = defineMessages({
|
||||
withPlayer: {
|
||||
id: 'app.home.greeting.with-player',
|
||||
defaultMessage: 'Welcome back, {name}. {greeting}',
|
||||
},
|
||||
welcomeWithPlayer: {
|
||||
id: 'app.home.greeting.welcome-with-player',
|
||||
defaultMessage: 'Welcome back, {name}.',
|
||||
},
|
||||
welcome: {
|
||||
id: 'app.home.greeting.welcome',
|
||||
defaultMessage: 'Welcome back.',
|
||||
},
|
||||
minimalWithPlayer: {
|
||||
id: 'app.home.greeting.minimal.with-player',
|
||||
defaultMessage: '{greeting}, {name}',
|
||||
},
|
||||
minimalLateNight: {
|
||||
id: 'app.home.greeting.minimal.late-night',
|
||||
defaultMessage: 'Good evening',
|
||||
},
|
||||
minimalDawn: {
|
||||
id: 'app.home.greeting.minimal.dawn',
|
||||
defaultMessage: 'Good morning',
|
||||
},
|
||||
minimalMorning: {
|
||||
id: 'app.home.greeting.minimal.morning',
|
||||
defaultMessage: 'Good morning',
|
||||
},
|
||||
minimalAfternoon: {
|
||||
id: 'app.home.greeting.minimal.afternoon',
|
||||
defaultMessage: 'Good afternoon',
|
||||
},
|
||||
minimalEvening: {
|
||||
id: 'app.home.greeting.minimal.evening',
|
||||
defaultMessage: 'Good evening',
|
||||
},
|
||||
minimalNight: {
|
||||
id: 'app.home.greeting.minimal.night',
|
||||
defaultMessage: 'Good evening',
|
||||
},
|
||||
'late-night': {
|
||||
id: 'app.home.greeting.late-night',
|
||||
defaultMessage:
|
||||
'The moon is still working overtime.\nA quiet world is waiting.\nNight shifts build great stories.\nOne more block before dawn?\nThe stars have your server covered.\nLate hours, legendary saves.\nThe torchlight looks especially good now.\nYour next adventure is still awake.\nThe caves are calmer after midnight.\nA peaceful spawn point awaits.\nThe night belongs to patient builders.\nKeep the soundtrack low and the ideas loud.\nEvery great base starts with one block.\nThe End can wait, unless it cannot.\nA small session still counts.\nThe world has been saved for you.',
|
||||
},
|
||||
dawn: {
|
||||
id: 'app.home.greeting.dawn',
|
||||
defaultMessage:
|
||||
'First light, fresh chunks.\nA new day is loading in.\nThe sunrise buff is active.\nMorning worlds feel brand new.\nCoffee first, diamonds second.\nYour base missed you overnight.\nA calm start makes a fine adventure.\nThe overworld is waking up.\nFresh air, fresh resource packs.\nToday is a good day to explore.\nThe village is already open for trade.\nA quiet morning suits a big build.\nNew day, new coordinates.\nThe creepers are not morning people either.\nStart small and see where it goes.\nYour next session is ready when you are.',
|
||||
},
|
||||
morning: {
|
||||
id: 'app.home.greeting.morning',
|
||||
defaultMessage:
|
||||
'Good morning, adventurer.\nThe day is full of unexplored chunks.\nA fine time for a fresh start.\nYour tools are ready for the day.\nThe overworld has excellent plans.\nBuild something your future self will love.\nA new session is a clean canvas.\nThe sun is up and so are the villagers.\nLet today be a little more blocky.\nThe mines have been suspiciously quiet.\nYour next project is only one launch away.\nA good morning for a good world.\nThere is always room for one more idea.\nThe crafting table is on standby.\nThe map is waiting for new markers.\nSettle in and make some progress.',
|
||||
},
|
||||
afternoon: {
|
||||
id: 'app.home.greeting.afternoon',
|
||||
defaultMessage:
|
||||
'Afternoon break, excellent timing.\nA short session can become a great one.\nThe world is ready for your next move.\nTime to check on that half-finished build.\nA little exploration goes a long way.\nThe next biome is calling.\nYour inventory has been waiting patiently.\nThe village market is still open.\nA good hour for a focused project.\nThe redstone probably behaves today.\nYour pickaxe is ready to work.\nA new route is waiting beyond spawn.\nThe afternoon is made for side quests.\nOne quick visit to your world?\nThe next chapter starts here.\nTake a moment and make something.',
|
||||
},
|
||||
evening: {
|
||||
id: 'app.home.greeting.evening',
|
||||
defaultMessage:
|
||||
"Evening is prime building time.\nThe day is winding down; the world is opening up.\nA familiar world makes a good landing spot.\nTime to return to your favorite project.\nThe sunset looks better from a new tower.\nYour base lights are waiting.\nA relaxed session sounds about right.\nThe villagers are closing shop soon.\nYour next build deserves an evening glow.\nA good time to wander without a plan.\nThe campfire is already lit.\nOne more room for the base?\nThe horizon is looking especially inviting.\nA quiet night starts with a good world.\nThe next block is yours to place.\nMake tonight's progress count.",
|
||||
},
|
||||
night: {
|
||||
id: 'app.home.greeting.night',
|
||||
defaultMessage:
|
||||
'The night shift is ready.\nA good evening for familiar worlds.\nYour favorite instance is waiting nearby.\nThe stars are out; the plans are in.\nA calm session can end the day well.\nThe world is quieter after dark.\nTime to put a few more blocks in place.\nYour base is glowing in the distance.\nThe next adventure starts at sunset.\nA night well spent has a good save file.\nThe campfire crackles, somewhere.\nThe moon makes every build look dramatic.\nA little Minecraft before tomorrow.\nYour worlds are ready for a visit.\nThe night is still young, in chunks.\nSettle in for a well-earned session.',
|
||||
},
|
||||
})
|
||||
|
||||
const minimalGreetingMessages = {
|
||||
'late-night': messages.minimalLateNight,
|
||||
dawn: messages.minimalDawn,
|
||||
morning: messages.minimalMorning,
|
||||
afternoon: messages.minimalAfternoon,
|
||||
evening: messages.minimalEvening,
|
||||
night: messages.minimalNight,
|
||||
}
|
||||
|
||||
const greeting = computed(() => {
|
||||
const bucket = getTimeBucket(now.value)
|
||||
const variants = formatMessage(messages[bucket]).split('\n').filter(Boolean)
|
||||
const seed = `${locale.value}:${now.value.toDateString()}:${bucket}:${props.playerName ?? ''}`
|
||||
return variants[stableGreetingIndex(seed, variants.length)] ?? ''
|
||||
})
|
||||
|
||||
const minimalGreeting = computed(() =>
|
||||
formatMessage(minimalGreetingMessages[getTimeBucket(now.value)]),
|
||||
)
|
||||
|
||||
const automaticWelcome = computed(() =>
|
||||
props.playerName
|
||||
? formatMessage(messages.welcomeWithPlayer, { name: props.playerName })
|
||||
: formatMessage(messages.welcome),
|
||||
)
|
||||
|
||||
const dateLabel = computed(() =>
|
||||
new Intl.DateTimeFormat(locale.value, {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(now.value),
|
||||
)
|
||||
|
||||
const heading = computed(() => {
|
||||
if (props.variant === 'minimal') {
|
||||
return props.playerName
|
||||
? formatMessage(messages.minimalWithPlayer, {
|
||||
name: props.playerName,
|
||||
greeting: minimalGreeting.value,
|
||||
})
|
||||
: minimalGreeting.value
|
||||
}
|
||||
|
||||
const customText = props.greetingText.trim()
|
||||
if (props.greetingMode === 'text') return customText || greeting.value
|
||||
if (props.greetingMode === 'text-and-greeting') {
|
||||
return `${customText || automaticWelcome.value} ${greeting.value}`
|
||||
}
|
||||
return greeting.value
|
||||
})
|
||||
|
||||
const greetingFontFamilies: Record<HomeGreetingFont, string> = {
|
||||
sans: 'var(--font-standard)',
|
||||
minecraft: "'bundled-minecraft-font-mrapp', monospace",
|
||||
mono: 'var(--mono-font)',
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
}
|
||||
|
||||
const headingStyle = computed(() =>
|
||||
props.dashboardSize
|
||||
? {
|
||||
'--home-greeting-font-family': greetingFontFamilies[props.greetingFont],
|
||||
'--home-greeting-font-size': `${props.greetingFontSize}px`,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const updateClock = () => {
|
||||
now.value = new Date()
|
||||
}
|
||||
const timer = window.setInterval(updateClock, 60_000)
|
||||
|
||||
onUnmounted(() => window.clearInterval(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="home-greeting flex min-w-0 flex-col"
|
||||
:class="
|
||||
variant === 'minimal'
|
||||
? 'items-center gap-3 text-center'
|
||||
: dashboardSize
|
||||
? 'h-full justify-center gap-2'
|
||||
: 'gap-1 py-2'
|
||||
"
|
||||
>
|
||||
<span v-if="variant !== 'minimal' && dashboardSize" class="text-xs font-bold leading-none tracking-normal text-secondary">
|
||||
{{ dateLabel }}
|
||||
</span>
|
||||
<h1
|
||||
class="m-0 max-w-full break-words font-extrabold text-contrast"
|
||||
:class="dashboardSize ? 'home-greeting-heading' : 'text-2xl'"
|
||||
:style="headingStyle"
|
||||
>
|
||||
{{ heading }}
|
||||
</h1>
|
||||
<div v-if="variant === 'minimal'" class="h-0.5 w-8 rounded-full bg-brand" aria-hidden="true" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-greeting-heading {
|
||||
max-width: 44rem;
|
||||
font-family: var(--home-greeting-font-family, var(--font-standard));
|
||||
font-size: var(--home-greeting-font-size, 1.375rem);
|
||||
line-height: 1.35;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,249 @@
|
||||
<script setup lang="ts">
|
||||
import { MessageIcon, SaveIcon, SparklesIcon, TextCursorInputIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
Slider,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
HOME_GREETING_DEFAULT_FONT,
|
||||
HOME_GREETING_DEFAULT_FONT_SIZE,
|
||||
HOME_GREETING_DEFAULT_MODE,
|
||||
HOME_GREETING_FONT_SIZE_MAX,
|
||||
HOME_GREETING_FONT_SIZE_MIN,
|
||||
type HomeGreetingFont,
|
||||
type HomeGreetingMode,
|
||||
type HomeWidgetPlacement,
|
||||
} from '@/components/home/home-dashboard'
|
||||
import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
playerName: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [id: string, mode: HomeGreetingMode, text: string, font: HomeGreetingFont, fontSize: number]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const textInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const widgetId = ref('')
|
||||
const mode = ref<HomeGreetingMode>(HOME_GREETING_DEFAULT_MODE)
|
||||
const text = ref('')
|
||||
const font = ref<HomeGreetingFont>(HOME_GREETING_DEFAULT_FONT)
|
||||
const fontSize = ref(HOME_GREETING_DEFAULT_FONT_SIZE)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.home.greeting.settings.title', defaultMessage: 'Customize greeting' },
|
||||
modeLabel: { id: 'app.home.greeting.settings.mode', defaultMessage: 'Display style' },
|
||||
greetingMode: {
|
||||
id: 'app.home.greeting.settings.mode.greeting',
|
||||
defaultMessage: 'Greeting only',
|
||||
},
|
||||
greetingModeDescription: {
|
||||
id: 'app.home.greeting.settings.mode.greeting-description',
|
||||
defaultMessage: 'Show a rotating greeting based on the time of day.',
|
||||
},
|
||||
textAndGreetingMode: {
|
||||
id: 'app.home.greeting.settings.mode.text-and-greeting',
|
||||
defaultMessage: 'Text + greeting',
|
||||
},
|
||||
textAndGreetingModeDescription: {
|
||||
id: 'app.home.greeting.settings.mode.text-and-greeting-description',
|
||||
defaultMessage: 'Put your own message before the rotating greeting.',
|
||||
},
|
||||
textMode: {
|
||||
id: 'app.home.greeting.settings.mode.text',
|
||||
defaultMessage: 'Custom text only',
|
||||
},
|
||||
textModeDescription: {
|
||||
id: 'app.home.greeting.settings.mode.text-description',
|
||||
defaultMessage: 'Replace the automatic greeting with your own message.',
|
||||
},
|
||||
textLabel: { id: 'app.home.greeting.settings.text', defaultMessage: 'Custom text' },
|
||||
prefixPlaceholder: {
|
||||
id: 'app.home.greeting.settings.prefix-placeholder',
|
||||
defaultMessage: 'Welcome back, {name}.',
|
||||
},
|
||||
textPlaceholder: {
|
||||
id: 'app.home.greeting.settings.text-placeholder',
|
||||
defaultMessage: 'The next adventure starts here.',
|
||||
},
|
||||
textFallback: {
|
||||
id: 'app.home.greeting.settings.text-fallback',
|
||||
defaultMessage: 'Leave this empty to use the current automatic greeting.',
|
||||
},
|
||||
preview: { id: 'app.home.greeting.settings.preview', defaultMessage: 'Preview' },
|
||||
fontLabel: { id: 'app.home.greeting.settings.font', defaultMessage: 'Font' },
|
||||
fontSizeLabel: { id: 'app.home.greeting.settings.font-size', defaultMessage: 'Font size' },
|
||||
fontSans: { id: 'app.home.greeting.settings.font.sans', defaultMessage: 'Launcher' },
|
||||
fontMinecraft: { id: 'app.home.greeting.settings.font.minecraft', defaultMessage: 'Minecraft' },
|
||||
fontMono: { id: 'app.home.greeting.settings.font.mono', defaultMessage: 'Monospace' },
|
||||
fontSerif: { id: 'app.home.greeting.settings.font.serif', defaultMessage: 'Serif' },
|
||||
})
|
||||
|
||||
const modeOptions = computed(() => [
|
||||
{
|
||||
id: 'greeting' as const,
|
||||
label: formatMessage(messages.greetingMode),
|
||||
description: formatMessage(messages.greetingModeDescription),
|
||||
icon: SparklesIcon,
|
||||
},
|
||||
{
|
||||
id: 'text-and-greeting' as const,
|
||||
label: formatMessage(messages.textAndGreetingMode),
|
||||
description: formatMessage(messages.textAndGreetingModeDescription),
|
||||
icon: MessageIcon,
|
||||
},
|
||||
{
|
||||
id: 'text' as const,
|
||||
label: formatMessage(messages.textMode),
|
||||
description: formatMessage(messages.textModeDescription),
|
||||
icon: TextCursorInputIcon,
|
||||
},
|
||||
])
|
||||
|
||||
const placeholder = computed(() =>
|
||||
mode.value === 'text-and-greeting'
|
||||
? formatMessage(messages.prefixPlaceholder, { name: props.playerName ?? 'Steve' })
|
||||
: formatMessage(messages.textPlaceholder),
|
||||
)
|
||||
|
||||
const fontOptions = computed(() => [
|
||||
{ value: 'sans' as const, label: formatMessage(messages.fontSans) },
|
||||
{ value: 'minecraft' as const, label: formatMessage(messages.fontMinecraft) },
|
||||
{ value: 'mono' as const, label: formatMessage(messages.fontMono) },
|
||||
{ value: 'serif' as const, label: formatMessage(messages.fontSerif) },
|
||||
])
|
||||
|
||||
function selectMode(nextMode: HomeGreetingMode) {
|
||||
mode.value = nextMode
|
||||
if (nextMode !== 'greeting') void nextTick(() => textInput.value?.focus())
|
||||
}
|
||||
|
||||
function show(widget: HomeWidgetPlacement) {
|
||||
widgetId.value = widget.id
|
||||
mode.value = widget.options?.greetingMode ?? HOME_GREETING_DEFAULT_MODE
|
||||
text.value = widget.options?.greetingText ?? ''
|
||||
font.value = widget.options?.greetingFont ?? HOME_GREETING_DEFAULT_FONT
|
||||
fontSize.value = widget.options?.greetingFontSize ?? HOME_GREETING_DEFAULT_FONT_SIZE
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit('save', widgetId.value, mode.value, text.value, font.value, fontSize.value)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" width="560px" max-width="560px">
|
||||
<div class="flex min-w-0 flex-col gap-5">
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<h3 class="m-0 text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.modeLabel) }}
|
||||
</h3>
|
||||
<div class="grid grid-cols-3 overflow-hidden rounded-lg border border-solid border-divider">
|
||||
<button
|
||||
v-for="option in modeOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="flex min-h-28 cursor-pointer flex-col items-start gap-2 border-0 border-r border-solid border-divider bg-transparent p-3 text-left last:border-r-0 hover:bg-button-bg focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:class="{ 'bg-button-bg': mode === option.id }"
|
||||
:aria-pressed="mode === option.id"
|
||||
@click="selectMode(option.id)"
|
||||
>
|
||||
<component
|
||||
:is="option.icon"
|
||||
class="size-5"
|
||||
:class="mode === option.id ? 'text-brand' : 'text-secondary'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<strong class="text-sm text-contrast">{{ option.label }}</strong>
|
||||
<span class="text-xs leading-4 text-secondary">{{ option.description }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label v-if="mode !== 'greeting'" class="flex min-w-0 flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-contrast">{{
|
||||
formatMessage(messages.textLabel)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="textInput"
|
||||
v-model="text"
|
||||
multiline
|
||||
:rows="2"
|
||||
:maxlength="120"
|
||||
:placeholder="placeholder"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.textFallback) }}</span>
|
||||
</label>
|
||||
|
||||
<section class="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] gap-5">
|
||||
<label class="flex min-w-0 flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-contrast">{{
|
||||
formatMessage(messages.fontLabel)
|
||||
}}</span>
|
||||
<Combobox v-model="font" :options="fontOptions" />
|
||||
</label>
|
||||
<label class="flex min-w-0 flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-contrast">{{
|
||||
formatMessage(messages.fontSizeLabel)
|
||||
}}</span>
|
||||
<Slider
|
||||
v-model="fontSize"
|
||||
:min="HOME_GREETING_FONT_SIZE_MIN"
|
||||
:max="HOME_GREETING_FONT_SIZE_MAX"
|
||||
:step="1"
|
||||
unit="px"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<h3 class="m-0 text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preview) }}
|
||||
</h3>
|
||||
<div class="min-h-28 rounded-lg bg-button-bg px-4 py-3">
|
||||
<HomeGreeting
|
||||
:player-name="playerName"
|
||||
:greeting-mode="mode"
|
||||
:greeting-text="text"
|
||||
:greeting-font="font"
|
||||
:greeting-font-size="fontSize"
|
||||
dashboard-size="2x1"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="save">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
86
apps/app-frontend/src/components/home/HomeInstanceCard.vue
Normal file
86
apps/app-frontend/src/components/home/HomeInstanceCard.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { MoreVerticalIcon, PinIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, OverflowMenu, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
type InstanceCardLayout = 'spotlight' | 'row' | 'tile'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
pinned: boolean
|
||||
playing?: boolean
|
||||
layout?: InstanceCardLayout
|
||||
}>(),
|
||||
{
|
||||
playing: false,
|
||||
layout: 'row',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'pinned-change': [instance: GameInstance, pinned: boolean]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
pin: { id: 'app.home.instances.pin', defaultMessage: 'Pin to Home' },
|
||||
unpin: { id: 'app.home.instances.unpin', defaultMessage: 'Unpin from Home' },
|
||||
})
|
||||
|
||||
const compact = computed(() => props.layout !== 'tile')
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: props.pinned ? 'unpin' : 'pin',
|
||||
action: () => emit('pinned-change', props.instance, !props.pinned),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-instance-card relative min-w-0" :data-layout="layout" :data-compact="compact">
|
||||
<Instance
|
||||
:instance="instance"
|
||||
:compact="compact"
|
||||
:flat="true"
|
||||
:playing="playing"
|
||||
:first="layout === 'spotlight'"
|
||||
/>
|
||||
<div class="home-instance-menu" @click.stop>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<OverflowMenu
|
||||
:options="menuOptions"
|
||||
:tooltip="formatMessage(pinned ? messages.unpin : messages.pin)"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #pin><PinIcon /> {{ formatMessage(messages.pin) }}</template>
|
||||
<template #unpin>
|
||||
<PinIcon class="rotate-45" /> {{ formatMessage(messages.unpin) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-instance-card[data-compact='true'] {
|
||||
padding-right: 2.25rem;
|
||||
}
|
||||
|
||||
.home-instance-menu {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.home-instance-card[data-compact='true'] .home-instance-menu {
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon } from '@modrinth/assets'
|
||||
import { defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import InstancePickerList from '@/components/ui/instance/InstancePickerList.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instances: GameInstance[]
|
||||
selectedInstanceId?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [instance: GameInstance]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const instancePicker = ref<InstanceType<typeof InstancePickerList>>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.home.minimal.picker.title',
|
||||
defaultMessage: 'Choose a Home instance',
|
||||
},
|
||||
search: {
|
||||
id: 'app.home.minimal.picker.search',
|
||||
defaultMessage: 'Search instances',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'app.home.minimal.picker.no-instances',
|
||||
defaultMessage: 'No instances available',
|
||||
},
|
||||
noResults: {
|
||||
id: 'app.home.minimal.picker.no-results',
|
||||
defaultMessage: 'No matching instances',
|
||||
},
|
||||
select: {
|
||||
id: 'app.home.minimal.picker.select',
|
||||
defaultMessage: 'Choose {name}',
|
||||
},
|
||||
})
|
||||
|
||||
function show() {
|
||||
instancePicker.value?.reset()
|
||||
modal.value?.show()
|
||||
void nextTick(() => instancePicker.value?.focus())
|
||||
}
|
||||
|
||||
function selectInstance(instance: GameInstance) {
|
||||
emit('select', instance)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
max-width="560px"
|
||||
width="min(560px, calc(100vw - 2rem))"
|
||||
scrollable
|
||||
max-content-height="min(36rem, 70vh)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<InstancePickerList
|
||||
ref="instancePicker"
|
||||
:instances="instances"
|
||||
:search-placeholder="formatMessage(messages.search)"
|
||||
:no-instances-message="formatMessage(messages.noInstances)"
|
||||
:no-matches-message="formatMessage(messages.noResults)"
|
||||
:select-label="(instance) => formatMessage(messages.select, { name: instance.name })"
|
||||
@select="selectInstance"
|
||||
>
|
||||
<template #action="{ instance }">
|
||||
<CheckIcon
|
||||
v-if="instance.id === selectedInstanceId"
|
||||
class="size-5 shrink-0 text-brand"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</InstancePickerList>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
124
apps/app-frontend/src/components/home/HomeMinecraftNews.vue
Normal file
124
apps/app-frontend/src/components/home/HomeMinecraftNews.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, NewspaperIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { get_minecraft_news, type MinecraftNewsItem } from '@/helpers/mc_news'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { offline } = useNetworkStatus()
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'medium' })
|
||||
|
||||
const messages = defineMessages({
|
||||
news: { id: 'app.home.news.title', defaultMessage: 'Minecraft news' },
|
||||
openArticle: { id: 'app.home.news.open-article', defaultMessage: 'Read on minecraft.net' },
|
||||
})
|
||||
|
||||
const NEWS_COUNT = 12
|
||||
const NEWS_SKELETON_COUNT = 4
|
||||
|
||||
const newsItems = ref<MinecraftNewsItem[]>([])
|
||||
const loading = ref(true)
|
||||
const htmlDecoder = document.createElement('textarea')
|
||||
|
||||
get_minecraft_news(NEWS_COUNT)
|
||||
.then((items) => {
|
||||
newsItems.value = items.map((item) => ({
|
||||
...item,
|
||||
title: decodeHtmlEntities(item.title),
|
||||
}))
|
||||
})
|
||||
.catch(() => {
|
||||
newsItems.value = []
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
const visible = computed(() => !offline.value && (loading.value || newsItems.value.length > 0))
|
||||
|
||||
function newsDateLabel(item: MinecraftNewsItem): string | null {
|
||||
if (!item.date) return null
|
||||
const parsed = new Date(item.date)
|
||||
return Number.isNaN(parsed.getTime()) ? null : formatDate(parsed)
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
htmlDecoder.innerHTML = value
|
||||
return htmlDecoder.value
|
||||
}
|
||||
|
||||
async function openArticle(item: MinecraftNewsItem) {
|
||||
try {
|
||||
await openUrl(item.read_more_url)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="visible"
|
||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<NewspaperIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 truncate text-lg">
|
||||
{{ formatMessage(messages.news) }}
|
||||
</h2>
|
||||
</div>
|
||||
<ul v-if="loading" class="m-0 flex list-none flex-col gap-1.5 p-0" aria-hidden="true">
|
||||
<li
|
||||
v-for="index in NEWS_SKELETON_COUNT"
|
||||
:key="index"
|
||||
class="flex animate-pulse items-center gap-2.5 px-1.5 py-1.5"
|
||||
>
|
||||
<div class="h-9 w-16 shrink-0 rounded-lg bg-button-bg" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div class="h-3 w-full rounded bg-button-bg" />
|
||||
<div class="h-3 w-1/2 rounded bg-button-bg" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else class="m-0 flex list-none flex-col p-0">
|
||||
<li v-for="item in newsItems" :key="`${item.date ?? ''}:${item.title}`" class="group min-w-0">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.openArticle)"
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-2.5 rounded-lg border-0 bg-transparent px-1.5 py-1.5 text-left transition-colors hover:bg-button-bg"
|
||||
@click="openArticle(item)"
|
||||
>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt=""
|
||||
class="h-9 w-16 shrink-0 rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="h-9 w-16 shrink-0 rounded-lg bg-button-bg" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="line-clamp-2 text-sm font-semibold leading-snug text-contrast">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<span v-if="newsDateLabel(item)" class="truncate text-xs text-secondary">
|
||||
{{ newsDateLabel(item) }}
|
||||
</span>
|
||||
</div>
|
||||
<ExternalIcon
|
||||
class="size-3.5 shrink-0 text-secondary opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
337
apps/app-frontend/src/components/home/HomeMinimal.vue
Normal file
337
apps/app-frontend/src/components/home/HomeMinimal.vue
Normal file
@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
ListIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Card,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import HomeGreeting from '@/components/home/HomeGreeting.vue'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
selectedInstanceId?: string | null
|
||||
playerName?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
choose: []
|
||||
create: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const messages = defineMessages({
|
||||
chooseInstance: {
|
||||
id: 'app.home.minimal.choose-instance',
|
||||
defaultMessage: 'Choose instance',
|
||||
},
|
||||
changeInstance: {
|
||||
id: 'app.home.minimal.change-instance',
|
||||
defaultMessage: 'Change Home instance',
|
||||
},
|
||||
createInstance: {
|
||||
id: 'app.home.instances.create',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'app.home.instances.empty',
|
||||
defaultMessage: 'No instances yet',
|
||||
},
|
||||
loading: {
|
||||
id: 'app.instance.loading',
|
||||
defaultMessage: 'Instance is loading...',
|
||||
},
|
||||
played: {
|
||||
id: 'app.instance.played',
|
||||
defaultMessage: 'Played {time}',
|
||||
},
|
||||
neverPlayed: {
|
||||
id: 'app.instance.never-played',
|
||||
defaultMessage: 'Never played',
|
||||
},
|
||||
offlineInstalledOnly: {
|
||||
id: 'app.instance.offline-installed-only',
|
||||
defaultMessage: 'Offline mode can only launch fully downloaded instances.',
|
||||
},
|
||||
})
|
||||
|
||||
const selectedInstance = computed(() =>
|
||||
props.instances.find((instance) => instance.id === props.selectedInstanceId),
|
||||
)
|
||||
const running = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentEvent = ref<string | null>(null)
|
||||
const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
|
||||
const installing = computed(
|
||||
() => selectedInstance.value?.install_stage.includes('installing') ?? false,
|
||||
)
|
||||
const busy = computed(
|
||||
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value),
|
||||
)
|
||||
|
||||
const lastPlayed = computed(() => {
|
||||
if (!selectedInstance.value?.last_played) return formatMessage(messages.neverPlayed)
|
||||
return formatMessage(messages.played, {
|
||||
time: formatRelativeTime(dayjs(selectedInstance.value.last_played).toISOString()),
|
||||
})
|
||||
})
|
||||
|
||||
async function refreshProcessState() {
|
||||
if (!selectedInstance.value) {
|
||||
running.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
running.value = processes.length > 0
|
||||
}
|
||||
|
||||
async function playInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeMinimal',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
} finally {
|
||||
loading.value = false
|
||||
await refreshProcessState()
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
await kill(instance.id).catch(handleError)
|
||||
running.value = false
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeMinimal',
|
||||
})
|
||||
}
|
||||
|
||||
async function installInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (
|
||||
instance.install_stage !== 'pack_installed' &&
|
||||
(instance.link?.type === 'modrinth_modpack' ||
|
||||
instance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(instance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: instance.link.project_id ?? instance.link.server_project_id ?? '',
|
||||
version_id: instance.link.version_id ?? instance.link.content_version_id ?? '',
|
||||
title: instance.name,
|
||||
})
|
||||
} else {
|
||||
await install_existing_instance(instance.id, false)
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedInstanceId,
|
||||
() => {
|
||||
currentEvent.value = null
|
||||
void refreshProcessState()
|
||||
},
|
||||
)
|
||||
|
||||
await refreshProcessState()
|
||||
|
||||
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
|
||||
if (event.instance_id !== selectedInstance.value?.id) return
|
||||
currentEvent.value = event.event
|
||||
if (event.event === 'finished') running.value = false
|
||||
else void refreshProcessState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcess()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-onboarding-id="home-instances"
|
||||
class="minimal-home-stage flex min-w-0 items-center justify-center px-6 pb-14 pt-8"
|
||||
>
|
||||
<div class="flex w-full max-w-3xl flex-col items-center text-center">
|
||||
<HomeGreeting :player-name="playerName" variant="minimal" />
|
||||
|
||||
<template v-if="selectedInstance">
|
||||
<Card class="mb-0 mt-10 w-full text-left">
|
||||
<div
|
||||
class="grid min-w-0 grid-cols-1 items-center gap-5 sm:grid-cols-[minmax(0,1fr)_auto]"
|
||||
>
|
||||
<router-link
|
||||
:to="`/instance/${encodeURIComponent(selectedInstance.id)}`"
|
||||
class="group flex min-w-0 items-center gap-5 rounded-lg text-inherit no-underline focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
>
|
||||
<InstanceIcon
|
||||
class="size-20 shrink-0 transition-transform group-hover:scale-[1.03]"
|
||||
:icon-path="selectedInstance.icon_path"
|
||||
:instance-id="selectedInstance.id"
|
||||
:loader="selectedInstance.loader"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<h2 class="m-0 truncate text-xl font-bold text-contrast group-hover:underline">
|
||||
{{ selectedInstance.name }}
|
||||
</h2>
|
||||
<div class="flex min-w-0 flex-wrap gap-x-4 gap-y-1 text-sm text-secondary">
|
||||
<span class="flex min-w-0 items-center gap-1.5 capitalize">
|
||||
<GameIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="truncate">
|
||||
{{ selectedInstance.loader }} {{ selectedInstance.game_version }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<TimerIcon class="size-4 shrink-0" aria-hidden="true" />
|
||||
<span class="truncate">{{ lastPlayed }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
|
||||
<ButtonStyled v-if="running" color="red" size="large">
|
||||
<button class="w-36 justify-center" @click="stopInstance">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="busy" size="large">
|
||||
<button class="w-36 justify-center" disabled>
|
||||
<SpinnerIcon class="animate-spin" aria-hidden="true" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
formatMessage(installing ? commonMessages.installingLabel : messages.loading)
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="installed" color="brand" size="large">
|
||||
<button class="w-36 justify-center" @click="playInstance">
|
||||
<PlayIcon class="translate-x-px" aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" size="large">
|
||||
<button
|
||||
v-tooltip="offline ? formatMessage(messages.offlineInstalledOnly) : undefined"
|
||||
class="w-36 justify-center"
|
||||
:disabled="offline"
|
||||
@click="installInstance"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.installButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled circular size="large" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.changeInstance)"
|
||||
:aria-label="formatMessage(messages.changeInstance)"
|
||||
@click="emit('choose')"
|
||||
>
|
||||
<ListIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<Card class="mb-0 mt-10 w-full text-left">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-4">
|
||||
<div
|
||||
class="flex size-16 shrink-0 items-center justify-center rounded-lg bg-button-bg text-secondary"
|
||||
>
|
||||
<ListIcon class="size-7" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-48 flex-1">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">
|
||||
{{
|
||||
formatMessage(
|
||||
instances.length > 0 ? messages.chooseInstance : messages.noInstances,
|
||||
)
|
||||
}}
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button v-if="instances.length > 0" @click="emit('choose')">
|
||||
<ListIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.chooseInstance) }}
|
||||
</button>
|
||||
<button v-else @click="emit('create')">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.minimal-home-stage {
|
||||
min-height: calc(100vh - var(--top-bar-height) - 4rem);
|
||||
}
|
||||
</style>
|
||||
129
apps/app-frontend/src/components/home/HomePinnedInstances.vue
Normal file
129
apps/app-frontend/src/components/home/HomePinnedInstances.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { GridIcon, RightArrowIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import HomeInstanceCard from '@/components/home/HomeInstanceCard.vue'
|
||||
import { set_pinned } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
dashboard?: boolean
|
||||
dashboardSize?: HomeWidgetSize | null
|
||||
}>()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { runningInstanceIds } = useHomeDashboardRuntime()
|
||||
const messages = defineMessages({
|
||||
pinnedInstances: {
|
||||
id: 'app.home.instances.pinned',
|
||||
defaultMessage: 'Pinned instances',
|
||||
},
|
||||
emptyPinned: {
|
||||
id: 'app.home.instances.pinned-empty',
|
||||
defaultMessage: 'Pin an instance from its card menu or the library to keep it here.',
|
||||
},
|
||||
viewAllInstances: {
|
||||
id: 'app.home.instances.view-all',
|
||||
defaultMessage: 'View all instances',
|
||||
},
|
||||
})
|
||||
|
||||
const pinnedInstances = computed(() =>
|
||||
props.instances
|
||||
.filter((instance) => instance.pinned_at)
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.pinned_at ?? 0).getTime() - new Date(a.pinned_at ?? 0).getTime()),
|
||||
)
|
||||
const cardLayout = computed(() => {
|
||||
if (props.dashboardSize === '1x1') return 'spotlight' as const
|
||||
if (props.dashboardSize === '2x2') return 'tile' as const
|
||||
return 'row' as const
|
||||
})
|
||||
|
||||
async function updatePinned(instance: GameInstance, pinned: boolean) {
|
||||
await set_pinned(instance.id, pinned).catch(handleError)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home-pinned-instances flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
|
||||
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
||||
<h2>
|
||||
{{ formatMessage(messages.pinnedInstances) }}
|
||||
</h2>
|
||||
<ButtonStyled v-if="dashboardSize !== '1x1'" type="transparent" size="small" class="ml-auto">
|
||||
<router-link to="/library">
|
||||
<span v-if="dashboardSize === '2x2'">{{ formatMessage(messages.viewAllInstances) }}</span>
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
</router-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="pinnedInstances.length > 0" class="home-instance-list grid min-w-0 min-h-0 flex-1 grid-auto-rows-max gap-1 overflow-x-hidden overflow-y-auto pr-1">
|
||||
<HomeInstanceCard
|
||||
v-for="instance in pinnedInstances"
|
||||
:key="instance.id"
|
||||
:instance="instance"
|
||||
:pinned="true"
|
||||
:layout="cardLayout"
|
||||
:playing="runningInstanceIds.includes(instance.id)"
|
||||
@pinned-change="updatePinned"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="home-widget-empty">
|
||||
<GridIcon aria-hidden="true" />
|
||||
<span>{{ formatMessage(messages.emptyPinned) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-widget-heading h2 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-pinned-instances[data-size='2x1'] .home-instance-list,
|
||||
.home-pinned-instances[data-size='2x2'] .home-instance-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.home-pinned-instances[data-size='1x1'] {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.home-pinned-instances[data-size='1x1'] .home-widget-heading {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.home-widget-empty {
|
||||
display: flex;
|
||||
max-width: 22rem;
|
||||
margin: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-widget-empty svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
356
apps/app-frontend/src/components/home/HomePinnedServers.vue
Normal file
356
apps/app-frontend/src/components/home/HomePinnedServers.vue
Normal file
@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
NoSignalIcon,
|
||||
PinIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
SignalIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
type ServerWorld,
|
||||
PROTECTED_SERVER_ADDRESS,
|
||||
set_world_display_status,
|
||||
start_join_server,
|
||||
type WorldWithInstance,
|
||||
} from '@/helpers/worlds'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
dashboard?: boolean
|
||||
dashboardSize?: HomeWidgetSize | null
|
||||
}>()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const runtime = useHomeDashboardRuntime()
|
||||
const { favoriteWorlds, runningInstanceIds } = runtime
|
||||
|
||||
const messages = defineMessages({
|
||||
pinnedServers: {
|
||||
id: 'app.home.servers.pinned',
|
||||
defaultMessage: 'Pinned servers',
|
||||
},
|
||||
emptyServers: {
|
||||
id: 'app.home.servers.empty',
|
||||
defaultMessage: 'Favorite a server and it will be pinned here.',
|
||||
},
|
||||
playersOnline: {
|
||||
id: 'app.home.servers.players-online',
|
||||
defaultMessage: '{online}/{max} online',
|
||||
},
|
||||
offline: {
|
||||
id: 'app.home.servers.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
join: {
|
||||
id: 'app.home.servers.join',
|
||||
defaultMessage: 'Join server',
|
||||
},
|
||||
stop: {
|
||||
id: 'app.home.servers.stop',
|
||||
defaultMessage: 'Stop',
|
||||
},
|
||||
unpin: {
|
||||
id: 'app.home.servers.unpin',
|
||||
defaultMessage: 'Unpin from Home',
|
||||
},
|
||||
moreOptions: {
|
||||
id: 'app.home.servers.more-options',
|
||||
defaultMessage: 'More options',
|
||||
},
|
||||
protectedServerName: {
|
||||
id: 'app.home.servers.protected-name',
|
||||
defaultMessage: 'Starlight Server',
|
||||
},
|
||||
})
|
||||
|
||||
const startingServerKey = ref<string | null>(null)
|
||||
|
||||
const instanceById = computed(
|
||||
() => new Map(props.instances.map((instance) => [instance.id, instance])),
|
||||
)
|
||||
const servers = computed(() => {
|
||||
const favoriteServers = favoriteWorlds.value.flatMap((world) => {
|
||||
if (world.type !== 'server' || world.address === PROTECTED_SERVER_ADDRESS) return []
|
||||
const instance = instanceById.value.get(world.instance_id)
|
||||
return instance ? [{ instance, world: world as ServerWorld & WorldWithInstance }] : []
|
||||
})
|
||||
const protectedInstance =
|
||||
props.instances.find((instance) => instance.install_stage === 'installed') ?? props.instances[0]
|
||||
if (!protectedInstance) return favoriteServers
|
||||
|
||||
const protectedServer: ServerWorld & WorldWithInstance = {
|
||||
instance_id: protectedInstance.id,
|
||||
name: formatMessage(messages.protectedServerName),
|
||||
last_played: undefined,
|
||||
icon: undefined,
|
||||
display_status: 'favorite',
|
||||
type: 'server',
|
||||
index: -1,
|
||||
address: PROTECTED_SERVER_ADDRESS,
|
||||
pack_status: 'prompt',
|
||||
}
|
||||
return [{ instance: protectedInstance, world: protectedServer }, ...favoriteServers]
|
||||
})
|
||||
|
||||
function serverKey(world: ServerWorld & WorldWithInstance): string {
|
||||
return `${world.instance_id}:${world.address}`
|
||||
}
|
||||
|
||||
function dataFor(world: ServerWorld & WorldWithInstance) {
|
||||
return runtime.getServerData(world.instance_id, world.address)
|
||||
}
|
||||
|
||||
async function joinServer(world: ServerWorld & WorldWithInstance, instance: GameInstance) {
|
||||
const key = serverKey(world)
|
||||
startingServerKey.value = key
|
||||
|
||||
try {
|
||||
await start_join_server(world.instance_id, world.address)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedServer',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
} finally {
|
||||
startingServerKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedServer',
|
||||
})
|
||||
}
|
||||
|
||||
async function unpinServer(world: ServerWorld & WorldWithInstance) {
|
||||
await set_world_display_status(world.instance_id, 'server', world.address, 'normal').catch(
|
||||
handleError,
|
||||
)
|
||||
await runtime.refreshFavorites()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="home-pinned-servers flex min-w-0 min-h-0 h-full flex-col gap-3"
|
||||
:data-size="dashboardSize"
|
||||
>
|
||||
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
||||
<ServerIcon class="size-5 shrink-0 text-brand" aria-hidden="true" />
|
||||
<h2>{{ formatMessage(messages.pinnedServers) }}</h2>
|
||||
</div>
|
||||
<div v-if="servers.length === 0" class="home-widget-empty">
|
||||
<ServerIcon aria-hidden="true" />
|
||||
<span>{{ formatMessage(messages.emptyServers) }}</span>
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
class="home-server-list grid min-w-0 min-h-0 flex-1 grid-auto-rows-max gap-1 m-0 overflow-x-hidden overflow-y-auto pr-1 list-none"
|
||||
>
|
||||
<li
|
||||
v-for="server in servers"
|
||||
:key="serverKey(server.world)"
|
||||
class="home-server-row group hover:bg-button-bg focus-within:bg-button-bg"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
<Avatar
|
||||
:src="dataFor(server.world).status?.favicon ?? (server.world.icon || undefined)"
|
||||
:tint-by="server.world.address"
|
||||
size="36px"
|
||||
/>
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-solid border-bg-raised"
|
||||
:class="
|
||||
dataFor(server.world).refreshing
|
||||
? 'animate-pulse bg-secondary'
|
||||
: dataFor(server.world).status
|
||||
? 'bg-brand-green'
|
||||
: 'bg-red'
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate text-sm font-semibold text-contrast">
|
||||
{{ server.world.name }}
|
||||
</span>
|
||||
<span class="truncate text-xs text-secondary">{{ server.world.address }}</span>
|
||||
<span
|
||||
v-if="dataFor(server.world).status"
|
||||
class="flex min-w-0 items-center gap-1 text-xs text-secondary"
|
||||
>
|
||||
<SignalIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
formatMessage(messages.playersOnline, {
|
||||
online: dataFor(server.world).status?.players?.online ?? 0,
|
||||
max: dataFor(server.world).status?.players?.max ?? 0,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="dataFor(server.world).refreshing"
|
||||
class="truncate text-xs text-secondary"
|
||||
>
|
||||
{{ server.world.address }}
|
||||
</span>
|
||||
<span v-else class="flex min-w-0 items-center gap-1 text-xs text-secondary">
|
||||
<NoSignalIcon class="size-3 shrink-0" aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(messages.offline) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
<ButtonStyled
|
||||
v-if="runningInstanceIds.includes(server.instance.id)"
|
||||
circular
|
||||
size="small"
|
||||
type="transparent"
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
class="!text-red"
|
||||
@click="stopInstance(server.instance)"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.join)"
|
||||
class="!text-brand opacity-60 transition-opacity group-hover:opacity-100"
|
||||
:disabled="startingServerKey === serverKey(server.world)"
|
||||
@click="joinServer(server.world, server.instance)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="startingServerKey === serverKey(server.world)"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<PlayIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="server.world.address !== PROTECTED_SERVER_ADDRESS"
|
||||
circular
|
||||
size="small"
|
||||
type="transparent"
|
||||
class="home-server-menu"
|
||||
>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'unpin',
|
||||
action: () => unpinServer(server.world),
|
||||
},
|
||||
]"
|
||||
:tooltip="formatMessage(messages.moreOptions)"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #unpin>
|
||||
<PinIcon class="rotate-45" aria-hidden="true" />
|
||||
{{ formatMessage(messages.unpin) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-widget-heading h2 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-server-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.home-pinned-servers[data-size='2x1'] .home-server-list,
|
||||
.home-pinned-servers[data-size='2x2'] .home-server-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.home-pinned-servers[data-size='1x1'] {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.home-pinned-servers[data-size='1x1'] .home-widget-heading {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.home-pinned-servers[data-size='1x1'] .home-server-row {
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.home-pinned-servers[data-size='1x1'] .home-server-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-widget-empty {
|
||||
display: flex;
|
||||
max-width: 20rem;
|
||||
margin: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-widget-empty svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
198
apps/app-frontend/src/components/home/HomePinnedWorlds.vue
Normal file
198
apps/app-frontend/src/components/home/HomePinnedWorlds.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon } from '@modrinth/assets'
|
||||
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
getWorldIdentifier,
|
||||
hasWorldQuickPlaySupport,
|
||||
start_join_singleplayer_world,
|
||||
type WorldWithInstance,
|
||||
} from '@/helpers/worlds'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
dashboard?: boolean
|
||||
dashboardSize?: HomeWidgetSize | null
|
||||
}>()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const runtime = useHomeDashboardRuntime()
|
||||
const { favoriteWorlds, gameVersions, runningInstanceIds } = runtime
|
||||
const messages = defineMessages({
|
||||
pinnedWorlds: {
|
||||
id: 'app.home.worlds.pinned',
|
||||
defaultMessage: 'Pinned worlds',
|
||||
},
|
||||
emptyWorlds: {
|
||||
id: 'app.home.worlds.empty',
|
||||
defaultMessage: 'Favorite a world and it will be pinned here.',
|
||||
},
|
||||
})
|
||||
|
||||
const startingWorldKey = ref<string | null>(null)
|
||||
const playingWorldKey = ref<string | null>(null)
|
||||
|
||||
const instanceById = computed(
|
||||
() => new Map(props.instances.map((instance) => [instance.id, instance])),
|
||||
)
|
||||
const favorites = computed(() =>
|
||||
favoriteWorlds.value.flatMap((world) => {
|
||||
if (world.type !== 'singleplayer') return []
|
||||
const instance = instanceById.value.get(world.instance_id)
|
||||
return instance ? [{ instance, world }] : []
|
||||
}),
|
||||
)
|
||||
const worldDensity = computed(() => getHomeWidgetCardDensity(props.dashboardSize))
|
||||
|
||||
function favoriteKey(world: WorldWithInstance): string {
|
||||
return `${world.instance_id}:${world.type}:${getWorldIdentifier(world)}`
|
||||
}
|
||||
|
||||
watch(runningInstanceIds, (instanceIds) => {
|
||||
if (playingWorldKey.value && !instanceIds.includes(playingWorldKey.value.split(':', 1)[0])) {
|
||||
playingWorldKey.value = null
|
||||
}
|
||||
})
|
||||
|
||||
async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
if (world.type !== 'singleplayer') return
|
||||
const key = favoriteKey(world)
|
||||
startingWorldKey.value = key
|
||||
|
||||
try {
|
||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||
playingWorldKey.value = key
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
} finally {
|
||||
startingWorldKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
playingWorldKey.value = null
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomePinnedWorld',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home-pinned-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
|
||||
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
||||
<h2>{{ formatMessage(messages.pinnedWorlds) }}</h2>
|
||||
</div>
|
||||
<div v-if="favorites.length > 0" class="home-world-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
|
||||
<WorldItem
|
||||
v-for="favorite in favorites"
|
||||
:key="favoriteKey(favorite.world)"
|
||||
:world="favorite.world"
|
||||
:playing-instance="runningInstanceIds.includes(favorite.instance.id)"
|
||||
:playing-world="playingWorldKey === favoriteKey(favorite.world)"
|
||||
:starting-instance="startingWorldKey === favoriteKey(favorite.world)"
|
||||
:supports-world-quick-play="
|
||||
hasWorldQuickPlaySupport(gameVersions, favorite.instance.game_version)
|
||||
"
|
||||
:game-mode="
|
||||
favorite.world.type === 'singleplayer' ? GAME_MODES[favorite.world.game_mode] : undefined
|
||||
"
|
||||
:instance-id="favorite.instance.id"
|
||||
:instance-name="favorite.instance.name"
|
||||
:instance-icon="favorite.instance.icon_path"
|
||||
:instance-loader="favorite.instance.loader"
|
||||
:shortcut-instance-id="favorite.instance.id"
|
||||
:flat="dashboard"
|
||||
:dashboard-density="worldDensity"
|
||||
@play="joinWorld(favorite.world, favorite.instance)"
|
||||
@play-instance="playInstance(favorite.instance)"
|
||||
@stop="stopInstance(favorite.instance)"
|
||||
@update="runtime.refreshFavorites"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="home-widget-empty">
|
||||
<GameIcon aria-hidden="true" />
|
||||
<span>{{ formatMessage(messages.emptyWorlds) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-widget-heading h2 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-pinned-worlds[data-size='1x1'] {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.home-pinned-worlds[data-size='1x1'] .home-widget-heading {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.home-widget-empty {
|
||||
display: flex;
|
||||
max-width: 20rem;
|
||||
margin: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-widget-empty svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
186
apps/app-frontend/src/components/home/HomePlayInsights.vue
Normal file
186
apps/app-frontend/src/components/home/HomePlayInsights.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { ChartIcon, ClockIcon, GameIcon, TrendingUpIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { type DailyPlaytime, get_daily_playtime } from '@/helpers/instance'
|
||||
|
||||
import { toDateKey } from './home-utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
insights: { id: 'app.home.insights.title', defaultMessage: 'Play insights' },
|
||||
thisWeek: { id: 'app.home.insights.this-week', defaultMessage: 'This week: {duration}' },
|
||||
thisWeekMore: {
|
||||
id: 'app.home.insights.this-week-more',
|
||||
defaultMessage: 'This week: {duration} ({percent}% more than last week)',
|
||||
},
|
||||
thisWeekLess: {
|
||||
id: 'app.home.insights.this-week-less',
|
||||
defaultMessage: 'This week: {duration} ({percent}% less than last week)',
|
||||
},
|
||||
thisWeekSame: {
|
||||
id: 'app.home.insights.this-week-same',
|
||||
defaultMessage: 'This week: {duration} (same as last week)',
|
||||
},
|
||||
streak: {
|
||||
id: 'app.home.insights.streak',
|
||||
defaultMessage: '{days, plural, one {# day played in a row} other {# days played in a row}}',
|
||||
},
|
||||
weekTop: { id: 'app.home.insights.week-top', defaultMessage: 'Most played: {name}' },
|
||||
empty: {
|
||||
id: 'app.home.insights.empty',
|
||||
defaultMessage: 'Play something and your stats will show up here.',
|
||||
},
|
||||
minutes: { id: 'app.home.playtime.minutes', defaultMessage: '{minutes}m' },
|
||||
hoursMinutes: { id: 'app.home.playtime.hours-minutes', defaultMessage: '{hours}h {minutes}m' },
|
||||
seconds: { id: 'app.home.playtime.seconds', defaultMessage: '{seconds}s' },
|
||||
})
|
||||
|
||||
const HISTORY_DAYS = 90
|
||||
|
||||
const dailyPlaytime = ref<DailyPlaytime[]>([])
|
||||
|
||||
function shiftedDate(base: Date, days: number): Date {
|
||||
const result = new Date(base)
|
||||
result.setDate(result.getDate() + days)
|
||||
return result
|
||||
}
|
||||
|
||||
function startOfWeek(date: Date): Date {
|
||||
return shiftedDate(date, -((date.getDay() + 6) % 7))
|
||||
}
|
||||
|
||||
async function refreshPlaytime() {
|
||||
const today = new Date()
|
||||
dailyPlaytime.value = await get_daily_playtime(
|
||||
toDateKey(shiftedDate(today, -HISTORY_DAYS)),
|
||||
toDateKey(today),
|
||||
).catch((): DailyPlaytime[] => [])
|
||||
}
|
||||
|
||||
const dailyByDate = computed(() => new Map(dailyPlaytime.value.map((entry) => [entry.date, entry])))
|
||||
|
||||
function rangeSeconds(start: Date, days: number): number {
|
||||
let total = 0
|
||||
for (let offset = 0; offset < days; offset++) {
|
||||
total += dailyByDate.value.get(toDateKey(shiftedDate(start, offset)))?.played_seconds ?? 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const weekStart = computed(() => startOfWeek(new Date()))
|
||||
const thisWeekSeconds = computed(() => rangeSeconds(weekStart.value, 7))
|
||||
const lastWeekSeconds = computed(() => rangeSeconds(shiftedDate(weekStart.value, -7), 7))
|
||||
|
||||
const streakDays = computed(() => {
|
||||
const today = new Date()
|
||||
let start = 0
|
||||
if (!(dailyByDate.value.get(toDateKey(today))?.played_seconds ?? 0)) {
|
||||
start = 1
|
||||
}
|
||||
let days = 0
|
||||
for (let offset = start; offset <= HISTORY_DAYS; offset++) {
|
||||
if (dailyByDate.value.get(toDateKey(shiftedDate(today, -offset)))?.played_seconds ?? 0) {
|
||||
days += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
const weekTopInstance = computed(() => {
|
||||
const totals = new Map<string, number>()
|
||||
for (let offset = 0; offset < 7; offset++) {
|
||||
const entry = dailyByDate.value.get(toDateKey(shiftedDate(weekStart.value, offset)))
|
||||
if (entry?.top_instance_name && entry.played_seconds > 0) {
|
||||
totals.set(
|
||||
entry.top_instance_name,
|
||||
(totals.get(entry.top_instance_name) ?? 0) + entry.played_seconds,
|
||||
)
|
||||
}
|
||||
}
|
||||
let topName: string | null = null
|
||||
let topSeconds = 0
|
||||
for (const [name, seconds] of totals) {
|
||||
if (seconds > topSeconds) {
|
||||
topName = name
|
||||
topSeconds = seconds
|
||||
}
|
||||
}
|
||||
return topName
|
||||
})
|
||||
|
||||
const hasAnyPlaytime = computed(() => dailyPlaytime.value.some((entry) => entry.played_seconds > 0))
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const roundedSeconds = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(roundedSeconds / 3600)
|
||||
const minutes = Math.floor((roundedSeconds % 3600) / 60)
|
||||
if (hours > 0) return formatMessage(messages.hoursMinutes, { hours, minutes })
|
||||
if (minutes > 0) return formatMessage(messages.minutes, { minutes })
|
||||
return formatMessage(messages.seconds, { seconds: roundedSeconds })
|
||||
}
|
||||
|
||||
const thisWeekLine = computed(() => {
|
||||
const duration = formatDuration(thisWeekSeconds.value)
|
||||
if (lastWeekSeconds.value === 0) {
|
||||
return formatMessage(messages.thisWeek, { duration })
|
||||
}
|
||||
const percent = Math.round(
|
||||
(Math.abs(thisWeekSeconds.value - lastWeekSeconds.value) / lastWeekSeconds.value) * 100,
|
||||
)
|
||||
if (percent === 0) return formatMessage(messages.thisWeekSame, { duration })
|
||||
return formatMessage(
|
||||
thisWeekSeconds.value > lastWeekSeconds.value ? messages.thisWeekMore : messages.thisWeekLess,
|
||||
{ duration, percent },
|
||||
)
|
||||
})
|
||||
|
||||
await refreshPlaytime()
|
||||
|
||||
const unlistenProcesses = await process_listener(async (event: { event: string }) => {
|
||||
if (event.event === 'finished') {
|
||||
await refreshPlaytime()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChartIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<h2 class="m-0 truncate text-lg">
|
||||
{{ formatMessage(messages.insights) }}
|
||||
</h2>
|
||||
</div>
|
||||
<p v-if="!hasAnyPlaytime" class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col gap-2 p-0">
|
||||
<li class="flex min-w-0 items-center gap-2 text-sm text-primary">
|
||||
<ClockIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0">{{ thisWeekLine }}</span>
|
||||
</li>
|
||||
<li v-if="streakDays > 0" class="flex min-w-0 items-center gap-2 text-sm text-primary">
|
||||
<TrendingUpIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0">{{ formatMessage(messages.streak, { days: streakDays }) }}</span>
|
||||
</li>
|
||||
<li v-if="weekTopInstance" class="flex min-w-0 items-center gap-2 text-sm text-primary">
|
||||
<GameIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="min-w-0 truncate">
|
||||
{{ formatMessage(messages.weekTop, { name: weekTopInstance }) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
270
apps/app-frontend/src/components/home/HomeRecentWorlds.vue
Normal file
270
apps/app-frontend/src/components/home/HomeRecentWorlds.vue
Normal file
@ -0,0 +1,270 @@
|
||||
<script setup lang="ts">
|
||||
import { HistoryIcon } from '@modrinth/assets'
|
||||
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
getHomeWidgetCardDensity,
|
||||
HOME_RECENT_DEFAULT_LIMIT,
|
||||
type HomeRecentLimit,
|
||||
type HomeWidgetSize,
|
||||
} from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
getWorldIdentifier,
|
||||
hasServerQuickPlaySupport,
|
||||
hasWorldQuickPlaySupport,
|
||||
type ServerWorld,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
type WorldWithInstance,
|
||||
} from '@/helpers/worlds'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instances: GameInstance[]
|
||||
dashboard?: boolean
|
||||
dashboardSize?: HomeWidgetSize | null
|
||||
limit?: HomeRecentLimit
|
||||
}>(),
|
||||
{
|
||||
limit: HOME_RECENT_DEFAULT_LIMIT,
|
||||
},
|
||||
)
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const runtime = useHomeDashboardRuntime()
|
||||
const { gameVersions, recentWorlds, runningInstanceIds } = runtime
|
||||
|
||||
const messages = defineMessages({
|
||||
recentTitle: {
|
||||
id: 'app.home.recent.title',
|
||||
defaultMessage: 'Start from your recent projects',
|
||||
},
|
||||
emptyRecent: {
|
||||
id: 'app.home.recent.empty',
|
||||
defaultMessage: 'No recent activity yet.',
|
||||
},
|
||||
})
|
||||
|
||||
type RecentItem =
|
||||
| { type: 'world'; last_played: Dayjs; instance: GameInstance; world: WorldWithInstance }
|
||||
| { type: 'instance'; last_played: Dayjs; instance: GameInstance }
|
||||
|
||||
const startingWorldKey = ref<string | null>(null)
|
||||
const playingWorldKey = ref<string | null>(null)
|
||||
|
||||
const instanceById = computed(
|
||||
() => new Map(props.instances.map((instance) => [instance.id, instance])),
|
||||
)
|
||||
|
||||
const recentItems = computed<RecentItem[]>(() => {
|
||||
const worldItems: RecentItem[] = recentWorlds.value.flatMap((world) => {
|
||||
const instance = instanceById.value.get(world.instance_id)
|
||||
if (!instance || !world.last_played) return []
|
||||
return [{ type: 'world', last_played: dayjs(world.last_played), instance, world }]
|
||||
})
|
||||
const coveredInstanceIds = new Set(worldItems.map((item) => item.instance.id))
|
||||
const instanceItems: RecentItem[] = props.instances
|
||||
.filter((instance) => instance.last_played && !coveredInstanceIds.has(instance.id))
|
||||
.map((instance) => ({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played),
|
||||
instance,
|
||||
}))
|
||||
|
||||
return [...worldItems, ...instanceItems]
|
||||
.sort((a, b) => b.last_played.diff(a.last_played))
|
||||
.slice(0, props.limit)
|
||||
})
|
||||
const itemDensity = computed(() => getHomeWidgetCardDensity(props.dashboardSize))
|
||||
|
||||
function worldKey(world: WorldWithInstance): string {
|
||||
return `${world.instance_id}:${world.type}:${getWorldIdentifier(world)}`
|
||||
}
|
||||
|
||||
function serverDataFor(world: WorldWithInstance) {
|
||||
return world.type === 'server'
|
||||
? runtime.getServerData(world.instance_id, world.address)
|
||||
: undefined
|
||||
}
|
||||
|
||||
watch(runningInstanceIds, (instanceIds) => {
|
||||
if (playingWorldKey.value && !instanceIds.includes(playingWorldKey.value.split(':', 1)[0])) {
|
||||
playingWorldKey.value = null
|
||||
}
|
||||
})
|
||||
|
||||
async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
|
||||
const key = worldKey(world)
|
||||
startingWorldKey.value = key
|
||||
|
||||
try {
|
||||
if (world.type === 'server') {
|
||||
await start_join_server(world.instance_id, world.address)
|
||||
} else {
|
||||
await start_join_singleplayer_world(world.instance_id, world.path)
|
||||
}
|
||||
playingWorldKey.value = key
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
} finally {
|
||||
startingWorldKey.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
try {
|
||||
await run(instance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.id,
|
||||
instance_name: instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
}
|
||||
}
|
||||
|
||||
async function stopInstance(instance: GameInstance) {
|
||||
await kill(instance.id).catch(handleError)
|
||||
playingWorldKey.value = null
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'HomeRecentWorld',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="home-recent-worlds flex min-w-0 min-h-0 h-full flex-col gap-3" :data-size="dashboardSize">
|
||||
<div class="home-widget-heading flex min-w-0 h-8 flex-none items-center gap-2">
|
||||
<h2>{{ formatMessage(messages.recentTitle) }}</h2>
|
||||
</div>
|
||||
<div v-if="recentItems.length > 0" class="home-recent-list flex min-w-0 min-h-0 flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto pr-1">
|
||||
<template
|
||||
v-for="item in recentItems"
|
||||
:key="item.type === 'world' ? worldKey(item.world) : `${item.instance.id}:instance`"
|
||||
>
|
||||
<WorldItem
|
||||
v-if="item.type === 'world'"
|
||||
:world="item.world"
|
||||
:playing-instance="runningInstanceIds.includes(item.instance.id)"
|
||||
:playing-world="playingWorldKey === worldKey(item.world)"
|
||||
:starting-instance="startingWorldKey === worldKey(item.world)"
|
||||
:supports-server-quick-play="
|
||||
item.world.type === 'server' &&
|
||||
hasServerQuickPlaySupport(gameVersions, item.instance.game_version)
|
||||
"
|
||||
:supports-world-quick-play="
|
||||
item.world.type === 'singleplayer' &&
|
||||
hasWorldQuickPlaySupport(gameVersions, item.instance.game_version)
|
||||
"
|
||||
:current-protocol="runtime.getProtocolVersion(item.instance.id)"
|
||||
:refreshing="
|
||||
item.world.type === 'server' ? serverDataFor(item.world)?.refreshing : undefined
|
||||
"
|
||||
:server-status="
|
||||
item.world.type === 'server' ? serverDataFor(item.world)?.status : undefined
|
||||
"
|
||||
:rendered-motd="
|
||||
item.world.type === 'server' ? serverDataFor(item.world)?.renderedMotd : undefined
|
||||
"
|
||||
:game-mode="
|
||||
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
||||
"
|
||||
:instance-id="item.instance.id"
|
||||
:instance-name="item.instance.name"
|
||||
:instance-icon="item.instance.icon_path"
|
||||
:instance-loader="item.instance.loader"
|
||||
:shortcut-instance-id="item.instance.id"
|
||||
:flat="dashboard"
|
||||
:dashboard-density="itemDensity"
|
||||
@play="joinWorld(item.world, item.instance)"
|
||||
@play-instance="playInstance(item.instance)"
|
||||
@stop="stopInstance(item.instance)"
|
||||
@refresh="
|
||||
item.world.type === 'server'
|
||||
? runtime.refreshServer(item.instance.id, (item.world as ServerWorld).address, true)
|
||||
: undefined
|
||||
"
|
||||
@update="runtime.refreshRecentWorlds"
|
||||
/>
|
||||
<InstanceItem
|
||||
v-else
|
||||
:instance="item.instance"
|
||||
:last-played="item.last_played"
|
||||
:flat="dashboard"
|
||||
:playing="runningInstanceIds.includes(item.instance.id)"
|
||||
:dashboard-density="itemDensity"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="home-widget-empty">
|
||||
<HistoryIcon aria-hidden="true" />
|
||||
<span>{{ formatMessage(messages.emptyRecent) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-widget-heading h2 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-recent-worlds[data-size='2x1'] {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.home-widget-empty {
|
||||
display: flex;
|
||||
max-width: 20rem;
|
||||
margin: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-widget-empty svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
462
apps/app-frontend/src/components/home/HomeShortcutWidget.vue
Normal file
462
apps/app-frontend/src/components/home/HomeShortcutWidget.vue
Normal file
@ -0,0 +1,462 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
GameIcon,
|
||||
IssuesIcon,
|
||||
NoSignalIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
SignalIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
GAME_MODES,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
getWorldIdentifier,
|
||||
hasServerQuickPlaySupport,
|
||||
hasWorldQuickPlaySupport,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
type World,
|
||||
} from '@/helpers/worlds'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = defineProps<{
|
||||
placement: HomeWidgetPlacement
|
||||
instances: GameInstance[]
|
||||
dashboardSize: HomeWidgetSize
|
||||
}>()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const runtime = useHomeDashboardRuntime()
|
||||
const { gameVersions, runningInstanceIds } = runtime
|
||||
const world = ref<World | null>(null)
|
||||
const starting = ref(false)
|
||||
const loadingTarget = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
unavailable: {
|
||||
id: 'app.home.widgets.unavailable',
|
||||
defaultMessage: 'Content unavailable',
|
||||
},
|
||||
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
|
||||
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
|
||||
instance: { id: 'app.home.shortcut.kind.instance', defaultMessage: 'Instance' },
|
||||
world: { id: 'app.home.shortcut.kind.world', defaultMessage: 'World' },
|
||||
server: { id: 'app.home.shortcut.kind.server', defaultMessage: 'Server' },
|
||||
offline: { id: 'app.home.shortcut.server.offline', defaultMessage: 'Server offline' },
|
||||
playersOnline: {
|
||||
id: 'app.home.shortcut.server.players-online',
|
||||
defaultMessage: '{count} online',
|
||||
},
|
||||
hardcore: { id: 'instance.worlds.hardcore', defaultMessage: 'Hardcore mode' },
|
||||
noServerQuickPlay: {
|
||||
id: 'instance.worlds.no_server_quick_play',
|
||||
defaultMessage: 'Direct server join is unavailable for this Minecraft version.',
|
||||
},
|
||||
noWorldQuickPlay: {
|
||||
id: 'instance.worlds.no_singleplayer_quick_play',
|
||||
defaultMessage: 'Direct world launch is unavailable for this Minecraft version.',
|
||||
},
|
||||
})
|
||||
|
||||
const instance = computed(() =>
|
||||
props.instances.find((candidate) => candidate.id === props.placement.target?.instanceId),
|
||||
)
|
||||
const missing = computed(
|
||||
() => !instance.value || (props.placement.kind !== 'instance' && !world.value),
|
||||
)
|
||||
const serverData = computed(() =>
|
||||
instance.value && world.value?.type === 'server'
|
||||
? runtime.getServerData(instance.value.id, world.value.address)
|
||||
: undefined,
|
||||
)
|
||||
const isRunning = computed(() =>
|
||||
instance.value ? runningInstanceIds.value.includes(instance.value.id) : false,
|
||||
)
|
||||
const versionLabel = computed(() => {
|
||||
if (!instance.value) return ''
|
||||
const loader = instance.value.loader === 'vanilla' ? 'Minecraft' : instance.value.loader
|
||||
return `${loader} ${instance.value.game_version}`
|
||||
})
|
||||
const lastPlayedLabel = computed(() => {
|
||||
const lastPlayed = world.value?.last_played ?? instance.value?.last_played
|
||||
return lastPlayed
|
||||
? formatMessage(messages.played, {
|
||||
time: formatRelativeTime(dayjs(lastPlayed).toISOString()),
|
||||
})
|
||||
: formatMessage(messages.neverPlayed)
|
||||
})
|
||||
const shortcutTitle = computed(
|
||||
() =>
|
||||
(props.placement.kind === 'instance' ? instance.value?.name : world.value?.name) ??
|
||||
props.placement.target?.fallbackLabel ??
|
||||
'',
|
||||
)
|
||||
const shortcutRoute = computed(() => {
|
||||
if (!instance.value) return '/'
|
||||
if (!world.value) return `/instance/${encodeURIComponent(instance.value.id)}`
|
||||
return `/instance/${encodeURIComponent(instance.value.id)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world.value))}`
|
||||
})
|
||||
const shortcutIcon = computed(() => {
|
||||
if (!world.value) return undefined
|
||||
return world.value.type === 'server'
|
||||
? (serverData.value?.status?.favicon ?? world.value.icon)
|
||||
: world.value.icon
|
||||
})
|
||||
const kindLabel = computed(() =>
|
||||
formatMessage(
|
||||
props.placement.kind === 'instance'
|
||||
? messages.instance
|
||||
: props.placement.kind === 'world'
|
||||
? messages.world
|
||||
: messages.server,
|
||||
),
|
||||
)
|
||||
const kindIcon = computed(() =>
|
||||
props.placement.kind === 'instance'
|
||||
? BoxIcon
|
||||
: props.placement.kind === 'world'
|
||||
? GameIcon
|
||||
: ServerIcon,
|
||||
)
|
||||
const primaryLabel = computed(() => {
|
||||
if (!world.value) return versionLabel.value
|
||||
if (world.value.type === 'singleplayer') {
|
||||
return world.value.hardcore
|
||||
? formatMessage(messages.hardcore)
|
||||
: formatMessage(GAME_MODES[world.value.game_mode].message)
|
||||
}
|
||||
if (serverData.value?.refreshing) return formatMessage(commonMessages.loadingLabel)
|
||||
if (!serverData.value?.status) return formatMessage(messages.offline)
|
||||
return formatMessage(messages.playersOnline, {
|
||||
count: serverData.value.status.players?.online ?? 0,
|
||||
})
|
||||
})
|
||||
const secondaryLabel = computed(() => {
|
||||
if (world.value?.type === 'server') return world.value.address
|
||||
if (world.value) return `${instance.value?.name ?? ''} · ${lastPlayedLabel.value}`
|
||||
return lastPlayedLabel.value
|
||||
})
|
||||
const statusIcon = computed(() => {
|
||||
if (world.value?.type !== 'server') return world.value ? GameIcon : TimerIcon
|
||||
return serverData.value?.status ? SignalIcon : NoSignalIcon
|
||||
})
|
||||
const supportsQuickPlay = computed(() => {
|
||||
if (!world.value || !instance.value) return true
|
||||
return world.value.type === 'server'
|
||||
? hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version)
|
||||
: hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version)
|
||||
})
|
||||
const playTooltip = computed(() => {
|
||||
if (supportsQuickPlay.value) return formatMessage(commonMessages.playButton)
|
||||
return formatMessage(
|
||||
world.value?.type === 'server' ? messages.noServerQuickPlay : messages.noWorldQuickPlay,
|
||||
)
|
||||
})
|
||||
|
||||
async function refreshTarget(force = false) {
|
||||
world.value = null
|
||||
const target = props.placement.target
|
||||
if (!target || props.placement.kind === 'instance' || !instance.value) return
|
||||
|
||||
loadingTarget.value = true
|
||||
try {
|
||||
const available = await runtime.getInstanceWorlds(target.instanceId, force)
|
||||
world.value =
|
||||
available.find((candidate) =>
|
||||
candidate.type === 'server'
|
||||
? props.placement.kind === 'server' && candidate.address === target.address
|
||||
: props.placement.kind === 'world' && candidate.path === target.path,
|
||||
) ?? null
|
||||
|
||||
if (world.value?.type === 'server') {
|
||||
await runtime.refreshServer(target.instanceId, world.value.address, force)
|
||||
}
|
||||
} finally {
|
||||
loadingTarget.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function playInstance(targetInstance: GameInstance) {
|
||||
starting.value = true
|
||||
try {
|
||||
await run(targetInstance.id)
|
||||
trackEvent('InstanceStart', {
|
||||
loader: targetInstance.loader,
|
||||
game_version: targetInstance.game_version,
|
||||
source: 'HomeInstanceWidget',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: targetInstance.id,
|
||||
instance_name: targetInstance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: targetInstance.id })
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function playWorld() {
|
||||
if (!instance.value || !world.value) return
|
||||
starting.value = true
|
||||
try {
|
||||
if (world.value.type === 'server') {
|
||||
await start_join_server(instance.value.id, world.value.address)
|
||||
} else {
|
||||
await start_join_singleplayer_world(instance.value.id, world.value.path)
|
||||
}
|
||||
trackEvent('InstanceStart', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
source: 'HomeShortcutWidget',
|
||||
})
|
||||
} catch (error) {
|
||||
const handled = await handleMinecraftLaunchError(error, {
|
||||
instance_id: instance.value.id,
|
||||
instance_name: instance.value.name,
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.value.id })
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function playShortcut() {
|
||||
if (!instance.value) return
|
||||
if (world.value) await playWorld()
|
||||
else await playInstance(instance.value)
|
||||
}
|
||||
|
||||
async function stopInstance() {
|
||||
if (!instance.value) return
|
||||
await kill(instance.value.id).catch(handleError)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.placement, props.instances] as const,
|
||||
() => refreshTarget(),
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="home-shortcut-widget min-w-0 min-h-0 h-full"
|
||||
:data-size="dashboardSize"
|
||||
:data-kind="placement.kind"
|
||||
>
|
||||
<div
|
||||
v-if="loadingTarget"
|
||||
class="flex min-w-0 min-h-0 h-full flex-col items-center justify-center gap-2 p-4 box-border text-center"
|
||||
>
|
||||
<SpinnerIcon class="size-6 animate-spin text-secondary" aria-hidden="true" />
|
||||
<span class="text-sm text-secondary">{{ formatMessage(commonMessages.loadingLabel) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="missing"
|
||||
class="flex min-w-0 min-h-0 h-full flex-col items-center justify-center gap-2 p-4 box-border text-center"
|
||||
>
|
||||
<IssuesIcon class="size-6 text-secondary" aria-hidden="true" />
|
||||
<strong class="max-w-full truncate text-contrast">{{
|
||||
placement.target?.fallbackLabel
|
||||
}}</strong>
|
||||
<span class="text-sm text-secondary">{{ formatMessage(messages.unavailable) }}</span>
|
||||
</div>
|
||||
<div v-else class="home-shortcut-card grid min-w-0 min-h-0 h-full overflow-hidden">
|
||||
<router-link
|
||||
class="home-shortcut-visual relative flex min-w-0 min-h-0 items-center justify-center overflow-hidden bg-button-bg text-secondary no-underline"
|
||||
:to="shortcutRoute"
|
||||
tabindex="-1"
|
||||
>
|
||||
<component
|
||||
:is="kindIcon"
|
||||
class="home-shortcut-watermark absolute -bottom-3 right-3 size-[4.5rem] opacity-[0.08]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="shortcutIcon"
|
||||
class="home-shortcut-icon relative z-10 flex-none shadow-[var(--shadow-card)]"
|
||||
:src="shortcutIcon"
|
||||
:size="dashboardSize === '2x1' ? '72px' : '44px'"
|
||||
/>
|
||||
<InstanceIcon
|
||||
v-else-if="instance"
|
||||
class="home-shortcut-icon relative z-10 flex-none shadow-[var(--shadow-card)]"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
:size="dashboardSize === '2x1' ? '72px' : '44px'"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<div class="home-shortcut-body relative flex min-w-0 min-h-0 items-stretch">
|
||||
<router-link
|
||||
class="home-shortcut-copy flex min-w-0 flex-1 flex-col text-inherit no-underline"
|
||||
:to="shortcutRoute"
|
||||
>
|
||||
<span
|
||||
class="home-shortcut-kind flex min-w-0 items-center gap-[0.3rem] text-secondary text-[0.6875rem] font-bold leading-none"
|
||||
>
|
||||
<component :is="kindIcon" aria-hidden="true" />
|
||||
{{ kindLabel }}
|
||||
</span>
|
||||
<strong
|
||||
class="home-shortcut-title min-w-0 truncate text-contrast font-[750]"
|
||||
>{{ shortcutTitle }}</strong
|
||||
>
|
||||
<span
|
||||
class="home-shortcut-meta home-shortcut-primary flex min-w-0 items-center gap-[0.35rem] truncate text-xs font-semibold leading-[1.2] text-secondary"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="world?.type === 'server' && serverData?.refreshing"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component :is="statusIcon" v-else aria-hidden="true" />
|
||||
{{ primaryLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="home-shortcut-meta home-shortcut-secondary flex min-w-0 items-center gap-[0.35rem] truncate text-xs font-semibold leading-[1.2] text-secondary"
|
||||
>
|
||||
<TimerIcon v-if="world?.type !== 'server'" aria-hidden="true" />
|
||||
<ServerIcon v-else aria-hidden="true" />
|
||||
{{ secondaryLabel }}
|
||||
</span>
|
||||
</router-link>
|
||||
|
||||
<div class="absolute bottom-3 right-3 z-[2]">
|
||||
<ButtonStyled v-if="isRunning" circular size="small" color="red">
|
||||
<button v-tooltip="formatMessage(commonMessages.stopButton)" @click="stopInstance">
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else circular size="small" color="brand">
|
||||
<button
|
||||
v-tooltip="playTooltip"
|
||||
:disabled="starting || !supportsQuickPlay"
|
||||
@click="playShortcut"
|
||||
>
|
||||
<SpinnerIcon v-if="starting" class="animate-spin" />
|
||||
<PlayIcon v-else class="translate-x-px" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-shortcut-copy:focus-visible {
|
||||
border-radius: 6px;
|
||||
outline: 4px solid var(--color-brand-shadow);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.home-shortcut-kind svg,
|
||||
.home-shortcut-meta svg {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.home-shortcut-copy:hover .home-shortcut-title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-card {
|
||||
grid-template-rows: 3.75rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-visual {
|
||||
justify-content: flex-start;
|
||||
padding: 0 0.875rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-watermark {
|
||||
right: 0.5rem;
|
||||
bottom: -1.25rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-body {
|
||||
padding: 0.625rem 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-copy {
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-kind {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-title {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-primary {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='1x1'] .home-shortcut-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='2x1'] .home-shortcut-card {
|
||||
grid-template-columns: minmax(8.5rem, 0.8fr) minmax(0, 1.65fr);
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='2x1'] .home-shortcut-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='2x1'] .home-shortcut-copy {
|
||||
justify-content: center;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='2x1'] .home-shortcut-title {
|
||||
margin-top: 0.4rem;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='2x1'] .home-shortcut-primary {
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.home-shortcut-widget[data-size='2x1'] .home-shortcut-secondary {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
434
apps/app-frontend/src/components/home/HomeWidgetPickerModal.vue
Normal file
434
apps/app-frontend/src/components/home/HomeWidgetPickerModal.vue
Normal file
@ -0,0 +1,434 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CollectionIcon,
|
||||
GameIcon,
|
||||
GridIcon,
|
||||
HistoryIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
UserIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, NewModal, StyledInput, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { HomeWidgetKind, HomeWidgetPlacement } from '@/components/home/home-dashboard'
|
||||
import {
|
||||
HOME_GREETING_DEFAULT_MODE,
|
||||
HOME_RECENT_DEFAULT_LIMIT,
|
||||
HOME_WIDGET_DEFAULT_SIZE,
|
||||
} from '@/components/home/home-dashboard'
|
||||
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import type { World } from '@/helpers/worlds'
|
||||
|
||||
const props = defineProps<{
|
||||
instances: GameInstance[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [widget: HomeWidgetPlacement]
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const runtime = useHomeDashboardRuntime()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const searchInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const searchQuery = ref('')
|
||||
const selectedKind = ref<HomeWidgetKind | null>(null)
|
||||
const selectedInstance = ref<GameInstance | null>(null)
|
||||
const worlds = ref<World[]>([])
|
||||
const loadingWorlds = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.home.widgets.add-title', defaultMessage: 'Add widget' },
|
||||
search: { id: 'app.home.widgets.search', defaultMessage: 'Search' },
|
||||
back: { id: 'app.home.widgets.back', defaultMessage: 'Back' },
|
||||
noResults: { id: 'app.home.widgets.no-results', defaultMessage: 'No matching items' },
|
||||
loading: { id: 'app.home.widgets.loading', defaultMessage: 'Loading...' },
|
||||
overviewGroup: { id: 'app.home.widgets.group.overview', defaultMessage: 'Overview' },
|
||||
collectionsGroup: {
|
||||
id: 'app.home.widgets.group.collections',
|
||||
defaultMessage: 'Pinned collections',
|
||||
},
|
||||
shortcutsGroup: {
|
||||
id: 'app.home.widgets.group.shortcuts',
|
||||
defaultMessage: 'Single-item shortcuts',
|
||||
},
|
||||
greeting: { id: 'app.home.widgets.greeting', defaultMessage: 'Greeting' },
|
||||
greetingDescription: {
|
||||
id: 'app.home.widgets.greeting-description',
|
||||
defaultMessage: 'A personal welcome that changes throughout the day.',
|
||||
},
|
||||
recent: { id: 'app.home.widgets.recent', defaultMessage: 'Recently played' },
|
||||
recentDescription: {
|
||||
id: 'app.home.widgets.recent-description',
|
||||
defaultMessage: 'Resume the worlds and instances you played most recently.',
|
||||
},
|
||||
calendar: { id: 'app.home.widgets.calendar', defaultMessage: 'Calendar' },
|
||||
calendarDescription: {
|
||||
id: 'app.home.widgets.calendar-description',
|
||||
defaultMessage: 'See the month and your play activity at a glance.',
|
||||
},
|
||||
pinnedInstances: {
|
||||
id: 'app.home.widgets.pinned-instances',
|
||||
defaultMessage: 'All pinned instances',
|
||||
},
|
||||
pinnedInstancesDescription: {
|
||||
id: 'app.home.widgets.pinned-instances-description',
|
||||
defaultMessage: 'Automatically collects every instance pinned to Home.',
|
||||
},
|
||||
pinnedWorlds: {
|
||||
id: 'app.home.widgets.pinned-worlds',
|
||||
defaultMessage: 'All favorite worlds',
|
||||
},
|
||||
pinnedWorldsDescription: {
|
||||
id: 'app.home.widgets.pinned-worlds-description',
|
||||
defaultMessage: 'Automatically collects favorite singleplayer worlds.',
|
||||
},
|
||||
pinnedServers: {
|
||||
id: 'app.home.widgets.pinned-servers',
|
||||
defaultMessage: 'All favorite servers',
|
||||
},
|
||||
pinnedServersDescription: {
|
||||
id: 'app.home.widgets.pinned-servers-description',
|
||||
defaultMessage: 'Automatically collects favorite multiplayer servers.',
|
||||
},
|
||||
instance: { id: 'app.home.widgets.instance', defaultMessage: 'Single instance' },
|
||||
instanceDescription: {
|
||||
id: 'app.home.widgets.instance-description',
|
||||
defaultMessage: 'Choose one instance for a dedicated launch shortcut.',
|
||||
},
|
||||
world: { id: 'app.home.widgets.world', defaultMessage: 'Single world' },
|
||||
worldDescription: {
|
||||
id: 'app.home.widgets.world-description',
|
||||
defaultMessage: 'Choose one world for a dedicated play shortcut.',
|
||||
},
|
||||
server: { id: 'app.home.widgets.server', defaultMessage: 'Single server' },
|
||||
serverDescription: {
|
||||
id: 'app.home.widgets.server-description',
|
||||
defaultMessage: 'Choose one server for a dedicated join shortcut.',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.home.widgets.choose-instance',
|
||||
defaultMessage: 'Choose an instance',
|
||||
},
|
||||
chooseWorld: { id: 'app.home.widgets.choose-world', defaultMessage: 'Choose a world' },
|
||||
chooseServer: { id: 'app.home.widgets.choose-server', defaultMessage: 'Choose a server' },
|
||||
})
|
||||
|
||||
const catalogSections = computed(() => [
|
||||
{
|
||||
id: 'overview',
|
||||
label: formatMessage(messages.overviewGroup),
|
||||
icon: LayoutTemplateIcon,
|
||||
items: [
|
||||
{
|
||||
kind: 'greeting' as const,
|
||||
label: formatMessage(messages.greeting),
|
||||
description: formatMessage(messages.greetingDescription),
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
kind: 'recent' as const,
|
||||
label: formatMessage(messages.recent),
|
||||
description: formatMessage(messages.recentDescription),
|
||||
icon: HistoryIcon,
|
||||
},
|
||||
{
|
||||
kind: 'calendar' as const,
|
||||
label: formatMessage(messages.calendar),
|
||||
description: formatMessage(messages.calendarDescription),
|
||||
icon: CalendarIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'collections',
|
||||
label: formatMessage(messages.collectionsGroup),
|
||||
icon: CollectionIcon,
|
||||
items: [
|
||||
{
|
||||
kind: 'pinned-instances' as const,
|
||||
label: formatMessage(messages.pinnedInstances),
|
||||
description: formatMessage(messages.pinnedInstancesDescription),
|
||||
icon: GridIcon,
|
||||
},
|
||||
{
|
||||
kind: 'pinned-worlds' as const,
|
||||
label: formatMessage(messages.pinnedWorlds),
|
||||
description: formatMessage(messages.pinnedWorldsDescription),
|
||||
icon: GameIcon,
|
||||
},
|
||||
{
|
||||
kind: 'pinned-servers' as const,
|
||||
label: formatMessage(messages.pinnedServers),
|
||||
description: formatMessage(messages.pinnedServersDescription),
|
||||
icon: ServerIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
label: formatMessage(messages.shortcutsGroup),
|
||||
icon: LinkIcon,
|
||||
items: [
|
||||
{
|
||||
kind: 'instance' as const,
|
||||
label: formatMessage(messages.instance),
|
||||
description: formatMessage(messages.instanceDescription),
|
||||
icon: GridIcon,
|
||||
},
|
||||
{
|
||||
kind: 'world' as const,
|
||||
label: formatMessage(messages.world),
|
||||
description: formatMessage(messages.worldDescription),
|
||||
icon: GameIcon,
|
||||
},
|
||||
{
|
||||
kind: 'server' as const,
|
||||
label: formatMessage(messages.server),
|
||||
description: formatMessage(messages.serverDescription),
|
||||
icon: ServerIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const filteredInstances = computed(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase(locale.value)
|
||||
return props.instances.filter((instance) =>
|
||||
query ? instance.name.toLocaleLowerCase(locale.value).includes(query) : true,
|
||||
)
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() => {
|
||||
const type = selectedKind.value === 'server' ? 'server' : 'singleplayer'
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase(locale.value)
|
||||
return worlds.value.filter(
|
||||
(world) =>
|
||||
world.type === type && (!query || world.name.toLocaleLowerCase(locale.value).includes(query)),
|
||||
)
|
||||
})
|
||||
|
||||
const pickerTitle = computed(() => {
|
||||
if (!selectedKind.value) return formatMessage(messages.title)
|
||||
if (!selectedInstance.value) return formatMessage(messages.chooseInstance)
|
||||
return formatMessage(
|
||||
selectedKind.value === 'server' ? messages.chooseServer : messages.chooseWorld,
|
||||
)
|
||||
})
|
||||
|
||||
function show(kind: HomeWidgetKind | null = null) {
|
||||
selectedKind.value = kind
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
searchQuery.value = ''
|
||||
modal.value?.show()
|
||||
if (kind) void nextTick(() => searchInput.value?.focus())
|
||||
}
|
||||
|
||||
function addWidget(widget: HomeWidgetPlacement) {
|
||||
emit('add', widget)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function chooseKind(kind: HomeWidgetKind) {
|
||||
if (kind !== 'instance' && kind !== 'world' && kind !== 'server') {
|
||||
addWidget({
|
||||
id: crypto.randomUUID(),
|
||||
kind,
|
||||
size: HOME_WIDGET_DEFAULT_SIZE[kind],
|
||||
...(kind === 'recent' ? { options: { recentLimit: HOME_RECENT_DEFAULT_LIMIT } } : {}),
|
||||
...(kind === 'greeting' ? { options: { greetingMode: HOME_GREETING_DEFAULT_MODE } } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
selectedKind.value = kind
|
||||
searchQuery.value = ''
|
||||
void nextTick(() => searchInput.value?.focus())
|
||||
}
|
||||
|
||||
async function chooseInstance(instance: GameInstance) {
|
||||
if (selectedKind.value === 'instance') {
|
||||
addWidget({
|
||||
id: crypto.randomUUID(),
|
||||
kind: 'instance',
|
||||
size: HOME_WIDGET_DEFAULT_SIZE.instance,
|
||||
target: { instanceId: instance.id, fallbackLabel: instance.name },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
selectedInstance.value = instance
|
||||
searchQuery.value = ''
|
||||
loadingWorlds.value = true
|
||||
worlds.value = await runtime.getInstanceWorlds(instance.id)
|
||||
loadingWorlds.value = false
|
||||
}
|
||||
|
||||
function chooseWorld(world: World) {
|
||||
if (!selectedInstance.value || (world.type !== 'server' && world.type !== 'singleplayer')) return
|
||||
const kind = world.type === 'server' ? 'server' : 'world'
|
||||
addWidget({
|
||||
id: crypto.randomUUID(),
|
||||
kind,
|
||||
size: HOME_WIDGET_DEFAULT_SIZE[kind],
|
||||
target: {
|
||||
instanceId: selectedInstance.value.id,
|
||||
...(world.type === 'server' ? { address: world.address } : { path: world.path }),
|
||||
fallbackLabel: world.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
searchQuery.value = ''
|
||||
if (selectedInstance.value) {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
} else {
|
||||
selectedKind.value = null
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="pickerTitle"
|
||||
max-width="640px"
|
||||
width="min(640px, calc(100vw - 2rem))"
|
||||
scrollable
|
||||
max-content-height="min(38rem, 72vh)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<div v-if="selectedKind" class="flex min-w-0 items-center gap-3">
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.back)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.back)"
|
||||
@click="goBack"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-if="selectedInstance" class="flex min-w-0 items-center gap-2">
|
||||
<InstanceIcon
|
||||
class="size-8 shrink-0"
|
||||
:icon-path="selectedInstance.icon_path"
|
||||
:instance-id="selectedInstance.id"
|
||||
:loader="selectedInstance.loader"
|
||||
/>
|
||||
<span class="truncate text-sm font-semibold text-contrast">{{
|
||||
selectedInstance.name
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!selectedKind" class="flex min-w-0 flex-col gap-5">
|
||||
<section v-for="section in catalogSections" :key="section.id" class="min-w-0">
|
||||
<h3 class="mb-2 mt-0 flex items-center gap-2 px-1 text-sm font-semibold text-secondary">
|
||||
<component :is="section.icon" class="size-4" aria-hidden="true" />
|
||||
{{ section.label }}
|
||||
</h3>
|
||||
<div class="overflow-hidden rounded-lg border border-solid border-divider bg-bg-raised">
|
||||
<button
|
||||
v-for="item in section.items"
|
||||
:key="item.kind"
|
||||
type="button"
|
||||
class="group flex min-h-16 w-full cursor-pointer items-center gap-3 border-0 border-b border-solid border-divider bg-transparent px-3 py-2 text-left text-primary transition-colors last:border-b-0 hover:bg-button-bg focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
@click="chooseKind(item.kind)"
|
||||
>
|
||||
<span
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-button-bg text-secondary transition-colors group-hover:text-brand"
|
||||
>
|
||||
<component :is="item.icon" class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-sm text-contrast">{{ item.label }}</strong>
|
||||
<span class="line-clamp-2 text-xs leading-5 text-secondary">{{
|
||||
item.description
|
||||
}}</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<StyledInput
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
/>
|
||||
<p v-if="loadingWorlds" class="m-0 py-8 text-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</p>
|
||||
<ul
|
||||
v-else
|
||||
class="m-0 flex list-none flex-col overflow-hidden rounded-lg border border-solid border-divider bg-bg-raised p-0"
|
||||
>
|
||||
<li
|
||||
v-for="item in selectedInstance ? filteredWorlds : filteredInstances"
|
||||
:key="'id' in item ? item.id : item.type === 'server' ? item.address : item.path"
|
||||
class="min-w-0 border-0 border-b border-solid border-divider last:border-b-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg focus-visible:z-[1] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
@click="
|
||||
selectedInstance ? chooseWorld(item as World) : chooseInstance(item as GameInstance)
|
||||
"
|
||||
>
|
||||
<InstanceIcon
|
||||
v-if="'id' in item"
|
||||
class="size-9 shrink-0"
|
||||
:icon-path="item.icon_path"
|
||||
:instance-id="item.id"
|
||||
:loader="item.loader"
|
||||
/>
|
||||
<ServerIcon v-else-if="item.type === 'server'" class="size-5 shrink-0" />
|
||||
<GameIcon v-else class="size-5 shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-sm text-contrast">{{ item.name }}</strong>
|
||||
<span v-if="'id' in item" class="truncate text-xs capitalize text-secondary">
|
||||
{{ item.loader }} · {{ item.game_version }}
|
||||
</span>
|
||||
<span v-else-if="item.type === 'server'" class="truncate text-xs text-secondary">
|
||||
{{ item.address }}
|
||||
</span>
|
||||
<span v-else class="truncate text-xs text-secondary">
|
||||
{{ selectedInstance?.name }}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-if="
|
||||
!loadingWorlds &&
|
||||
(selectedInstance ? filteredWorlds.length === 0 : filteredInstances.length === 0)
|
||||
"
|
||||
class="m-0 py-8 text-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noResults) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
208
apps/app-frontend/src/components/home/home-dashboard-runtime.ts
Normal file
208
apps/app-frontend/src/components/home/home-dashboard-runtime.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import type { GameVersion } from '@modrinth/ui'
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject, onUnmounted, provide, reactive, ref } from 'vue'
|
||||
|
||||
import { instance_listener, process_listener } from '@/helpers/events'
|
||||
import { get_all } from '@/helpers/process'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import {
|
||||
get_favorite_worlds,
|
||||
get_instance_protocol_version,
|
||||
get_instance_worlds,
|
||||
get_recent_worlds,
|
||||
type ProtocolVersion,
|
||||
refreshServerData,
|
||||
type ServerData,
|
||||
type World,
|
||||
type WorldWithInstance,
|
||||
} from '@/helpers/worlds'
|
||||
|
||||
type ErrorHandler = (error: unknown) => void
|
||||
|
||||
export type HomeDashboardRuntime = {
|
||||
favoriteWorlds: Ref<WorldWithInstance[]>
|
||||
recentWorlds: Ref<WorldWithInstance[]>
|
||||
runningInstanceIds: Ref<string[]>
|
||||
gameVersions: Ref<GameVersion[]>
|
||||
instanceRevision: Ref<number>
|
||||
refreshFavorites: () => Promise<void>
|
||||
refreshRecentWorlds: () => Promise<void>
|
||||
getInstanceWorlds: (instanceId: string, force?: boolean) => Promise<World[]>
|
||||
getServerData: (instanceId: string, address: string) => ServerData
|
||||
getProtocolVersion: (instanceId: string) => ProtocolVersion | null | undefined
|
||||
refreshServer: (instanceId: string, address: string, force?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const HOME_DASHBOARD_RUNTIME_KEY: InjectionKey<HomeDashboardRuntime> =
|
||||
Symbol('home-dashboard-runtime')
|
||||
|
||||
function serverKey(instanceId: string, address: string) {
|
||||
return `${instanceId}:${address}`
|
||||
}
|
||||
|
||||
export function provideHomeDashboardRuntime(handleError: ErrorHandler): HomeDashboardRuntime {
|
||||
const favoriteWorlds = ref<WorldWithInstance[]>([])
|
||||
const recentWorlds = ref<WorldWithInstance[]>([])
|
||||
const runningInstanceIds = ref<string[]>([])
|
||||
const gameVersions = ref<GameVersion[]>([])
|
||||
const instanceRevision = ref(0)
|
||||
const worldsByInstance = reactive<Record<string, World[]>>({})
|
||||
const serverData = reactive<Record<string, ServerData>>({})
|
||||
const protocolVersions = reactive<Record<string, ProtocolVersion | null>>({})
|
||||
const loadedWorlds = new Set<string>()
|
||||
const loadedServers = new Set<string>()
|
||||
const worldRequests = new Map<string, Promise<World[]>>()
|
||||
const serverRequests = new Map<string, Promise<void>>()
|
||||
const protocolRequests = new Map<string, Promise<ProtocolVersion | null>>()
|
||||
const unlisteners: Array<() => void> = []
|
||||
let disposed = false
|
||||
|
||||
async function refreshRunningInstances() {
|
||||
try {
|
||||
runningInstanceIds.value = (await get_all()).map((process) => process.instance_id)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProtocolVersion(instanceId: string) {
|
||||
if (Object.hasOwn(protocolVersions, instanceId)) return protocolVersions[instanceId]
|
||||
const pending = protocolRequests.get(instanceId)
|
||||
if (pending) return pending
|
||||
|
||||
const request = get_instance_protocol_version(instanceId)
|
||||
.catch(() => null)
|
||||
.then((protocolVersion) => {
|
||||
protocolVersions[instanceId] = protocolVersion
|
||||
return protocolVersion
|
||||
})
|
||||
.finally(() => protocolRequests.delete(instanceId))
|
||||
protocolRequests.set(instanceId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
function getServerData(instanceId: string, address: string) {
|
||||
return (serverData[serverKey(instanceId, address)] ??= { refreshing: true })
|
||||
}
|
||||
|
||||
async function refreshServer(instanceId: string, address: string, force = false) {
|
||||
const key = serverKey(instanceId, address)
|
||||
if (!force && loadedServers.has(key)) return
|
||||
const pending = serverRequests.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const request = (async () => {
|
||||
const protocolVersion = await ensureProtocolVersion(instanceId)
|
||||
await refreshServerData(getServerData(instanceId, address), protocolVersion, address)
|
||||
loadedServers.add(key)
|
||||
})().finally(() => serverRequests.delete(key))
|
||||
serverRequests.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
function warmServerData(worlds: WorldWithInstance[]) {
|
||||
for (const world of worlds) {
|
||||
if (world.type === 'server') void refreshServer(world.instance_id, world.address)
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFavorites() {
|
||||
try {
|
||||
favoriteWorlds.value = await get_favorite_worlds()
|
||||
warmServerData(favoriteWorlds.value)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
favoriteWorlds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecentWorlds() {
|
||||
try {
|
||||
recentWorlds.value = await get_recent_worlds(8, ['normal', 'favorite'])
|
||||
warmServerData(recentWorlds.value)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
recentWorlds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function getInstanceWorlds(instanceId: string, force = false) {
|
||||
if (!force && loadedWorlds.has(instanceId)) return worldsByInstance[instanceId] ?? []
|
||||
const pending = worldRequests.get(instanceId)
|
||||
if (pending) return pending
|
||||
|
||||
const request = get_instance_worlds(instanceId)
|
||||
.then((worlds) => {
|
||||
worldsByInstance[instanceId] = worlds
|
||||
loadedWorlds.add(instanceId)
|
||||
return worlds
|
||||
})
|
||||
.catch((error) => {
|
||||
handleError(error)
|
||||
return worldsByInstance[instanceId] ?? []
|
||||
})
|
||||
.finally(() => worldRequests.delete(instanceId))
|
||||
worldRequests.set(instanceId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
void get_game_versions()
|
||||
.then((versions) => {
|
||||
gameVersions.value = versions
|
||||
})
|
||||
.catch(() => undefined)
|
||||
void refreshRunningInstances()
|
||||
void refreshFavorites()
|
||||
void refreshRecentWorlds()
|
||||
|
||||
void process_listener(refreshRunningInstances)
|
||||
.then((unlisten) => {
|
||||
if (disposed) unlisten()
|
||||
else unlisteners.push(unlisten)
|
||||
})
|
||||
.catch(handleError)
|
||||
void instance_listener(async (event: { instance_id?: string }) => {
|
||||
if (event.instance_id) {
|
||||
loadedWorlds.delete(event.instance_id)
|
||||
Reflect.deleteProperty(worldsByInstance, event.instance_id)
|
||||
Reflect.deleteProperty(protocolVersions, event.instance_id)
|
||||
for (const key of loadedServers) {
|
||||
if (key.startsWith(`${event.instance_id}:`)) loadedServers.delete(key)
|
||||
}
|
||||
}
|
||||
instanceRevision.value += 1
|
||||
await Promise.all([refreshFavorites(), refreshRecentWorlds()])
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (disposed) unlisten()
|
||||
else unlisteners.push(unlisten)
|
||||
})
|
||||
.catch(handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
disposed = true
|
||||
for (const unlisten of unlisteners) unlisten()
|
||||
})
|
||||
|
||||
const runtime: HomeDashboardRuntime = {
|
||||
favoriteWorlds,
|
||||
recentWorlds,
|
||||
runningInstanceIds,
|
||||
gameVersions,
|
||||
instanceRevision,
|
||||
refreshFavorites,
|
||||
refreshRecentWorlds,
|
||||
getInstanceWorlds,
|
||||
getServerData,
|
||||
getProtocolVersion: (instanceId) => protocolVersions[instanceId],
|
||||
refreshServer,
|
||||
}
|
||||
provide(HOME_DASHBOARD_RUNTIME_KEY, runtime)
|
||||
return runtime
|
||||
}
|
||||
|
||||
export function useHomeDashboardRuntime() {
|
||||
const runtime = inject(HOME_DASHBOARD_RUNTIME_KEY)
|
||||
if (!runtime) throw new Error('Home dashboard runtime was not provided')
|
||||
return runtime
|
||||
}
|
||||
329
apps/app-frontend/src/components/home/home-dashboard.test.ts
Normal file
329
apps/app-frontend/src/components/home/home-dashboard.test.ts
Normal file
@ -0,0 +1,329 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
addHomeWidget,
|
||||
createDefaultHomeDashboard,
|
||||
createHomeDashboardSaveQueue,
|
||||
enableFreeHomeDashboard,
|
||||
findNearestFreeHomeWidgetPosition,
|
||||
getHomeGridColumnCount,
|
||||
getHomeWidgetDimensions,
|
||||
getHomeWidgetSpan,
|
||||
moveHomeWidget,
|
||||
normalizeHomeDashboard,
|
||||
packHomeWidgets,
|
||||
removeHomeWidget,
|
||||
replaceHomeDashboardWidgets,
|
||||
resizeHomeWidget,
|
||||
setHomeDashboardLayout,
|
||||
setHomeGreetingOptions,
|
||||
setHomeRecentLimit,
|
||||
setHomeWidgetPosition,
|
||||
} from './home-dashboard.ts'
|
||||
|
||||
test('derives one to four columns from the dashboard container width', () => {
|
||||
assert.equal(getHomeGridColumnCount(0), 1)
|
||||
assert.equal(getHomeGridColumnCount(495), 1)
|
||||
assert.equal(getHomeGridColumnCount(496), 2)
|
||||
assert.equal(getHomeGridColumnCount(752), 3)
|
||||
assert.equal(getHomeGridColumnCount(2000), 4)
|
||||
})
|
||||
|
||||
test('derives responsive widget dimensions from the current grid', () => {
|
||||
assert.deepEqual(getHomeWidgetDimensions('2x2', 4, 1008), {
|
||||
width: 496,
|
||||
height: 336,
|
||||
})
|
||||
assert.deepEqual(getHomeWidgetDimensions('2x1', 1, 320), {
|
||||
width: 320,
|
||||
height: 160,
|
||||
})
|
||||
assert.deepEqual(getHomeWidgetDimensions('3x2', 4, 1008), {
|
||||
width: 752,
|
||||
height: 336,
|
||||
})
|
||||
})
|
||||
|
||||
test('temporarily clamps wide widgets without changing their preferred size', () => {
|
||||
const config = createDefaultHomeDashboard()
|
||||
const preferredSize = config.widgets[0].size
|
||||
assert.deepEqual(getHomeWidgetSpan('2x2', 1), { columns: 1, rows: 2 })
|
||||
assert.deepEqual(getHomeWidgetSpan('2x2', 2), { columns: 2, rows: 2 })
|
||||
packHomeWidgets(config.widgets, 1)
|
||||
packHomeWidgets(config.widgets, 4)
|
||||
assert.equal(config.widgets[0].size, preferredSize)
|
||||
})
|
||||
|
||||
test('adds, reorders, resizes, and removes independent placements', () => {
|
||||
const original = createDefaultHomeDashboard()
|
||||
const pinnedInstances = original.widgets.find((widget) => widget.kind === 'pinned-instances')!
|
||||
const duplicate = { ...pinnedInstances, id: 'duplicate-pinned-instances' }
|
||||
const added = addHomeWidget(original, duplicate)
|
||||
assert.equal(added.widgets.length, original.widgets.length + 1)
|
||||
assert.equal(added.widgets.filter((widget) => widget.kind === 'pinned-instances').length, 2)
|
||||
|
||||
const moved = moveHomeWidget(added, added.widgets.length - 1, -1)
|
||||
assert.equal(moved.widgets.at(-2)?.id, duplicate.id)
|
||||
const resized = resizeHomeWidget(moved, duplicate.id, '1x1')
|
||||
assert.equal(resized.widgets.find((widget) => widget.id === duplicate.id)?.size, '1x1')
|
||||
const removed = removeHomeWidget(resized, duplicate.id)
|
||||
assert.deepEqual(removed.widgets, original.widgets)
|
||||
})
|
||||
|
||||
test('accepts draggable order and restores the complete default layout', () => {
|
||||
const original = createDefaultHomeDashboard()
|
||||
const reversed = replaceHomeDashboardWidgets(original, [...original.widgets].reverse())
|
||||
assert.deepEqual(
|
||||
reversed.widgets.map((widget) => widget.id),
|
||||
original.widgets.map((widget) => widget.id).reverse(),
|
||||
)
|
||||
assert.deepEqual(
|
||||
createDefaultHomeDashboard().widgets.map(({ kind, size }) => ({ kind, size })),
|
||||
[
|
||||
{ kind: 'greeting', size: '2x1' },
|
||||
{ kind: 'calendar', size: '1x2' },
|
||||
{ kind: 'recent', size: '2x2' },
|
||||
{ kind: 'pinned-worlds', size: '1x2' },
|
||||
{ kind: 'pinned-servers', size: '2x2' },
|
||||
{ kind: 'pinned-instances', size: '2x1' },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('enables free layout from packed positions and preserves manual coordinates', () => {
|
||||
const original = createDefaultHomeDashboard()
|
||||
const positioned = setHomeWidgetPosition(original, original.widgets[0].id, { column: 1, row: 3 })
|
||||
const free = enableFreeHomeDashboard(positioned, 4)
|
||||
|
||||
assert.equal(free.layout, 'free')
|
||||
assert.deepEqual(free.widgets[0].position, { column: 1, row: 3 })
|
||||
assert.deepEqual(free.widgets[1].position, { column: 2, row: 0 })
|
||||
assert.equal(setHomeDashboardLayout(free, 'grid').layout, 'grid')
|
||||
})
|
||||
|
||||
test('snaps manual placement to the nearest open cell without moving other widgets', () => {
|
||||
const free = enableFreeHomeDashboard(createDefaultHomeDashboard(), 4)
|
||||
const before = free.widgets.map((widget) => widget.position)
|
||||
const moving = free.widgets.at(-1)!
|
||||
const occupied = free.widgets[0].position!
|
||||
const resolved = findNearestFreeHomeWidgetPosition(free.widgets, moving, occupied, 4)
|
||||
|
||||
assert.notDeepEqual(resolved, occupied)
|
||||
assert.deepEqual(
|
||||
free.widgets.map((widget) => widget.position),
|
||||
before,
|
||||
)
|
||||
})
|
||||
|
||||
test('rolls back the latest failed save and reports the error', async () => {
|
||||
const original = createDefaultHomeDashboard()
|
||||
const changed = removeHomeWidget(original, original.widgets[0].id)
|
||||
let current = changed
|
||||
const errors: unknown[] = []
|
||||
const queue = createHomeDashboardSaveQueue(
|
||||
async () => {
|
||||
throw new Error('save failed')
|
||||
},
|
||||
(config) => {
|
||||
current = config
|
||||
},
|
||||
(error) => errors.push(error),
|
||||
)
|
||||
|
||||
await queue.enqueue(changed, original)
|
||||
await queue.flush()
|
||||
assert.deepEqual(current, original)
|
||||
assert.equal(errors.length, 1)
|
||||
})
|
||||
|
||||
test('normalizes a saved layout when entering Home again', () => {
|
||||
const saved = createDefaultHomeDashboard()
|
||||
const restored = normalizeHomeDashboard(JSON.parse(JSON.stringify(saved)))
|
||||
assert.deepEqual(restored, saved)
|
||||
})
|
||||
|
||||
test('normalizes legacy layouts and persisted free positions', () => {
|
||||
const legacy = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [{ id: 'legacy', kind: 'calendar', size: '1x2' }],
|
||||
})
|
||||
const free = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
layout: 'free',
|
||||
widgets: [
|
||||
{ id: 'valid', kind: 'calendar', size: '1x2', position: { column: 2, row: 4 } },
|
||||
{ id: 'invalid', kind: 'calendar', size: '1x2', position: { column: -10, row: 'top' } },
|
||||
],
|
||||
})
|
||||
|
||||
assert.equal(legacy?.layout, 'grid')
|
||||
assert.equal(free?.layout, 'free')
|
||||
assert.deepEqual(free?.widgets[0].position, { column: 2, row: 4 })
|
||||
assert.equal(free?.widgets[1].position, undefined)
|
||||
})
|
||||
|
||||
test('packs widgets into the earliest available cells', () => {
|
||||
const config = createDefaultHomeDashboard()
|
||||
const packed = packHomeWidgets(config.widgets, 4)
|
||||
assert.deepEqual(
|
||||
packed.map(({ column, row, effectiveColumns, effectiveRows }) => ({
|
||||
column,
|
||||
row,
|
||||
effectiveColumns,
|
||||
effectiveRows,
|
||||
})),
|
||||
[
|
||||
{ column: 1, row: 1, effectiveColumns: 2, effectiveRows: 1 },
|
||||
{ column: 3, row: 1, effectiveColumns: 1, effectiveRows: 2 },
|
||||
{ column: 1, row: 2, effectiveColumns: 2, effectiveRows: 2 },
|
||||
{ column: 4, row: 1, effectiveColumns: 1, effectiveRows: 2 },
|
||||
{ column: 3, row: 3, effectiveColumns: 2, effectiveRows: 2 },
|
||||
{ column: 1, row: 4, effectiveColumns: 2, effectiveRows: 1 },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizes malformed sizes while retaining duplicate widgets', () => {
|
||||
const normalized = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: 'one', kind: 'calendar', size: '9x9' },
|
||||
{ id: 'two', kind: 'calendar', size: '1x1' },
|
||||
{ id: 'three', kind: 'world', size: '1x1', target: { instanceId: 'a' } },
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
normalized?.widgets.map(({ kind, size }) => ({ kind, size })),
|
||||
[
|
||||
{ kind: 'calendar', size: '1x2' },
|
||||
{ kind: 'calendar', size: '1x2' },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizes every persisted calendar placement to the fixed 1x2 size', () => {
|
||||
const normalized = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: 'small', kind: 'calendar', size: '1x1' },
|
||||
{ id: 'wide', kind: 'calendar', size: '2x2' },
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
normalized?.widgets.map((widget) => widget.size),
|
||||
['1x2', '1x2'],
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizes every persisted greeting placement to the fixed 2x1 size', () => {
|
||||
const normalized = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: 'small', kind: 'greeting', size: '1x1' },
|
||||
{
|
||||
id: 'tall',
|
||||
kind: 'greeting',
|
||||
size: '1x2',
|
||||
options: { greetingMode: 'text', greetingText: ' Ready to play ' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
normalized?.widgets.map((widget) => widget.size),
|
||||
['2x1', '2x1'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
normalized?.widgets.map((widget) => widget.options),
|
||||
[
|
||||
{ greetingMode: 'greeting', greetingFont: 'sans', greetingFontSize: 22 },
|
||||
{
|
||||
greetingMode: 'text',
|
||||
greetingText: 'Ready to play',
|
||||
greetingFont: 'sans',
|
||||
greetingFontSize: 22,
|
||||
},
|
||||
],
|
||||
)
|
||||
assert.deepEqual(
|
||||
resizeHomeWidget(normalized!, 'small', '1x1').widgets.map((widget) => widget.size),
|
||||
['2x1', '2x1'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
setHomeGreetingOptions(normalized!, 'small', 'text-and-greeting', ' Hi ', 'minecraft', 29)
|
||||
.widgets[0].options,
|
||||
{
|
||||
greetingMode: 'text-and-greeting',
|
||||
greetingText: 'Hi',
|
||||
greetingFont: 'minecraft',
|
||||
greetingFontSize: 29,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizes greeting font settings and clamps font size', () => {
|
||||
const normalized = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{
|
||||
id: 'large',
|
||||
kind: 'greeting',
|
||||
size: '2x1',
|
||||
options: { greetingFont: 'serif', greetingFontSize: 80 },
|
||||
},
|
||||
{
|
||||
id: 'invalid',
|
||||
kind: 'greeting',
|
||||
size: '2x1',
|
||||
options: { greetingFont: 'comic-sans', greetingFontSize: 'large' },
|
||||
},
|
||||
],
|
||||
})!
|
||||
|
||||
assert.deepEqual(
|
||||
normalized.widgets.map((widget) => ({
|
||||
font: widget.options?.greetingFont,
|
||||
fontSize: widget.options?.greetingFontSize,
|
||||
})),
|
||||
[
|
||||
{ font: 'serif', fontSize: 32 },
|
||||
{ font: 'sans', fontSize: 22 },
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizes and updates recently played item limits', () => {
|
||||
const normalized = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: 'legacy', kind: 'recent', size: '2x2' },
|
||||
{ id: 'valid', kind: 'recent', size: '2x1', options: { recentLimit: 8 } },
|
||||
{ id: 'invalid', kind: 'recent', size: '1x2', options: { recentLimit: 99 } },
|
||||
],
|
||||
})!
|
||||
|
||||
assert.deepEqual(
|
||||
normalized.widgets.map((widget) => widget.options?.recentLimit),
|
||||
[4, 8, 4],
|
||||
)
|
||||
assert.equal(setHomeRecentLimit(normalized, 'legacy', 6).widgets[0].options?.recentLimit, 6)
|
||||
})
|
||||
|
||||
test('accepts and resizes the recent widget to 3-column layouts', () => {
|
||||
const normalized = normalizeHomeDashboard({
|
||||
version: 1,
|
||||
widgets: [{ id: 'recent', kind: 'recent', size: '3x2' }],
|
||||
})!
|
||||
const config = createDefaultHomeDashboard()
|
||||
const recent = config.widgets.find((widget) => widget.kind === 'recent')!
|
||||
|
||||
assert.equal(normalized.widgets[0].size, '3x2')
|
||||
assert.equal(
|
||||
resizeHomeWidget(config, recent.id, '3x1').widgets.find((widget) => widget.id === recent.id)
|
||||
?.size,
|
||||
'3x1',
|
||||
)
|
||||
})
|
||||
600
apps/app-frontend/src/components/home/home-dashboard.ts
Normal file
600
apps/app-frontend/src/components/home/home-dashboard.ts
Normal file
@ -0,0 +1,600 @@
|
||||
export const HOME_DASHBOARD_VERSION = 1 as const
|
||||
export const HOME_WIDGET_LAYOUTS = ['grid', 'free'] as const
|
||||
export const HOME_WIDGET_STANDARD_SIZES = ['1x1', '2x1', '1x2', '2x2'] as const
|
||||
export const HOME_WIDGET_SIZES = [...HOME_WIDGET_STANDARD_SIZES, '3x1', '3x2'] as const
|
||||
export const HOME_WIDGET_GRID_GAP = 16
|
||||
export const HOME_WIDGET_GRID_ROW_HEIGHT = 160
|
||||
export const HOME_RECENT_LIMIT_OPTIONS = [2, 4, 6, 8] as const
|
||||
export const HOME_RECENT_DEFAULT_LIMIT = 4
|
||||
export const HOME_GREETING_MODES = ['greeting', 'text-and-greeting', 'text'] as const
|
||||
export const HOME_GREETING_DEFAULT_MODE = 'greeting'
|
||||
export const HOME_GREETING_FONTS = ['sans', 'minecraft', 'mono', 'serif'] as const
|
||||
export const HOME_GREETING_DEFAULT_FONT = 'sans'
|
||||
export const HOME_GREETING_FONT_SIZE_MIN = 16
|
||||
export const HOME_GREETING_FONT_SIZE_MAX = 32
|
||||
export const HOME_GREETING_DEFAULT_FONT_SIZE = 22
|
||||
|
||||
export type HomeWidgetSize = (typeof HOME_WIDGET_SIZES)[number]
|
||||
export type HomeWidgetLayout = (typeof HOME_WIDGET_LAYOUTS)[number]
|
||||
export type HomeRecentLimit = (typeof HOME_RECENT_LIMIT_OPTIONS)[number]
|
||||
export type HomeGreetingMode = (typeof HOME_GREETING_MODES)[number]
|
||||
export type HomeGreetingFont = (typeof HOME_GREETING_FONTS)[number]
|
||||
export type HomeWidgetKind =
|
||||
| 'greeting'
|
||||
| 'recent'
|
||||
| 'calendar'
|
||||
| 'pinned-instances'
|
||||
| 'pinned-worlds'
|
||||
| 'pinned-servers'
|
||||
| 'instance'
|
||||
| 'world'
|
||||
| 'server'
|
||||
|
||||
export type HomeWidgetTarget = {
|
||||
instanceId: string
|
||||
path?: string
|
||||
address?: string
|
||||
fallbackLabel: string
|
||||
}
|
||||
|
||||
export type HomeWidgetOptions = {
|
||||
recentLimit?: HomeRecentLimit
|
||||
greetingMode?: HomeGreetingMode
|
||||
greetingText?: string
|
||||
greetingFont?: HomeGreetingFont
|
||||
greetingFontSize?: number
|
||||
}
|
||||
|
||||
export type HomeWidgetPosition = {
|
||||
column: number
|
||||
row: number
|
||||
}
|
||||
|
||||
export type HomeWidgetPlacement = {
|
||||
id: string
|
||||
kind: HomeWidgetKind
|
||||
size: HomeWidgetSize
|
||||
target?: HomeWidgetTarget
|
||||
options?: HomeWidgetOptions
|
||||
position?: HomeWidgetPosition
|
||||
}
|
||||
|
||||
export type HomeDashboardConfig = {
|
||||
version: typeof HOME_DASHBOARD_VERSION
|
||||
layout: HomeWidgetLayout
|
||||
widgets: HomeWidgetPlacement[]
|
||||
}
|
||||
|
||||
export type PackedHomeWidget = HomeWidgetPlacement & {
|
||||
column: number
|
||||
row: number
|
||||
effectiveColumns: number
|
||||
effectiveRows: number
|
||||
}
|
||||
|
||||
export type HomeDashboardSaveQueue = {
|
||||
enqueue: (config: HomeDashboardConfig, rollback: HomeDashboardConfig) => Promise<void>
|
||||
flush: () => Promise<void>
|
||||
}
|
||||
|
||||
export const HOME_WIDGET_SIZE_OPTIONS: Record<HomeWidgetKind, readonly HomeWidgetSize[]> = {
|
||||
greeting: ['2x1'],
|
||||
recent: ['2x1', '2x2', '3x1', '3x2'],
|
||||
calendar: ['1x2'],
|
||||
'pinned-instances': HOME_WIDGET_STANDARD_SIZES,
|
||||
'pinned-worlds': HOME_WIDGET_STANDARD_SIZES,
|
||||
'pinned-servers': HOME_WIDGET_STANDARD_SIZES,
|
||||
instance: ['1x1', '2x1'],
|
||||
world: ['1x1', '2x1'],
|
||||
server: ['1x1', '2x1'],
|
||||
}
|
||||
|
||||
export const HOME_WIDGET_DEFAULT_SIZE: Record<HomeWidgetKind, HomeWidgetSize> = {
|
||||
greeting: '2x1',
|
||||
recent: '2x2',
|
||||
calendar: '1x2',
|
||||
'pinned-instances': '2x2',
|
||||
'pinned-worlds': '1x2',
|
||||
'pinned-servers': '1x2',
|
||||
instance: '1x1',
|
||||
world: '1x1',
|
||||
server: '1x1',
|
||||
}
|
||||
|
||||
export function getHomeWidgetCardDensity(
|
||||
dashboardSize: HomeWidgetSize | null | undefined,
|
||||
): 'compact' | 'comfortable' {
|
||||
return dashboardSize === '1x1' ||
|
||||
dashboardSize === '1x2' ||
|
||||
dashboardSize === '2x1' ||
|
||||
dashboardSize === '2x2'
|
||||
? 'compact'
|
||||
: 'comfortable'
|
||||
}
|
||||
|
||||
const HOME_WIDGET_KINDS = new Set<HomeWidgetKind>(
|
||||
Object.keys(HOME_WIDGET_DEFAULT_SIZE) as HomeWidgetKind[],
|
||||
)
|
||||
|
||||
function createPlacement(
|
||||
kind: HomeWidgetKind,
|
||||
size = HOME_WIDGET_DEFAULT_SIZE[kind],
|
||||
): HomeWidgetPlacement {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
kind,
|
||||
size,
|
||||
...(kind === 'recent' ? { options: { recentLimit: HOME_RECENT_DEFAULT_LIMIT } } : {}),
|
||||
...(kind === 'greeting'
|
||||
? {
|
||||
options: {
|
||||
greetingMode: HOME_GREETING_DEFAULT_MODE,
|
||||
greetingFont: HOME_GREETING_DEFAULT_FONT,
|
||||
greetingFontSize: HOME_GREETING_DEFAULT_FONT_SIZE,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createDefaultHomeDashboard(includeRecent = true): HomeDashboardConfig {
|
||||
return {
|
||||
version: HOME_DASHBOARD_VERSION,
|
||||
layout: 'grid',
|
||||
widgets: [
|
||||
createPlacement('greeting'),
|
||||
createPlacement('calendar'),
|
||||
...(includeRecent ? [createPlacement('recent')] : []),
|
||||
createPlacement('pinned-worlds'),
|
||||
createPlacement('pinned-servers', '2x2'),
|
||||
createPlacement('pinned-instances', '2x1'),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeTarget(value: unknown): HomeWidgetTarget | undefined {
|
||||
if (!isRecord(value) || typeof value.instanceId !== 'string' || !value.instanceId)
|
||||
return undefined
|
||||
if (typeof value.fallbackLabel !== 'string' || !value.fallbackLabel) return undefined
|
||||
|
||||
return {
|
||||
instanceId: value.instanceId,
|
||||
...(typeof value.path === 'string' ? { path: value.path } : {}),
|
||||
...(typeof value.address === 'string' ? { address: value.address } : {}),
|
||||
fallbackLabel: value.fallbackLabel,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePosition(value: unknown): HomeWidgetPosition | undefined {
|
||||
if (!isRecord(value)) return undefined
|
||||
if (typeof value.column !== 'number' || !Number.isFinite(value.column)) return undefined
|
||||
if (typeof value.row !== 'number' || !Number.isFinite(value.row)) return undefined
|
||||
|
||||
return {
|
||||
column: Math.min(100, Math.max(0, Math.round(value.column))),
|
||||
row: Math.min(10_000, Math.max(0, Math.round(value.row))),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptions(kind: HomeWidgetKind, value: unknown): HomeWidgetOptions | undefined {
|
||||
if (kind === 'recent') {
|
||||
const recentLimit =
|
||||
isRecord(value) && HOME_RECENT_LIMIT_OPTIONS.includes(value.recentLimit as HomeRecentLimit)
|
||||
? (value.recentLimit as HomeRecentLimit)
|
||||
: HOME_RECENT_DEFAULT_LIMIT
|
||||
return { recentLimit }
|
||||
}
|
||||
|
||||
if (kind === 'greeting') {
|
||||
const greetingMode =
|
||||
isRecord(value) && HOME_GREETING_MODES.includes(value.greetingMode as HomeGreetingMode)
|
||||
? (value.greetingMode as HomeGreetingMode)
|
||||
: HOME_GREETING_DEFAULT_MODE
|
||||
const greetingText =
|
||||
isRecord(value) && typeof value.greetingText === 'string'
|
||||
? value.greetingText.trim().slice(0, 120)
|
||||
: ''
|
||||
const greetingFont =
|
||||
isRecord(value) && HOME_GREETING_FONTS.includes(value.greetingFont as HomeGreetingFont)
|
||||
? (value.greetingFont as HomeGreetingFont)
|
||||
: HOME_GREETING_DEFAULT_FONT
|
||||
const greetingFontSize = normalizeGreetingFontSize(
|
||||
isRecord(value) ? value.greetingFontSize : undefined,
|
||||
)
|
||||
return {
|
||||
greetingMode,
|
||||
...(greetingText ? { greetingText } : {}),
|
||||
greetingFont,
|
||||
greetingFontSize,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeGreetingFontSize(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return HOME_GREETING_DEFAULT_FONT_SIZE
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
HOME_GREETING_FONT_SIZE_MAX,
|
||||
Math.max(HOME_GREETING_FONT_SIZE_MIN, Math.round(value)),
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeHomeDashboard(value: unknown): HomeDashboardConfig | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
value.version !== HOME_DASHBOARD_VERSION ||
|
||||
!Array.isArray(value.widgets)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const usedIds = new Set<string>()
|
||||
const layout = HOME_WIDGET_LAYOUTS.includes(value.layout as HomeWidgetLayout)
|
||||
? (value.layout as HomeWidgetLayout)
|
||||
: 'grid'
|
||||
const widgets = value.widgets.flatMap((candidate): HomeWidgetPlacement[] => {
|
||||
if (!isRecord(candidate) || typeof candidate.kind !== 'string') return []
|
||||
if (!HOME_WIDGET_KINDS.has(candidate.kind as HomeWidgetKind)) return []
|
||||
|
||||
const kind = candidate.kind as HomeWidgetKind
|
||||
const target = normalizeTarget(candidate.target)
|
||||
const options = normalizeOptions(kind, candidate.options)
|
||||
const position = normalizePosition(candidate.position)
|
||||
if ((kind === 'instance' || kind === 'world' || kind === 'server') && !target) return []
|
||||
if (kind === 'world' && !target?.path) return []
|
||||
if (kind === 'server' && !target?.address) return []
|
||||
|
||||
let id = typeof candidate.id === 'string' && candidate.id ? candidate.id : crypto.randomUUID()
|
||||
if (usedIds.has(id)) id = crypto.randomUUID()
|
||||
usedIds.add(id)
|
||||
|
||||
const requestedSize = typeof candidate.size === 'string' ? candidate.size : ''
|
||||
const size = HOME_WIDGET_SIZE_OPTIONS[kind].includes(requestedSize as HomeWidgetSize)
|
||||
? (requestedSize as HomeWidgetSize)
|
||||
: HOME_WIDGET_DEFAULT_SIZE[kind]
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
kind,
|
||||
size,
|
||||
...(target ? { target } : {}),
|
||||
...(options ? { options } : {}),
|
||||
...(position ? { position } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return { version: HOME_DASHBOARD_VERSION, layout, widgets }
|
||||
}
|
||||
|
||||
export function replaceHomeDashboardWidgets(
|
||||
config: HomeDashboardConfig,
|
||||
widgets: HomeWidgetPlacement[],
|
||||
): HomeDashboardConfig {
|
||||
return { ...config, widgets }
|
||||
}
|
||||
|
||||
export function addHomeWidget(
|
||||
config: HomeDashboardConfig,
|
||||
widget: HomeWidgetPlacement,
|
||||
): HomeDashboardConfig {
|
||||
return replaceHomeDashboardWidgets(config, [...config.widgets, widget])
|
||||
}
|
||||
|
||||
export function setHomeDashboardLayout(
|
||||
config: HomeDashboardConfig,
|
||||
layout: HomeWidgetLayout,
|
||||
): HomeDashboardConfig {
|
||||
return { ...config, layout }
|
||||
}
|
||||
|
||||
export function setHomeWidgetPosition(
|
||||
config: HomeDashboardConfig,
|
||||
id: string,
|
||||
position: HomeWidgetPosition,
|
||||
): HomeDashboardConfig {
|
||||
return replaceHomeDashboardWidgets(
|
||||
config,
|
||||
config.widgets.map((widget) => (widget.id === id ? { ...widget, position } : widget)),
|
||||
)
|
||||
}
|
||||
|
||||
export function removeHomeWidget(config: HomeDashboardConfig, id: string): HomeDashboardConfig {
|
||||
return replaceHomeDashboardWidgets(
|
||||
config,
|
||||
config.widgets.filter((widget) => widget.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
export function resizeHomeWidget(
|
||||
config: HomeDashboardConfig,
|
||||
id: string,
|
||||
size: HomeWidgetSize,
|
||||
): HomeDashboardConfig {
|
||||
return replaceHomeDashboardWidgets(
|
||||
config,
|
||||
config.widgets.map((widget) =>
|
||||
widget.id === id && HOME_WIDGET_SIZE_OPTIONS[widget.kind].includes(size)
|
||||
? { ...widget, size }
|
||||
: widget,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function setHomeRecentLimit(
|
||||
config: HomeDashboardConfig,
|
||||
id: string,
|
||||
recentLimit: HomeRecentLimit,
|
||||
): HomeDashboardConfig {
|
||||
return replaceHomeDashboardWidgets(
|
||||
config,
|
||||
config.widgets.map((widget) =>
|
||||
widget.id === id && widget.kind === 'recent'
|
||||
? { ...widget, options: { ...widget.options, recentLimit } }
|
||||
: widget,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function setHomeGreetingOptions(
|
||||
config: HomeDashboardConfig,
|
||||
id: string,
|
||||
greetingMode: HomeGreetingMode,
|
||||
greetingText: string,
|
||||
greetingFont: HomeGreetingFont,
|
||||
greetingFontSize: number,
|
||||
): HomeDashboardConfig {
|
||||
const normalizedText = greetingText.trim().slice(0, 120)
|
||||
const normalizedFont = HOME_GREETING_FONTS.includes(greetingFont)
|
||||
? greetingFont
|
||||
: HOME_GREETING_DEFAULT_FONT
|
||||
return replaceHomeDashboardWidgets(
|
||||
config,
|
||||
config.widgets.map((widget) =>
|
||||
widget.id === id && widget.kind === 'greeting'
|
||||
? {
|
||||
...widget,
|
||||
options: {
|
||||
greetingMode,
|
||||
...(normalizedText ? { greetingText: normalizedText } : {}),
|
||||
greetingFont: normalizedFont,
|
||||
greetingFontSize: normalizeGreetingFontSize(greetingFontSize),
|
||||
},
|
||||
}
|
||||
: widget,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function moveHomeWidget(
|
||||
config: HomeDashboardConfig,
|
||||
index: number,
|
||||
direction: -1 | 1,
|
||||
): HomeDashboardConfig {
|
||||
const target = index + direction
|
||||
if (
|
||||
index < 0 ||
|
||||
index >= config.widgets.length ||
|
||||
target < 0 ||
|
||||
target >= config.widgets.length
|
||||
) {
|
||||
return config
|
||||
}
|
||||
|
||||
const widgets = [...config.widgets]
|
||||
const [widget] = widgets.splice(index, 1)
|
||||
widgets.splice(target, 0, widget)
|
||||
return replaceHomeDashboardWidgets(config, widgets)
|
||||
}
|
||||
|
||||
export function createHomeDashboardSaveQueue(
|
||||
persist: (config: HomeDashboardConfig) => Promise<void>,
|
||||
onRollback: (config: HomeDashboardConfig) => void,
|
||||
onError: (error: unknown) => void,
|
||||
): HomeDashboardSaveQueue {
|
||||
let queue = Promise.resolve()
|
||||
let version = 0
|
||||
|
||||
return {
|
||||
enqueue(config, rollback) {
|
||||
const operationVersion = ++version
|
||||
queue = queue.then(async () => {
|
||||
try {
|
||||
await persist(config)
|
||||
} catch (error) {
|
||||
if (operationVersion === version) onRollback(rollback)
|
||||
onError(error)
|
||||
}
|
||||
})
|
||||
return queue
|
||||
},
|
||||
flush: () => queue,
|
||||
}
|
||||
}
|
||||
|
||||
export function getHomeGridColumnCount(width: number): number {
|
||||
const minimumColumnWidth = 240
|
||||
const gap = 16
|
||||
return Math.min(
|
||||
4,
|
||||
Math.max(1, Math.floor((Math.max(0, width) + gap) / (minimumColumnWidth + gap))),
|
||||
)
|
||||
}
|
||||
|
||||
export function getHomeWidgetDimensions(
|
||||
size: HomeWidgetSize,
|
||||
columnCount: number,
|
||||
containerWidth: number,
|
||||
) {
|
||||
const columns = Math.max(1, columnCount)
|
||||
const span = getHomeWidgetSpan(size, columns)
|
||||
const columnWidth = Math.max(0, (containerWidth - HOME_WIDGET_GRID_GAP * (columns - 1)) / columns)
|
||||
|
||||
return {
|
||||
width: columnWidth * span.columns + HOME_WIDGET_GRID_GAP * (span.columns - 1),
|
||||
height: HOME_WIDGET_GRID_ROW_HEIGHT * span.rows + HOME_WIDGET_GRID_GAP * (span.rows - 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function getHomeWidgetSpan(size: HomeWidgetSize, columnCount: number) {
|
||||
const [columns, rows] = size.split('x').map(Number)
|
||||
return {
|
||||
columns: Math.min(columns, Math.max(1, columnCount)),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
function homeWidgetRect(
|
||||
widget: HomeWidgetPlacement,
|
||||
position: HomeWidgetPosition,
|
||||
columnCount: number,
|
||||
) {
|
||||
const span = getHomeWidgetSpan(widget.size, columnCount)
|
||||
return {
|
||||
left: position.column,
|
||||
top: position.row,
|
||||
right: position.column + span.columns,
|
||||
bottom: position.row + span.rows,
|
||||
}
|
||||
}
|
||||
|
||||
function homeWidgetRectsOverlap(
|
||||
left: ReturnType<typeof homeWidgetRect>,
|
||||
right: ReturnType<typeof homeWidgetRect>,
|
||||
) {
|
||||
return (
|
||||
left.left < right.right &&
|
||||
left.right > right.left &&
|
||||
left.top < right.bottom &&
|
||||
left.bottom > right.top
|
||||
)
|
||||
}
|
||||
|
||||
export function findNearestFreeHomeWidgetPosition(
|
||||
widgets: readonly HomeWidgetPlacement[],
|
||||
movingWidget: HomeWidgetPlacement,
|
||||
desiredPosition: HomeWidgetPosition,
|
||||
columnCount: number,
|
||||
): HomeWidgetPosition {
|
||||
const columns = Math.max(1, columnCount)
|
||||
const movingSpan = getHomeWidgetSpan(movingWidget.size, columns)
|
||||
const desired = {
|
||||
column: Math.min(
|
||||
Math.max(0, Math.round(desiredPosition.column)),
|
||||
Math.max(0, columns - movingSpan.columns),
|
||||
),
|
||||
row: Math.max(0, Math.round(desiredPosition.row)),
|
||||
}
|
||||
const occupied = widgets.flatMap((widget) =>
|
||||
widget.id !== movingWidget.id && widget.position
|
||||
? [homeWidgetRect(widget, widget.position, columns)]
|
||||
: [],
|
||||
)
|
||||
const isAvailable = (position: HomeWidgetPosition) => {
|
||||
const candidate = homeWidgetRect(movingWidget, position, columns)
|
||||
return occupied.every((rect) => !homeWidgetRectsOverlap(candidate, rect))
|
||||
}
|
||||
|
||||
if (isAvailable(desired)) return desired
|
||||
|
||||
const lastOccupiedRow = occupied.reduce((last, rect) => Math.max(last, rect.bottom), 0)
|
||||
const lastSearchRow = Math.max(
|
||||
desired.row + widgets.length * 2,
|
||||
lastOccupiedRow + movingSpan.rows,
|
||||
)
|
||||
let closest: HomeWidgetPosition | null = null
|
||||
let closestDistance = Number.POSITIVE_INFINITY
|
||||
for (let row = 0; row <= lastSearchRow; row += 1) {
|
||||
for (let column = 0; column <= columns - movingSpan.columns; column += 1) {
|
||||
const candidate = { column, row }
|
||||
if (!isAvailable(candidate)) continue
|
||||
const distance = Math.abs(column - desired.column) + Math.abs(row - desired.row)
|
||||
if (distance >= closestDistance) continue
|
||||
closest = candidate
|
||||
closestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return closest ?? desired
|
||||
}
|
||||
|
||||
export function packHomeWidgets(
|
||||
widgets: readonly HomeWidgetPlacement[],
|
||||
columnCount: number,
|
||||
): PackedHomeWidget[] {
|
||||
const columns = Math.max(1, columnCount)
|
||||
const occupied: boolean[][] = []
|
||||
|
||||
const fits = (column: number, row: number, width: number, height: number) => {
|
||||
if (column + width > columns) return false
|
||||
for (let y = row; y < row + height; y += 1) {
|
||||
for (let x = column; x < column + width; x += 1) {
|
||||
if (occupied[y]?.[x]) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return widgets.map((widget) => {
|
||||
const span = getHomeWidgetSpan(widget.size, columns)
|
||||
let row = 0
|
||||
let column = 0
|
||||
while (!fits(column, row, span.columns, span.rows)) {
|
||||
column += 1
|
||||
if (column >= columns) {
|
||||
column = 0
|
||||
row += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (let y = row; y < row + span.rows; y += 1) {
|
||||
occupied[y] ??= Array.from({ length: columns }, () => false)
|
||||
for (let x = column; x < column + span.columns; x += 1) occupied[y][x] = true
|
||||
}
|
||||
|
||||
return {
|
||||
...widget,
|
||||
column: column + 1,
|
||||
row: row + 1,
|
||||
effectiveColumns: span.columns,
|
||||
effectiveRows: span.rows,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function enableFreeHomeDashboard(
|
||||
config: HomeDashboardConfig,
|
||||
columnCount: number,
|
||||
): HomeDashboardConfig {
|
||||
const columns = Math.max(1, columnCount)
|
||||
const packedById = new Map(
|
||||
packHomeWidgets(config.widgets, columns).map((widget) => [widget.id, widget]),
|
||||
)
|
||||
|
||||
return {
|
||||
...config,
|
||||
layout: 'free',
|
||||
widgets: config.widgets.map((widget) => {
|
||||
if (widget.position) return widget
|
||||
const packed = packedById.get(widget.id)
|
||||
if (!packed) return widget
|
||||
|
||||
return {
|
||||
...widget,
|
||||
position: {
|
||||
column: packed.column - 1,
|
||||
row: packed.row - 1,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
62
apps/app-frontend/src/components/home/home-utils.test.ts
Normal file
62
apps/app-frontend/src/components/home/home-utils.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildHeatmapDays,
|
||||
getActivePlayerName,
|
||||
getPlaytimeLevel,
|
||||
getTimeBucket,
|
||||
stableGreetingIndex,
|
||||
toDateKey,
|
||||
} from './home-utils.ts'
|
||||
|
||||
test('uses the six local greeting time buckets', () => {
|
||||
const hour = (value: number) => new Date(2026, 6, 25, value, 0)
|
||||
assert.equal(getTimeBucket(hour(0)), 'late-night')
|
||||
assert.equal(getTimeBucket(hour(5)), 'dawn')
|
||||
assert.equal(getTimeBucket(hour(8)), 'morning')
|
||||
assert.equal(getTimeBucket(hour(12)), 'afternoon')
|
||||
assert.equal(getTimeBucket(hour(17)), 'evening')
|
||||
assert.equal(getTimeBucket(hour(21)), 'night')
|
||||
assert.equal(
|
||||
stableGreetingIndex('2026-07-25:morning', 16),
|
||||
stableGreetingIndex('2026-07-25:morning', 16),
|
||||
)
|
||||
assert.equal(stableGreetingIndex('any', 0), 0)
|
||||
})
|
||||
|
||||
test('builds Monday-first month and year heatmap grids', () => {
|
||||
const month = buildHeatmapDays(new Date(2026, 1, 17, 12), 'month')
|
||||
assert.equal(month[0]?.date.getDay(), 1)
|
||||
assert.equal(month.at(-1)?.date.getDay(), 0)
|
||||
assert.equal(month.filter((day) => day.inPeriod).length, 28)
|
||||
assert.equal(month.find((day) => day.inPeriod)?.dateKey, '2026-02-01')
|
||||
|
||||
const year = buildHeatmapDays(new Date(2024, 6, 1, 12), 'year')
|
||||
assert.equal(year[0]?.date.getDay(), 1)
|
||||
assert.equal(year.at(-1)?.date.getDay(), 0)
|
||||
assert.equal(year.filter((day) => day.inPeriod).length, 366)
|
||||
})
|
||||
|
||||
test('maps playtime thresholds and missing days deterministically', () => {
|
||||
assert.deepEqual(
|
||||
[0, 1, 30 * 60, 30 * 60 + 1, 90 * 60, 90 * 60 + 1, 180 * 60, 180 * 60 + 1].map(
|
||||
getPlaytimeLevel,
|
||||
),
|
||||
[0, 1, 1, 2, 2, 3, 3, 4],
|
||||
)
|
||||
assert.equal(toDateKey(new Date(2026, 6, 25, 12)), '2026-07-25')
|
||||
})
|
||||
|
||||
test('uses only active online accounts for player greetings', () => {
|
||||
const accounts = [
|
||||
{ account_type: 'offline', profile: { id: 'offline', name: 'Local player' } },
|
||||
{ account_type: 'microsoft', profile: { id: 'microsoft', name: 'Alex' } },
|
||||
{ account_type: 'yggdrasil', profile: { id: 'yggdrasil', name: 'Steve' } },
|
||||
]
|
||||
assert.equal(getActivePlayerName('microsoft', accounts), 'Alex')
|
||||
assert.equal(getActivePlayerName('yggdrasil', accounts), 'Steve')
|
||||
assert.equal(getActivePlayerName('offline', accounts), null)
|
||||
assert.equal(getActivePlayerName(undefined, accounts), null)
|
||||
assert.equal(getActivePlayerName('missing', accounts), null)
|
||||
})
|
||||
112
apps/app-frontend/src/components/home/home-utils.ts
Normal file
112
apps/app-frontend/src/components/home/home-utils.ts
Normal file
@ -0,0 +1,112 @@
|
||||
export type HomeTimeBucket = 'late-night' | 'dawn' | 'morning' | 'afternoon' | 'evening' | 'night'
|
||||
export type PlaytimeView = 'month' | 'year'
|
||||
|
||||
export type HeatmapDay = {
|
||||
date: Date
|
||||
dateKey: string
|
||||
inPeriod: boolean
|
||||
}
|
||||
|
||||
export type MinecraftAccountLike = {
|
||||
account_type?: string
|
||||
profile?: {
|
||||
id?: string
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimeBucket(date: Date): HomeTimeBucket {
|
||||
const hour = date.getHours()
|
||||
if (hour < 5) return 'late-night'
|
||||
if (hour < 8) return 'dawn'
|
||||
if (hour < 12) return 'morning'
|
||||
if (hour < 17) return 'afternoon'
|
||||
if (hour < 21) return 'evening'
|
||||
return 'night'
|
||||
}
|
||||
|
||||
export function stableGreetingIndex(seed: string, count: number): number {
|
||||
if (count <= 0) return 0
|
||||
|
||||
let hash = 0
|
||||
for (const character of seed) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) | 0
|
||||
}
|
||||
return Math.abs(hash) % count
|
||||
}
|
||||
|
||||
export function toDateKey(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
export function dateFromKey(dateKey: string): Date {
|
||||
const [year, month, day] = dateKey.split('-').map(Number)
|
||||
return new Date(year, month - 1, day, 12)
|
||||
}
|
||||
|
||||
export function startOfPeriod(anchor: Date, view: PlaytimeView): Date {
|
||||
return view === 'month'
|
||||
? new Date(anchor.getFullYear(), anchor.getMonth(), 1, 12)
|
||||
: new Date(anchor.getFullYear(), 0, 1, 12)
|
||||
}
|
||||
|
||||
export function endOfPeriod(anchor: Date, view: PlaytimeView): Date {
|
||||
return view === 'month'
|
||||
? new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0, 12)
|
||||
: new Date(anchor.getFullYear(), 11, 31, 12)
|
||||
}
|
||||
|
||||
export function shiftPeriod(anchor: Date, view: PlaytimeView, amount: number): Date {
|
||||
return view === 'month'
|
||||
? new Date(anchor.getFullYear(), anchor.getMonth() + amount, 1, 12)
|
||||
: new Date(anchor.getFullYear() + amount, 0, 1, 12)
|
||||
}
|
||||
|
||||
export function buildHeatmapDays(anchor: Date, view: PlaytimeView): HeatmapDay[] {
|
||||
const periodStart = startOfPeriod(anchor, view)
|
||||
const periodEnd = endOfPeriod(anchor, view)
|
||||
const periodStartKey = toDateKey(periodStart)
|
||||
const periodEndKey = toDateKey(periodEnd)
|
||||
const gridStart = new Date(periodStart)
|
||||
gridStart.setDate(periodStart.getDate() - ((periodStart.getDay() + 6) % 7))
|
||||
const gridEnd = new Date(periodEnd)
|
||||
gridEnd.setDate(periodEnd.getDate() + ((7 - gridEnd.getDay()) % 7))
|
||||
|
||||
const days: HeatmapDay[] = []
|
||||
const cursor = new Date(gridStart)
|
||||
while (cursor <= gridEnd) {
|
||||
const date = new Date(cursor)
|
||||
const dateKey = toDateKey(date)
|
||||
days.push({
|
||||
date,
|
||||
dateKey,
|
||||
inPeriod: dateKey >= periodStartKey && dateKey <= periodEndKey,
|
||||
})
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
export function getPlaytimeLevel(seconds: number): number {
|
||||
if (seconds <= 0) return 0
|
||||
if (seconds <= 30 * 60) return 1
|
||||
if (seconds <= 90 * 60) return 2
|
||||
if (seconds <= 180 * 60) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
export function getActivePlayerName(
|
||||
selectedUser: string | null | undefined,
|
||||
accounts: readonly MinecraftAccountLike[],
|
||||
): string | null {
|
||||
if (!selectedUser) return null
|
||||
const account = accounts.find(
|
||||
(candidate) =>
|
||||
candidate.profile?.id === selectedUser &&
|
||||
(candidate.account_type === 'microsoft' || candidate.account_type === 'yggdrasil'),
|
||||
)
|
||||
return account?.profile?.name ?? null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
|
||||
import {
|
||||
buildDependencyGraph,
|
||||
dependencyGraphMetrics,
|
||||
getConnectedComponents,
|
||||
getDependencyTreeRows,
|
||||
getRelatedNodeIds,
|
||||
layoutDependencyGraph,
|
||||
} from './dependency-graph.ts'
|
||||
|
||||
type Ref = { provider: 'modrinth'; projectId: string; releaseId: string }
|
||||
|
||||
type ItemOptions = {
|
||||
title?: string
|
||||
requires?: Ref[]
|
||||
requiredBy?: Ref[]
|
||||
autoDependency?: boolean
|
||||
entryId?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
function item(projectId: string, versionId: string, options: ItemOptions = {}): ContentItem {
|
||||
return {
|
||||
id: projectId,
|
||||
file_name: `${projectId}.jar`,
|
||||
file_path: options.filePath ?? `mods/${projectId}.jar`,
|
||||
size: 1,
|
||||
enabled: true,
|
||||
project_type: 'mod',
|
||||
project: { id: projectId, slug: projectId, title: options.title ?? projectId, icon_url: null },
|
||||
version: { id: versionId, version_number: versionId, file_name: `${projectId}.jar` },
|
||||
update: null,
|
||||
origin_provider: 'modrinth',
|
||||
provider_refs: [{ provider: 'modrinth', project_id: projectId, version_id: versionId }],
|
||||
instanceEntryId: options.entryId,
|
||||
dependency: {
|
||||
autoDependency: options.autoDependency ?? false,
|
||||
requires: options.requires ?? [],
|
||||
requiredBy: options.requiredBy ?? [],
|
||||
orphaned: false,
|
||||
},
|
||||
} as ContentItem
|
||||
}
|
||||
|
||||
const ref = (projectId: string, releaseId = '1') => ({
|
||||
provider: 'modrinth' as const,
|
||||
projectId,
|
||||
releaseId,
|
||||
})
|
||||
|
||||
function nodeId(projectId: string) {
|
||||
return `item:modrinth:${projectId}:1`
|
||||
}
|
||||
|
||||
test('builds dependency edges, roots, shared nodes, and relationship layout', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('c', '1', { requires: [ref('b')] }),
|
||||
item('b', '1'),
|
||||
])
|
||||
|
||||
assert.equal(graph.edges.length, 2)
|
||||
assert.equal(graph.rootIds.length, 2)
|
||||
assert.equal(graph.nodeById.get(nodeId('b'))?.shared, true)
|
||||
assert.equal(layoutDependencyGraph(graph).edges.length, 2)
|
||||
})
|
||||
|
||||
test('keeps unresolved dependency targets visible', () => {
|
||||
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('missing', '9')] })])
|
||||
assert.equal(graph.unresolvedIds.size, 1)
|
||||
assert.equal(graph.edges[0]?.resolved, false)
|
||||
assert.equal(graph.nodeById.get('missing:modrinth:missing:9')?.title, 'missing')
|
||||
})
|
||||
|
||||
test('deduplicates edges and stops tree traversal at cycles and shared references', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b'), ref('b')] }),
|
||||
item('b', '1', { requires: [ref('a')] }),
|
||||
])
|
||||
assert.equal(graph.edges.length, 2)
|
||||
assert.equal(graph.cycleIds.size, 2)
|
||||
|
||||
const rows = getDependencyTreeRows(graph, new Set([nodeId('a'), nodeId('b')]))
|
||||
assert.ok(rows.some((row) => row.kind === 'cycle'))
|
||||
assert.ok(rows.length < 6)
|
||||
})
|
||||
|
||||
test('preserves isolated content as a root without dependency edges', () => {
|
||||
const graph = buildDependencyGraph([item('standalone', '1')])
|
||||
assert.deepEqual(graph.rootIds, [nodeId('standalone')])
|
||||
assert.deepEqual(getDependencyTreeRows(graph, new Set()), [
|
||||
{
|
||||
id: `node:${nodeId('standalone')}:0`,
|
||||
nodeId: nodeId('standalone'),
|
||||
depth: 0,
|
||||
kind: 'node',
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
},
|
||||
])
|
||||
assert.equal(layoutDependencyGraph(graph).nodes.length, 0)
|
||||
})
|
||||
|
||||
test('keeps duplicate installed copies as distinct nodes and connects each matching copy', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('parent', '1', { requires: [ref('library')] }),
|
||||
item('library', '1', { entryId: 'library-a', filePath: 'mods/library-a.jar' }),
|
||||
item('library', '1', { entryId: 'library-b', filePath: 'mods/library-b.jar' }),
|
||||
])
|
||||
|
||||
assert.equal(graph.nodes.filter((node) => node.projectId === 'library').length, 2)
|
||||
assert.equal(graph.edges.length, 2)
|
||||
assert.ok(graph.nodeById.has('item:entry:library-a'))
|
||||
assert.ok(graph.nodeById.has('item:entry:library-b'))
|
||||
})
|
||||
|
||||
test('partitions unrelated relationships into compact graph components', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('b', '1'),
|
||||
item('c', '1', { requires: [ref('d')] }),
|
||||
item('d', '1'),
|
||||
item('isolated', '1'),
|
||||
])
|
||||
const relationshipIds = new Set(graph.edges.flatMap((edge) => [edge.source, edge.target]))
|
||||
const components = getConnectedComponents(graph, relationshipIds)
|
||||
const layout = layoutDependencyGraph(graph)
|
||||
|
||||
assert.equal(components.length, 2)
|
||||
assert.deepEqual(
|
||||
components.map((component) => component.nodeIds.length),
|
||||
[2, 2],
|
||||
)
|
||||
assert.equal(layout.components.length, 2)
|
||||
assert.equal(layout.nodes.length, 4)
|
||||
assert.equal(
|
||||
layout.nodes.some((node) => node.id === nodeId('isolated')),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('returns the complete relationship context for a filtered node', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('b', '1', { requires: [ref('c')] }),
|
||||
item('c', '1'),
|
||||
item('isolated', '1'),
|
||||
])
|
||||
const related = getRelatedNodeIds(graph, new Set([nodeId('b')]))
|
||||
|
||||
assert.deepEqual(related, new Set([nodeId('a'), nodeId('b'), nodeId('c')]))
|
||||
})
|
||||
|
||||
test('connects each edge from a source output port to a target input port with an HTML connector', () => {
|
||||
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('b')] }), item('b', '1')])
|
||||
const layout = layoutDependencyGraph(graph)
|
||||
const source = layout.nodes.find((node) => node.id === nodeId('a'))!
|
||||
const target = layout.nodes.find((node) => node.id === nodeId('b'))!
|
||||
const edge = layout.edges[0]!
|
||||
const expectedStartX =
|
||||
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance
|
||||
const expectedStartY = source.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
const expectedEndX = target.x - dependencyGraphMetrics.edgeClearance
|
||||
const expectedEndY = target.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
|
||||
assert.equal(edge.connector.x, expectedStartX)
|
||||
assert.equal(edge.connector.y, expectedStartY)
|
||||
assert.equal(
|
||||
edge.connector.length,
|
||||
Math.hypot(expectedEndX - expectedStartX, expectedEndY - expectedStartY),
|
||||
)
|
||||
assert.equal(
|
||||
edge.connector.rotation,
|
||||
(Math.atan2(expectedEndY - expectedStartY, expectedEndX - expectedStartX) * 180) / Math.PI,
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps output and input ports stable for reverse cycle connectors', () => {
|
||||
const graph = buildDependencyGraph([
|
||||
item('a', '1', { requires: [ref('b')] }),
|
||||
item('b', '1', { requires: [ref('a')] }),
|
||||
])
|
||||
const layout = layoutDependencyGraph(graph)
|
||||
const reverseEdge = layout.edges.find((edge) => edge.source === nodeId('b'))!
|
||||
const source = layout.nodes.find((node) => node.id === reverseEdge.source)!
|
||||
const target = layout.nodes.find((node) => node.id === reverseEdge.target)!
|
||||
|
||||
assert.equal(
|
||||
reverseEdge.connector.x,
|
||||
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance,
|
||||
)
|
||||
assert.equal(reverseEdge.connector.y, source.y + dependencyGraphMetrics.nodeHeight / 2)
|
||||
assert.equal(
|
||||
reverseEdge.connector.rotation,
|
||||
(Math.atan2(
|
||||
target.y + dependencyGraphMetrics.nodeHeight / 2 - reverseEdge.connector.y,
|
||||
target.x - dependencyGraphMetrics.edgeClearance - reverseEdge.connector.x,
|
||||
) *
|
||||
180) /
|
||||
Math.PI,
|
||||
)
|
||||
assert.ok(reverseEdge.connector.length > 0)
|
||||
assert.equal(
|
||||
reverseEdge.connector.length,
|
||||
Math.hypot(
|
||||
target.x - dependencyGraphMetrics.edgeClearance - reverseEdge.connector.x,
|
||||
target.y + dependencyGraphMetrics.nodeHeight / 2 - reverseEdge.connector.y,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test('uses dragged final coordinates for connectors and canvas bounds', () => {
|
||||
const graph = buildDependencyGraph([item('a', '1', { requires: [ref('b')] }), item('b', '1')])
|
||||
const offsets = new Map([[nodeId('b'), { x: 420, y: 180 }]])
|
||||
const layout = layoutDependencyGraph(graph, undefined, offsets)
|
||||
const source = layout.nodes.find((node) => node.id === nodeId('a'))!
|
||||
const target = layout.nodes.find((node) => node.id === nodeId('b'))!
|
||||
const edge = layout.edges[0]!
|
||||
|
||||
assert.ok(target.x >= dependencyGraphMetrics.canvasPadding + 420)
|
||||
assert.ok(target.y >= dependencyGraphMetrics.canvasPadding + 180)
|
||||
assert.equal(
|
||||
edge.connector.length,
|
||||
Math.hypot(
|
||||
target.x - dependencyGraphMetrics.edgeClearance - edge.connector.x,
|
||||
target.y + dependencyGraphMetrics.nodeHeight / 2 - edge.connector.y,
|
||||
),
|
||||
)
|
||||
assert.equal(
|
||||
edge.connector.x,
|
||||
source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance,
|
||||
)
|
||||
assert.ok(
|
||||
layout.width >=
|
||||
target.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.canvasPadding,
|
||||
)
|
||||
assert.ok(
|
||||
layout.height >=
|
||||
target.y + dependencyGraphMetrics.nodeHeight + dependencyGraphMetrics.canvasPadding,
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,658 @@
|
||||
import type { ContentItem } from '@modrinth/ui'
|
||||
|
||||
export type DependencyDirection = 'requires' | 'requiredBy'
|
||||
|
||||
export const dependencyGraphMetrics = {
|
||||
canvasPadding: 56,
|
||||
componentGap: 96,
|
||||
edgeClearance: 2,
|
||||
layerGap: 112,
|
||||
minHeight: 360,
|
||||
minWidth: 640,
|
||||
nodeHeight: 76,
|
||||
nodeWidth: 228,
|
||||
rowGap: 30,
|
||||
} as const
|
||||
|
||||
export type DependencyGraphNode = {
|
||||
id: string
|
||||
title: string
|
||||
iconUrl?: string
|
||||
projectId?: string
|
||||
versionId?: string
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
projectType: string
|
||||
provider: string
|
||||
ownershipKind?: ContentItem['instanceOwnershipKind']
|
||||
enabled?: boolean
|
||||
materializationState?: ContentItem['instanceMaterializationState']
|
||||
dependency: NonNullable<ContentItem['dependency']>
|
||||
resolved: boolean
|
||||
item?: ContentItem
|
||||
cycle: boolean
|
||||
shared: boolean
|
||||
}
|
||||
|
||||
export type DependencyGraphEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
resolved: boolean
|
||||
}
|
||||
|
||||
export type DependencyGraph = {
|
||||
nodes: DependencyGraphNode[]
|
||||
edges: DependencyGraphEdge[]
|
||||
nodeById: Map<string, DependencyGraphNode>
|
||||
edgesBySource: Map<string, DependencyGraphEdge[]>
|
||||
edgesByTarget: Map<string, DependencyGraphEdge[]>
|
||||
rootIds: string[]
|
||||
cycleIds: Set<string>
|
||||
unresolvedIds: Set<string>
|
||||
}
|
||||
|
||||
export type DependencyTreeRow = {
|
||||
id: string
|
||||
nodeId?: string
|
||||
depth: number
|
||||
kind: 'node' | 'reference' | 'cycle'
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
export type DependencyGraphConnector = {
|
||||
length: number
|
||||
rotation: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type DependencyGraphLayoutEdge = DependencyGraphEdge & {
|
||||
connector: DependencyGraphConnector
|
||||
}
|
||||
|
||||
export type DependencyGraphComponent = {
|
||||
edgeCount: number
|
||||
height: number
|
||||
id: string
|
||||
nodeIds: string[]
|
||||
width: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type DependencyGraphLayout = {
|
||||
components: DependencyGraphComponent[]
|
||||
edges: DependencyGraphLayoutEdge[]
|
||||
height: number
|
||||
nodes: Array<DependencyGraphNode & { x: number; y: number }>
|
||||
width: number
|
||||
}
|
||||
|
||||
type DependencyReference = {
|
||||
provider: string
|
||||
projectId: string
|
||||
releaseId: string
|
||||
}
|
||||
|
||||
type NodePosition = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type ComponentLayout = {
|
||||
edges: DependencyGraphLayoutEdge[]
|
||||
height: number
|
||||
nodeIds: string[]
|
||||
nodes: Array<DependencyGraphNode & NodePosition>
|
||||
width: number
|
||||
}
|
||||
|
||||
const emptyDependency = (): NonNullable<ContentItem['dependency']> => ({
|
||||
autoDependency: false,
|
||||
requiredBy: [],
|
||||
requires: [],
|
||||
orphaned: false,
|
||||
})
|
||||
|
||||
function normalizeProjectId(provider: string, projectId: string): string {
|
||||
return provider === 'curseforge' ? projectId.replace(/^curseforge:/, '') : projectId
|
||||
}
|
||||
|
||||
function referenceKey(reference: DependencyReference): string {
|
||||
return `${reference.provider}:${normalizeProjectId(reference.provider, reference.projectId)}:${reference.releaseId}`
|
||||
}
|
||||
|
||||
function itemReferenceKeys(item: ContentItem): Set<string> {
|
||||
const keys = new Set<string>()
|
||||
const projectId = item.project?.id
|
||||
const versionId = item.version?.id
|
||||
const provider = item.origin_provider ?? item.provider_refs[0]?.provider
|
||||
|
||||
if (projectId) keys.add(`project:${projectId}`)
|
||||
if (provider && projectId) {
|
||||
const normalizedProjectId = normalizeProjectId(provider, projectId)
|
||||
keys.add(`${provider}:${normalizedProjectId}:`)
|
||||
if (versionId) keys.add(`${provider}:${normalizedProjectId}:${versionId}`)
|
||||
}
|
||||
|
||||
for (const providerRef of item.provider_refs) {
|
||||
if (providerRef.provider === 'modrinth') {
|
||||
keys.add(`modrinth:${providerRef.project_id}:${providerRef.version_id ?? ''}`)
|
||||
keys.add(`modrinth:${providerRef.project_id}:`)
|
||||
} else {
|
||||
keys.add(`curseforge:${providerRef.project_id}:${providerRef.file_id ?? ''}`)
|
||||
keys.add(`curseforge:${providerRef.project_id}:`)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
function nodeIdForItem(item: ContentItem): string {
|
||||
if (item.instanceEntryId) return `item:entry:${item.instanceEntryId}`
|
||||
if (item.instanceMemberId) return `item:member:${item.instanceMemberId}`
|
||||
if (item.instanceFileId) return `item:file:${item.instanceFileId}`
|
||||
|
||||
const provider = item.origin_provider ?? item.provider_refs[0]?.provider
|
||||
const projectId = item.project?.id
|
||||
if (provider && projectId) {
|
||||
return `item:${provider}:${normalizeProjectId(provider, projectId)}:${item.version?.id ?? ''}`
|
||||
}
|
||||
if (item.file_path) return `item:path:${item.file_path}`
|
||||
if (item.file_name) return `item:name:${item.file_name}`
|
||||
return `item:id:${item.id}`
|
||||
}
|
||||
|
||||
function nodeFromItem(item: ContentItem, id: string): DependencyGraphNode {
|
||||
const dependency = item.dependency ?? emptyDependency()
|
||||
const provider = item.origin_provider ?? item.provider_refs[0]?.provider ?? 'local'
|
||||
return {
|
||||
id,
|
||||
title: item.project?.title ?? item.file_name,
|
||||
iconUrl: item.project?.icon_url,
|
||||
projectId: item.project?.id,
|
||||
versionId: item.version?.id,
|
||||
versionNumber: item.version?.version_number,
|
||||
fileName: item.file_name,
|
||||
projectType: item.project_type,
|
||||
provider,
|
||||
ownershipKind: item.instanceOwnershipKind,
|
||||
enabled: item.enabled,
|
||||
materializationState: item.instanceMaterializationState,
|
||||
dependency,
|
||||
resolved: true,
|
||||
item,
|
||||
cycle: false,
|
||||
shared: false,
|
||||
}
|
||||
}
|
||||
|
||||
function unresolvedNode(reference: DependencyReference): DependencyGraphNode {
|
||||
return {
|
||||
id: `missing:${referenceKey(reference)}`,
|
||||
title: reference.projectId || reference.releaseId || 'Unresolved dependency',
|
||||
projectId: reference.projectId,
|
||||
versionId: reference.releaseId,
|
||||
projectType: 'unknown',
|
||||
provider: reference.provider,
|
||||
dependency: emptyDependency(),
|
||||
resolved: false,
|
||||
cycle: false,
|
||||
shared: false,
|
||||
}
|
||||
}
|
||||
|
||||
function addNodeForReference(
|
||||
nodesByItemKey: Map<string, DependencyGraphNode[]>,
|
||||
key: string,
|
||||
node: DependencyGraphNode,
|
||||
) {
|
||||
const matches = nodesByItemKey.get(key) ?? []
|
||||
if (!matches.some((candidate) => candidate.id === node.id)) matches.push(node)
|
||||
nodesByItemKey.set(key, matches)
|
||||
}
|
||||
|
||||
function findNodesForReference(
|
||||
reference: DependencyReference,
|
||||
nodesByItemKey: Map<string, DependencyGraphNode[]>,
|
||||
): DependencyGraphNode[] {
|
||||
const exact = nodesByItemKey.get(referenceKey(reference))
|
||||
if (exact?.length) return exact
|
||||
|
||||
const byProviderProject = nodesByItemKey.get(
|
||||
`${reference.provider}:${normalizeProjectId(reference.provider, reference.projectId)}:`,
|
||||
)
|
||||
if (byProviderProject?.length) return byProviderProject
|
||||
|
||||
return nodesByItemKey.get(`project:${reference.projectId}`) ?? []
|
||||
}
|
||||
|
||||
function markCycles(
|
||||
nodes: DependencyGraphNode[],
|
||||
edgesBySource: Map<string, DependencyGraphEdge[]>,
|
||||
): Set<string> {
|
||||
const state = new Map<string, 0 | 1 | 2>()
|
||||
const cycleIds = new Set<string>()
|
||||
|
||||
function visit(id: string, stack: string[]) {
|
||||
const currentState = state.get(id) ?? 0
|
||||
if (currentState === 2) return
|
||||
if (currentState === 1) {
|
||||
const cycleStart = stack.indexOf(id)
|
||||
for (const cycleId of stack.slice(cycleStart)) cycleIds.add(cycleId)
|
||||
return
|
||||
}
|
||||
|
||||
state.set(id, 1)
|
||||
for (const edge of edgesBySource.get(id) ?? []) visit(edge.target, [...stack, id])
|
||||
state.set(id, 2)
|
||||
}
|
||||
|
||||
for (const node of nodes) visit(node.id, [])
|
||||
return cycleIds
|
||||
}
|
||||
|
||||
export function buildDependencyGraph(items: ContentItem[]): DependencyGraph {
|
||||
const nodesByItemKey = new Map<string, DependencyGraphNode[]>()
|
||||
const nodes = new Map<string, DependencyGraphNode>()
|
||||
|
||||
for (const item of items) {
|
||||
const id = nodeIdForItem(item)
|
||||
const node = nodeFromItem(item, id)
|
||||
nodes.set(id, node)
|
||||
for (const key of itemReferenceKeys(item)) addNodeForReference(nodesByItemKey, key, node)
|
||||
}
|
||||
|
||||
const edges = new Map<string, DependencyGraphEdge>()
|
||||
const addEdge = (source: DependencyGraphNode, target: DependencyGraphNode) => {
|
||||
if (!nodes.has(source.id)) nodes.set(source.id, source)
|
||||
if (!nodes.has(target.id)) nodes.set(target.id, target)
|
||||
const id = `${source.id}->${target.id}`
|
||||
edges.set(id, { id, source: source.id, target: target.id, resolved: target.resolved })
|
||||
}
|
||||
for (const source of nodes.values()) {
|
||||
for (const reference of source.dependency.requires as DependencyReference[]) {
|
||||
const targets = findNodesForReference(reference, nodesByItemKey)
|
||||
if (targets.length) {
|
||||
for (const target of targets) addEdge(source, target)
|
||||
} else {
|
||||
addEdge(source, unresolvedNode(reference))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const target of nodes.values()) {
|
||||
for (const reference of target.dependency.requiredBy as DependencyReference[]) {
|
||||
const sources = findNodesForReference(reference, nodesByItemKey)
|
||||
if (sources.length) {
|
||||
for (const source of sources) addEdge(source, target)
|
||||
} else {
|
||||
addEdge(unresolvedNode(reference), target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allNodes = [...nodes.values()]
|
||||
const allEdges = [...edges.values()]
|
||||
const edgesBySource = new Map<string, DependencyGraphEdge[]>()
|
||||
const edgesByTarget = new Map<string, DependencyGraphEdge[]>()
|
||||
for (const edge of allEdges) {
|
||||
const sourceEdges = edgesBySource.get(edge.source) ?? []
|
||||
sourceEdges.push(edge)
|
||||
edgesBySource.set(edge.source, sourceEdges)
|
||||
const targetEdges = edgesByTarget.get(edge.target) ?? []
|
||||
targetEdges.push(edge)
|
||||
edgesByTarget.set(edge.target, targetEdges)
|
||||
}
|
||||
|
||||
const cycleIds = markCycles(allNodes, edgesBySource)
|
||||
const unresolvedIds = new Set(allNodes.filter((node) => !node.resolved).map((node) => node.id))
|
||||
for (const node of allNodes) {
|
||||
node.cycle = cycleIds.has(node.id)
|
||||
node.shared = (edgesByTarget.get(node.id)?.length ?? 0) > 1
|
||||
}
|
||||
|
||||
const rootIds = allNodes
|
||||
.filter((node) => !(edgesByTarget.get(node.id)?.length ?? 0))
|
||||
.map((node) => node.id)
|
||||
.sort((a, b) => (nodes.get(a)!.title ?? '').localeCompare(nodes.get(b)!.title ?? ''))
|
||||
const covered = new Set<string>()
|
||||
const visitFromRoot = (id: string) => {
|
||||
if (covered.has(id)) return
|
||||
covered.add(id)
|
||||
for (const edge of edgesBySource.get(id) ?? []) visitFromRoot(edge.target)
|
||||
}
|
||||
for (const rootId of rootIds) visitFromRoot(rootId)
|
||||
for (const node of allNodes
|
||||
.filter((candidate) => !covered.has(candidate.id))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))) {
|
||||
rootIds.push(node.id)
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: allNodes,
|
||||
edges: allEdges,
|
||||
nodeById: nodes,
|
||||
edgesBySource,
|
||||
edgesByTarget,
|
||||
rootIds,
|
||||
cycleIds,
|
||||
unresolvedIds,
|
||||
}
|
||||
}
|
||||
|
||||
export function getDependencyTreeRows(
|
||||
graph: DependencyGraph,
|
||||
expandedIds: Set<string>,
|
||||
direction: DependencyDirection = 'requires',
|
||||
): DependencyTreeRow[] {
|
||||
const rows: DependencyTreeRow[] = []
|
||||
const seen = new Set<string>()
|
||||
const roots =
|
||||
direction === 'requires'
|
||||
? graph.rootIds
|
||||
: graph.nodes
|
||||
.filter((node) => !(graph.edgesBySource.get(node.id)?.length ?? 0))
|
||||
.map((node) => node.id)
|
||||
.sort((a, b) => graph.nodeById.get(a)!.title.localeCompare(graph.nodeById.get(b)!.title))
|
||||
|
||||
function visit(nodeId: string, depth: number, stack: Set<string>) {
|
||||
const node = graph.nodeById.get(nodeId)
|
||||
if (!node) return
|
||||
const edges =
|
||||
direction === 'requires' ? graph.edgesBySource.get(nodeId) : graph.edgesByTarget.get(nodeId)
|
||||
const children = (edges ?? []).map((edge) =>
|
||||
direction === 'requires' ? edge.target : edge.source,
|
||||
)
|
||||
const expanded = expandedIds.has(nodeId)
|
||||
const firstVisit = !seen.has(nodeId)
|
||||
if (firstVisit) {
|
||||
seen.add(nodeId)
|
||||
rows.push({
|
||||
id: `node:${nodeId}:${depth}`,
|
||||
nodeId,
|
||||
depth,
|
||||
kind: 'node',
|
||||
hasChildren: children.length > 0,
|
||||
expanded,
|
||||
})
|
||||
} else {
|
||||
rows.push({
|
||||
id: `reference:${nodeId}:${depth}`,
|
||||
nodeId,
|
||||
depth,
|
||||
kind: 'reference',
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!expanded) return
|
||||
for (const childId of children) {
|
||||
if (stack.has(childId)) {
|
||||
rows.push({
|
||||
id: `cycle:${childId}:${depth + 1}`,
|
||||
nodeId: childId,
|
||||
depth: depth + 1,
|
||||
kind: 'cycle',
|
||||
hasChildren: false,
|
||||
expanded: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
visit(childId, depth + 1, new Set([...stack, nodeId]))
|
||||
}
|
||||
}
|
||||
|
||||
for (const rootId of roots) visit(rootId, 0, new Set())
|
||||
return rows
|
||||
}
|
||||
|
||||
export function getRelatedNodeIds(
|
||||
graph: DependencyGraph,
|
||||
nodeIds: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const related = new Set(nodeIds)
|
||||
const queue = [...nodeIds]
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!
|
||||
for (const edge of [
|
||||
...(graph.edgesBySource.get(id) ?? []),
|
||||
...(graph.edgesByTarget.get(id) ?? []),
|
||||
]) {
|
||||
const next = edge.source === id ? edge.target : edge.source
|
||||
if (!related.has(next)) {
|
||||
related.add(next)
|
||||
queue.push(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
return related
|
||||
}
|
||||
|
||||
export function getConnectedComponents(
|
||||
graph: DependencyGraph,
|
||||
visibleIds: ReadonlySet<string>,
|
||||
): Array<{ edgeCount: number; nodeIds: string[] }> {
|
||||
const components: Array<{ edgeCount: number; nodeIds: string[] }> = []
|
||||
const remaining = new Set(visibleIds)
|
||||
|
||||
while (remaining.size) {
|
||||
const start = remaining.values().next().value as string
|
||||
const nodeIds = getRelatedNodeIds(graph, new Set([start]))
|
||||
const componentIds = [...nodeIds].filter((id) => visibleIds.has(id)).sort()
|
||||
for (const id of componentIds) remaining.delete(id)
|
||||
const edgeCount = graph.edges.filter(
|
||||
(edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target),
|
||||
).length
|
||||
components.push({ edgeCount, nodeIds: componentIds })
|
||||
}
|
||||
|
||||
return components.sort(
|
||||
(left, right) =>
|
||||
right.edgeCount - left.edgeCount ||
|
||||
right.nodeIds.length - left.nodeIds.length ||
|
||||
left.nodeIds[0]!.localeCompare(right.nodeIds[0]!),
|
||||
)
|
||||
}
|
||||
|
||||
function nodeDepths(graph: DependencyGraph, visibleIds: ReadonlySet<string>): Map<string, number> {
|
||||
const depths = new Map<string, number>()
|
||||
const visiting = new Set<string>()
|
||||
|
||||
function depth(id: string): number {
|
||||
const cached = depths.get(id)
|
||||
if (cached !== undefined) return cached
|
||||
if (visiting.has(id)) return 0
|
||||
visiting.add(id)
|
||||
const parents = (graph.edgesByTarget.get(id) ?? []).filter(
|
||||
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||
)
|
||||
const value =
|
||||
parents.length === 0 ? 0 : Math.max(...parents.map((edge) => depth(edge.source) + 1))
|
||||
visiting.delete(id)
|
||||
depths.set(id, value)
|
||||
return value
|
||||
}
|
||||
|
||||
for (const id of visibleIds) depth(id)
|
||||
return depths
|
||||
}
|
||||
|
||||
function edgeGeometry(
|
||||
source: NodePosition,
|
||||
target: NodePosition,
|
||||
): Pick<DependencyGraphLayoutEdge, 'connector'> {
|
||||
const x = source.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.edgeClearance
|
||||
const y = source.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
const endX = target.x - dependencyGraphMetrics.edgeClearance
|
||||
const endY = target.y + dependencyGraphMetrics.nodeHeight / 2
|
||||
const dx = endX - x
|
||||
const dy = endY - y
|
||||
|
||||
return {
|
||||
connector: {
|
||||
length: Math.hypot(dx, dy),
|
||||
rotation: (Math.atan2(dy, dx) * 180) / Math.PI,
|
||||
x,
|
||||
y,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function layoutComponent(
|
||||
graph: DependencyGraph,
|
||||
nodeIds: string[],
|
||||
nodeOffsets: ReadonlyMap<string, NodePosition>,
|
||||
): ComponentLayout {
|
||||
const visibleIds = new Set(nodeIds)
|
||||
const nodesToLayout = graph.nodes.filter((node) => visibleIds.has(node.id))
|
||||
const depths = nodeDepths(graph, visibleIds)
|
||||
const groups = new Map<number, DependencyGraphNode[]>()
|
||||
for (const node of nodesToLayout) {
|
||||
const depth = depths.get(node.id) ?? 0
|
||||
const group = groups.get(depth) ?? []
|
||||
group.push(node)
|
||||
groups.set(depth, group)
|
||||
}
|
||||
|
||||
const ranks = new Map(
|
||||
[...groups.keys()].sort((a, b) => a - b).map((depth, rank) => [depth, rank]),
|
||||
)
|
||||
const positions = new Map<string, NodePosition>()
|
||||
for (const [depth, group] of groups) {
|
||||
group.sort((left, right) => left.title.localeCompare(right.title))
|
||||
const rank = ranks.get(depth) ?? 0
|
||||
group.forEach((node, index) => {
|
||||
const offset = nodeOffsets.get(node.id) ?? { x: 0, y: 0 }
|
||||
positions.set(node.id, {
|
||||
x: offset.x + rank * (dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.layerGap),
|
||||
y: offset.y + index * (dependencyGraphMetrics.nodeHeight + dependencyGraphMetrics.rowGap),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const rawPositions = [...positions.values()]
|
||||
const minX = Math.min(...rawPositions.map((position) => position.x))
|
||||
const minY = Math.min(...rawPositions.map((position) => position.y))
|
||||
const normalizedPositions = new Map<string, NodePosition>()
|
||||
for (const [id, position] of positions) {
|
||||
normalizedPositions.set(id, { x: position.x - minX, y: position.y - minY })
|
||||
}
|
||||
|
||||
const nodes = nodesToLayout.map((node) => ({ ...node, ...normalizedPositions.get(node.id)! }))
|
||||
const edges = graph.edges
|
||||
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
|
||||
.map((edge) => ({
|
||||
...edge,
|
||||
...edgeGeometry(normalizedPositions.get(edge.source)!, normalizedPositions.get(edge.target)!),
|
||||
}))
|
||||
|
||||
return {
|
||||
edges,
|
||||
height: Math.max(
|
||||
dependencyGraphMetrics.nodeHeight,
|
||||
...nodes.map((node) => node.y + dependencyGraphMetrics.nodeHeight),
|
||||
),
|
||||
nodeIds,
|
||||
nodes,
|
||||
width: Math.max(
|
||||
dependencyGraphMetrics.nodeWidth,
|
||||
...nodes.map((node) => node.x + dependencyGraphMetrics.nodeWidth),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function layoutDependencyGraph(
|
||||
graph: DependencyGraph,
|
||||
visibleIds: ReadonlySet<string> = new Set(graph.nodes.map((node) => node.id)),
|
||||
nodeOffsets: ReadonlyMap<string, NodePosition> = new Map(),
|
||||
): DependencyGraphLayout {
|
||||
const relationshipIds = new Set(
|
||||
graph.edges
|
||||
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
|
||||
.flatMap((edge) => [edge.source, edge.target]),
|
||||
)
|
||||
const components = getConnectedComponents(graph, relationshipIds)
|
||||
if (!components.length) {
|
||||
return {
|
||||
components: [],
|
||||
edges: [],
|
||||
height: dependencyGraphMetrics.minHeight,
|
||||
nodes: [],
|
||||
width: dependencyGraphMetrics.minWidth,
|
||||
}
|
||||
}
|
||||
|
||||
const layouts = components.map((component) => ({
|
||||
component,
|
||||
layout: layoutComponent(graph, component.nodeIds, nodeOffsets),
|
||||
}))
|
||||
const rowWidth = Math.max(
|
||||
dependencyGraphMetrics.minWidth - dependencyGraphMetrics.canvasPadding * 2,
|
||||
Math.max(...layouts.map(({ layout }) => layout.width)),
|
||||
)
|
||||
let cursorX = dependencyGraphMetrics.canvasPadding
|
||||
let cursorY = dependencyGraphMetrics.canvasPadding
|
||||
let rowHeight = 0
|
||||
const layoutComponents: DependencyGraphComponent[] = []
|
||||
const nodes: DependencyGraphLayout['nodes'] = []
|
||||
const edges: DependencyGraphLayout['edges'] = []
|
||||
|
||||
for (const { component, layout } of layouts) {
|
||||
if (cursorX > dependencyGraphMetrics.canvasPadding && cursorX + layout.width > rowWidth) {
|
||||
cursorX = dependencyGraphMetrics.canvasPadding
|
||||
cursorY += rowHeight + dependencyGraphMetrics.componentGap
|
||||
rowHeight = 0
|
||||
}
|
||||
const id = component.nodeIds.join('|')
|
||||
layoutComponents.push({
|
||||
edgeCount: component.edgeCount,
|
||||
height: layout.height,
|
||||
id,
|
||||
nodeIds: component.nodeIds,
|
||||
width: layout.width,
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
})
|
||||
nodes.push(
|
||||
...layout.nodes.map((node) => ({ ...node, x: node.x + cursorX, y: node.y + cursorY })),
|
||||
)
|
||||
edges.push(
|
||||
...layout.edges.map((edge) => {
|
||||
const source = layout.nodes.find((node) => node.id === edge.source)!
|
||||
const target = layout.nodes.find((node) => node.id === edge.target)!
|
||||
return {
|
||||
...edge,
|
||||
...edgeGeometry(
|
||||
{ x: source.x + cursorX, y: source.y + cursorY },
|
||||
{ x: target.x + cursorX, y: target.y + cursorY },
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
cursorX += layout.width + dependencyGraphMetrics.componentGap
|
||||
rowHeight = Math.max(rowHeight, layout.height)
|
||||
}
|
||||
|
||||
return {
|
||||
components: layoutComponents,
|
||||
edges,
|
||||
height: Math.max(
|
||||
dependencyGraphMetrics.minHeight,
|
||||
cursorY + rowHeight + dependencyGraphMetrics.canvasPadding,
|
||||
),
|
||||
nodes,
|
||||
width: Math.max(
|
||||
dependencyGraphMetrics.minWidth,
|
||||
Math.max(
|
||||
...nodes.map(
|
||||
(node) =>
|
||||
node.x + dependencyGraphMetrics.nodeWidth + dependencyGraphMetrics.canvasPadding,
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
275
apps/app-frontend/src/components/instance/studio/NbtEditor.vue
Normal file
275
apps/app-frontend/src/components/instance/studio/NbtEditor.vue
Normal file
@ -0,0 +1,275 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { NbtString, NbtTag } from 'deepslate/nbt'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import NbtTreeNode from './NbtTreeNode.vue'
|
||||
import StudioEditor from './StudioEditor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
filePath: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [content: string]
|
||||
'update:mode': [mode: 'tree' | 'snbt']
|
||||
format: []
|
||||
save: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
tree: { id: 'instance.files.studio.nbt.tree', defaultMessage: 'Tree' },
|
||||
snbt: { id: 'instance.files.studio.nbt.snbt', defaultMessage: 'SNBT' },
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const mode = ref<'tree' | 'snbt'>('tree')
|
||||
const root = ref<NbtTag | null>(null)
|
||||
const parseError = ref('')
|
||||
const draft = ref(props.content)
|
||||
const snbtEditor = ref<InstanceType<typeof StudioEditor> | null>(null)
|
||||
const history = ref([props.content])
|
||||
let historyIndex = 0
|
||||
let lastEmittedContent = props.content
|
||||
|
||||
function parseContent(content: string) {
|
||||
draft.value = content
|
||||
try {
|
||||
const parsed = NbtTag.fromString(content)
|
||||
if (!parsed.isCompound()) throw new Error('NBT root must be a compound')
|
||||
root.value = parsed
|
||||
parseError.value = ''
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSnbt(content: string) {
|
||||
draft.value = content
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(content) => {
|
||||
parseContent(content)
|
||||
if (content !== lastEmittedContent) {
|
||||
history.value = [content]
|
||||
historyIndex = 0
|
||||
lastEmittedContent = content
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const rootCompound = computed(() => (root.value?.isCompound() ? root.value : null))
|
||||
|
||||
function setMode(nextMode: 'tree' | 'snbt') {
|
||||
if (nextMode === 'tree' && parseError.value) return
|
||||
if (nextMode === 'tree') {
|
||||
history.value = [draft.value]
|
||||
historyIndex = 0
|
||||
}
|
||||
mode.value = nextMode
|
||||
emit('update:mode', nextMode)
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function handleFocusout(event: FocusEvent) {
|
||||
const currentTarget = event.currentTarget
|
||||
const nextTarget = event.relatedTarget
|
||||
if (
|
||||
currentTarget instanceof HTMLElement &&
|
||||
nextTarget instanceof Node &&
|
||||
currentTarget.contains(nextTarget)
|
||||
)
|
||||
return
|
||||
emit('save')
|
||||
}
|
||||
|
||||
function resolve(path: (string | number)[]) {
|
||||
let current: NbtTag | undefined = root.value ?? undefined
|
||||
for (const segment of path) {
|
||||
if (!current) return undefined
|
||||
if (typeof segment === 'string' && current.isCompound()) current = current.get(segment)
|
||||
else if (typeof segment === 'number' && (current.isList() || current.isArray())) {
|
||||
current = current.get(segment)
|
||||
} else return undefined
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function parentOf(path: (string | number)[]) {
|
||||
return resolve(path.slice(0, -1))
|
||||
}
|
||||
|
||||
function updateContent() {
|
||||
if (!root.value) return
|
||||
const content = root.value.toPrettyString()
|
||||
draft.value = content
|
||||
if (history.value[historyIndex] !== content) {
|
||||
history.value = history.value.slice(0, historyIndex + 1)
|
||||
history.value.push(content)
|
||||
historyIndex += 1
|
||||
}
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (mode.value !== 'tree' || historyIndex === 0) return
|
||||
historyIndex -= 1
|
||||
const content = history.value[historyIndex]
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (mode.value !== 'tree' || historyIndex >= history.value.length - 1) return
|
||||
historyIndex += 1
|
||||
const content = history.value[historyIndex]
|
||||
parseContent(content)
|
||||
lastEmittedContent = content
|
||||
emit('update:content', content)
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!(event.ctrlKey || event.metaKey)) return
|
||||
if (event.key.toLowerCase() === 'z') {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey) redo()
|
||||
else undo()
|
||||
} else if (event.key.toLowerCase() === 'y') {
|
||||
event.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
function editValue(path: (string | number)[], value: string) {
|
||||
const target = resolve(path)
|
||||
if (!target) return
|
||||
try {
|
||||
const parsed = target.isString() ? new NbtString(value) : NbtTag.fromString(value)
|
||||
if (parsed.getId() !== target.getId()) throw new Error('Value type cannot be changed')
|
||||
const parent = parentOf(path)
|
||||
const last = path.at(-1)
|
||||
if (parent?.isCompound() && typeof last === 'string') parent.set(last, parsed)
|
||||
else if (parent?.isList() && typeof last === 'number') parent.set(last, parsed)
|
||||
else if (parent?.isByteArray() && typeof last === 'number' && parsed.isByte())
|
||||
parent.set(last, parsed)
|
||||
else if (parent?.isIntArray() && typeof last === 'number' && parsed.isInt())
|
||||
parent.set(last, parsed)
|
||||
else if (parent?.isLongArray() && typeof last === 'number' && parsed.isLong())
|
||||
parent.set(last, parsed)
|
||||
else throw new Error('Value cannot be changed')
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function removeValue(path: (string | number)[]) {
|
||||
const parent = parentOf(path)
|
||||
const last = path.at(-1)
|
||||
if (parent?.isCompound() && typeof last === 'string') parent.delete(last)
|
||||
else if (parent?.isListOrArray() && typeof last === 'number') parent.delete(last)
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
}
|
||||
|
||||
function renameValue(path: (string | number)[], name: string) {
|
||||
const parent = parentOf(path)
|
||||
const oldName = path.at(-1)
|
||||
if (!parent?.isCompound() || typeof oldName !== 'string' || parent.has(name)) return
|
||||
const value = parent.get(oldName)
|
||||
if (!value) return
|
||||
parent.delete(oldName)
|
||||
parent.set(name, value)
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
}
|
||||
|
||||
function addValue(path: (string | number)[], input: string) {
|
||||
const parent = resolve(path)
|
||||
if (!parent) return
|
||||
try {
|
||||
const separator = parent.isCompound() ? input.indexOf(':') : -1
|
||||
const name = separator === -1 ? undefined : input.slice(0, separator).trim()
|
||||
const valueText = separator === -1 ? input.trim() : input.slice(separator + 1).trim()
|
||||
const value = NbtTag.fromString(valueText)
|
||||
if (parent.isCompound() && name && !parent.has(name)) parent.set(name, value)
|
||||
else if (parent.isList() && value.getId() === parent.getType()) parent.add(value)
|
||||
else if (parent.isByteArray() && value.isByte()) parent.add(value)
|
||||
else if (parent.isIntArray() && value.isInt()) parent.add(value)
|
||||
else if (parent.isLongArray() && value.isLong()) parent.add(value)
|
||||
else throw new Error('Value type does not match the container')
|
||||
parseError.value = ''
|
||||
updateContent()
|
||||
} catch (error) {
|
||||
parseError.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function formatDocument() {
|
||||
await snbtEditor.value?.formatDocument()
|
||||
}
|
||||
|
||||
defineExpose({ formatDocument })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full min-h-0 min-w-0 flex-col bg-surface-2" @focusout="handleFocusout">
|
||||
<div
|
||||
class="flex h-10 shrink-0 items-center gap-1 border-0 border-b border-solid border-surface-4 px-3"
|
||||
>
|
||||
<button
|
||||
v-for="candidate in ['tree', 'snbt'] as const"
|
||||
:key="candidate"
|
||||
type="button"
|
||||
class="rounded border-0 px-3 py-1 text-xs font-semibold capitalize"
|
||||
:class="
|
||||
mode === candidate
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-transparent text-secondary hover:bg-surface-3'
|
||||
"
|
||||
@click="setMode(candidate)"
|
||||
>
|
||||
{{ formatMessage(messages[candidate]) }}
|
||||
</button>
|
||||
<span v-if="parseError" class="ml-2 truncate text-xs text-red">{{ parseError }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'tree'"
|
||||
class="min-h-0 flex-1 overflow-auto p-2"
|
||||
tabindex="0"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<NbtTreeNode
|
||||
v-if="rootCompound"
|
||||
:tag="rootCompound"
|
||||
:path="[]"
|
||||
:depth="0"
|
||||
:read-only="readOnly"
|
||||
@edit="editValue"
|
||||
@remove="removeValue"
|
||||
@rename="renameValue"
|
||||
@add="addValue"
|
||||
/>
|
||||
</div>
|
||||
<StudioEditor
|
||||
v-else
|
||||
ref="snbtEditor"
|
||||
:file-path="filePath"
|
||||
:content="draft"
|
||||
:read-only="readOnly"
|
||||
:language="'snbt'"
|
||||
@update:content="updateSnbt"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
222
apps/app-frontend/src/components/instance/studio/NbtTreeNode.vue
Normal file
222
apps/app-frontend/src/components/instance/studio/NbtTreeNode.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type NbtTag, NbtType } from 'deepslate/nbt'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import NbtTypeIcon from './NbtTypeIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string
|
||||
tag: NbtTag
|
||||
path: (string | number)[]
|
||||
depth: number
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [path: (string | number)[], value: string]
|
||||
remove: [path: (string | number)[]]
|
||||
rename: [path: (string | number)[], value: string]
|
||||
add: [path: (string | number)[], value: string]
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'instance.files.studio.nbt.collapse', defaultMessage: 'Collapse node' },
|
||||
expand: { id: 'instance.files.studio.nbt.expand', defaultMessage: 'Expand node' },
|
||||
add: { id: 'instance.files.studio.nbt.add', defaultMessage: 'Add child' },
|
||||
remove: { id: 'instance.files.studio.nbt.remove', defaultMessage: 'Remove node' },
|
||||
addPlaceholder: {
|
||||
id: 'instance.files.studio.nbt.add-placeholder',
|
||||
defaultMessage: 'name:value',
|
||||
},
|
||||
})
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const expanded = ref(props.depth < 1)
|
||||
const editing = ref(false)
|
||||
const renaming = ref(false)
|
||||
const draft = ref('')
|
||||
const renameDraft = ref(props.name ?? '')
|
||||
const adding = ref(false)
|
||||
const addDraft = ref('')
|
||||
|
||||
const expandable = computed(
|
||||
() =>
|
||||
props.tag.isCompound() ||
|
||||
props.tag.isList() ||
|
||||
props.tag.isByteArray() ||
|
||||
props.tag.isIntArray() ||
|
||||
props.tag.isLongArray(),
|
||||
)
|
||||
const typeName = computed(() => NbtType[props.tag.getId()])
|
||||
const displayValue = computed(() => {
|
||||
if (props.tag.isCompound()) return `${props.tag.size} entries`
|
||||
if (props.tag.isList()) return `${props.tag.length} ${NbtType[props.tag.getType()]} values`
|
||||
if (props.tag.isArray()) return `${props.tag.length} values`
|
||||
return props.tag.toString()
|
||||
})
|
||||
|
||||
function children(): Array<{ name?: string; tag: NbtTag; path: (string | number)[] }> {
|
||||
if (props.tag.isCompound()) {
|
||||
return [...props.tag.keys()].map((name) => ({
|
||||
name,
|
||||
tag: props.tag.get(name)!,
|
||||
path: [...props.path, name],
|
||||
}))
|
||||
}
|
||||
if (props.tag.isList()) {
|
||||
return Array.from({ length: props.tag.length }, (_, index) => ({
|
||||
tag: props.tag.get(index),
|
||||
path: [...props.path, index],
|
||||
}))
|
||||
}
|
||||
if (props.tag.isArray()) {
|
||||
return Array.from({ length: props.tag.length }, (_, index) => ({
|
||||
tag: props.tag.get(index),
|
||||
path: [...props.path, index],
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function beginEdit() {
|
||||
if (props.readOnly || expandable.value) return
|
||||
draft.value = props.tag.isString() ? props.tag.getAsString() : props.tag.toString()
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (draft.value.trim()) emit('edit', props.path, draft.value)
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
if (renameDraft.value.trim() && renameDraft.value !== props.name) {
|
||||
emit('rename', props.path, renameDraft.value.trim())
|
||||
}
|
||||
renaming.value = false
|
||||
}
|
||||
|
||||
function commitAdd() {
|
||||
if (addDraft.value.trim()) emit('add', props.path, addDraft.value)
|
||||
addDraft.value = ''
|
||||
adding.value = false
|
||||
}
|
||||
|
||||
function forwardEdit(path: (string | number)[], value: string) {
|
||||
emit('edit', path, value)
|
||||
}
|
||||
|
||||
function forwardRename(path: (string | number)[], value: string) {
|
||||
emit('rename', path, value)
|
||||
}
|
||||
|
||||
function forwardAdd(path: (string | number)[], value: string) {
|
||||
emit('add', path, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="group flex min-h-8 items-center gap-2 rounded px-2 text-sm hover:bg-surface-3"
|
||||
:style="{ paddingLeft: `${depth * 1.25 + 0.5}rem` }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-secondary"
|
||||
:class="expandable ? 'cursor-pointer' : 'cursor-default'"
|
||||
:aria-label="formatMessage(expanded ? messages.collapse : messages.expand)"
|
||||
@click="expandable && (expanded = !expanded)"
|
||||
>
|
||||
<span v-if="expandable">{{ expanded ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<NbtTypeIcon :type="tag.getId()" />
|
||||
<template v-if="name !== undefined">
|
||||
<input
|
||||
v-if="renaming"
|
||||
v-model="renameDraft"
|
||||
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-1 text-sm text-contrast"
|
||||
@blur="commitRename"
|
||||
@keydown.enter.prevent="commitRename"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="shrink-0 border-0 bg-transparent p-0 text-secondary"
|
||||
:class="{ 'cursor-text': !readOnly }"
|
||||
@dblclick="!readOnly && (renaming = true)"
|
||||
>
|
||||
{{ name }}:
|
||||
</button>
|
||||
</template>
|
||||
<span class="text-xs text-secondary">{{ typeName }}</span>
|
||||
<input
|
||||
v-if="editing"
|
||||
v-model="draft"
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded border border-brand bg-surface-1 px-2 py-0.5 font-mono text-xs text-contrast"
|
||||
@blur="commitEdit"
|
||||
@keydown.enter.prevent="commitEdit"
|
||||
@keydown.escape="editing = false"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="min-w-0 truncate border-0 bg-transparent p-0 text-left font-mono text-xs text-primary"
|
||||
:class="{ 'cursor-text': !expandable && !readOnly }"
|
||||
@dblclick="beginEdit"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!readOnly && expandable"
|
||||
type="button"
|
||||
class="ml-auto hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-contrast"
|
||||
:aria-label="formatMessage(messages.add)"
|
||||
@click="adding = !adding"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
v-if="!readOnly && path.length > 0"
|
||||
type="button"
|
||||
class="hidden rounded border-0 bg-transparent px-1 text-xs text-secondary group-hover:inline-flex hover:text-red"
|
||||
:aria-label="formatMessage(messages.remove)"
|
||||
@click="emit('remove', path)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="adding"
|
||||
class="flex items-center gap-2 px-3 py-1"
|
||||
:style="{ paddingLeft: `${(depth + 1) * 1.25 + 2.25}rem` }"
|
||||
>
|
||||
<input
|
||||
v-model="addDraft"
|
||||
autofocus
|
||||
class="min-w-0 flex-1 rounded border border-surface-5 bg-surface-1 px-2 py-1 font-mono text-xs text-contrast"
|
||||
:placeholder="formatMessage(messages.addPlaceholder)"
|
||||
@keydown.enter="commitAdd"
|
||||
@blur="commitAdd"
|
||||
@keydown.escape="adding = false"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="expanded && expandable">
|
||||
<NbtTreeNode
|
||||
v-for="child in children()"
|
||||
:key="child.path.join('.')"
|
||||
:name="child.name"
|
||||
:tag="child.tag"
|
||||
:path="child.path"
|
||||
:depth="depth + 1"
|
||||
:read-only="readOnly"
|
||||
@edit="forwardEdit"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="forwardRename"
|
||||
@add="forwardAdd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { NbtType } from 'deepslate/nbt'
|
||||
|
||||
defineProps<{
|
||||
type: NbtType
|
||||
}>()
|
||||
|
||||
const colors: Record<NbtType, string> = {
|
||||
[NbtType.End]: 'text-secondary',
|
||||
[NbtType.Byte]: 'text-orange',
|
||||
[NbtType.Short]: 'text-yellow',
|
||||
[NbtType.Int]: 'text-green',
|
||||
[NbtType.Long]: 'text-blue',
|
||||
[NbtType.Float]: 'text-purple',
|
||||
[NbtType.Double]: 'text-pink',
|
||||
[NbtType.ByteArray]: 'text-orange',
|
||||
[NbtType.String]: 'text-brand',
|
||||
[NbtType.List]: 'text-cyan',
|
||||
[NbtType.Compound]: 'text-contrast',
|
||||
[NbtType.IntArray]: 'text-green',
|
||||
[NbtType.LongArray]: 'text-blue',
|
||||
}
|
||||
|
||||
const labels: Partial<Record<NbtType, string>> = {
|
||||
[NbtType.Byte]: 'B',
|
||||
[NbtType.Short]: 'S',
|
||||
[NbtType.Int]: 'I',
|
||||
[NbtType.Long]: 'L',
|
||||
[NbtType.Float]: 'F',
|
||||
[NbtType.Double]: 'D',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
class="size-4 shrink-0"
|
||||
:class="colors[type]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path v-if="type === NbtType.Compound" d="M3 2.5h7l3 3v8H3zM10 2.5v3h3" />
|
||||
<path v-else-if="type === NbtType.List" d="M3 3h10M3 8h10M3 13h10M5 3v10" />
|
||||
<path
|
||||
v-else-if="type === NbtType.String"
|
||||
d="M4 3h8M4 13h8M5 3c-3 2-3 8 0 10M11 3c3 2 3 8 0 10"
|
||||
/>
|
||||
<path
|
||||
v-else-if="
|
||||
type === NbtType.ByteArray || type === NbtType.IntArray || type === NbtType.LongArray
|
||||
"
|
||||
d="M4 2.5h8v11H4zM6.5 5h3M6.5 8h3M6.5 11h3"
|
||||
/>
|
||||
<path v-else d="M3 3h10v10H3z" />
|
||||
<text
|
||||
v-if="labels[type]"
|
||||
x="8"
|
||||
y="11"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
text-anchor="middle"
|
||||
font-size="7"
|
||||
font-weight="700"
|
||||
>
|
||||
{{ labels[type] }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import type * as Monaco from 'monaco-editor'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__axolotlMonacoRuntime?: Promise<typeof Monaco>
|
||||
require?: {
|
||||
config(config: Record<string, unknown>): void
|
||||
(dependencies: string[], callback: (monaco: typeof Monaco) => void): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
filePath: string
|
||||
language: string
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:content': [content: string]
|
||||
save: []
|
||||
blur: []
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'instance.files.studio.editor-loading',
|
||||
defaultMessage: 'Loading editor...',
|
||||
},
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const editorElement = ref<HTMLElement | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
let monaco: typeof Monaco | null = null
|
||||
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
|
||||
let model: Monaco.editor.ITextModel | null = null
|
||||
let contentSubscription: Monaco.IDisposable | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let applyingExternalContent = false
|
||||
let disposed = false
|
||||
|
||||
function loadCodiconStyles() {
|
||||
if (document.querySelector('link[data-monaco-codicons]')) return
|
||||
const stylesheet = document.createElement('link')
|
||||
stylesheet.rel = 'stylesheet'
|
||||
stylesheet.href = '/monaco/codicon/codicon.css'
|
||||
stylesheet.dataset.monacoCodicons = 'true'
|
||||
document.head.append(stylesheet)
|
||||
}
|
||||
|
||||
function loadMonaco(): Promise<typeof Monaco> {
|
||||
if (window.__axolotlMonacoRuntime) return window.__axolotlMonacoRuntime
|
||||
|
||||
window.__axolotlMonacoRuntime = new Promise((resolve, reject) => {
|
||||
loadCodiconStyles()
|
||||
const initialize = () => {
|
||||
const require = window.require
|
||||
if (!require) {
|
||||
reject(new Error('Monaco loader did not initialize'))
|
||||
return
|
||||
}
|
||||
require.config({ paths: { vs: '/monaco/vs' } })
|
||||
require(['vs/editor/editor.main'], (loadedMonaco: typeof Monaco) => resolve(loadedMonaco))
|
||||
}
|
||||
|
||||
if (window.require) {
|
||||
initialize()
|
||||
return
|
||||
}
|
||||
|
||||
const existingLoader = document.querySelector<HTMLScriptElement>('script[data-monaco-loader]')
|
||||
if (existingLoader) {
|
||||
existingLoader.addEventListener('load', initialize, { once: true })
|
||||
existingLoader.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('Failed to load Monaco editor')),
|
||||
{ once: true },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const loader = document.createElement('script')
|
||||
loader.src = '/monaco/vs/loader.js'
|
||||
loader.dataset.monacoLoader = 'true'
|
||||
loader.onload = initialize
|
||||
loader.onerror = () => reject(new Error('Failed to load Monaco editor'))
|
||||
document.head.append(loader)
|
||||
})
|
||||
|
||||
return window.__axolotlMonacoRuntime
|
||||
}
|
||||
|
||||
function cssVariable(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
if (!monaco) return
|
||||
const isLight = document.documentElement.classList.contains('light-mode')
|
||||
monaco.editor.defineTheme('axolotl-studio', {
|
||||
base: isLight ? 'vs' : 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': cssVariable('--surface-2'),
|
||||
'editor.foreground': cssVariable('--color-base'),
|
||||
'editorGutter.background': cssVariable('--surface-2'),
|
||||
'editorLineNumber.foreground': cssVariable('--color-secondary'),
|
||||
'editor.lineHighlightBackground': cssVariable('--surface-3'),
|
||||
'editorCursor.foreground': cssVariable('--color-brand'),
|
||||
},
|
||||
})
|
||||
monaco.editor.setTheme('axolotl-studio')
|
||||
}
|
||||
|
||||
function registerStudioLanguages() {
|
||||
if (!monaco) return
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'toml')) {
|
||||
monaco.languages.register({ id: 'toml', extensions: ['.toml'] })
|
||||
monaco.languages.setMonarchTokensProvider('toml', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/#.*/, 'comment'],
|
||||
[/\[\[?.*?\]\]?/, 'type.identifier'],
|
||||
[/^[\w.-]+(?=\s*=)/, 'key'],
|
||||
[/"([^"\\]|\\.)*"/, 'string'],
|
||||
[/'[^']*'/, 'string'],
|
||||
[/\b(true|false)\b/, 'keyword'],
|
||||
[/[-+]?\b\d+(\.\d+)?\b/, 'number'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'properties')) {
|
||||
monaco.languages.register({ id: 'properties', extensions: ['.properties'] })
|
||||
monaco.languages.setMonarchTokensProvider('properties', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/^[#!].*$/, 'comment'],
|
||||
[/^[^\s:=]+(?=\s*[:=])/, 'key'],
|
||||
[/[:=]/, 'delimiter'],
|
||||
[/\\./, 'string.escape'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!monaco.languages.getLanguages().some(({ id }) => id === 'snbt')) {
|
||||
monaco.languages.register({ id: 'snbt' })
|
||||
monaco.languages.setMonarchTokensProvider('snbt', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\/\/.*$/, 'comment'],
|
||||
[/[{}[\],:]/, 'delimiter'],
|
||||
[/(?:true|false)\b/, 'keyword'],
|
||||
[/-?(?:\d+\.?\d*|\.\d+)(?:[bBsSlLfFdD])?\b/, 'number'],
|
||||
[/'(?:[^'\\]|\\.)*'/, 'string'],
|
||||
[/"(?:[^"\\]|\\.)*"/, 'string'],
|
||||
[/[A-Za-z0-9_.+-]+(?=\s*:)/, 'key'],
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createModel() {
|
||||
if (!monaco || !editor) return
|
||||
contentSubscription?.dispose()
|
||||
model?.dispose()
|
||||
model = monaco.editor.createModel(
|
||||
props.content,
|
||||
props.language,
|
||||
monaco.Uri.parse(
|
||||
`axolotl-instance://studio/${props.filePath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
),
|
||||
)
|
||||
editor.setModel(model)
|
||||
contentSubscription = model.onDidChangeContent(() => {
|
||||
if (!applyingExternalContent) emit('update:content', model?.getValue() ?? '')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
monaco = await loadMonaco()
|
||||
} catch (error) {
|
||||
loading.value = false
|
||||
console.error('Failed to load Monaco editor', error)
|
||||
return
|
||||
}
|
||||
if (disposed) return
|
||||
registerStudioLanguages()
|
||||
applyTheme()
|
||||
|
||||
if (!editorElement.value) return
|
||||
editor = monaco.editor.create(editorElement.value, {
|
||||
automaticLayout: false,
|
||||
fontSize: 14,
|
||||
fontLigatures: false,
|
||||
minimap: { enabled: true },
|
||||
padding: { top: 12 },
|
||||
readOnly: props.readOnly,
|
||||
renderWhitespace: 'selection',
|
||||
scrollBeyondLastLine: false,
|
||||
theme: 'axolotl-studio',
|
||||
})
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => emit('save'))
|
||||
editor.onDidBlurEditorWidget(() => emit('blur'))
|
||||
createModel()
|
||||
|
||||
resizeObserver = new ResizeObserver(() => editor?.layout())
|
||||
resizeObserver.observe(editorElement.value)
|
||||
themeObserver = new MutationObserver(applyTheme)
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
loading.value = false
|
||||
})
|
||||
watch(
|
||||
() => props.filePath,
|
||||
() => createModel(),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(content) => {
|
||||
if (!model || model.getValue() === content) return
|
||||
applyingExternalContent = true
|
||||
model.setValue(content)
|
||||
applyingExternalContent = false
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.language,
|
||||
(language) => {
|
||||
if (monaco && model) monaco.editor.setModelLanguage(model, language)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.readOnly,
|
||||
(readOnly) => editor?.updateOptions({ readOnly }),
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
contentSubscription?.dispose()
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
editor?.dispose()
|
||||
model?.dispose()
|
||||
})
|
||||
|
||||
async function formatDocument() {
|
||||
await editor?.getAction('editor.action.formatDocument')?.run()
|
||||
}
|
||||
|
||||
defineExpose({ formatDocument })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative size-full min-h-0 min-w-0 bg-surface-2">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 z-[1] flex items-center justify-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div ref="editorElement" class="size-full min-h-0 min-w-0" />
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { FileCodeIcon, XIcon } from '@modrinth/assets'
|
||||
import { commonMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { StudioDocument } from './useStudioDocuments'
|
||||
|
||||
defineProps<{
|
||||
documents: StudioDocument[]
|
||||
activePath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: [path: string]
|
||||
close: [path: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
let middleClickPath: string | null = null
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
const container = event.currentTarget as HTMLElement
|
||||
if (container.scrollWidth <= container.clientWidth) return
|
||||
event.preventDefault()
|
||||
container.scrollLeft += event.deltaY || event.deltaX
|
||||
}
|
||||
|
||||
function handleAuxClick(event: MouseEvent, path: string) {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (middleClickPath === path) {
|
||||
middleClickPath = null
|
||||
return
|
||||
}
|
||||
emit('close', path)
|
||||
}
|
||||
|
||||
function handleMouseDown(event: MouseEvent, path: string) {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
middleClickPath = path
|
||||
emit('close', path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-w-0 flex-1 overflow-x-auto" @wheel="handleWheel">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.path"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="document.path === activePath"
|
||||
class="flex h-full max-w-[14rem] min-w-[8rem] shrink-0 select-none items-center gap-2 border-0 border-r border-solid border-surface-4 px-3 text-left text-sm text-secondary hover:bg-surface-2"
|
||||
:class="{ 'bg-surface-2 !text-contrast': document.path === activePath }"
|
||||
@click="emit('activate', document.path)"
|
||||
@mousedown="handleMouseDown($event, document.path)"
|
||||
@auxclick="handleAuxClick($event, document.path)"
|
||||
@keydown.enter="emit('activate', document.path)"
|
||||
@keydown.space.prevent="emit('activate', document.path)"
|
||||
>
|
||||
<XIcon v-if="document.kind === 'unsupported'" class="size-4 shrink-0 text-red" />
|
||||
<FileCodeIcon v-else class="size-4 shrink-0 text-secondary" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ document.name }}</span>
|
||||
<span
|
||||
v-if="document.content !== document.savedContent"
|
||||
class="size-2 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(commonMessages.closeButton)"
|
||||
class="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded border-0 bg-transparent p-0 text-secondary hover:bg-surface-4 hover:text-contrast"
|
||||
@pointerdown.stop
|
||||
@click.stop.prevent="emit('close', document.path)"
|
||||
>
|
||||
<XIcon class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,137 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface StudioDocument {
|
||||
kind: 'text' | 'nbt' | 'image' | 'video' | 'unsupported'
|
||||
path: string
|
||||
name: string
|
||||
content: string
|
||||
savedContent: string
|
||||
saving: boolean
|
||||
}
|
||||
|
||||
export function useStudioDocuments(
|
||||
writeDocument: (document: StudioDocument, content: string) => Promise<void>,
|
||||
onSaveError: (error: unknown) => void,
|
||||
) {
|
||||
const documents = ref<StudioDocument[]>([])
|
||||
const activeIndex = ref(-1)
|
||||
const savePromises = new Map<string, Promise<boolean>>()
|
||||
|
||||
const activeDocument = computed(() => documents.value[activeIndex.value] ?? null)
|
||||
const activePath = computed(() => activeDocument.value?.path ?? '')
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
activeDocument.value !== null &&
|
||||
activeDocument.value.content !== activeDocument.value.savedContent,
|
||||
)
|
||||
const hasAnyUnsavedChanges = computed(() =>
|
||||
documents.value.some((document) => document.content !== document.savedContent),
|
||||
)
|
||||
|
||||
function saveDocument(document: StudioDocument | null): Promise<boolean> {
|
||||
if (
|
||||
!document ||
|
||||
(document.kind !== 'text' && document.kind !== 'nbt') ||
|
||||
document.content === document.savedContent
|
||||
) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
const existingPromise = savePromises.get(document.path)
|
||||
if (existingPromise) return existingPromise
|
||||
|
||||
document.saving = true
|
||||
const contentToSave = document.content
|
||||
const savePromise = writeDocument(document, contentToSave)
|
||||
.then(() => {
|
||||
document.savedContent = contentToSave
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
onSaveError(error)
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
document.saving = false
|
||||
savePromises.delete(document.path)
|
||||
})
|
||||
|
||||
savePromises.set(document.path, savePromise)
|
||||
return savePromise
|
||||
}
|
||||
|
||||
async function activate(path: string) {
|
||||
if (path === activePath.value) return true
|
||||
if (!(await saveDocument(activeDocument.value))) return false
|
||||
const nextIndex = documents.value.findIndex((document) => document.path === path)
|
||||
if (nextIndex === -1) return false
|
||||
activeIndex.value = nextIndex
|
||||
return true
|
||||
}
|
||||
|
||||
async function open(document: StudioDocument) {
|
||||
const existing = documents.value.find((candidate) => candidate.path === document.path)
|
||||
if (existing) return activate(existing.path)
|
||||
if (!(await saveDocument(activeDocument.value))) return false
|
||||
documents.value.push(document)
|
||||
activeIndex.value = documents.value.length - 1
|
||||
return true
|
||||
}
|
||||
|
||||
async function close(path: string) {
|
||||
const index = documents.value.findIndex((document) => document.path === path)
|
||||
if (index === -1) return false
|
||||
if (!(await saveDocument(documents.value[index]))) return false
|
||||
|
||||
const wasActive = activeIndex.value === index
|
||||
documents.value.splice(index, 1)
|
||||
if (documents.value.length === 0) {
|
||||
activeIndex.value = -1
|
||||
} else if (wasActive) {
|
||||
activeIndex.value = Math.min(index, documents.value.length - 1)
|
||||
} else if (index < activeIndex.value) {
|
||||
activeIndex.value -= 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function updateActiveContent(content: string) {
|
||||
if (activeDocument.value) activeDocument.value.content = content
|
||||
}
|
||||
|
||||
function discardActiveChanges() {
|
||||
if (activeDocument.value) activeDocument.value.content = activeDocument.value.savedContent
|
||||
}
|
||||
|
||||
async function saveActive() {
|
||||
return saveDocument(activeDocument.value)
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const results = await Promise.all(documents.value.map((document) => saveDocument(document)))
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
documents.value = []
|
||||
activeIndex.value = -1
|
||||
savePromises.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
documents,
|
||||
activeDocument,
|
||||
activePath,
|
||||
hasUnsavedChanges,
|
||||
hasAnyUnsavedChanges,
|
||||
activate,
|
||||
open,
|
||||
close,
|
||||
saveDocument,
|
||||
saveActive,
|
||||
saveAll,
|
||||
updateActiveContent,
|
||||
discardActiveChanges,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { FileArchiveIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
|
||||
defineProps<{
|
||||
path: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:path': [path: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
selectFile: {
|
||||
id: 'app.lab.mod-translation.select-file',
|
||||
defaultMessage: 'Choose a mod JAR',
|
||||
},
|
||||
})
|
||||
|
||||
async function pickFile() {
|
||||
const path = await open({
|
||||
multiple: false,
|
||||
title: 'Choose a Minecraft mod JAR',
|
||||
filters: [{ name: 'Minecraft mod', extensions: ['jar'] }],
|
||||
})
|
||||
if (typeof path === 'string') emit('update:path', path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-picker">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button class="file-pick-button" @click="pickFile">
|
||||
<FileArchiveIcon />
|
||||
<span>{{ formatMessage(messages.selectFile) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span
|
||||
v-if="path"
|
||||
class="selected-path min-w-0 overflow-hidden flex-1 text-contrast text-[0.78rem] truncate"
|
||||
:title="path"
|
||||
>{{ path }}</span
|
||||
>
|
||||
<span v-else class="selected-path empty min-w-0 overflow-hidden flex-1 text-secondary text-[0.78rem] truncate"
|
||||
>{{ formatMessage(messages.selectFile) }}…</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.file-pick-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
import ModTranslationJobProgress from './ModTranslationJobProgress.vue'
|
||||
import ModTranslationJobResult from './ModTranslationJobResult.vue'
|
||||
import ModTranslationJobSummary from './ModTranslationJobSummary.vue'
|
||||
import ModTranslationTaskTimeline from './ModTranslationTaskTimeline.vue'
|
||||
import ModTranslationTechnicalDetails from './ModTranslationTechnicalDetails.vue'
|
||||
|
||||
const props = defineProps<{ job: ModTranslationJob }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [taskId: string]
|
||||
remove: [taskId: string]
|
||||
openOutput: [job: ModTranslationJob]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="job-card flex min-w-0 flex-col gap-[0.7rem] rounded-[var(--radius-lg)] bg-surface-3 p-[0.95rem_1rem]"
|
||||
:class="`job-card--${job.status}`"
|
||||
>
|
||||
<ModTranslationJobSummary
|
||||
:job="props.job"
|
||||
@cancel="emit('cancel', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
@open-output="emit('openOutput', $event)"
|
||||
/>
|
||||
<ModTranslationJobProgress :job="props.job" />
|
||||
<ModTranslationTaskTimeline :entries="job.timeline" />
|
||||
<ModTranslationJobResult :job="props.job" />
|
||||
<ModTranslationTechnicalDetails :job="props.job" />
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.job-card--running {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-brand) 7%, transparent),
|
||||
transparent 4.5rem
|
||||
),
|
||||
var(--surface-3);
|
||||
}
|
||||
|
||||
.job-card--completed {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-green) 6%, transparent),
|
||||
transparent 4.5rem
|
||||
),
|
||||
var(--surface-3);
|
||||
}
|
||||
|
||||
.job-card--failed {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--color-red) 6%, transparent),
|
||||
transparent 4.5rem
|
||||
),
|
||||
var(--surface-3);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, EmptyState, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { countModTranslationJobs } from '@/lab/mod-translation/job-state.ts'
|
||||
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
import ModTranslationJobCard from './ModTranslationJobCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
jobs: ModTranslationJob[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [taskId: string]
|
||||
remove: [taskId: string]
|
||||
openOutput: [job: ModTranslationJob]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
noJobs: {
|
||||
id: 'app.lab.mod-translation.no-jobs',
|
||||
defaultMessage: 'Started jobs will appear here.',
|
||||
},
|
||||
activeCount: {
|
||||
id: 'app.lab.mod-translation.active-count',
|
||||
defaultMessage: '{count} running',
|
||||
},
|
||||
failedCount: {
|
||||
id: 'app.lab.mod-translation.failed-count',
|
||||
defaultMessage: '{count} failed',
|
||||
},
|
||||
completedCount: {
|
||||
id: 'app.lab.mod-translation.completed-count',
|
||||
defaultMessage: '{count} completed',
|
||||
},
|
||||
allFinished: {
|
||||
id: 'app.lab.mod-translation.all-finished',
|
||||
defaultMessage: 'All finished',
|
||||
},
|
||||
})
|
||||
|
||||
const counts = computed(() => countModTranslationJobs(props.jobs))
|
||||
const headerState = computed(() => {
|
||||
if (counts.value.running > 0) return 'running'
|
||||
if (counts.value.failed > 0) return 'failed'
|
||||
return 'completed'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<template v-if="jobs.length">
|
||||
<div class="job-list-header">
|
||||
<span
|
||||
class="w-2 h-2 flex-none rounded-full"
|
||||
:class="
|
||||
headerState === 'running'
|
||||
? 'live-dot--running'
|
||||
: headerState === 'completed'
|
||||
? 'bg-green'
|
||||
: 'bg-red'
|
||||
"
|
||||
/>
|
||||
<span v-if="counts.running" class="header-chip header-chip--running">
|
||||
{{ formatMessage(messages.activeCount, { count: counts.running }) }}
|
||||
</span>
|
||||
<span v-if="counts.failed" class="header-chip header-chip--failed">
|
||||
{{ formatMessage(messages.failedCount, { count: counts.failed }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="counts.completed && (counts.running || counts.failed)"
|
||||
class="header-chip header-chip--done"
|
||||
>
|
||||
{{ formatMessage(messages.completedCount, { count: counts.completed }) }}
|
||||
</span>
|
||||
<span v-if="!counts.running && !counts.failed" class="header-chip header-chip--done">
|
||||
{{ formatMessage(messages.allFinished) }}
|
||||
</span>
|
||||
</div>
|
||||
<TransitionGroup name="job-list" tag="div" class="flex flex-col gap-3">
|
||||
<ModTranslationJobCard
|
||||
v-for="job in jobs"
|
||||
:key="job.taskId"
|
||||
:job="job"
|
||||
@cancel="emit('cancel', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
@open-output="emit('openOutput', $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
<EmptyState v-else :heading="formatMessage(messages.noJobs)" type="empty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.job-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.live-dot--running {
|
||||
background: var(--color-brand);
|
||||
animation: list-pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes list-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-brand) 50%, transparent);
|
||||
}
|
||||
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0.5rem transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.header-chip {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-chip--running {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.header-chip--done {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.header-chip--failed {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.job-list-enter-active {
|
||||
transition:
|
||||
opacity 0.35s ease,
|
||||
transform 0.35s ease;
|
||||
}
|
||||
|
||||
.job-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.985);
|
||||
}
|
||||
|
||||
.job-list-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.job-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { modTranslationPhaseSteps } from '@/lab/mod-translation/i18n'
|
||||
import { phaseIndex } from '@/lab/mod-translation/job-state'
|
||||
import type { ModTranslationJob, ModTranslationPhase } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
const props = defineProps<{ job: ModTranslationJob }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const activeIndex = computed(() => phaseIndex(props.job.phase))
|
||||
const measurable = computed(() => props.job.weightTotal > 0)
|
||||
const verificationLabel = computed(() => {
|
||||
if (!measurable.value) return '正在建立可验证工作量…'
|
||||
return `当前复验通过 ${formatWeight(props.job.weightVerified)} / ${formatWeight(props.job.weightTotal)}`
|
||||
})
|
||||
const itemLabel = computed(() => {
|
||||
if (props.job.total <= 0) return undefined
|
||||
return `当前批次 ${props.job.completed.toLocaleString()} / ${props.job.total.toLocaleString()}`
|
||||
})
|
||||
|
||||
function stepState(step: ModTranslationPhase): 'done' | 'current' | 'failed' | 'pending' {
|
||||
const index = phaseIndex(step)
|
||||
if (props.job.status === 'completed') return 'done'
|
||||
if (props.job.status === 'failed' && index === activeIndex.value) return 'failed'
|
||||
if (index < activeIndex.value) return 'done'
|
||||
if (index === activeIndex.value) return 'current'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function formatWeight(value: number): string {
|
||||
return Number.isInteger(value) ? value.toLocaleString() : value.toFixed(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="progress-section" aria-label="Task progress">
|
||||
<div class="stepper">
|
||||
<template v-for="(step, index) in modTranslationPhaseSteps" :key="step.id">
|
||||
<span
|
||||
v-if="index > 0"
|
||||
class="connector"
|
||||
:class="{ 'connector--on': index <= activeIndex || job.status === 'completed' }"
|
||||
/>
|
||||
<span
|
||||
class="step"
|
||||
:class="`step--${stepState(step.id)}`"
|
||||
:title="formatMessage(step.label)"
|
||||
>
|
||||
<component :is="step.icon" />
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="track"
|
||||
:class="{ 'track--indeterminate': !measurable && job.status === 'running' }"
|
||||
role="progressbar"
|
||||
:aria-valuenow="measurable ? job.percent : undefined"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuetext="verificationLabel"
|
||||
>
|
||||
<div
|
||||
class="fill"
|
||||
:class="[`fill--${job.level}`, { 'fill--failed': job.status === 'failed' }]"
|
||||
:style="{ width: measurable ? `${job.percent}%` : '36%' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="live" :class="[`live--${job.level}`, { 'live--failed': job.status === 'failed' }]">
|
||||
<span v-if="job.status === 'running'" class="live-dot" />
|
||||
<strong>{{ job.message || '正在准备…' }}</strong>
|
||||
<span class="verification max-sm:hidden">{{ verificationLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="itemLabel" class="stats">
|
||||
<span class="stat-pill"><CheckCircleIcon />{{ itemLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="job.sample" class="sample">
|
||||
<span class="sample-label"><SparklesIcon />最近写入</span>
|
||||
<div class="sample-row">
|
||||
<span :title="job.sample.source">{{ job.sample.source }}</span>
|
||||
<span class="sample-arrow">→</span>
|
||||
<strong :title="job.sample.translation">{{ job.sample.translation }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: grid;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-text-secondary);
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
color 0.25s ease;
|
||||
}
|
||||
|
||||
.step :deep(svg) {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
}
|
||||
|
||||
.step--current {
|
||||
background: color-mix(in srgb, var(--color-brand) 18%, var(--color-button-bg));
|
||||
color: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 10%, transparent);
|
||||
}
|
||||
|
||||
.step--done {
|
||||
background: color-mix(in srgb, var(--color-green) 16%, var(--color-button-bg));
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.step--failed {
|
||||
background: color-mix(in srgb, var(--color-red) 16%, var(--color-button-bg));
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.connector {
|
||||
height: 2px;
|
||||
min-width: 0.45rem;
|
||||
flex: 1;
|
||||
background: var(--color-divider);
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
|
||||
.connector--on {
|
||||
background: color-mix(in srgb, var(--color-green) 65%, var(--color-divider));
|
||||
}
|
||||
|
||||
.track {
|
||||
height: 0.42rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--color-button-bg);
|
||||
}
|
||||
|
||||
.fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--color-brand);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.fill--warn {
|
||||
background: var(--color-orange);
|
||||
}
|
||||
|
||||
.fill--error,
|
||||
.fill--failed {
|
||||
background: var(--color-red);
|
||||
}
|
||||
|
||||
.track--indeterminate .fill {
|
||||
animation: indeterminate 1.25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(310%);
|
||||
}
|
||||
}
|
||||
|
||||
.live {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-brand) 8%, var(--color-button-bg));
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.live--warn {
|
||||
background: color-mix(in srgb, var(--color-orange) 9%, var(--color-button-bg));
|
||||
}
|
||||
|
||||
.live--error,
|
||||
.live--failed {
|
||||
background: color-mix(in srgb, var(--color-red) 9%, var(--color-button-bg));
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 15%, transparent);
|
||||
}
|
||||
|
||||
.live strong {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 0.73rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.verification {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.66rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-button-bg);
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-pill :deep(svg),
|
||||
.sample-label :deep(svg) {
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
}
|
||||
|
||||
.sample {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand) 18%, var(--color-divider));
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--color-brand) 5%, var(--color-button-bg));
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.sample-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.sample-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.sample-row span,
|
||||
.sample-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sample-row strong {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.sample-arrow {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
const props = defineProps<{ job: ModTranslationJob }>()
|
||||
const hasWarnings = computed(
|
||||
() => props.job.status === 'completed' && Boolean(props.job.report?.warnings?.length),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="job.status !== 'running'"
|
||||
class="result"
|
||||
:class="[`result--${job.status}`, { 'result--warning': hasWarnings }]"
|
||||
>
|
||||
<div class="result-head flex items-center gap-[0.45rem] text-green">
|
||||
<span class="result-badge">
|
||||
<CheckCircleIcon v-if="job.status === 'completed'" />
|
||||
<XIcon v-else />
|
||||
</span>
|
||||
<strong>{{
|
||||
job.status === 'completed'
|
||||
? hasWarnings
|
||||
? '已生成,但仍有未覆盖文本'
|
||||
: '翻译完成'
|
||||
: job.error?.code || 'UNKNOWN_ERROR'
|
||||
}}</strong>
|
||||
</div>
|
||||
<template v-if="job.status === 'completed'">
|
||||
<div v-if="job.report?.modName?.name" class="result-row">
|
||||
<span>模组</span><strong>{{ job.report.modName.name }}</strong>
|
||||
</div>
|
||||
<div class="result-stats">
|
||||
<div>
|
||||
<span>语言条目</span
|
||||
><strong
|
||||
>{{ job.report?.languageAccepted ?? 0 }}/{{
|
||||
job.report?.languageAttempted ?? 0
|
||||
}}</strong
|
||||
>
|
||||
</div>
|
||||
<div v-if="job.report?.classTotal">
|
||||
<span>Class 文本</span
|
||||
><strong>{{ job.report.classResolved }}/{{ job.report.classTotal }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="job.report?.classChangedFiles?.length" class="secondary"
|
||||
>改动文件:{{ job.report.classChangedFiles.join('、') }}</span
|
||||
>
|
||||
<ul v-if="job.report?.warnings?.length" class="warnings">
|
||||
<li v-for="warning in job.report.warnings" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
<span class="path" :title="job.outputPath">{{ job.outputPath }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>{{ job.error?.message || job.message }}</p>
|
||||
<pre v-if="job.error?.details">{{ JSON.stringify(job.error.details, null, 2) }}</pre>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.7rem 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.result--completed {
|
||||
border-color: color-mix(in srgb, var(--color-green) 24%, var(--color-divider));
|
||||
background: color-mix(in srgb, var(--color-green) 7%, var(--color-button-bg));
|
||||
}
|
||||
|
||||
.result--failed {
|
||||
border-color: color-mix(in srgb, var(--color-red) 24%, var(--color-divider));
|
||||
background: color-mix(in srgb, var(--color-red) 7%, var(--color-button-bg));
|
||||
}
|
||||
|
||||
.result--warning {
|
||||
border-color: color-mix(in srgb, var(--color-orange) 30%, var(--color-divider));
|
||||
background: color-mix(in srgb, var(--color-orange) 7%, var(--color-button-bg));
|
||||
}
|
||||
|
||||
.result--failed .result-head {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.result--warning .result-head {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.result-badge {
|
||||
display: grid;
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.result-badge :deep(svg) {
|
||||
width: 0.82rem;
|
||||
height: 0.82rem;
|
||||
}
|
||||
|
||||
.result-head strong {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.result-row strong {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.result-stats div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-3);
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.result-stats span,
|
||||
.secondary,
|
||||
.path,
|
||||
.result p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.warnings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--color-orange);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.result-stats strong {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.path {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.64rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.result pre {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-1);
|
||||
padding: 0.55rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.64rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, FileArchiveIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
const props = defineProps<{ job: ModTranslationJob }>()
|
||||
const emit = defineEmits<{
|
||||
cancel: [taskId: string]
|
||||
remove: [taskId: string]
|
||||
openOutput: [job: ModTranslationJob]
|
||||
}>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const cancelling = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const messages = defineMessages({
|
||||
cancel: { id: 'app.lab.mod-translation.cancel', defaultMessage: 'Cancel' },
|
||||
cancelling: { id: 'app.lab.mod-translation.cancelling', defaultMessage: 'Cancelling…' },
|
||||
openOutput: { id: 'app.lab.mod-translation.open-output', defaultMessage: 'Open output folder' },
|
||||
done: { id: 'app.lab.mod-translation.done', defaultMessage: 'Done' },
|
||||
failed: { id: 'app.lab.mod-translation.failed', defaultMessage: 'Failed' },
|
||||
})
|
||||
|
||||
const fileName = computed(() => props.job.inputPath.split(/[\\/]/).pop() || props.job.inputPath)
|
||||
const elapsed = computed(() => {
|
||||
const startedAt = Date.parse(props.job.startedAt)
|
||||
if (!Number.isFinite(startedAt)) return '00:00'
|
||||
const endedAt = props.job.status === 'running' ? now.value : Date.parse(props.job.updatedAt)
|
||||
return formatDuration(Math.floor((Math.max(startedAt, endedAt) - startedAt) / 1000))
|
||||
})
|
||||
const badgeClass = computed(() => {
|
||||
if (props.job.status === 'completed') return 'file-badge--ok'
|
||||
if (props.job.status === 'failed') return 'file-badge--fail'
|
||||
return `file-badge--${props.job.level}`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.job.status,
|
||||
(status) => {
|
||||
if (status !== 'running') cancelling.value = false
|
||||
},
|
||||
)
|
||||
|
||||
function cancel() {
|
||||
if (cancelling.value) return
|
||||
cancelling.value = true
|
||||
emit('cancel', props.job.taskId)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const safe = Math.max(0, Math.round(seconds))
|
||||
const hours = Math.floor(safe / 3600)
|
||||
const minutes = Math.floor((safe % 3600) / 60)
|
||||
const rest = safe % 60
|
||||
const mm = String(minutes).padStart(2, '0')
|
||||
const ss = String(rest).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="job-header">
|
||||
<span class="file-badge" :class="badgeClass"><FileArchiveIcon /></span>
|
||||
<div class="job-title">
|
||||
<strong :title="job.inputPath">{{ fileName }}</strong>
|
||||
<span>{{ elapsed }}</span>
|
||||
</div>
|
||||
<span v-if="job.status === 'running'" class="job-percent">{{ job.percent }}%</span>
|
||||
<span
|
||||
v-else
|
||||
class="job-status inline-flex items-center gap-[0.3rem] text-[0.72rem] font-extrabold text-green"
|
||||
:class="{ 'text-red': job.status === 'failed' }"
|
||||
>
|
||||
<CheckCircleIcon v-if="job.status === 'completed'" />
|
||||
<XIcon v-else />
|
||||
{{ formatMessage(job.status === 'completed' ? messages.done : messages.failed) }}
|
||||
</span>
|
||||
<div class="actions">
|
||||
<ButtonStyled v-if="job.status === 'running'" color="red" type="outlined" size="small">
|
||||
<button :disabled="cancelling" @click="cancel">
|
||||
{{ formatMessage(cancelling ? messages.cancelling : messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="job.status === 'completed'" color="brand" size="small">
|
||||
<button @click="emit('openOutput', job)">
|
||||
<FolderOpenIcon />{{ formatMessage(messages.openOutput) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="job.status !== 'running'" type="outlined" size="small">
|
||||
<button aria-label="Remove task" @click="emit('remove', job.taskId)"><XIcon /></button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.job-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.file-badge {
|
||||
display: inline-flex;
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-brand-highlight);
|
||||
color: var(--color-brand);
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
.file-badge :deep(svg) {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
}
|
||||
|
||||
.file-badge--warn {
|
||||
background: color-mix(in srgb, var(--color-orange) 16%, transparent);
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.file-badge--error,
|
||||
.file-badge--fail {
|
||||
background: color-mix(in srgb, var(--color-red) 16%, transparent);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.file-badge--ok {
|
||||
background: color-mix(in srgb, var(--color-green) 16%, transparent);
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.job-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.job-title strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.job-title span {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.66rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.job-percent {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.job-status :deep(svg),
|
||||
.actions :deep(svg) {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.actions :deep(button) {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.actions :deep(button svg) {
|
||||
font-size: initial;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { Combobox, type ComboboxOption, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import AIIcon from '@/components/ui/settings/AIIcon.vue'
|
||||
import { type AIProviderDefinition, getAICatalog, getAIState, sharedAIState } from '@/helpers/ai'
|
||||
import { getTranslationSettings } from '@/helpers/translation'
|
||||
import type { ModTranslationOptions } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ModTranslationOptions
|
||||
providerId: string
|
||||
modelId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ModTranslationOptions]
|
||||
'update:providerId': [value: string]
|
||||
'update:modelId': [value: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const aiCatalog = ref<AIProviderDefinition[]>([])
|
||||
const loading = ref(true)
|
||||
const aiLoadFailed = ref(false)
|
||||
|
||||
const AI_LOAD_TIMEOUT_MS = 8000
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs)
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
provider: { id: 'app.lab.mod-translation.provider', defaultMessage: 'AI provider' },
|
||||
model: { id: 'app.lab.mod-translation.model', defaultMessage: 'Text model' },
|
||||
aiNotConfigured: {
|
||||
id: 'app.lab.mod-translation.ai-not-configured',
|
||||
defaultMessage: 'AI is not configured. Open the AI settings to enable a provider and model.',
|
||||
},
|
||||
aiLoadError: {
|
||||
id: 'app.lab.mod-translation.ai-load-error',
|
||||
defaultMessage: 'AI settings could not be loaded. Check your connection and try again.',
|
||||
},
|
||||
options: { id: 'app.lab.mod-translation.options', defaultMessage: 'Options' },
|
||||
batchSize: { id: 'app.lab.mod-translation.batch-size', defaultMessage: 'Batch size' },
|
||||
generateModName: {
|
||||
id: 'app.lab.mod-translation.generate-mod-name',
|
||||
defaultMessage: 'AI-generate a Chinese mod name',
|
||||
},
|
||||
repairEnabled: {
|
||||
id: 'app.lab.mod-translation.repair-enabled',
|
||||
defaultMessage: 'Repair difficult translations after verification',
|
||||
},
|
||||
classTextEnabled: {
|
||||
id: 'app.lab.mod-translation.class-text-enabled',
|
||||
defaultMessage: 'Rewrite advanced .class text candidates',
|
||||
},
|
||||
})
|
||||
|
||||
const configuredProviders = computed(() =>
|
||||
(sharedAIState.value?.providers ?? []).filter(
|
||||
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
|
||||
),
|
||||
)
|
||||
const aiAvailable = computed(
|
||||
() => !!sharedAIState.value?.settings.enabled && configuredProviders.value.length > 0,
|
||||
)
|
||||
|
||||
const providerOptions = computed<ComboboxOption[]>(() =>
|
||||
configuredProviders.value.map((provider) => ({
|
||||
value: provider.provider_id,
|
||||
label:
|
||||
provider.custom_name ||
|
||||
aiCatalog.value.find((definition) => definition.id === provider.provider_id)?.name ||
|
||||
provider.provider_id,
|
||||
})),
|
||||
)
|
||||
|
||||
const modelOptions = computed<ComboboxOption[]>(() =>
|
||||
(
|
||||
configuredProviders.value.find((provider) => provider.provider_id === props.providerId)
|
||||
?.models ?? []
|
||||
)
|
||||
.filter((model) => model.enabled)
|
||||
.map((model) => ({ value: model.id, label: model.name || model.id })),
|
||||
)
|
||||
|
||||
const batchSizeOptions: ComboboxOption[] = [
|
||||
{ value: '20', label: '20' },
|
||||
{ value: '40', label: '40' },
|
||||
{ value: '80', label: '80' },
|
||||
]
|
||||
|
||||
watchEffect(() => {
|
||||
const providers = configuredProviders.value
|
||||
if (!providers.length) return
|
||||
if (!providers.some((provider) => provider.provider_id === props.providerId)) {
|
||||
const first = providers[0]
|
||||
emit('update:providerId', first.provider_id)
|
||||
emit('update:modelId', first.models.find((model) => model.enabled)?.id ?? '')
|
||||
}
|
||||
})
|
||||
|
||||
function selectProvider(value: string) {
|
||||
emit('update:providerId', value)
|
||||
const provider = configuredProviders.value.find((item) => item.provider_id === value)
|
||||
emit('update:modelId', provider?.models.find((model) => model.enabled)?.id ?? '')
|
||||
}
|
||||
|
||||
async function loadDefaults() {
|
||||
try {
|
||||
const [settingsResult, stateResult, catalogResult] = await Promise.allSettled([
|
||||
withTimeout(getTranslationSettings(), AI_LOAD_TIMEOUT_MS),
|
||||
withTimeout(getAIState(), AI_LOAD_TIMEOUT_MS),
|
||||
withTimeout(getAICatalog(), AI_LOAD_TIMEOUT_MS),
|
||||
])
|
||||
if (catalogResult.status === 'fulfilled') aiCatalog.value = catalogResult.value
|
||||
aiLoadFailed.value = stateResult.status === 'rejected' && !sharedAIState.value
|
||||
const settings = settingsResult.status === 'fulfilled' ? settingsResult.value : null
|
||||
if (settings?.ai_provider_id && settings.ai_model_id && (!props.providerId || !props.modelId)) {
|
||||
emit('update:providerId', settings.ai_provider_id)
|
||||
emit('update:modelId', settings.ai_model_id)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
loadDefaults()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-if="loading" class="text-sm text-secondary">…</div>
|
||||
<template v-else>
|
||||
<div v-if="aiLoadFailed" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.aiLoadError) }}
|
||||
</div>
|
||||
<div v-else-if="!aiAvailable" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.aiNotConfigured) }}
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.provider) }}
|
||||
<Combobox
|
||||
:model-value="providerId"
|
||||
:options="providerOptions"
|
||||
@update:model-value="selectProvider"
|
||||
>
|
||||
<template #selected="{ label }">
|
||||
<span class="inline-flex min-w-0 items-center gap-2">
|
||||
<AIIcon kind="provider-avatar" :value="providerId" :size="20" />
|
||||
<span class="truncate">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<AIIcon kind="provider-avatar" :value="String(item.value)" :size="22" />
|
||||
<span
|
||||
class="truncate font-semibold leading-tight"
|
||||
:class="isSelected ? 'text-brand' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.model) }}
|
||||
<div class="model-combobox relative" :class="{ 'has-model-icon': modelId }">
|
||||
<AIIcon
|
||||
v-if="modelId"
|
||||
class="pointer-events-none absolute left-3 top-1/2 z-[2] -translate-y-1/2"
|
||||
kind="model"
|
||||
:value="modelId"
|
||||
:size="20"
|
||||
/>
|
||||
<Combobox
|
||||
:model-value="modelId"
|
||||
:options="modelOptions"
|
||||
searchable
|
||||
@update:model-value="emit('update:modelId', String($event))"
|
||||
>
|
||||
<template #option="{ item, isSelected }">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<AIIcon kind="model" :value="String(item.value)" :size="22" />
|
||||
<span
|
||||
class="truncate font-semibold leading-tight"
|
||||
:class="isSelected ? 'text-brand' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="m-0 text-sm font-semibold text-contrast">{{ formatMessage(messages.options) }}</h3>
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-primary">
|
||||
<span>{{ formatMessage(messages.batchSize) }}</span>
|
||||
<div class="w-36">
|
||||
<Combobox
|
||||
:model-value="String(modelValue.batchSize)"
|
||||
:options="batchSizeOptions"
|
||||
@update:model-value="
|
||||
emit('update:modelValue', { ...modelValue, batchSize: Number($event) })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-primary">
|
||||
<span>{{ formatMessage(messages.generateModName) }}</span>
|
||||
<Toggle
|
||||
:model-value="modelValue.generateModName"
|
||||
@update:model-value="
|
||||
emit('update:modelValue', { ...modelValue, generateModName: Boolean($event) })
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-primary">
|
||||
<span>{{ formatMessage(messages.repairEnabled) }}</span>
|
||||
<Toggle
|
||||
:model-value="modelValue.repairEnabled"
|
||||
@update:model-value="
|
||||
emit('update:modelValue', { ...modelValue, repairEnabled: Boolean($event) })
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 text-sm text-primary">
|
||||
<span>{{ formatMessage(messages.classTextEnabled) }}</span>
|
||||
<Toggle
|
||||
:model-value="modelValue.classTextEnabled"
|
||||
@update:model-value="
|
||||
emit('update:modelValue', { ...modelValue, classTextEnabled: Boolean($event) })
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.model-combobox.has-model-icon :deep(input) {
|
||||
padding-left: 2.75rem !important;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon, HistoryIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import { groupTimelineByRepairPass } from '@/lab/mod-translation/timeline'
|
||||
import type { ModTranslationTimelineEntry } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
const props = defineProps<{ entries: ModTranslationTimelineEntry[] }>()
|
||||
const open = ref(false)
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const stayAtBottom = ref(true)
|
||||
const debugOpen = ref(new Set<string>())
|
||||
const groups = computed(() => groupTimelineByRepairPass(props.entries))
|
||||
|
||||
function onScroll() {
|
||||
const element = scrollEl.value
|
||||
if (!element) return
|
||||
stayAtBottom.value = element.scrollHeight - element.scrollTop - element.clientHeight < 24
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.entries.length,
|
||||
async () => {
|
||||
if (!open.value || !stayAtBottom.value) return
|
||||
await nextTick()
|
||||
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||
},
|
||||
)
|
||||
|
||||
function toggleDebug(id: string) {
|
||||
const next = new Set(debugOpen.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
debugOpen.value = next
|
||||
}
|
||||
|
||||
function copyDebug(value: unknown) {
|
||||
void navigator.clipboard.writeText(JSON.stringify(value, null, 2))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-[0.35rem] border-t border-divider pt-[0.55rem]">
|
||||
<button type="button" class="toggle" :aria-expanded="open" @click="open = !open">
|
||||
<HistoryIcon />处理时间线 ({{ entries.length }}) <ChevronUpIcon v-if="open" /><ChevronDownIcon
|
||||
v-else
|
||||
/>
|
||||
</button>
|
||||
<div v-show="open" ref="scrollEl" class="timeline" @scroll="onScroll">
|
||||
<div v-if="!groups.length" class="empty">等待处理事件…</div>
|
||||
<section v-for="group in groups" :key="group.id" class="group">
|
||||
<h4 v-if="group.pass">Repair Pass {{ group.pass }}</h4>
|
||||
<div
|
||||
v-for="entry in group.entries"
|
||||
:key="entry.id"
|
||||
class="entry grid grid-cols-[auto_minmax(0,1fr)] gap-2"
|
||||
:class="`entry--${entry.status}`"
|
||||
>
|
||||
<span class="dot" />
|
||||
<div class="entry-body">
|
||||
<strong>{{ entry.title }}</strong>
|
||||
<span v-if="entry.summary">{{ entry.summary }}</span>
|
||||
<span v-if="entry.issueIds.length" class="issues"
|
||||
>{{ entry.issueIds.length }} issue IDs</span
|
||||
>
|
||||
<div v-if="entry.debug" class="debug">
|
||||
<button
|
||||
type="button"
|
||||
:aria-expanded="debugOpen.has(entry.id)"
|
||||
@click="toggleDebug(entry.id)"
|
||||
>
|
||||
技术详情
|
||||
</button>
|
||||
<button type="button" @click="copyDebug(entry.debug)">复制诊断</button>
|
||||
<pre v-if="debugOpen.has(entry.id)">{{ JSON.stringify(entry.debug, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toggle {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle:hover {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
.toggle:focus-visible,
|
||||
.debug button:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.toggle :deep(svg) {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
.toggle :deep(svg:last-child) {
|
||||
margin-left: auto;
|
||||
}
|
||||
.timeline {
|
||||
display: flex;
|
||||
max-height: 18rem;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-button-bg);
|
||||
padding: 0.65rem 0.7rem;
|
||||
}
|
||||
.empty {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.group h4 {
|
||||
margin: 0;
|
||||
color: var(--color-brand);
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-top: 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
.entry--success .dot {
|
||||
background: var(--color-green);
|
||||
}
|
||||
.entry--warning .dot,
|
||||
.entry--warn .dot {
|
||||
background: var(--color-orange);
|
||||
}
|
||||
.entry--error .dot {
|
||||
background: var(--color-red);
|
||||
}
|
||||
.entry-body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.12rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.entry-body span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.issues {
|
||||
font-family: monospace;
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
.debug {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.debug button {
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
padding: 0.2rem 0.4rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
.debug pre {
|
||||
width: 100%;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-1);
|
||||
padding: 0.55rem;
|
||||
font-size: 0.62rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { ModTranslationJob } from '@/lab/mod-translation/types.ts'
|
||||
|
||||
const props = defineProps<{ job: ModTranslationJob }>()
|
||||
const open = ref(false)
|
||||
const diagnostic = computed(() => ({
|
||||
taskId: props.job.taskId,
|
||||
inputHash: props.job.inputHash,
|
||||
lastSequence: props.job.lastSequence,
|
||||
status: props.job.status,
|
||||
error: props.job.error,
|
||||
events: props.job.events,
|
||||
}))
|
||||
|
||||
function copy() {
|
||||
void navigator.clipboard.writeText(JSON.stringify(diagnostic.value, null, 2))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-[0.4rem]">
|
||||
<div class="flex justify-between gap-2">
|
||||
<button
|
||||
:aria-expanded="open"
|
||||
class="inline-flex items-center gap-[0.3rem] border-0 bg-transparent p-[0.2rem] text-secondary text-[0.68rem]"
|
||||
@click="open = !open"
|
||||
>
|
||||
技术详情 <ChevronUpIcon v-if="open" /><ChevronDownIcon v-else />
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-[0.3rem] border-0 bg-transparent p-[0.2rem] text-secondary text-[0.68rem]"
|
||||
@click="copy"
|
||||
>
|
||||
复制诊断信息
|
||||
</button>
|
||||
</div>
|
||||
<pre v-if="open">{{ JSON.stringify(diagnostic, null, 2) }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head button:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.head :deep(svg) {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
pre {
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-1);
|
||||
padding: 0.65rem;
|
||||
font-size: 0.62rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,297 @@
|
||||
<!-- 由 S4 集成 -->
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
WorldIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types.d.ts'
|
||||
import {
|
||||
get_instance_worlds,
|
||||
isSingleplayerWorld,
|
||||
type SingleplayerWorld,
|
||||
sortWorlds,
|
||||
} from '@/helpers/worlds.ts'
|
||||
|
||||
export type RecipeWorldInstallTarget = {
|
||||
instanceId: string
|
||||
worldPath: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [target: RecipeWorldInstallTarget]
|
||||
saveAs: []
|
||||
}>()
|
||||
|
||||
withDefaults(defineProps<{ showSaveAs?: boolean }>(), {
|
||||
showSaveAs: true,
|
||||
})
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const instances = ref<GameInstance[]>([])
|
||||
const selectedInstance = ref<GameInstance | null>(null)
|
||||
const worlds = ref<SingleplayerWorld[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const worldError = ref('')
|
||||
const installingWorldPath = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.recipe-generator.instance-export.title',
|
||||
defaultMessage: 'Install datapack into world',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.lab.recipe-generator.instance-export.choose-instance',
|
||||
defaultMessage: 'Choose the instance that contains the world',
|
||||
},
|
||||
chooseWorld: {
|
||||
id: 'app.lab.recipe-generator.instance-export.choose-world',
|
||||
defaultMessage: 'Choose a singleplayer world',
|
||||
},
|
||||
back: {
|
||||
id: 'app.lab.recipe-generator.instance-export.back',
|
||||
defaultMessage: 'Back to instances',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'app.lab.recipe-generator.instance-export.no-instances',
|
||||
defaultMessage: 'No installed instances are available.',
|
||||
},
|
||||
noWorlds: {
|
||||
id: 'app.lab.recipe-generator.instance-export.no-worlds',
|
||||
defaultMessage: 'This instance has no singleplayer worlds yet.',
|
||||
},
|
||||
installWorld: {
|
||||
id: 'app.lab.recipe-generator.instance-export.install-world',
|
||||
defaultMessage: 'Install datapack into {name}',
|
||||
},
|
||||
lastPlayed: {
|
||||
id: 'app.lab.recipe-generator.instance-export.last-played',
|
||||
defaultMessage: 'Played {ago}',
|
||||
},
|
||||
neverPlayed: {
|
||||
id: 'app.lab.recipe-generator.instance-export.never-played',
|
||||
defaultMessage: 'Not played yet',
|
||||
},
|
||||
saveAs: {
|
||||
id: 'app.lab.recipe-generator.instance-export.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
})
|
||||
|
||||
async function show(instanceId?: string) {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
error.value = ''
|
||||
worldError.value = ''
|
||||
installingWorldPath.value = null
|
||||
loading.value = true
|
||||
modal.value?.show()
|
||||
try {
|
||||
const loaded = await list()
|
||||
instances.value = loaded
|
||||
.filter((instance) => instance.install_stage === 'installed')
|
||||
.sort((left, right) => {
|
||||
const lastPlayed =
|
||||
Number(new Date(right.last_played ?? 0)) - Number(new Date(left.last_played ?? 0))
|
||||
return lastPlayed || left.name.localeCompare(right.name, locale.value)
|
||||
})
|
||||
const initialInstance = instances.value.find((instance) => instance.id === instanceId)
|
||||
if (initialInstance) {
|
||||
await openInstance(initialInstance)
|
||||
}
|
||||
} catch (caught) {
|
||||
instances.value = []
|
||||
error.value = caught instanceof Error ? caught.message : String(caught)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openInstance(instance: GameInstance) {
|
||||
selectedInstance.value = instance
|
||||
worlds.value = []
|
||||
worldError.value = ''
|
||||
installingWorldPath.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const loaded = await get_instance_worlds(instance.id)
|
||||
sortWorlds(loaded)
|
||||
worlds.value = loaded.filter(isSingleplayerWorld)
|
||||
} catch (caught) {
|
||||
worlds.value = []
|
||||
worldError.value = caught instanceof Error ? caught.message : String(caught)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToInstances() {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
worldError.value = ''
|
||||
installingWorldPath.value = null
|
||||
}
|
||||
|
||||
async function installWorld(world: SingleplayerWorld) {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance || installingWorldPath.value) return
|
||||
installingWorldPath.value = world.path
|
||||
emit('select', { instanceId: instance.id, worldPath: world.path })
|
||||
modal.value?.hide()
|
||||
installingWorldPath.value = null
|
||||
}
|
||||
|
||||
function saveAs() {
|
||||
emit('saveAs')
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(620px, calc(100vw - 2rem))"
|
||||
max-width="620px"
|
||||
scrollable
|
||||
max-content-height="min(38rem, 76vh)"
|
||||
actions-divider
|
||||
>
|
||||
<div class="flex min-h-[18rem] min-w-0 flex-col gap-4">
|
||||
<template v-if="!selectedInstance">
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.chooseInstance) }}</p>
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center text-secondary">
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-brand-red"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="instances.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstances) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li v-for="instance in instances" :key="instance.id" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
@click="openInstance(instance)"
|
||||
>
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-contrast">{{ instance.name }}</strong>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ instance.game_version }} · {{ instance.loader }}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button type="button" @click="backToInstances">
|
||||
<ChevronLeftIcon />{{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<strong class="min-w-0 truncate text-contrast">{{ selectedInstance.name }}</strong>
|
||||
</div>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.chooseWorld) }}</p>
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center text-secondary">
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="worldError"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-brand-red"
|
||||
>
|
||||
{{ worldError }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="worlds.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noWorlds) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li v-for="world in worlds" :key="world.path" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="installingWorldPath !== null"
|
||||
:aria-label="formatMessage(messages.installWorld, { name: world.name })"
|
||||
@click="installWorld(world)"
|
||||
>
|
||||
<Avatar v-if="world.icon" class="size-10 shrink-0 rounded-lg" :src="world.icon" />
|
||||
<span
|
||||
v-else
|
||||
class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-button-bg text-secondary"
|
||||
>
|
||||
<WorldIcon class="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-contrast">{{ world.name }}</strong>
|
||||
<span class="truncate text-sm text-secondary">
|
||||
{{
|
||||
world.last_played
|
||||
? formatMessage(messages.lastPlayed, {
|
||||
ago: formatRelativeTime(dayjs(world.last_played).toISOString()),
|
||||
})
|
||||
: formatMessage(messages.neverPlayed)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<SpinnerIcon
|
||||
v-if="installingWorldPath === world.path"
|
||||
class="size-5 shrink-0 animate-spin text-secondary"
|
||||
/>
|
||||
<ChevronRightIcon v-else class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div v-if="showSaveAs" class="flex justify-end">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" @click="saveAs">
|
||||
<SaveIcon />{{ formatMessage(messages.saveAs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, StyledInput, useVIntl, useVirtualScroll } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
import RecipeSlotDragLayer from './RecipeSlotDragLayer.vue'
|
||||
|
||||
export type PaletteEntry = {
|
||||
key: string
|
||||
name: string
|
||||
id: string
|
||||
display: SlotDisplay
|
||||
value: SlotValue
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entries: PaletteEntry[]
|
||||
atlas: TextureAtlas
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [value: SlotValue]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const search = ref('')
|
||||
const debouncedSearch = ref('')
|
||||
let searchTimer: ReturnType<typeof window.setTimeout> | undefined
|
||||
let lastPickKey = ''
|
||||
let lastPickAt = 0
|
||||
const draggingKey = ref('')
|
||||
let suppressClicksUntil = 0
|
||||
|
||||
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
|
||||
const isTauriRuntime = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const PALETTE_MIN_COLUMN_WIDTH = 72
|
||||
const PALETTE_ROW_GAP = 6.4
|
||||
const PALETTE_ROW_HEIGHT = 64 + PALETTE_ROW_GAP
|
||||
|
||||
type StartDrag = (
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: (moved: boolean) => void,
|
||||
) => void
|
||||
|
||||
const messages = defineMessages({
|
||||
searchPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.items.search-placeholder',
|
||||
defaultMessage: 'Search items',
|
||||
},
|
||||
loading: { id: 'app.lab.recipe-generator.items.loading', defaultMessage: 'Loading items' },
|
||||
empty: {
|
||||
id: 'app.lab.recipe-generator.items.empty',
|
||||
defaultMessage: 'No items match your search.',
|
||||
},
|
||||
addItem: { id: 'app.lab.recipe-generator.items.add', defaultMessage: 'Add to recipe' },
|
||||
})
|
||||
|
||||
watch(search, (value) => {
|
||||
if (searchTimer) window.clearTimeout(searchTimer)
|
||||
searchTimer = window.setTimeout(() => {
|
||||
debouncedSearch.value = value
|
||||
}, 120)
|
||||
})
|
||||
|
||||
const fuse = computed(
|
||||
() =>
|
||||
new Fuse(props.entries, {
|
||||
keys: ['name', 'id'],
|
||||
threshold: 0.35,
|
||||
ignoreLocation: true,
|
||||
}),
|
||||
)
|
||||
|
||||
const visibleEntries = computed(() => {
|
||||
const query = debouncedSearch.value.trim()
|
||||
if (!query) return props.entries
|
||||
return fuse.value.search(query).map((result) => result.item)
|
||||
})
|
||||
|
||||
const gridScroller = ref<HTMLElement | null>(null)
|
||||
const columns = ref(1)
|
||||
let gridObserver: ResizeObserver | null = null
|
||||
|
||||
function updateColumns() {
|
||||
const element = gridScroller.value
|
||||
if (!element) return
|
||||
const availableWidth = Math.max(0, element.clientWidth - 6)
|
||||
columns.value = Math.max(
|
||||
1,
|
||||
Math.floor((availableWidth + PALETTE_ROW_GAP) / (PALETTE_MIN_COLUMN_WIDTH + PALETTE_ROW_GAP)),
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
gridScroller,
|
||||
(element) => {
|
||||
gridObserver?.disconnect()
|
||||
gridObserver = null
|
||||
if (!element) return
|
||||
updateColumns()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
gridObserver = new ResizeObserver(updateColumns)
|
||||
gridObserver.observe(element)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridObserver?.disconnect()
|
||||
gridObserver = null
|
||||
})
|
||||
|
||||
const paletteRows = computed<PaletteEntry[][]>(() => {
|
||||
const count = Math.max(1, columns.value)
|
||||
const rows: PaletteEntry[][] = []
|
||||
for (let index = 0; index < visibleEntries.value.length; index += count) {
|
||||
rows.push(visibleEntries.value.slice(index, index + count))
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleTop,
|
||||
visibleItems: visibleRows,
|
||||
} = useVirtualScroll(paletteRows, {
|
||||
itemHeight: PALETTE_ROW_HEIGHT,
|
||||
bufferSize: 4,
|
||||
})
|
||||
|
||||
function rowKey(row: PaletteEntry[]) {
|
||||
return row[0]?.key ?? row.length
|
||||
}
|
||||
|
||||
function pick(value: SlotValue) {
|
||||
const key = JSON.stringify(value)
|
||||
const now = Date.now()
|
||||
if (key === lastPickKey && now - lastPickAt < 300) return
|
||||
lastPickKey = key
|
||||
lastPickAt = now
|
||||
emit('pick', value)
|
||||
}
|
||||
|
||||
function pickFromClick(event: MouseEvent, value: SlotValue) {
|
||||
if (event.detail > 1 || Date.now() < suppressClicksUntil) return
|
||||
pick(value)
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, entry: PaletteEntry) {
|
||||
if (isTauriRuntime) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) return
|
||||
const payload = JSON.stringify(entry.value)
|
||||
draggingKey.value = entry.key
|
||||
dataTransfer.effectAllowed = 'copy'
|
||||
dataTransfer.setData(RECIPE_SLOT_MIME_TYPE, payload)
|
||||
dataTransfer.setData('text/plain', payload)
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingKey.value = ''
|
||||
suppressClicksUntil = Date.now() + 350
|
||||
}
|
||||
|
||||
function startPointerDrag(event: PointerEvent, entry: PaletteEntry, startDrag: StartDrag) {
|
||||
if (!isTauriRuntime || event.button !== 0) return
|
||||
draggingKey.value = entry.key
|
||||
startDrag(event, entry.value, entry.display, props.atlas, (moved) => {
|
||||
draggingKey.value = ''
|
||||
if (moved) suppressClicksUntil = Date.now() + 350
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeSlotDragLayer v-slot="{ startDrag }">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2 p-3">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
clearable
|
||||
class="w-full shrink-0"
|
||||
/>
|
||||
<div v-if="loading" class="flex min-h-24 items-center justify-center text-sm text-secondary">
|
||||
{{ formatMessage(messages.loading) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!visibleEntries.length"
|
||||
class="flex min-h-24 items-center justify-center px-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else ref="gridScroller" class="recipe-palette-grid">
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="recipe-palette-virtual"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 grid grid-auto-rows-[4rem] gap-[0.4rem] p-[0.1rem_0.25rem_0.25rem_0.1rem]"
|
||||
:style="{
|
||||
top: `${visibleTop}px`,
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}"
|
||||
>
|
||||
<template v-for="row in visibleRows" :key="rowKey(row)">
|
||||
<button
|
||||
v-for="entry in row"
|
||||
:key="entry.key"
|
||||
type="button"
|
||||
:draggable="!isTauriRuntime"
|
||||
class="recipe-palette-item"
|
||||
:class="{ 'is-dragging': draggingKey === entry.key }"
|
||||
:style="{ touchAction: isTauriRuntime ? 'none' : undefined }"
|
||||
:title="`${formatMessage(messages.addItem)}: ${entry.name}`"
|
||||
:aria-label="`${formatMessage(messages.addItem)}: ${entry.name}`"
|
||||
@click="pickFromClick($event, entry.value)"
|
||||
@pointerdown="startPointerDrag($event, entry, startDrag)"
|
||||
@dragstart="onDragStart($event, entry)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<RecipeItemIcon
|
||||
:display="entry.display"
|
||||
:atlas="atlas"
|
||||
:size="34"
|
||||
:show-count="false"
|
||||
/>
|
||||
<span class="recipe-palette-name">{{ entry.name }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RecipeSlotDragLayer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-palette-grid {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.recipe-palette-virtual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-palette-item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-4);
|
||||
padding: 0.2rem 0.1rem;
|
||||
color: var(--color-contrast);
|
||||
cursor: grab;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.recipe-palette-item:hover,
|
||||
.recipe-palette-item:focus-visible {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-surface-3);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-palette-item:active {
|
||||
cursor: grabbing;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.recipe-palette-item.is-dragging {
|
||||
opacity: 0.6;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-palette-name {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, ExternalIcon, ImageIcon, InfoIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = useTemplateRef<InstanceType<typeof ModalWrapper>>('modal')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.recipe-generator.copyright.title',
|
||||
defaultMessage: 'Copyright and attribution',
|
||||
},
|
||||
tagsHeading: {
|
||||
id: 'app.lab.recipe-generator.copyright.tags-heading',
|
||||
defaultMessage: 'Vanilla tags',
|
||||
},
|
||||
tagsBody: {
|
||||
id: 'app.lab.recipe-generator.copyright.tags-body',
|
||||
defaultMessage:
|
||||
'Expanded item tags are sourced from the crafting generator by destruc7i0n, provided under the MIT License.',
|
||||
},
|
||||
viewTags: {
|
||||
id: 'app.lab.recipe-generator.copyright.view-tags',
|
||||
defaultMessage: 'View vanilla tags',
|
||||
},
|
||||
texturesHeading: {
|
||||
id: 'app.lab.recipe-generator.copyright.textures-heading',
|
||||
defaultMessage: 'Item textures and metadata',
|
||||
},
|
||||
texturesBody: {
|
||||
id: 'app.lab.recipe-generator.copyright.textures-body',
|
||||
defaultMessage:
|
||||
'Item identifiers, readable names, and icons are sourced from minecraft-textures by destruc7i0n, provided under the GNU General Public License v3.',
|
||||
},
|
||||
viewTextures: {
|
||||
id: 'app.lab.recipe-generator.copyright.view-textures',
|
||||
defaultMessage: 'View minecraft-textures',
|
||||
},
|
||||
disclaimerHeading: {
|
||||
id: 'app.lab.recipe-generator.copyright.disclaimer-heading',
|
||||
defaultMessage: 'Unofficial tool',
|
||||
},
|
||||
disclaimerBody: {
|
||||
id: 'app.lab.recipe-generator.copyright.disclaimer-body',
|
||||
defaultMessage:
|
||||
'Minecraft assets are Copyright Mojang Studios / Microsoft and are used only to identify compatible content. Axolotl Launcher is not affiliated with or endorsed by Mojang Studios or Microsoft.',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: (event?: MouseEvent) => modal.value?.show(event),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="copyright-notice">
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<CodeIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.tagsHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.tagsBody) }}</p>
|
||||
<ButtonStyled size="small" type="outlined">
|
||||
<button @click="openUrl('https://github.com/destruc7i0n/crafting')">
|
||||
{{ formatMessage(messages.viewTags) }}
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<ImageIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.texturesHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.texturesBody) }}</p>
|
||||
<ButtonStyled size="small" type="outlined">
|
||||
<button @click="openUrl('https://github.com/destruc7i0n/minecraft-textures')">
|
||||
{{ formatMessage(messages.viewTextures) }}
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.disclaimerHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.disclaimerBody) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.copyright-notice {
|
||||
display: flex;
|
||||
width: min(34rem, calc(100vw - 3rem));
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notice-section:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.notice-section > svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notice-section h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.notice-section p {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { countFontSize, countInset, countShadow } from '@/lab/recipe-generator/count-display'
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
display: SlotDisplay | null
|
||||
atlas: TextureAtlas
|
||||
size?: number
|
||||
showCount?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 32,
|
||||
showCount: true,
|
||||
},
|
||||
)
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const region = computed(() => {
|
||||
const texture = props.display?.texture
|
||||
return texture ? props.atlas.layout[texture] : undefined
|
||||
})
|
||||
|
||||
const contentSize = computed(() => Math.max(1, props.size - 2))
|
||||
|
||||
const countStyle = computed(() => {
|
||||
if (!props.display?.count || props.display.count <= 1) return undefined
|
||||
const inset = countInset(props.size)
|
||||
return {
|
||||
fontSize: `${countFontSize(props.size)}px`,
|
||||
right: `${inset}px`,
|
||||
bottom: `${inset}px`,
|
||||
textShadow: countShadow(props.size),
|
||||
}
|
||||
})
|
||||
|
||||
const imageCache = new Map<string, Promise<HTMLImageElement>>()
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
const cached = imageCache.get(url)
|
||||
if (cached) return cached
|
||||
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error(`Unable to load image: ${url}`))
|
||||
image.src = url
|
||||
})
|
||||
imageCache.set(url, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
let drawToken = 0
|
||||
|
||||
async function drawIcon() {
|
||||
const canvas = canvasRef.value
|
||||
const display = props.display
|
||||
if (!canvas || !display?.texture) return
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
const token = ++drawToken
|
||||
const size = contentSize.value
|
||||
if (canvas.width !== size) canvas.width = size
|
||||
if (canvas.height !== size) canvas.height = size
|
||||
context.clearRect(0, 0, size, size)
|
||||
context.imageSmoothingEnabled = false
|
||||
|
||||
try {
|
||||
const currentRegion = region.value
|
||||
const image = await loadImage(currentRegion ? props.atlas.url : display.texture)
|
||||
if (token !== drawToken || canvas !== canvasRef.value) return
|
||||
const sourceX = currentRegion?.[0] ?? 0
|
||||
const sourceY = currentRegion?.[1] ?? 0
|
||||
const sourceWidth = currentRegion?.[2] ?? image.naturalWidth
|
||||
const sourceHeight = currentRegion?.[3] ?? image.naturalHeight
|
||||
if (!sourceWidth || !sourceHeight) return
|
||||
const scale = Math.min(size / sourceWidth, size / sourceHeight)
|
||||
const drawWidth = Math.max(1, Math.round(sourceWidth * scale))
|
||||
const drawHeight = Math.max(1, Math.round(sourceHeight * scale))
|
||||
const drawX = Math.round((size - drawWidth) / 2)
|
||||
const drawY = Math.round((size - drawHeight) / 2)
|
||||
context.drawImage(
|
||||
image,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
drawX,
|
||||
drawY,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
)
|
||||
} catch {
|
||||
// Missing textures render as an empty slot.
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[canvasRef, () => props.display?.texture, () => props.atlas.url, () => props.size],
|
||||
drawIcon,
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-block flex-none overflow-hidden border border-surface-5 box-border"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
:title="display?.label"
|
||||
>
|
||||
<canvas
|
||||
v-if="display?.texture"
|
||||
ref="canvasRef"
|
||||
class="recipe-item-canvas"
|
||||
:width="contentSize"
|
||||
:height="contentSize"
|
||||
></canvas>
|
||||
<span v-else class="recipe-item-empty" aria-hidden="true"></span>
|
||||
<span
|
||||
v-if="showCount && display?.count && display.count > 1"
|
||||
class="absolute text-white font-bold leading-none pointer-events-none"
|
||||
:style="countStyle"
|
||||
>{{ display.count }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-item-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.recipe-item-empty {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: repeating-conic-gradient(var(--surface-5) 0% 25%, var(--surface-3) 0% 50%);
|
||||
background-size: 8px 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -0,0 +1,200 @@
|
||||
<!-- 由 S4 集成到 LabRecipeGenerator.vue -->
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useResultCountWheel } from '@/composables/lab/useResultCountWheel'
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { RecipeSlot, SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
|
||||
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
recipeSlot: RecipeSlot
|
||||
value: SlotValue | undefined
|
||||
display: SlotDisplay | null
|
||||
atlas: TextureAtlas
|
||||
count?: number
|
||||
countEditable?: boolean
|
||||
result?: boolean
|
||||
}>(),
|
||||
{
|
||||
count: 1,
|
||||
countEditable: false,
|
||||
result: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
dropValue: [value: SlotValue]
|
||||
updateCount: [count: number]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const dragDepth = ref(0)
|
||||
|
||||
const messages = defineMessages({
|
||||
emptySlot: { id: 'app.lab.recipe-generator.slots.empty', defaultMessage: 'Empty slot' },
|
||||
})
|
||||
|
||||
const dragActive = computed(() => dragDepth.value > 0)
|
||||
const slotLabel = computed(() => `${formatMessage(messages.emptySlot)} ${props.recipeSlot}`)
|
||||
const { hint: wheelHint, onWheel: onResultWheel } = useResultCountWheel({
|
||||
getSlot: () => (props.countEditable ? props.recipeSlot : null),
|
||||
getValue: () => props.value,
|
||||
getCount: () => props.count ?? 1,
|
||||
setCount: (count) => emit('updateCount', count),
|
||||
})
|
||||
|
||||
function hasRecipePayload(event: DragEvent) {
|
||||
const types = event.dataTransfer?.types
|
||||
if (!types) return false
|
||||
const typeList = Array.from(types)
|
||||
return (
|
||||
!typeList.includes('Files') &&
|
||||
(typeList.includes(RECIPE_SLOT_MIME_TYPE) || typeList.includes('text/plain'))
|
||||
)
|
||||
}
|
||||
|
||||
function isSlotValue(value: unknown): value is SlotValue {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const candidate = value as { kind?: unknown; id?: unknown; uid?: unknown }
|
||||
switch (candidate.kind) {
|
||||
case 'item':
|
||||
case 'vanilla_tag':
|
||||
return typeof candidate.id === 'string'
|
||||
case 'custom_item':
|
||||
case 'custom_tag':
|
||||
return typeof candidate.uid === 'string'
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseSlotValue(raw: string): SlotValue | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return isSlotValue(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function onSlotDropEvent(event: Event) {
|
||||
const detail = (event as CustomEvent<{ value?: unknown }>).detail
|
||||
if (!detail || !isSlotValue(detail.value)) return
|
||||
emit('dropValue', detail.value)
|
||||
}
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
if (!hasRecipePayload(event)) return
|
||||
dragDepth.value += 1
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) return
|
||||
if (!hasRecipePayload(event)) {
|
||||
dataTransfer.dropEffect = 'none'
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
if (!hasRecipePayload(event)) return
|
||||
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragDepth.value = 0
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer || !hasRecipePayload(event)) return
|
||||
const raw = dataTransfer.getData(RECIPE_SLOT_MIME_TYPE) || dataTransfer.getData('text/plain')
|
||||
const value = parseSlotValue(raw)
|
||||
if (!value) return
|
||||
event.preventDefault()
|
||||
emit('dropValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex min-w-0 flex-col items-center gap-[0.35rem]"
|
||||
:class="{ 'is-drag-target': dragActive }"
|
||||
:data-recipe-slot="recipeSlot"
|
||||
@axolotl-recipe-slot-drop="onSlotDropEvent"
|
||||
@dragenter="onDragEnter"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<button
|
||||
v-tooltip="wheelHint"
|
||||
type="button"
|
||||
class="recipe-slot-button"
|
||||
:class="{ 'recipe-result-button': result }"
|
||||
:title="wheelHint ?? slotLabel"
|
||||
:aria-label="wheelHint ?? slotLabel"
|
||||
@click="emit('clear')"
|
||||
@wheel="onResultWheel(recipeSlot, $event)"
|
||||
>
|
||||
<RecipeItemIcon :display="display" :atlas="atlas" :size="48" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-slot-button {
|
||||
display: flex;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
padding: 0;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 rgb(0 0 0 / 20%),
|
||||
inset -1px -1px 0 rgb(255 255 255 / 10%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.recipe-slot-button:hover {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-surface-3);
|
||||
}
|
||||
|
||||
.recipe-slot-button:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.is-drag-target .recipe-slot-button {
|
||||
border-color: var(--color-brand);
|
||||
background: var(--color-brand-highlight);
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.recipe-result-button {
|
||||
border-color: color-mix(in srgb, var(--color-brand) 55%, var(--color-surface-5));
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.recipe-slot-button {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,187 @@
|
||||
<!-- 由 S4 集成到 LabRecipeGenerator.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
|
||||
type DragFinish = (moved: boolean) => void
|
||||
|
||||
type StartDrag = (
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: DragFinish,
|
||||
) => void
|
||||
|
||||
type ActiveDrag = {
|
||||
value: SlotValue
|
||||
display: SlotDisplay
|
||||
atlas: TextureAtlas
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
onFinish?: DragFinish
|
||||
}
|
||||
|
||||
defineSlots<{
|
||||
default: (props: { startDrag: StartDrag }) => unknown
|
||||
}>()
|
||||
|
||||
const drag = ref<ActiveDrag | null>(null)
|
||||
const ghostRef = ref<HTMLElement | null>(null)
|
||||
let hoveredSlot: HTMLElement | null = null
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let moved = false
|
||||
let frame: number | null = null
|
||||
let lastHitTestAt = 0
|
||||
|
||||
function startDrag(
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: DragFinish,
|
||||
) {
|
||||
if (event.button !== 0 || drag.value) return
|
||||
drag.value = {
|
||||
value,
|
||||
display,
|
||||
atlas,
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
onFinish,
|
||||
}
|
||||
pointerX = event.clientX
|
||||
pointerY = event.clientY
|
||||
moved = false
|
||||
const target = event.currentTarget as HTMLElement | null
|
||||
try {
|
||||
target?.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
// Pointer capture is optional; window listeners still track mouse drags.
|
||||
}
|
||||
window.addEventListener('pointermove', handlePointerMove, { passive: false })
|
||||
window.addEventListener('pointerup', handlePointerEnd)
|
||||
window.addEventListener('pointercancel', handlePointerCancel)
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
const current = drag.value
|
||||
if (!current || current.pointerId !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
pointerX = event.clientX
|
||||
pointerY = event.clientY
|
||||
if (!moved) {
|
||||
moved = Math.abs(pointerX - current.startX) > 4 || Math.abs(pointerY - current.startY) > 4
|
||||
}
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
function scheduleFrame() {
|
||||
if (frame !== null) return
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null
|
||||
updateGhostPosition()
|
||||
updateHoveredSlot()
|
||||
})
|
||||
}
|
||||
|
||||
function updateGhostPosition() {
|
||||
const ghost = ghostRef.value
|
||||
if (!ghost) return
|
||||
ghost.style.transform = `translate3d(${pointerX}px, ${pointerY}px, 0) translate(-50%, -50%)`
|
||||
}
|
||||
|
||||
function updateHoveredSlot() {
|
||||
const now = performance.now()
|
||||
if (now - lastHitTestAt < 32) return
|
||||
lastHitTestAt = now
|
||||
const target = document.elementFromPoint(pointerX, pointerY)
|
||||
const next = target?.closest<HTMLElement>('[data-recipe-slot]') ?? null
|
||||
if (next === hoveredSlot) return
|
||||
hoveredSlot?.classList.remove('is-drag-target')
|
||||
next?.classList.add('is-drag-target')
|
||||
hoveredSlot = next
|
||||
}
|
||||
|
||||
function handlePointerEnd(event: PointerEvent) {
|
||||
const current = drag.value
|
||||
if (!current || current.pointerId !== event.pointerId) return
|
||||
cleanup()
|
||||
if (moved) {
|
||||
event.preventDefault()
|
||||
const target = document.elementFromPoint(pointerX, pointerY)
|
||||
const slot = target?.closest<HTMLElement>('[data-recipe-slot]')
|
||||
if (slot) {
|
||||
slot.dispatchEvent(
|
||||
new CustomEvent('axolotl-recipe-slot-drop', {
|
||||
detail: { value: current.value },
|
||||
bubbles: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
current.onFinish?.(moved)
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
function handlePointerCancel(event: PointerEvent) {
|
||||
const current = drag.value
|
||||
if (!current || current.pointerId !== event.pointerId) return
|
||||
cleanup()
|
||||
current.onFinish?.(false)
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (frame !== null) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
window.removeEventListener('pointerup', handlePointerEnd)
|
||||
window.removeEventListener('pointercancel', handlePointerCancel)
|
||||
hoveredSlot?.classList.remove('is-drag-target')
|
||||
hoveredSlot = null
|
||||
lastHitTestAt = 0
|
||||
}
|
||||
|
||||
onUnmounted(cleanup)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot :start-drag="startDrag" />
|
||||
<Teleport to="body">
|
||||
<div v-if="drag" ref="ghostRef" class="recipe-slot-drag-ghost">
|
||||
<RecipeItemIcon :display="drag.display" :atlas="drag.atlas" :size="48" :show-count="false" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-slot-drag-ghost {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--color-brand);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 30%);
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,178 @@
|
||||
<!-- 由 S4 集成到 LabRecipeGenerator.vue -->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { getSlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { RecipeSlot, RecipeSlotContext, SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeSlotCell from './RecipeSlotCell.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
slots: readonly RecipeSlot[]
|
||||
values: Partial<Record<RecipeSlot, SlotValue>>
|
||||
ctx: RecipeSlotContext | null
|
||||
atlas: TextureAtlas
|
||||
variant?: 'crafting' | 'row'
|
||||
twoByTwo?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'row',
|
||||
twoByTwo: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSlot: [slot: RecipeSlot, value: SlotValue | undefined]
|
||||
updateCount: [slot: RecipeSlot, count: number]
|
||||
}>()
|
||||
|
||||
const TWO_BY_TWO_DISABLED_SLOTS = new Set<RecipeSlot>([
|
||||
'crafting.3',
|
||||
'crafting.6',
|
||||
'crafting.7',
|
||||
'crafting.8',
|
||||
'crafting.9',
|
||||
])
|
||||
|
||||
const gridSlots = computed(() => {
|
||||
const slots =
|
||||
props.variant === 'crafting'
|
||||
? props.slots.filter((slot) => slot !== 'crafting.result')
|
||||
: props.slots
|
||||
if (props.variant === 'crafting' && props.twoByTwo) {
|
||||
return slots.filter((slot) => !TWO_BY_TWO_DISABLED_SLOTS.has(slot))
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
function slotDisplay(slot: RecipeSlot) {
|
||||
return props.ctx ? getSlotDisplay(props.values[slot], props.ctx) : null
|
||||
}
|
||||
|
||||
function countFor(slot: RecipeSlot) {
|
||||
const value = props.values[slot]
|
||||
return value && (value.kind === 'item' || value.kind === 'custom_item') && value.count
|
||||
? value.count
|
||||
: 1
|
||||
}
|
||||
|
||||
function canEditCount(slot: RecipeSlot) {
|
||||
return slot === 'crafting.result' || slot === 'stonecutter.result'
|
||||
}
|
||||
|
||||
function updateSlot(slot: RecipeSlot, value: SlotValue | undefined) {
|
||||
emit('updateSlot', slot, value)
|
||||
}
|
||||
|
||||
function updateCount(slot: RecipeSlot, count: number) {
|
||||
emit('updateCount', slot, count)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="recipe-slot-grid" :class="`is-${variant}`">
|
||||
<div v-if="variant === 'crafting'" class="recipe-crafting-editor">
|
||||
<div
|
||||
class="recipe-crafting-grid grid grid-cols-[repeat(3,3.75rem)] grid-auto-rows-[3.75rem] gap-[0.45rem] border border-surface-5 rounded-[var(--radius-md)] bg-surface-1 p-[0.6rem]"
|
||||
:class="{ 'is-two-by-two': twoByTwo }"
|
||||
>
|
||||
<RecipeSlotCell
|
||||
v-for="slot in gridSlots"
|
||||
:key="slot"
|
||||
:recipe-slot="slot"
|
||||
:value="values[slot]"
|
||||
:display="slotDisplay(slot)"
|
||||
:atlas="atlas"
|
||||
:count="countFor(slot)"
|
||||
:count-editable="canEditCount(slot)"
|
||||
@clear="updateSlot(slot, undefined)"
|
||||
@drop-value="updateSlot(slot, $event)"
|
||||
@update-count="updateCount(slot, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="slots.includes('crafting.result')" class="recipe-result-column">
|
||||
<RecipeSlotCell
|
||||
:recipe-slot="'crafting.result'"
|
||||
:value="values['crafting.result']"
|
||||
:display="slotDisplay('crafting.result')"
|
||||
:atlas="atlas"
|
||||
:count="countFor('crafting.result')"
|
||||
count-editable
|
||||
result
|
||||
@clear="updateSlot('crafting.result', undefined)"
|
||||
@drop-value="updateSlot('crafting.result', $event)"
|
||||
@update-count="updateCount('crafting.result', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="recipe-slot-row">
|
||||
<RecipeSlotCell
|
||||
v-for="slot in slots"
|
||||
:key="slot"
|
||||
:recipe-slot="slot"
|
||||
:value="values[slot]"
|
||||
:display="slotDisplay(slot)"
|
||||
:atlas="atlas"
|
||||
:count="countFor(slot)"
|
||||
:count-editable="canEditCount(slot)"
|
||||
:result="slot === 'crafting.result' || slot === 'stonecutter.result'"
|
||||
@clear="updateSlot(slot, undefined)"
|
||||
@drop-value="updateSlot(slot, $event)"
|
||||
@update-count="updateCount(slot, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-slot-grid {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipe-crafting-editor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.recipe-crafting-grid.is-two-by-two {
|
||||
grid-template-columns: repeat(2, 3.75rem);
|
||||
grid-auto-rows: 3.75rem;
|
||||
}
|
||||
|
||||
.recipe-result-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 1.5rem;
|
||||
border-left: 1px solid var(--color-surface-5);
|
||||
}
|
||||
|
||||
.recipe-slot-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.recipe-crafting-editor {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-result-column {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.recipe-slot-row {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,495 @@
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { defineMessages, StyledInput, useVIntl, useVirtualScroll } from '@modrinth/ui'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getSlotDisplay, type SlotDisplay } from '@/lab/recipe-generator/display'
|
||||
import type { TextureAtlas } from '@/lab/recipe-generator/resources'
|
||||
import type { CustomTag, RecipeSlotContext, SlotValue } from '@/lab/recipe-generator/types'
|
||||
|
||||
import RecipeItemIcon from './RecipeItemIcon.vue'
|
||||
import RecipeSlotDragLayer from './RecipeSlotDragLayer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
vanillaTags: Record<string, string[]>
|
||||
customTags: CustomTag[]
|
||||
ctx: RecipeSlotContext
|
||||
atlas: TextureAtlas
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [value: SlotValue]
|
||||
addCustomTag: [tag: CustomTag]
|
||||
updateCustomTag: [tag: CustomTag]
|
||||
deleteCustomTag: [uid: string]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const tab = ref<'vanilla' | 'custom'>('vanilla')
|
||||
const search = ref('')
|
||||
const newTagId = ref('')
|
||||
const valueDrafts = ref<Record<string, string>>({})
|
||||
let lastPickKey = ''
|
||||
let lastPickAt = 0
|
||||
const draggingTagUid = ref('')
|
||||
let suppressClicksUntil = 0
|
||||
|
||||
const RECIPE_SLOT_MIME_TYPE = 'application/x-axolotl-recipe-slot'
|
||||
const isTauriRuntime = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const TAG_ROW_HEIGHT = 44.8
|
||||
|
||||
type StartDrag = (
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
atlas: TextureAtlas,
|
||||
onFinish?: (moved: boolean) => void,
|
||||
) => void
|
||||
|
||||
const messages = defineMessages({
|
||||
vanillaTab: { id: 'app.lab.recipe-generator.tags.vanilla', defaultMessage: 'Vanilla tags' },
|
||||
customTab: { id: 'app.lab.recipe-generator.tags.custom', defaultMessage: 'Custom tags' },
|
||||
searchPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.tags.search-placeholder',
|
||||
defaultMessage: 'Search tags',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.lab.recipe-generator.tags.empty',
|
||||
defaultMessage: 'No tags match your search.',
|
||||
},
|
||||
addTag: { id: 'app.lab.recipe-generator.tags.add', defaultMessage: 'Add tag' },
|
||||
tagIdPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.tags.id-placeholder',
|
||||
defaultMessage: 'namespace:tag_id',
|
||||
},
|
||||
tagValuesPlaceholder: {
|
||||
id: 'app.lab.recipe-generator.tags.values-placeholder',
|
||||
defaultMessage: 'One item or #tag per line',
|
||||
},
|
||||
deleteTag: { id: 'app.lab.recipe-generator.tags.delete', defaultMessage: 'Delete tag' },
|
||||
useTag: { id: 'app.lab.recipe-generator.tags.use', defaultMessage: 'Use in recipe' },
|
||||
noCustomTags: {
|
||||
id: 'app.lab.recipe-generator.tags.no-custom',
|
||||
defaultMessage: 'No custom tags yet.',
|
||||
},
|
||||
})
|
||||
|
||||
const vanillaList = computed(() => Object.keys(props.vanillaTags).sort())
|
||||
const fuse = computed(() => new Fuse(vanillaList.value, { threshold: 0.4, ignoreLocation: true }))
|
||||
const visibleVanillaTags = computed(() => {
|
||||
const query = search.value.trim()
|
||||
if (!query) return vanillaList.value
|
||||
return fuse.value.search(query).map((result) => result.item)
|
||||
})
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
totalHeight,
|
||||
visibleTop,
|
||||
visibleItems: visibleVanillaRows,
|
||||
} = useVirtualScroll(visibleVanillaTags, {
|
||||
itemHeight: TAG_ROW_HEIGHT,
|
||||
bufferSize: 8,
|
||||
})
|
||||
|
||||
function vanillaDisplay(tagId: string) {
|
||||
return getSlotDisplay({ kind: 'vanilla_tag', id: tagId }, props.ctx)
|
||||
}
|
||||
|
||||
function pickTag(value: SlotValue) {
|
||||
const key = JSON.stringify(value)
|
||||
const now = Date.now()
|
||||
if (key === lastPickKey && now - lastPickAt < 300) return
|
||||
lastPickKey = key
|
||||
lastPickAt = now
|
||||
emit('pick', value)
|
||||
}
|
||||
|
||||
function pickFromClick(event: MouseEvent, value: SlotValue) {
|
||||
if (event.detail > 1 || Date.now() < suppressClicksUntil) return
|
||||
pickTag(value)
|
||||
}
|
||||
|
||||
function onTagDragStart(event: DragEvent, value: SlotValue) {
|
||||
if (isTauriRuntime) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) return
|
||||
const payload = JSON.stringify(value)
|
||||
dataTransfer.effectAllowed = 'copy'
|
||||
dataTransfer.setData(RECIPE_SLOT_MIME_TYPE, payload)
|
||||
dataTransfer.setData('text/plain', payload)
|
||||
}
|
||||
|
||||
function onCustomTagDragStart(event: DragEvent, tag: CustomTag) {
|
||||
draggingTagUid.value = tag.uid
|
||||
onTagDragStart(event, { kind: 'custom_tag', uid: tag.uid })
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingTagUid.value = ''
|
||||
suppressClicksUntil = Date.now() + 350
|
||||
}
|
||||
|
||||
function startPointerDrag(
|
||||
event: PointerEvent,
|
||||
value: SlotValue,
|
||||
display: SlotDisplay,
|
||||
startDrag: StartDrag,
|
||||
dragKey?: string,
|
||||
) {
|
||||
if (!isTauriRuntime || event.button !== 0) return
|
||||
if (dragKey) draggingTagUid.value = dragKey
|
||||
startDrag(event, value, display, props.atlas, (moved) => {
|
||||
draggingTagUid.value = ''
|
||||
if (moved) suppressClicksUntil = Date.now() + 350
|
||||
})
|
||||
}
|
||||
|
||||
function customTagDisplay(tag: CustomTag): SlotDisplay {
|
||||
return getSlotDisplay({ kind: 'custom_tag', uid: tag.uid }, props.ctx)
|
||||
}
|
||||
|
||||
function addCustomTag() {
|
||||
const id = newTagId.value.trim()
|
||||
if (!id) return
|
||||
const tag: CustomTag = {
|
||||
uid: crypto.randomUUID(),
|
||||
id,
|
||||
values: [],
|
||||
}
|
||||
emit('addCustomTag', tag)
|
||||
valueDrafts.value[tag.uid] = ''
|
||||
newTagId.value = ''
|
||||
}
|
||||
|
||||
function commitValues(tag: CustomTag) {
|
||||
const draft = valueDrafts.value[tag.uid] ?? ''
|
||||
const values = draft
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) =>
|
||||
line.startsWith('#')
|
||||
? { type: 'tag' as const, id: line.slice(1) }
|
||||
: { type: 'item' as const, id: line },
|
||||
)
|
||||
emit('updateCustomTag', { ...tag, values })
|
||||
}
|
||||
|
||||
function draftText(tag: CustomTag) {
|
||||
return tag.values.map((entry) => (entry.type === 'tag' ? `#${entry.id}` : entry.id)).join('\n')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeSlotDragLayer v-slot="{ startDrag }">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-2 p-3">
|
||||
<div
|
||||
class="recipe-tag-tabs flex gap-1 border border-surface-5 rounded-[var(--radius-sm)] bg-surface-3 p-[0.2rem]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab === 'vanilla'"
|
||||
:class="{ active: tab === 'vanilla' }"
|
||||
@click="tab = 'vanilla'"
|
||||
>
|
||||
{{ formatMessage(messages.vanillaTab) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="tab === 'custom'"
|
||||
:class="{ active: tab === 'custom' }"
|
||||
@click="tab = 'custom'"
|
||||
>
|
||||
{{ formatMessage(messages.customTab) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="tab === 'vanilla'">
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
clearable
|
||||
class="w-full shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-if="!visibleVanillaTags.length"
|
||||
class="flex min-h-24 items-center justify-center px-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</div>
|
||||
<div v-else class="recipe-tag-scroll">
|
||||
<div
|
||||
ref="listContainer"
|
||||
class="recipe-tag-virtual"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="recipe-tag-window" :style="{ top: `${visibleTop}px` }">
|
||||
<button
|
||||
v-for="item in visibleVanillaRows"
|
||||
:key="item"
|
||||
type="button"
|
||||
:draggable="!isTauriRuntime"
|
||||
class="recipe-tag-row"
|
||||
:title="formatMessage(messages.useTag)"
|
||||
:aria-label="`${formatMessage(messages.useTag)}: ${item}`"
|
||||
:style="{ touchAction: isTauriRuntime ? 'none' : undefined }"
|
||||
@click="pickFromClick($event, { kind: 'vanilla_tag', id: item })"
|
||||
@pointerdown="
|
||||
startPointerDrag(
|
||||
$event,
|
||||
{ kind: 'vanilla_tag', id: item },
|
||||
vanillaDisplay(item),
|
||||
startDrag,
|
||||
)
|
||||
"
|
||||
@dragstart="onTagDragStart($event, { kind: 'vanilla_tag', id: item })"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<RecipeItemIcon
|
||||
:display="vanillaDisplay(item)"
|
||||
:atlas="atlas"
|
||||
:size="26"
|
||||
:show-count="false"
|
||||
/>
|
||||
<span>{{ item }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<StyledInput
|
||||
v-model="newTagId"
|
||||
:placeholder="formatMessage(messages.tagIdPlaceholder)"
|
||||
class="min-w-0 flex-1"
|
||||
@keydown.enter.prevent="addCustomTag"
|
||||
/>
|
||||
<button type="button" class="recipe-add-button" @click="addCustomTag">
|
||||
{{ formatMessage(messages.addTag) }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!customTags.length"
|
||||
class="flex min-h-24 items-center justify-center px-4 text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noCustomTags) }}
|
||||
</div>
|
||||
<div v-else class="recipe-custom-tag-list">
|
||||
<div
|
||||
v-for="tag in customTags"
|
||||
:key="tag.uid"
|
||||
class="recipe-custom-tag"
|
||||
:class="{ 'is-dragging': draggingTagUid === tag.uid }"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<StyledInput
|
||||
:model-value="tag.id"
|
||||
size="small"
|
||||
class="min-w-0 flex-1"
|
||||
@update:model-value="emit('updateCustomTag', { ...tag, id: String($event) })"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="recipe-delete-button"
|
||||
:title="formatMessage(messages.deleteTag)"
|
||||
:aria-label="formatMessage(messages.deleteTag)"
|
||||
@click="emit('deleteCustomTag', tag.uid)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="recipe-add-button"
|
||||
:draggable="!isTauriRuntime"
|
||||
:title="formatMessage(messages.useTag)"
|
||||
:aria-label="formatMessage(messages.useTag)"
|
||||
:style="{ touchAction: isTauriRuntime ? 'none' : undefined }"
|
||||
@click="pickFromClick($event, { kind: 'custom_tag', uid: tag.uid })"
|
||||
@pointerdown="
|
||||
startPointerDrag(
|
||||
$event,
|
||||
{ kind: 'custom_tag', uid: tag.uid },
|
||||
customTagDisplay(tag),
|
||||
startDrag,
|
||||
tag.uid,
|
||||
)
|
||||
"
|
||||
@dragstart="onCustomTagDragStart($event, tag)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
:value="valueDrafts[tag.uid] ?? draftText(tag)"
|
||||
:placeholder="formatMessage(messages.tagValuesPlaceholder)"
|
||||
rows="2"
|
||||
class="recipe-tag-values w-full resize-y border border-surface-5 rounded-[var(--radius-sm)] bg-surface-2 p-[0.4rem] text-contrast font-mono text-xs leading-[1.4] outline-none"
|
||||
@input="valueDrafts[tag.uid] = ($event.target as HTMLTextAreaElement).value"
|
||||
@blur="commitValues(tag)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</RecipeSlotDragLayer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recipe-tag-tabs button {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
border-radius: calc(var(--radius-sm) - 1px);
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.5rem;
|
||||
color: var(--color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-tag-tabs button.active {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.recipe-tag-scroll {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.recipe-tag-virtual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipe-tag-window {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.25rem 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.recipe-tag-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-4);
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: var(--color-contrast);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.recipe-tag-row[draggable='true'],
|
||||
.recipe-add-button[draggable='true'] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.recipe-tag-row:hover,
|
||||
.recipe-tag-row:focus-visible {
|
||||
border-color: var(--color-brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-tag-row span {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-custom-tag-list {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.1rem 0.25rem 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.recipe-custom-tag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-3);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.recipe-tag-row:active,
|
||||
.recipe-add-button:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.recipe-custom-tag.is-dragging {
|
||||
opacity: 0.6;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-tag-values:focus {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-add-button,
|
||||
.recipe-delete-button {
|
||||
display: inline-flex;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-4);
|
||||
padding: 0 0.6rem;
|
||||
color: var(--color-contrast);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recipe-add-button:hover {
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
|
||||
.recipe-delete-button:hover {
|
||||
border-color: var(--color-red);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.recipe-add-button svg,
|
||||
.recipe-delete-button svg {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/切石机.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/切石机.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 969 B |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/合成.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/合成.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/熔炼.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/熔炼.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/篝火.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/篝火.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/锻造.png
Normal file
BIN
apps/app-frontend/src/components/lab/recipe-generator/bg/锻造.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, EditIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, NewModal, StyledInput, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import type { SchematicBlockState } from '@/lab/schematic-preview/backend'
|
||||
import {
|
||||
type LoadedSchematicResources,
|
||||
resolveSchematicMaterialTexture,
|
||||
} from '@/lab/schematic-preview/resources'
|
||||
|
||||
import SchematicMaterialSwatch from './SchematicMaterialSwatch.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
blocks: SchematicBlockState[]
|
||||
resources?: LoadedSchematicResources
|
||||
selectedCount: number
|
||||
displayName: (name: string) => string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
replace: [state: SchematicBlockState]
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const searchInput = useTemplateRef<InstanceType<typeof StyledInput>>('searchInput')
|
||||
const search = ref('')
|
||||
const selectedName = ref('')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.schematic-preview.block-picker.title',
|
||||
defaultMessage: 'Replace blocks',
|
||||
},
|
||||
description: {
|
||||
id: 'app.lab.schematic-preview.block-picker.description',
|
||||
defaultMessage: 'Choose the block that will replace the {count} selected blocks.',
|
||||
},
|
||||
search: {
|
||||
id: 'app.lab.schematic-preview.block-picker.search',
|
||||
defaultMessage: 'Search blocks by name or ID',
|
||||
},
|
||||
results: {
|
||||
id: 'app.lab.schematic-preview.block-picker.results',
|
||||
defaultMessage: '{count} blocks',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.lab.schematic-preview.block-picker.empty',
|
||||
defaultMessage: 'No matching blocks',
|
||||
},
|
||||
cancel: { id: 'app.lab.schematic-preview.block-picker.cancel', defaultMessage: 'Cancel' },
|
||||
confirm: {
|
||||
id: 'app.lab.schematic-preview.block-picker.confirm',
|
||||
defaultMessage: 'Replace {count} blocks',
|
||||
},
|
||||
})
|
||||
|
||||
const visibleBlocks = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase(locale.value)
|
||||
return props.blocks
|
||||
.filter((block) => {
|
||||
if (!query) return true
|
||||
return [props.displayName(block.name), block.name].some((value) =>
|
||||
value.toLocaleLowerCase(locale.value).includes(query),
|
||||
)
|
||||
})
|
||||
.sort((left, right) =>
|
||||
props
|
||||
.displayName(left.name)
|
||||
.localeCompare(props.displayName(right.name), locale.value, { sensitivity: 'base' }),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedBlock = computed(() =>
|
||||
props.blocks.find((block) => block.name === selectedName.value),
|
||||
)
|
||||
|
||||
function textureUv(name: string) {
|
||||
return props.resources
|
||||
? resolveSchematicMaterialTexture(name, props.resources.previewResources)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function fallbackColor(name: string) {
|
||||
let hash = 0
|
||||
for (const character of name) hash = (hash * 31 + character.charCodeAt(0)) | 0
|
||||
return `hsl(${Math.abs(hash) % 360} 42% 48%)`
|
||||
}
|
||||
|
||||
async function show(preferredName?: string) {
|
||||
search.value = ''
|
||||
selectedName.value = props.blocks.some((block) => block.name === preferredName)
|
||||
? (preferredName ?? '')
|
||||
: ''
|
||||
modal.value?.show()
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!selectedBlock.value) return
|
||||
emit('replace', selectedBlock.value)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(760px, calc(100vw - 2rem))"
|
||||
max-width="760px"
|
||||
scrollable
|
||||
max-content-height="min(44rem, 76vh)"
|
||||
>
|
||||
<div class="flex min-h-[28rem] min-w-0 flex-col gap-4">
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.description, { count: selectedCount }) }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<StyledInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
class="min-w-0 flex-1"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
clearable
|
||||
/>
|
||||
<span class="shrink-0 text-xs tabular-nums text-secondary">
|
||||
{{ formatMessage(messages.results, { count: visibleBlocks.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="visibleBlocks.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<div v-else class="block-picker-grid grid grid-cols-4 gap-2" role="listbox">
|
||||
<button
|
||||
v-for="block in visibleBlocks"
|
||||
:key="block.name"
|
||||
type="button"
|
||||
class="block-picker-option"
|
||||
:class="{ 'block-picker-option-selected': selectedName === block.name }"
|
||||
:aria-selected="selectedName === block.name"
|
||||
:title="`${displayName(block.name)}\n${block.name}`"
|
||||
role="option"
|
||||
@click="selectedName = block.name"
|
||||
@dblclick="confirm"
|
||||
>
|
||||
<SchematicMaterialSwatch
|
||||
v-if="resources"
|
||||
class="block-picker-swatch"
|
||||
:atlas="resources.atlas"
|
||||
:uv="textureUv(block.name)"
|
||||
:fallback-color="fallbackColor(block.name)"
|
||||
:state="block"
|
||||
:resources="resources"
|
||||
/>
|
||||
<span v-else class="block-picker-swatch bg-surface-4"></span>
|
||||
<span class="block-picker-copy">
|
||||
<strong>{{ displayName(block.name) }}</strong>
|
||||
<small>{{ block.name }}</small>
|
||||
</span>
|
||||
<span v-if="selectedName === block.name" class="block-picker-check">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" @click="modal?.hide()">
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="!selectedBlock" @click="confirm">
|
||||
<EditIcon />{{ formatMessage(messages.confirm, { count: selectedCount }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.block-picker-option {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 4.25rem;
|
||||
cursor: pointer;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.625rem;
|
||||
background: var(--surface-2);
|
||||
color: var(--color-text-dark);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 4.25rem;
|
||||
}
|
||||
|
||||
.block-picker-option:hover {
|
||||
border-color: var(--surface-5);
|
||||
background: var(--color-button-bg);
|
||||
}
|
||||
|
||||
.block-picker-option-selected {
|
||||
border-color: var(--color-brand);
|
||||
background: color-mix(in srgb, var(--color-brand) 10%, var(--surface-2));
|
||||
}
|
||||
|
||||
.block-picker-swatch {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 1px solid rgb(255 255 255 / 14%);
|
||||
border-radius: var(--radius-sm);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.block-picker-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.block-picker-copy strong,
|
||||
.block-picker-copy small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.block-picker-copy strong {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.block-picker-copy small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.block-picker-check {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
display: grid;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-brand-inverted);
|
||||
}
|
||||
|
||||
.block-picker-check svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.block-picker-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.block-picker-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ScanEyeIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import type { SchematicPreviewManifest, SchematicRegion } from '@/lab/schematic-preview/backend'
|
||||
|
||||
defineProps<{
|
||||
manifest: SchematicPreviewManifest
|
||||
format: string
|
||||
warnings: string[]
|
||||
regionVisibility: Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
regionVisibility: [regionId: string, visible: boolean]
|
||||
focusRegion: [region: SchematicRegion]
|
||||
}>()
|
||||
|
||||
const { formatMessage, formatNumber } = useVIntl()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.lab.schematic-preview.info.title', defaultMessage: 'Schematic info' },
|
||||
metadata: { id: 'app.lab.schematic-preview.metadata', defaultMessage: 'Metadata' },
|
||||
regions: { id: 'app.lab.schematic-preview.regions', defaultMessage: 'Regions' },
|
||||
focusRegion: { id: 'app.lab.schematic-preview.focus-region', defaultMessage: 'Focus region' },
|
||||
author: { id: 'app.lab.schematic-preview.author', defaultMessage: 'Author' },
|
||||
format: { id: 'app.lab.schematic-preview.format', defaultMessage: 'Format' },
|
||||
dataVersion: { id: 'app.lab.schematic-preview.data-version', defaultMessage: 'Data version' },
|
||||
coordinates: { id: 'app.lab.schematic-preview.coordinates', defaultMessage: 'Coordinates' },
|
||||
blocks: { id: 'app.lab.schematic-preview.blocks', defaultMessage: 'Blocks' },
|
||||
entities: { id: 'app.lab.schematic-preview.entities', defaultMessage: 'Entities' },
|
||||
blockEntities: {
|
||||
id: 'app.lab.schematic-preview.block-entities',
|
||||
defaultMessage: 'Block entities',
|
||||
},
|
||||
warnings: { id: 'app.lab.schematic-preview.warnings', defaultMessage: 'Warnings' },
|
||||
})
|
||||
|
||||
defineExpose({ show: () => modal.value?.show() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(620px, calc(100vw - 2rem))"
|
||||
max-width="620px"
|
||||
scrollable
|
||||
max-content-height="min(42rem, 76vh)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-6">
|
||||
<section class="info-section">
|
||||
<h2>{{ formatMessage(messages.metadata) }}</h2>
|
||||
<dl class="metadata-grid">
|
||||
<dt>{{ formatMessage(messages.format) }}</dt>
|
||||
<dd>{{ format }}</dd>
|
||||
<template v-if="manifest.author">
|
||||
<dt>{{ formatMessage(messages.author) }}</dt>
|
||||
<dd>{{ manifest.author }}</dd>
|
||||
</template>
|
||||
<template v-if="manifest.dataVersion">
|
||||
<dt>{{ formatMessage(messages.dataVersion) }}</dt>
|
||||
<dd>{{ manifest.dataVersion }}</dd>
|
||||
</template>
|
||||
<dt>{{ formatMessage(messages.coordinates) }}</dt>
|
||||
<dd>{{ manifest.min.join(', ') }} -> {{ manifest.max.join(', ') }}</dd>
|
||||
<dt>{{ formatMessage(messages.blocks) }}</dt>
|
||||
<dd>{{ formatNumber(manifest.blockCount) }}</dd>
|
||||
<dt>{{ formatMessage(messages.entities) }}</dt>
|
||||
<dd>{{ formatNumber(manifest.entityCount) }}</dd>
|
||||
<dt>{{ formatMessage(messages.blockEntities) }}</dt>
|
||||
<dd>{{ formatNumber(manifest.blockEntityCount) }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="info-section">
|
||||
<h2>{{ formatMessage(messages.regions) }}</h2>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-for="region in manifest.regions" :key="region.id" class="region-row">
|
||||
<Checkbox
|
||||
:model-value="regionVisibility[region.id]"
|
||||
:label="region.name"
|
||||
@update:model-value="emit('regionVisibility', region.id, $event)"
|
||||
/>
|
||||
<span class="text-xs text-secondary">
|
||||
{{ region.size.join(' x ') }} - {{ formatNumber(region.blockCount) }}
|
||||
</span>
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.focusRegion)"
|
||||
:title="formatMessage(messages.focusRegion)"
|
||||
@click="emit('focusRegion', region)"
|
||||
>
|
||||
<ScanEyeIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="warnings.length" class="info-section flex min-w-0 flex-col gap-3 warning-section text-orange">
|
||||
<h2><TriangleAlertIcon />{{ formatMessage(messages.warnings) }}</h2>
|
||||
<ul>
|
||||
<li v-for="warning in warnings" :key="warning">{{ warning }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-section h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
color: var(--color-text-dark);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.info-section h2 svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.metadata-grid dt {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.metadata-grid dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-dark);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.region-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.region-row :deep(.checkbox-outer) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.region-row :deep(.checkbox-outer > span:last-child) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.warning-section ul {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding-left: 1.15rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.region-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.region-row > span {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,454 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
FileArchiveIcon,
|
||||
FolderIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useFormatBytes,
|
||||
useVIntl,
|
||||
useVirtualScroll,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types.d.ts'
|
||||
import {
|
||||
type InstanceSchematicFile,
|
||||
listInstanceSchematics,
|
||||
type SchematicPreviewSource,
|
||||
} from '@/lab/schematic-preview/backend'
|
||||
import {
|
||||
buildInstanceSchematicRows,
|
||||
collectSchematicFolders,
|
||||
type InstanceSchematicRow,
|
||||
} from '@/lab/schematic-preview/instance-files'
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [source: SchematicPreviewSource, instance: GameInstance]
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
const formatBytes = useFormatBytes()
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const searchInput = useTemplateRef<InstanceType<typeof StyledInput>>('searchInput')
|
||||
const instances = ref<GameInstance[]>([])
|
||||
const selectedInstanceId = ref('')
|
||||
const files = ref<InstanceSchematicFile[]>([])
|
||||
const search = ref('')
|
||||
const expandedFolders = ref<Set<string>>(new Set())
|
||||
const loadingInstances = ref(false)
|
||||
const loadingFiles = ref(false)
|
||||
const error = ref('')
|
||||
let fileRequest = 0
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.title',
|
||||
defaultMessage: 'Open from an instance',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.choose-instance',
|
||||
defaultMessage: 'Choose the instance that contains the schematic',
|
||||
},
|
||||
searchInstances: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.search-instances',
|
||||
defaultMessage: 'Search instances',
|
||||
},
|
||||
searchSchematics: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.search',
|
||||
defaultMessage: 'Search schematics',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.no-instances',
|
||||
defaultMessage: 'No installed instances are available.',
|
||||
},
|
||||
noMatchingInstances: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.no-matching-instances',
|
||||
defaultMessage: 'No instances match your search.',
|
||||
},
|
||||
noSchematics: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.empty',
|
||||
defaultMessage: 'No .litematic or .schem files were found in this instance.',
|
||||
},
|
||||
noMatchingSchematics: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.no-matching-schematics',
|
||||
defaultMessage: 'No schematics match your search.',
|
||||
},
|
||||
back: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.back',
|
||||
defaultMessage: 'Back to instances',
|
||||
},
|
||||
selectInstance: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.select-instance',
|
||||
defaultMessage: 'Browse schematics in {name}',
|
||||
},
|
||||
openSchematic: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.open',
|
||||
defaultMessage: 'Open {name}',
|
||||
},
|
||||
expandFolder: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.expand-folder',
|
||||
defaultMessage: 'Expand folder {name}',
|
||||
},
|
||||
collapseFolder: {
|
||||
id: 'app.lab.schematic-preview.instance-picker.collapse-folder',
|
||||
defaultMessage: 'Collapse folder {name}',
|
||||
},
|
||||
})
|
||||
|
||||
const selectedInstance = computed(() =>
|
||||
instances.value.find((instance) => instance.id === selectedInstanceId.value),
|
||||
)
|
||||
const visibleInstances = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase(locale.value)
|
||||
return instances.value.filter((instance) => {
|
||||
if (!query) return true
|
||||
return [instance.name, instance.loader, instance.game_version].some((value) =>
|
||||
value.toLocaleLowerCase(locale.value).includes(query),
|
||||
)
|
||||
})
|
||||
})
|
||||
const visibleRows = computed<InstanceSchematicRow[]>(() =>
|
||||
buildInstanceSchematicRows(files.value, expandedFolders.value, search.value, locale.value),
|
||||
)
|
||||
const { listContainer, totalHeight, visibleTop, visibleItems } = useVirtualScroll(visibleRows, {
|
||||
itemHeight: 64,
|
||||
bufferSize: 6,
|
||||
})
|
||||
|
||||
function formatModified(value?: number) {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value * 1000))
|
||||
}
|
||||
|
||||
function toggleFolder(path: string) {
|
||||
const next = new Set(expandedFolders.value)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
expandedFolders.value = next
|
||||
}
|
||||
|
||||
function rowPadding(depth: number) {
|
||||
return { paddingLeft: `${0.75 + depth * 1.25}rem` }
|
||||
}
|
||||
|
||||
function rowKey(row: InstanceSchematicRow) {
|
||||
return row.kind === 'folder' ? row.path : row.file.relativePath
|
||||
}
|
||||
|
||||
async function loadFiles(instanceId = selectedInstanceId.value) {
|
||||
const request = ++fileRequest
|
||||
if (!instanceId) {
|
||||
files.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loadingFiles.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await listInstanceSchematics(instanceId)
|
||||
if (request !== fileRequest || instanceId !== selectedInstanceId.value) return
|
||||
files.value = result
|
||||
expandedFolders.value = new Set(collectSchematicFolders(result))
|
||||
} catch (caught) {
|
||||
if (request !== fileRequest || instanceId !== selectedInstanceId.value) return
|
||||
files.value = []
|
||||
error.value = caught instanceof Error ? caught.message : String(caught)
|
||||
} finally {
|
||||
if (request === fileRequest) loadingFiles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function show(preferredInstanceId?: string) {
|
||||
fileRequest += 1
|
||||
selectedInstanceId.value = ''
|
||||
files.value = []
|
||||
search.value = ''
|
||||
expandedFolders.value = new Set()
|
||||
error.value = ''
|
||||
loadingFiles.value = false
|
||||
loadingInstances.value = true
|
||||
modal.value?.show()
|
||||
|
||||
try {
|
||||
instances.value = (await list())
|
||||
.filter((instance) => instance.install_stage === 'installed')
|
||||
.sort((left, right) => {
|
||||
const lastPlayed =
|
||||
Number(new Date(right.last_played ?? 0)) - Number(new Date(left.last_played ?? 0))
|
||||
return lastPlayed || left.name.localeCompare(right.name, locale.value)
|
||||
})
|
||||
|
||||
const preferredInstance = instances.value.find(
|
||||
(instance) => instance.id === preferredInstanceId,
|
||||
)
|
||||
if (preferredInstance) {
|
||||
selectedInstanceId.value = preferredInstance.id
|
||||
await loadFiles(preferredInstance.id)
|
||||
}
|
||||
} catch (caught) {
|
||||
instances.value = []
|
||||
files.value = []
|
||||
error.value = caught instanceof Error ? caught.message : String(caught)
|
||||
} finally {
|
||||
loadingInstances.value = false
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
async function selectInstance(instance: GameInstance) {
|
||||
selectedInstanceId.value = instance.id
|
||||
files.value = []
|
||||
search.value = ''
|
||||
expandedFolders.value = new Set()
|
||||
await loadFiles(instance.id)
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
async function backToInstances() {
|
||||
fileRequest += 1
|
||||
selectedInstanceId.value = ''
|
||||
files.value = []
|
||||
search.value = ''
|
||||
expandedFolders.value = new Set()
|
||||
error.value = ''
|
||||
loadingFiles.value = false
|
||||
await nextTick()
|
||||
searchInput.value?.focus()
|
||||
}
|
||||
|
||||
function openFile(file: InstanceSchematicFile) {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
emit(
|
||||
'open',
|
||||
{ kind: 'instance', instanceId: instance.id, relativePath: file.relativePath },
|
||||
instance,
|
||||
)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
width="min(640px, calc(100vw - 2rem))"
|
||||
max-width="640px"
|
||||
scrollable
|
||||
max-content-height="min(40rem, 78vh)"
|
||||
>
|
||||
<div class="flex min-h-[24rem] min-w-0 flex-col gap-4">
|
||||
<template v-if="!selectedInstance">
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.chooseInstance) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchInstances)"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div v-if="loadingInstances" class="flex flex-1 items-center justify-center text-secondary">
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-brand-red"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="instances.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstances) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="visibleInstances.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noMatchingInstances) }}
|
||||
</p>
|
||||
<ul v-else class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li v-for="instance in visibleInstances" :key="instance.id" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:aria-label="formatMessage(messages.selectInstance, { name: instance.name })"
|
||||
@click="selectInstance(instance)"
|
||||
>
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-contrast">{{ instance.name }}</strong>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<ButtonStyled circular size="small" type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.back)"
|
||||
:title="formatMessage(messages.back)"
|
||||
@click="backToInstances"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<InstanceIcon
|
||||
class="size-10 shrink-0"
|
||||
:icon-path="selectedInstance.icon_path"
|
||||
:instance-id="selectedInstance.id"
|
||||
:loader="selectedInstance.loader"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<strong class="truncate text-contrast">{{ selectedInstance.name }}</strong>
|
||||
<span class="truncate text-sm capitalize text-secondary">
|
||||
{{ selectedInstance.loader }} {{ selectedInstance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StyledInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
:placeholder="formatMessage(messages.searchSchematics)"
|
||||
wrapper-class="w-full"
|
||||
clearable
|
||||
/>
|
||||
|
||||
<div v-if="loadingFiles" class="flex flex-1 items-center justify-center text-secondary">
|
||||
<SpinnerIcon class="size-6 animate-spin" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-brand-red"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="files.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noSchematics) }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="visibleRows.length === 0"
|
||||
class="m-0 flex flex-1 items-center justify-center text-center text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noMatchingSchematics) }}
|
||||
</p>
|
||||
<div v-else class="max-h-[30rem] overflow-y-auto pr-1">
|
||||
<div
|
||||
ref="listContainer"
|
||||
role="list"
|
||||
class="relative"
|
||||
:style="{ height: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="absolute inset-x-0" :style="{ top: `${visibleTop}px` }">
|
||||
<template v-for="row in visibleItems" :key="rowKey(row)">
|
||||
<button
|
||||
v-if="row.kind === 'folder'"
|
||||
type="button"
|
||||
role="listitem"
|
||||
class="flex h-16 w-full cursor-pointer items-center gap-3 border-0 border-b border-solid border-surface-5 bg-transparent py-2 pr-3 text-left text-primary transition-colors last:border-b-0 hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:style="rowPadding(row.depth)"
|
||||
:title="row.path"
|
||||
:aria-expanded="row.expanded"
|
||||
:aria-label="
|
||||
formatMessage(row.expanded ? messages.collapseFolder : messages.expandFolder, {
|
||||
name: row.name,
|
||||
})
|
||||
"
|
||||
@click="toggleFolder(row.path)"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
v-if="row.expanded"
|
||||
class="size-5 shrink-0 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
v-else
|
||||
class="size-5 shrink-0 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<FolderIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<strong class="truncate text-contrast">{{ row.name }}</strong>
|
||||
<span class="shrink-0 text-sm font-medium text-secondary">
|
||||
({{ row.fileCount }})
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
role="listitem"
|
||||
class="flex h-16 w-full cursor-pointer items-center gap-3 border-0 border-b border-solid border-surface-5 bg-transparent py-2 pr-3 text-left text-primary transition-colors last:border-b-0 hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:style="rowPadding(row.depth)"
|
||||
:title="row.file.relativePath"
|
||||
:aria-label="
|
||||
formatMessage(messages.openSchematic, { name: row.file.relativePath })
|
||||
"
|
||||
@click="openFile(row.file)"
|
||||
>
|
||||
<FileArchiveIcon class="size-5 shrink-0 text-secondary" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong class="truncate text-contrast">{{ row.file.fileName }}</strong>
|
||||
<span class="truncate text-xs uppercase text-secondary">
|
||||
<span v-if="row.parentPath">{{ row.parentPath }} · </span
|
||||
>{{ row.file.format }} · {{ formatBytes(row.file.size) }}
|
||||
<span v-if="row.file.modifiedAt">
|
||||
· {{ formatModified(row.file.modifiedAt) }}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="size-5 shrink-0 text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import type { SchematicBlockState } from '@/lab/schematic-preview/backend'
|
||||
import { renderSchematicBlockPreview } from '@/lab/schematic-preview/block-preview'
|
||||
import type { LoadedSchematicResources } from '@/lab/schematic-preview/resources'
|
||||
|
||||
const props = defineProps<{
|
||||
atlas: HTMLCanvasElement
|
||||
uv?: [number, number, number, number]
|
||||
fallbackColor: string
|
||||
state?: SchematicBlockState
|
||||
resources?: LoadedSchematicResources
|
||||
}>()
|
||||
|
||||
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')
|
||||
let visible = false
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
function render() {
|
||||
const target = canvas.value
|
||||
if (!target || !visible) return
|
||||
if (
|
||||
props.state &&
|
||||
props.resources &&
|
||||
renderSchematicBlockPreview(target, props.state, props.resources)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const context = target.getContext('2d')
|
||||
if (!context) return
|
||||
context.clearRect(0, 0, target.width, target.height)
|
||||
context.imageSmoothingEnabled = false
|
||||
if (!props.uv) {
|
||||
context.fillStyle = props.fallbackColor
|
||||
context.fillRect(0, 0, target.width, target.height)
|
||||
return
|
||||
}
|
||||
const [u0, v0, u1, v1] = props.uv
|
||||
const sourceX = Math.round(u0 * props.atlas.width)
|
||||
const sourceY = Math.round(v0 * props.atlas.height)
|
||||
const sourceWidth = Math.max(1, Math.round((u1 - u0) * props.atlas.width))
|
||||
const sourceHeight = Math.max(1, Math.round((v1 - v0) * props.atlas.height))
|
||||
context.drawImage(
|
||||
props.atlas,
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
0,
|
||||
0,
|
||||
target.width,
|
||||
target.height,
|
||||
)
|
||||
}
|
||||
|
||||
watch(() => [props.atlas, props.uv, props.fallbackColor, props.state, props.resources], render)
|
||||
onMounted(() => {
|
||||
const target = canvas.value
|
||||
if (!target || typeof IntersectionObserver === 'undefined') {
|
||||
visible = true
|
||||
render()
|
||||
return
|
||||
}
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (!entries.some((entry) => entry.isIntersecting)) return
|
||||
visible = true
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
render()
|
||||
})
|
||||
observer.observe(target)
|
||||
})
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" :width="64" :height="64" aria-hidden="true"></canvas>
|
||||
</template>
|
||||
@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, LayersIcon, SearchIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
PopoutMenu,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
SEED_MAP_BIOME_NAMES,
|
||||
SEED_MAP_BIOMES,
|
||||
type SeedMapBiomeCategory,
|
||||
seedMapBiomeGroups,
|
||||
type SeedMapDimension,
|
||||
} from '@/lab/seed-map'
|
||||
|
||||
const props = defineProps<{
|
||||
dimension: SeedMapDimension
|
||||
enabled: boolean
|
||||
highlightedBiomes: number[]
|
||||
/** Container used for the popover while the map is in browser fullscreen. */
|
||||
container?: HTMLElement | string | boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:dimension': [dimension: SeedMapDimension]
|
||||
'update:enabled': [enabled: boolean]
|
||||
'update:highlightedBiomes': [biomes: number[]]
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
biomeHighlight: { id: 'app.lab.seed-map.biome-highlight', defaultMessage: 'Biome highlight' },
|
||||
chooseBiome: { id: 'app.lab.seed-map.choose-biome', defaultMessage: 'Choose biome' },
|
||||
selectedBiomes: {
|
||||
id: 'app.lab.seed-map.selected-biomes',
|
||||
defaultMessage: '{count, plural, one {# biome} other {# biomes}} selected',
|
||||
},
|
||||
selectionCount: {
|
||||
id: 'app.lab.seed-map.biome-selection-count',
|
||||
defaultMessage: '{selected} of {total}',
|
||||
},
|
||||
search: { id: 'app.lab.seed-map.search-biomes', defaultMessage: 'Search biomes' },
|
||||
selectAll: { id: 'app.lab.seed-map.select-all', defaultMessage: 'Select all' },
|
||||
invert: { id: 'app.lab.seed-map.invert-selection', defaultMessage: 'Invert' },
|
||||
clear: { id: 'app.lab.seed-map.clear', defaultMessage: 'Clear' },
|
||||
noMatches: {
|
||||
id: 'app.lab.seed-map.no-matching-biomes',
|
||||
defaultMessage: 'No matching biomes',
|
||||
},
|
||||
overworld: { id: 'app.lab.seed-map.dimension.overworld', defaultMessage: 'Overworld' },
|
||||
nether: { id: 'app.lab.seed-map.dimension.nether', defaultMessage: 'Nether' },
|
||||
end: { id: 'app.lab.seed-map.dimension.end', defaultMessage: 'The End' },
|
||||
groupBeach: { id: 'app.lab.seed-map.biome-group.beach', defaultMessage: 'Beaches' },
|
||||
groupCave: { id: 'app.lab.seed-map.biome-group.cave', defaultMessage: 'Caves' },
|
||||
groupDesert: { id: 'app.lab.seed-map.biome-group.desert', defaultMessage: 'Desert' },
|
||||
groupForest: { id: 'app.lab.seed-map.biome-group.forest', defaultMessage: 'Forests' },
|
||||
groupIce: { id: 'app.lab.seed-map.biome-group.ice', defaultMessage: 'Icy biomes' },
|
||||
groupJungle: { id: 'app.lab.seed-map.biome-group.jungle', defaultMessage: 'Jungles' },
|
||||
groupMesa: { id: 'app.lab.seed-map.biome-group.mesa', defaultMessage: 'Badlands' },
|
||||
groupMountains: { id: 'app.lab.seed-map.biome-group.mountains', defaultMessage: 'Mountains' },
|
||||
groupMushroom: { id: 'app.lab.seed-map.biome-group.mushroom', defaultMessage: 'Mushroom' },
|
||||
groupOcean: { id: 'app.lab.seed-map.biome-group.ocean', defaultMessage: 'Oceans' },
|
||||
groupPlains: { id: 'app.lab.seed-map.biome-group.plains', defaultMessage: 'Plains' },
|
||||
groupRiver: { id: 'app.lab.seed-map.biome-group.river', defaultMessage: 'Rivers' },
|
||||
groupSavanna: { id: 'app.lab.seed-map.biome-group.savanna', defaultMessage: 'Savannas' },
|
||||
groupSwamp: { id: 'app.lab.seed-map.biome-group.swamp', defaultMessage: 'Swamps' },
|
||||
groupTaiga: { id: 'app.lab.seed-map.biome-group.taiga', defaultMessage: 'Taiga' },
|
||||
groupNether: { id: 'app.lab.seed-map.biome-group.nether', defaultMessage: 'Nether' },
|
||||
groupEnd: { id: 'app.lab.seed-map.biome-group.end', defaultMessage: 'The End' },
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const search = ref('')
|
||||
|
||||
const categoryMessages: Record<SeedMapBiomeCategory, (typeof messages)[keyof typeof messages]> = {
|
||||
beach: messages.groupBeach,
|
||||
cave: messages.groupCave,
|
||||
desert: messages.groupDesert,
|
||||
forest: messages.groupForest,
|
||||
ice: messages.groupIce,
|
||||
jungle: messages.groupJungle,
|
||||
mesa: messages.groupMesa,
|
||||
mountains: messages.groupMountains,
|
||||
mushroom: messages.groupMushroom,
|
||||
ocean: messages.groupOcean,
|
||||
plains: messages.groupPlains,
|
||||
river: messages.groupRiver,
|
||||
savanna: messages.groupSavanna,
|
||||
swamp: messages.groupSwamp,
|
||||
taiga: messages.groupTaiga,
|
||||
nether: messages.groupNether,
|
||||
end: messages.groupEnd,
|
||||
}
|
||||
|
||||
const dimensionMessages: Record<SeedMapDimension, (typeof messages)[keyof typeof messages]> = {
|
||||
overworld: messages.overworld,
|
||||
nether: messages.nether,
|
||||
end: messages.end,
|
||||
}
|
||||
|
||||
const currentBiomeIds = computed(() =>
|
||||
SEED_MAP_BIOMES.filter((biome) => biome.dimensions.includes(props.dimension)).map(
|
||||
(biome) => biome.id,
|
||||
),
|
||||
)
|
||||
const allBiomeIds = SEED_MAP_BIOMES.map((biome) => biome.id)
|
||||
const activeBiomes = computed(() =>
|
||||
props.highlightedBiomes.filter((biome) => currentBiomeIds.value.includes(biome)),
|
||||
)
|
||||
const groups = seedMapBiomeGroups()
|
||||
const visibleGroups = computed(() => {
|
||||
const query = search.value.trim().toLocaleLowerCase()
|
||||
if (!query) return groups
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
biomes: group.biomes.filter((biome) => {
|
||||
const referenceName = SEED_MAP_BIOME_NAMES[biome.id] ?? ''
|
||||
return `${biomeLabel(biome.id)} ${referenceName}`.toLocaleLowerCase().includes(query)
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.biomes.length > 0)
|
||||
})
|
||||
|
||||
const enabledModel = computed({
|
||||
get: () => props.enabled,
|
||||
set: (enabled: boolean) => {
|
||||
if (enabled && activeBiomes.value.length === 0 && currentBiomeIds.value[0] !== undefined) {
|
||||
emit('update:highlightedBiomes', [...props.highlightedBiomes, currentBiomeIds.value[0]])
|
||||
}
|
||||
emit('update:enabled', enabled)
|
||||
},
|
||||
})
|
||||
|
||||
function biomeLabel(biome: number) {
|
||||
const name = SEED_MAP_BIOME_NAMES[biome]
|
||||
if (!name) return formatMessage(messages.chooseBiome)
|
||||
return formatMessage({
|
||||
id: `app.lab.seed-map.biome.${biomeSlug(name)}`,
|
||||
defaultMessage: name,
|
||||
})
|
||||
}
|
||||
|
||||
function biomeSlug(name: string) {
|
||||
return name.toLocaleLowerCase().replaceAll(' ', '-')
|
||||
}
|
||||
|
||||
function biomeImageSource(biome: number) {
|
||||
const name = SEED_MAP_BIOME_NAMES[biome]
|
||||
return name ? `/seed-map-assets/biomes/${biomeSlug(name)}.webp` : ''
|
||||
}
|
||||
|
||||
function toggleBiome(biome: number, selected: boolean) {
|
||||
const definition = SEED_MAP_BIOMES.find((item) => item.id === biome)
|
||||
if (!definition) return
|
||||
const next = selected
|
||||
? [...new Set([...props.highlightedBiomes, biome])]
|
||||
: props.highlightedBiomes.filter((item) => item !== biome)
|
||||
emit('update:highlightedBiomes', next)
|
||||
emit('update:enabled', next.length > 0)
|
||||
const targetDimension = definition.dimensions[0]
|
||||
if (selected && targetDimension && targetDimension !== props.dimension) {
|
||||
emit('update:dimension', targetDimension)
|
||||
}
|
||||
}
|
||||
|
||||
function clearBiomes() {
|
||||
emit('update:highlightedBiomes', [])
|
||||
emit('update:enabled', false)
|
||||
}
|
||||
|
||||
function selectAllBiomes() {
|
||||
emit('update:highlightedBiomes', [...allBiomeIds])
|
||||
emit('update:enabled', true)
|
||||
}
|
||||
|
||||
function invertBiomes() {
|
||||
const selected = new Set(props.highlightedBiomes)
|
||||
const next = allBiomeIds.filter((biome) => !selected.has(biome))
|
||||
emit('update:highlightedBiomes', next)
|
||||
emit('update:enabled', next.length > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="biome-cluster">
|
||||
<label class="biome-toggle-pill">
|
||||
<span>{{ formatMessage(messages.biomeHighlight) }}</span>
|
||||
<Toggle v-model="enabledModel" small />
|
||||
</label>
|
||||
<ButtonStyled class="biome-picker-trigger" type="outlined">
|
||||
<PopoutMenu
|
||||
:aria-label="formatMessage(messages.chooseBiome)"
|
||||
dropdown-class="seed-map-biome-popout"
|
||||
:container="props.container"
|
||||
placement="top-start"
|
||||
>
|
||||
<LayersIcon />
|
||||
<span>
|
||||
{{
|
||||
props.highlightedBiomes.length === 0
|
||||
? formatMessage(messages.chooseBiome)
|
||||
: props.highlightedBiomes.length === 1
|
||||
? biomeLabel(props.highlightedBiomes[0])
|
||||
: formatMessage(messages.selectedBiomes, {
|
||||
count: props.highlightedBiomes.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<ChevronDownIcon />
|
||||
<template #menu>
|
||||
<div
|
||||
class="flex w-[min(30rem,calc(100vw-1.5rem))] max-h-[min(30rem,calc(100dvh-2rem))] min-h-0 flex-col gap-[0.65rem] overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="biome-picker-heading flex items-center justify-between gap-3 p-[0.1rem_0.2rem] text-contrast max-sm:flex-col max-sm:items-start"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ formatMessage(messages.chooseBiome) }}</strong>
|
||||
<small>
|
||||
{{
|
||||
formatMessage(messages.selectionCount, {
|
||||
selected: props.highlightedBiomes.length,
|
||||
total: allBiomeIds.length,
|
||||
})
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-[0.15rem] max-sm:w-full max-sm:flex-wrap">
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button @click="selectAllBiomes">
|
||||
{{ formatMessage(messages.selectAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button @click="invertBiomes">
|
||||
{{ formatMessage(messages.invert) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button :disabled="props.highlightedBiomes.length === 0" @click="clearBiomes">
|
||||
{{ formatMessage(messages.clear) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<StyledInput
|
||||
v-model="search"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.search)"
|
||||
wrapper-class="biome-picker-search"
|
||||
/>
|
||||
<div class="biome-picker-groups">
|
||||
<Accordion
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.category"
|
||||
class="biome-picker-group"
|
||||
button-class="biome-picker-group-trigger"
|
||||
content-class="biome-picker-group-options"
|
||||
>
|
||||
<template #title>
|
||||
<strong>{{ formatMessage(categoryMessages[group.category]) }}</strong>
|
||||
<span
|
||||
class="biome-picker-dimension"
|
||||
:class="{ active: group.dimension === props.dimension }"
|
||||
>
|
||||
{{ formatMessage(dimensionMessages[group.dimension]) }}
|
||||
</span>
|
||||
<small>
|
||||
{{
|
||||
group.biomes.filter((biome) => props.highlightedBiomes.includes(biome.id))
|
||||
.length
|
||||
}}/{{ group.biomes.length }}
|
||||
</small>
|
||||
</template>
|
||||
<div class="grid grid-cols-2 gap-[0.45rem] max-sm:grid-cols-1">
|
||||
<Checkbox
|
||||
v-for="biome in group.biomes"
|
||||
:key="biome.id"
|
||||
:model-value="props.highlightedBiomes.includes(biome.id)"
|
||||
:description="biomeLabel(biome.id)"
|
||||
@update:model-value="toggleBiome(biome.id, $event)"
|
||||
>
|
||||
<img
|
||||
class="biome-picker-icon"
|
||||
:src="biomeImageSource(biome.id)"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
:style="{ '--biome-color': biome.color }"
|
||||
/>
|
||||
<span class="biome-picker-option-label">{{ biomeLabel(biome.id) }}</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Accordion>
|
||||
<p v-if="visibleGroups.length === 0" class="biome-picker-empty">
|
||||
{{ formatMessage(messages.noMatches) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.biome-cluster {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.biome-toggle-pill {
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-4);
|
||||
padding: 0 0.6rem;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.biome-picker-trigger {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.biome-picker-trigger :deep(button) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.biome-picker-trigger :deep(button > span) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.biome-picker-trigger :deep(button > svg:last-child) {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.biome-picker-heading > div:first-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.biome-picker-heading small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.biome-picker-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.biome-picker-groups {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.biome-picker-group {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.biome-picker-group-trigger) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.biome-picker-group-trigger:hover) {
|
||||
background: var(--surface-4);
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.biome-picker-group-trigger > div) {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.biome-picker-group-trigger strong) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.biome-picker-dimension {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.12rem 0.35rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.biome-picker-dimension.active {
|
||||
border-color: var(--color-brand-highlight);
|
||||
background: var(--color-brand-highlight);
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.biome-picker-group-trigger small) {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.biome-picker-group-options) {
|
||||
padding: 0.25rem 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.checkbox-outer) {
|
||||
min-width: 0;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.checkbox-outer:hover) {
|
||||
background: var(--surface-4);
|
||||
}
|
||||
|
||||
.biome-picker-groups :deep(.checkbox-outer > span:last-child) {
|
||||
overflow: hidden;
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.biome-picker-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid color-mix(in srgb, var(--biome-color) 70%, var(--surface-5));
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--biome-color);
|
||||
image-rendering: pixelated;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.biome-picker-option-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.biome-picker-empty {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:global(.v-popper__popper.seed-map-biome-popout) {
|
||||
z-index: 10050 !important;
|
||||
max-width: calc(100vw - 0.75rem);
|
||||
}
|
||||
|
||||
:global(.v-popper__popper.seed-map-biome-popout .v-popper__inner) {
|
||||
max-width: calc(100vw - 0.75rem);
|
||||
max-height: calc(100dvh - 0.75rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon, ExternalIcon, ImageIcon, InfoIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = useTemplateRef<InstanceType<typeof ModalWrapper>>('modal')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.seed-map.copyright.title',
|
||||
defaultMessage: 'Copyright and attribution',
|
||||
},
|
||||
iconHeading: {
|
||||
id: 'app.lab.seed-map.copyright.icons-heading',
|
||||
defaultMessage: 'Map artwork',
|
||||
},
|
||||
iconBody: {
|
||||
id: 'app.lab.seed-map.copyright.icons-body',
|
||||
defaultMessage:
|
||||
'Some structure and biome icons used by this tool were sourced from MinecraftSearch. MinecraftSearch and the respective creators retain their rights in that artwork.',
|
||||
},
|
||||
visitMinecraftSearch: {
|
||||
id: 'app.lab.seed-map.copyright.visit-minecraft-search',
|
||||
defaultMessage: 'Visit MinecraftSearch',
|
||||
},
|
||||
engineHeading: {
|
||||
id: 'app.lab.seed-map.copyright.engine-heading',
|
||||
defaultMessage: 'Local map engine',
|
||||
},
|
||||
engineBody: {
|
||||
id: 'app.lab.seed-map.copyright.engine-body',
|
||||
defaultMessage:
|
||||
'Biome, terrain, spawn, and structure data is generated locally with cubiomes, Copyright (c) 2020 Cubitect, provided under the MIT License, together with the Axolotl native integration.',
|
||||
},
|
||||
viewCubiomes: {
|
||||
id: 'app.lab.seed-map.copyright.view-cubiomes',
|
||||
defaultMessage: 'View cubiomes',
|
||||
},
|
||||
disclaimerHeading: {
|
||||
id: 'app.lab.seed-map.copyright.disclaimer-heading',
|
||||
defaultMessage: 'Unofficial tool',
|
||||
},
|
||||
disclaimerBody: {
|
||||
id: 'app.lab.seed-map.copyright.disclaimer-body',
|
||||
defaultMessage:
|
||||
'Minecraft is a trademark of Mojang Synergies AB. Axolotl Launcher is not affiliated with or endorsed by Mojang or MinecraftSearch.',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: (event?: MouseEvent) => modal.value?.show(event),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="copyright-notice">
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<ImageIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.iconHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.iconBody) }}</p>
|
||||
<ButtonStyled size="small" type="outlined">
|
||||
<button @click="openUrl('https://minecraftsearch.com')">
|
||||
{{ formatMessage(messages.visitMinecraftSearch) }}
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<CodeIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.engineHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.engineBody) }}</p>
|
||||
<ButtonStyled size="small" type="outlined">
|
||||
<button @click="openUrl('https://github.com/Cubitect/cubiomes')">
|
||||
{{ formatMessage(messages.viewCubiomes) }}
|
||||
<ExternalIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="notice-section grid grid-cols-[1.5rem_minmax(0,1fr)] gap-3 border-b border-surface-5 pb-4">
|
||||
<InfoIcon aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{{ formatMessage(messages.disclaimerHeading) }}</h3>
|
||||
<p>{{ formatMessage(messages.disclaimerBody) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.copyright-notice {
|
||||
display: flex;
|
||||
width: min(34rem, calc(100vw - 3rem));
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notice-section:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.notice-section > svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-top: 0.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.notice-section h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.notice-section p {
|
||||
margin: 0.35rem 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeftIcon, ChevronRightIcon, SpinnerIcon, WorldIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_full_path, list } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
get_instance_worlds,
|
||||
isSingleplayerWorld,
|
||||
type SingleplayerWorld,
|
||||
sortWorlds,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { readSeedMapLevelDat } from '@/lab/seed-map'
|
||||
|
||||
export type SeedMapWorldImport = {
|
||||
seed: string
|
||||
version?: string
|
||||
instance: GameInstance
|
||||
world: SingleplayerWorld
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
import: [selection: SeedMapWorldImport]
|
||||
}>()
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const modal = ref()
|
||||
const instances = ref<GameInstance[]>([])
|
||||
const worlds = ref<SingleplayerWorld[]>([])
|
||||
const selectedInstance = ref<GameInstance | null>(null)
|
||||
const loading = ref(false)
|
||||
const importingWorldPath = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.lab.seed-map.import-world.title',
|
||||
defaultMessage: 'Load a seed from your instances',
|
||||
},
|
||||
chooseInstance: {
|
||||
id: 'app.lab.seed-map.import-world.choose-instance',
|
||||
defaultMessage: 'Choose the instance that contains the world',
|
||||
},
|
||||
chooseWorld: {
|
||||
id: 'app.lab.seed-map.import-world.choose-world',
|
||||
defaultMessage: 'Choose a singleplayer world',
|
||||
},
|
||||
back: { id: 'app.lab.seed-map.import-world.back', defaultMessage: 'Back to instances' },
|
||||
noInstances: {
|
||||
id: 'app.lab.seed-map.import-world.no-instances',
|
||||
defaultMessage: 'No instances found. Install an instance first.',
|
||||
},
|
||||
noWorlds: {
|
||||
id: 'app.lab.seed-map.import-world.no-worlds',
|
||||
defaultMessage: 'This instance has no singleplayer worlds yet.',
|
||||
},
|
||||
lastPlayed: {
|
||||
id: 'app.lab.seed-map.import-world.last-played',
|
||||
defaultMessage: 'Played {ago}',
|
||||
},
|
||||
neverPlayed: {
|
||||
id: 'app.lab.seed-map.import-world.never-played',
|
||||
defaultMessage: 'Not played yet',
|
||||
},
|
||||
})
|
||||
|
||||
async function show() {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
modal.value?.show()
|
||||
loading.value = true
|
||||
try {
|
||||
const loaded = await list()
|
||||
loaded.sort((a, b) => {
|
||||
if (!a.last_played) return 1
|
||||
if (!b.last_played) return -1
|
||||
return dayjs(b.last_played).diff(dayjs(a.last_played))
|
||||
})
|
||||
instances.value = loaded
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
instances.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openInstance(instance: GameInstance) {
|
||||
selectedInstance.value = instance
|
||||
worlds.value = []
|
||||
loading.value = true
|
||||
try {
|
||||
const instanceWorlds = await get_instance_worlds(instance.id)
|
||||
sortWorlds(instanceWorlds)
|
||||
worlds.value = instanceWorlds.filter(isSingleplayerWorld)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
worlds.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToInstances() {
|
||||
selectedInstance.value = null
|
||||
worlds.value = []
|
||||
}
|
||||
|
||||
async function importWorld(world: SingleplayerWorld) {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance || importingWorldPath.value) return
|
||||
importingWorldPath.value = world.path
|
||||
try {
|
||||
const instancePath = await get_full_path(instance.id)
|
||||
const levelDat = await readSeedMapLevelDat(`${instancePath}/saves/${world.path}/level.dat`)
|
||||
emit('import', {
|
||||
seed: levelDat.seed,
|
||||
version: levelDat.version,
|
||||
instance,
|
||||
world,
|
||||
})
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
importingWorldPath.value = null
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="seed-import-body">
|
||||
<div v-if="!selectedInstance" class="seed-import-step">
|
||||
<p class="seed-import-hint">{{ formatMessage(messages.chooseInstance) }}</p>
|
||||
<div v-if="loading" class="seed-import-status">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</div>
|
||||
<p v-else-if="instances.length === 0" class="seed-import-status">
|
||||
{{ formatMessage(messages.noInstances) }}
|
||||
</p>
|
||||
<div v-else class="seed-import-list">
|
||||
<button
|
||||
v-for="instance in instances"
|
||||
:key="instance.id"
|
||||
class="seed-import-row flex items-center gap-[0.65rem] border border-surface-5 rounded-[var(--radius-md)] bg-surface-2 px-[0.65rem] py-2 text-contrast cursor-pointer text-left enabled:hover:bg-surface-4 disabled:cursor-default disabled:opacity-70"
|
||||
@click="openInstance(instance)"
|
||||
>
|
||||
<InstanceIcon
|
||||
class="seed-import-avatar"
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
/>
|
||||
<span class="seed-import-row-text">
|
||||
<span class="seed-import-row-title">{{ instance.name }}</span>
|
||||
<span class="seed-import-row-subtitle">
|
||||
{{ instance.game_version }} · {{ instance.loader }}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRightIcon class="seed-import-row-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="seed-import-step">
|
||||
<div class="seed-import-world-heading">
|
||||
<ButtonStyled size="small" type="transparent">
|
||||
<button @click="backToInstances">
|
||||
<ChevronLeftIcon />{{ formatMessage(messages.back) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<strong>{{ selectedInstance.name }}</strong>
|
||||
</div>
|
||||
<p class="seed-import-hint">{{ formatMessage(messages.chooseWorld) }}</p>
|
||||
<div v-if="loading" class="seed-import-status">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</div>
|
||||
<p v-else-if="worlds.length === 0" class="seed-import-status">
|
||||
{{ formatMessage(messages.noWorlds) }}
|
||||
</p>
|
||||
<div v-else class="seed-import-list">
|
||||
<button
|
||||
v-for="world in worlds"
|
||||
:key="world.path"
|
||||
class="seed-import-row flex items-center gap-[0.65rem] border border-surface-5 rounded-[var(--radius-md)] bg-surface-2 px-[0.65rem] py-2 text-contrast cursor-pointer text-left enabled:hover:bg-surface-4 disabled:cursor-default disabled:opacity-70"
|
||||
:disabled="importingWorldPath !== null"
|
||||
@click="importWorld(world)"
|
||||
>
|
||||
<Avatar v-if="world.icon" class="seed-import-avatar" :src="world.icon" />
|
||||
<span v-else class="seed-import-avatar seed-import-avatar-fallback">
|
||||
<WorldIcon />
|
||||
</span>
|
||||
<span class="seed-import-row-text">
|
||||
<span class="seed-import-row-title">{{ world.name }}</span>
|
||||
<span class="seed-import-row-subtitle">
|
||||
{{
|
||||
world.last_played
|
||||
? formatMessage(messages.lastPlayed, {
|
||||
ago: formatRelativeTime(dayjs(world.last_played).toISOString()),
|
||||
})
|
||||
: formatMessage(messages.neverPlayed)
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<SpinnerIcon
|
||||
v-if="importingWorldPath === world.path"
|
||||
class="seed-import-row-chevron animate-spin"
|
||||
/>
|
||||
<ChevronRightIcon v-else class="seed-import-row-chevron" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.seed-import-body {
|
||||
display: flex;
|
||||
width: min(28rem, calc(100vw - 3rem));
|
||||
min-height: 16rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.seed-import-step {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.seed-import-hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.seed-import-status {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 2rem 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.seed-import-list {
|
||||
display: flex;
|
||||
max-height: min(22rem, 55vh);
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.seed-import-avatar {
|
||||
width: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.seed-import-avatar-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--surface-5);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-4);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.seed-import-avatar-fallback svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.seed-import-row-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.seed-import-row-title {
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seed-import-row-subtitle {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.72rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.seed-import-row-chevron {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.seed-import-world-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.seed-import-world-heading strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,60 @@
|
||||
export type SkinEditorTheme = {
|
||||
dark: boolean
|
||||
colors: Record<string, string>
|
||||
metrics: Record<string, string>
|
||||
}
|
||||
|
||||
const themeMetricMap = {
|
||||
'gap-xs': '--gap-xs',
|
||||
'gap-sm': '--gap-sm',
|
||||
'gap-md': '--gap-md',
|
||||
'gap-lg': '--gap-lg',
|
||||
'radius-xs': '--radius-xs',
|
||||
'radius-sm': '--radius-sm',
|
||||
'radius-md': '--radius-md',
|
||||
} as const
|
||||
|
||||
const themeColorMap = {
|
||||
ui: '--surface-2',
|
||||
back: '--surface-1',
|
||||
dark: '--surface-1',
|
||||
border: '--surface-5',
|
||||
selected: '--surface-3',
|
||||
elevated: '--surface-3',
|
||||
button: '--surface-4',
|
||||
bright_ui: '--surface-4',
|
||||
accent: '--color-brand',
|
||||
accent_highlight: '--color-brand-highlight',
|
||||
focus_ring: '--color-focus-ring',
|
||||
hover: '--surface-3',
|
||||
frame: '--surface-1',
|
||||
text: '--color-base',
|
||||
light: '--color-contrast',
|
||||
accent_text: '--color-accent-contrast',
|
||||
bright_ui_text: '--color-contrast',
|
||||
subtle_text: '--color-secondary',
|
||||
grid: '--surface-5',
|
||||
wireframe: '--color-secondary',
|
||||
checkerboard: '--surface-1-5',
|
||||
menu_separator: '--surface-5',
|
||||
bright_border: '--surface-5',
|
||||
} as const
|
||||
|
||||
function readVariables(styles: CSSStyleDeclaration, variables: Record<string, string>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(variables).map(([name, variable]) => [
|
||||
name,
|
||||
styles.getPropertyValue(variable).trim(),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export function createSkinEditorTheme(): SkinEditorTheme {
|
||||
const root = document.documentElement
|
||||
const styles = getComputedStyle(root)
|
||||
return {
|
||||
dark: root.classList.contains('dark-mode') || root.classList.contains('oled-mode'),
|
||||
colors: readVariables(styles, themeColorMap),
|
||||
metrics: readVariables(styles, themeMetricMap),
|
||||
}
|
||||
}
|
||||
1316
apps/app-frontend/src/components/multiplayer/MultiplayerRooms.vue
Normal file
1316
apps/app-frontend/src/components/multiplayer/MultiplayerRooms.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { commonMessages, defineMessages, MultiStageModal } from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import {
|
||||
createCreateServerFlowContext,
|
||||
provideCreateServerFlow,
|
||||
} from '@/components/multiplayer/servers/create-server-flow'
|
||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [serverId: string]
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
||||
const eulaModal = useTemplateRef<ComponentExposed<typeof EulaModal>>('eulaModal')
|
||||
|
||||
const ctx = createCreateServerFlowContext(modal)
|
||||
provideCreateServerFlow(ctx)
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadInBackground: {
|
||||
id: 'app.servers.wizard.download-in-background',
|
||||
defaultMessage: 'Download in background',
|
||||
},
|
||||
})
|
||||
|
||||
const wizardShown = ref(false)
|
||||
const wasHiddenDuringInstall = ref(false)
|
||||
const creationReported = ref(false)
|
||||
|
||||
const cancelButton = computed(() => {
|
||||
if (ctx.installPhase.value === 'done') {
|
||||
return null
|
||||
}
|
||||
// The download continues in the background once the wizard closes; only the
|
||||
// first-run boot locks closing until the server reaches its EULA gate.
|
||||
return {
|
||||
label: ctx.formatMessage(
|
||||
ctx.installPhase.value === 'downloading'
|
||||
? messages.downloadInBackground
|
||||
: commonMessages.cancelButton,
|
||||
),
|
||||
disabled: ctx.installPhase.value === 'first-run',
|
||||
onClick: () => modal.value?.hide(),
|
||||
}
|
||||
})
|
||||
|
||||
watch(ctx.showEulaModal, (visible) => {
|
||||
if (visible) {
|
||||
// When the setup finished in the background, don't pop a EULA dialog over
|
||||
// whatever page the user is on; starting the server gates on it instead.
|
||||
if (wizardShown.value) eulaModal.value?.show()
|
||||
} else {
|
||||
eulaModal.value?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
function show() {
|
||||
wizardShown.value = true
|
||||
wasHiddenDuringInstall.value = false
|
||||
creationReported.value = false
|
||||
ctx.reset()
|
||||
modal.value?.setStage(0)
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
const wasShown = wizardShown.value
|
||||
wizardShown.value = false
|
||||
// An explicit "Finish" (wizard still open at a terminal state) navigates to
|
||||
// the new server. A background close (wizard dismissed mid-install) leaves
|
||||
// the server in the list instead of yanking the user to another page.
|
||||
if (
|
||||
wasShown &&
|
||||
!wasHiddenDuringInstall.value &&
|
||||
ctx.createdServer.value &&
|
||||
(ctx.installPhase.value === 'done' || ctx.installPhase.value === 'eula')
|
||||
) {
|
||||
if (!creationReported.value) {
|
||||
creationReported.value = true
|
||||
emit('created', ctx.createdServer.value.id)
|
||||
}
|
||||
} else {
|
||||
wasHiddenDuringInstall.value = true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, hide: () => modal.value?.hide() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiStageModal
|
||||
ref="modal"
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
:back-button-enabled="
|
||||
(flowCtx) =>
|
||||
flowCtx.installPhase.value !== 'downloading' && flowCtx.installPhase.value !== 'first-run'
|
||||
"
|
||||
:cancel-button="cancelButton"
|
||||
@hide="handleHide"
|
||||
/>
|
||||
<EulaModal
|
||||
ref="eulaModal"
|
||||
:text="ctx.eulaText.value"
|
||||
@continue="ctx.acceptEula"
|
||||
@decline="ctx.declineEula"
|
||||
/>
|
||||
</template>
|
||||
@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
const emit = defineEmits<{
|
||||
continue: []
|
||||
decline: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.servers.eula.title', defaultMessage: 'Minecraft EULA' },
|
||||
description: {
|
||||
id: 'app.servers.eula.description',
|
||||
defaultMessage:
|
||||
'By continuing, you agree to the Minecraft End User License Agreement (EULA). Please review the agreement below before proceeding.',
|
||||
},
|
||||
continue: {
|
||||
id: 'app.servers.eula.continue',
|
||||
defaultMessage: 'Continue',
|
||||
},
|
||||
decline: { id: 'app.servers.eula.decline', defaultMessage: 'Cancel' },
|
||||
})
|
||||
|
||||
const modal = useTemplateRef<ComponentExposed<typeof NewModal>>('modal')
|
||||
|
||||
defineExpose({
|
||||
show: (event?: MouseEvent) => modal.value?.show(event),
|
||||
hide: () => modal.value?.hide(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.description) }}
|
||||
</p>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-col justify-end gap-2 sm:flex-row">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="emit('decline')">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.decline) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" @click="emit('continue')">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.continue) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
isServerStatusVisible,
|
||||
SERVER_STATUS_META,
|
||||
} from '@/components/multiplayer/servers/server-status'
|
||||
import ServerIcon from '@/components/multiplayer/servers/ServerIcon.vue'
|
||||
import { serverSetupStatus } from '@/composables/useServerInstalls'
|
||||
import type { ServerView } from '@/composables/useServers'
|
||||
|
||||
const props = defineProps<{
|
||||
server: ServerView
|
||||
variant: 'standard' | 'library'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
'start-stop': []
|
||||
resume: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
start: { id: 'app.servers.action.start', defaultMessage: 'Start' },
|
||||
stop: { id: 'app.servers.action.stop', defaultMessage: 'Stop' },
|
||||
continueDownload: {
|
||||
id: 'app.servers.action.continue-download',
|
||||
defaultMessage: 'Continue download',
|
||||
},
|
||||
retryDownload: { id: 'app.servers.action.retry-download', defaultMessage: 'Retry download' },
|
||||
downloading: { id: 'app.servers.status.downloading', defaultMessage: 'Downloading' },
|
||||
downloadInterrupted: {
|
||||
id: 'app.servers.status.download-interrupted',
|
||||
defaultMessage: 'Download interrupted',
|
||||
},
|
||||
downloadFailed: { id: 'app.servers.status.download-failed', defaultMessage: 'Download failed' },
|
||||
})
|
||||
|
||||
const statusMeta = computed(() => SERVER_STATUS_META[props.server.status])
|
||||
|
||||
const setupStatus = computed(() => serverSetupStatus(props.server))
|
||||
|
||||
/** Setup states take precedence over the runtime status tag. */
|
||||
const displayTag = computed(() => {
|
||||
switch (setupStatus.value) {
|
||||
case 'installing':
|
||||
return { label: messages.downloading, color: 'text-orange' }
|
||||
case 'interrupted':
|
||||
return { label: messages.downloadInterrupted, color: 'text-orange' }
|
||||
case 'failed':
|
||||
return { label: messages.downloadFailed, color: 'text-red' }
|
||||
default:
|
||||
return isServerStatusVisible(props.server.status)
|
||||
? { label: statusMeta.value.label, color: statusMeta.value.color }
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
const setupTooltip = computed(() => {
|
||||
if (setupStatus.value === 'interrupted') return formatMessage(messages.continueDownload)
|
||||
if (setupStatus.value === 'failed') return formatMessage(messages.retryDownload)
|
||||
return formatMessage(messages.downloading)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="variant === 'library'"
|
||||
data-onboarding-id="server-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group relative flex w-full cursor-pointer select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid border-surface-4 bg-surface-3 p-3 text-left transition-[border-color,filter,transform] hover:border-surface-5 hover:brightness-110 active:scale-[0.98]"
|
||||
@click="emit('open')"
|
||||
@keydown.enter="emit('open')"
|
||||
@keydown.space.prevent="emit('open')"
|
||||
>
|
||||
<div
|
||||
class="relative flex aspect-square w-full shrink-0 items-center justify-center overflow-clip rounded-2xl bg-surface-2"
|
||||
>
|
||||
<ServerIcon
|
||||
:icon-path="server.iconPath"
|
||||
:server-type="server.serverType"
|
||||
:server-id="server.id"
|
||||
size="96px"
|
||||
/>
|
||||
<TagItem v-if="displayTag" class="absolute left-3 top-3">
|
||||
<span :class="'font-semibold ' + displayTag.color">
|
||||
{{ formatMessage(displayTag.label) }}
|
||||
</span>
|
||||
</TagItem>
|
||||
<div class="absolute bottom-1.5 right-1.5" @click.stop @keydown.stop>
|
||||
<div
|
||||
v-if="setupStatus === 'installing'"
|
||||
v-tooltip="setupTooltip"
|
||||
class="flex size-10 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-3"
|
||||
>
|
||||
<SpinnerIcon class="size-5 animate-spin text-orange" />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else-if="setupStatus === 'interrupted'"
|
||||
v-tooltip="setupTooltip"
|
||||
color="brand"
|
||||
size="large"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('resume')"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="setupStatus === 'failed'"
|
||||
v-tooltip="setupTooltip"
|
||||
color="red"
|
||||
size="large"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('resume')"
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="server.status !== 'running'" color="brand" size="large" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.start)"
|
||||
type="button"
|
||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('start-stop')"
|
||||
>
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="red" size="large" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
type="button"
|
||||
class="scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('start-stop')"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col items-start justify-center gap-1 px-0.5">
|
||||
<p class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast">
|
||||
{{ server.name }}
|
||||
</p>
|
||||
<p class="m-0 w-full truncate text-sm font-medium leading-[18px] text-primary">
|
||||
{{ server.serverType }} {{ server.gameVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
data-onboarding-id="server-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group button-base flex w-full cursor-pointer select-none gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-4 text-left transition-[border-color,filter,transform] hover:border-surface-5 hover:brightness-110 active:scale-[0.98]"
|
||||
@click="emit('open')"
|
||||
@keydown.enter="emit('open')"
|
||||
@keydown.space.prevent="emit('open')"
|
||||
>
|
||||
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
||||
<ServerIcon
|
||||
:icon-path="server.iconPath"
|
||||
:server-type="server.serverType"
|
||||
:server-id="server.id"
|
||||
size="48px"
|
||||
class="transition-all group-hover:brightness-75"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center" @click.stop @keydown.stop>
|
||||
<div
|
||||
v-if="setupStatus === 'installing'"
|
||||
v-tooltip="setupTooltip"
|
||||
class="flex size-9 origin-bottom scale-75 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-3 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<SpinnerIcon class="size-4 animate-spin text-orange" />
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else-if="setupStatus === 'interrupted'"
|
||||
v-tooltip="setupTooltip"
|
||||
color="brand"
|
||||
size="large"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('resume')"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="setupStatus === 'failed'"
|
||||
v-tooltip="setupTooltip"
|
||||
color="red"
|
||||
size="large"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('resume')"
|
||||
>
|
||||
<RefreshCwIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="server.status !== 'running'" color="brand" size="large" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.start)"
|
||||
type="button"
|
||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('start-stop')"
|
||||
>
|
||||
<PlayIcon class="translate-x-[1px]" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="red" size="large" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stop)"
|
||||
type="button"
|
||||
class="origin-bottom scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
@click="emit('start-stop')"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="m-0 min-w-0 truncate text-base font-bold leading-tight text-contrast">
|
||||
{{ server.name }}
|
||||
</p>
|
||||
<TagItem v-if="displayTag" class="shrink-0">
|
||||
<span :class="'font-semibold ' + displayTag.color">
|
||||
{{ formatMessage(displayTag.label) }}
|
||||
</span>
|
||||
</TagItem>
|
||||
</div>
|
||||
<p class="m-0 mt-1 truncate text-sm font-semibold text-secondary">
|
||||
{{ server.serverType }} {{ server.gameVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ConsolePageLayout,
|
||||
createConsoleState,
|
||||
defineMessages,
|
||||
JLineCommandInput,
|
||||
provideConsoleManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { ServerConsoleBuffer } from '@/composables/server-console-buffer'
|
||||
import {
|
||||
hydrateLog,
|
||||
type ServerView,
|
||||
subscribeServerConsoleOutput,
|
||||
useServers,
|
||||
} from '@/composables/useServers'
|
||||
import { servers } from '@/helpers/servers'
|
||||
|
||||
const props = defineProps<{
|
||||
server: ServerView
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
forgeCommandPlaceholder: {
|
||||
id: 'app.servers.console.forge-command-placeholder',
|
||||
defaultMessage: 'Send a command - Tab completion supported',
|
||||
},
|
||||
notRunning: {
|
||||
id: 'app.servers.console.not-running',
|
||||
defaultMessage: 'The server is not running',
|
||||
},
|
||||
})
|
||||
|
||||
const { logLines, sendCommand } = useServers()
|
||||
const consoleState = createConsoleState()
|
||||
const loading = ref(true)
|
||||
const hasLogs = computed(() => consoleState.output.value.length > 0)
|
||||
const isForge = computed(() => props.server.serverType === 'forge')
|
||||
const jlineInput = ref<InstanceType<typeof JLineCommandInput> | null>(null)
|
||||
let consumedLines = 0
|
||||
// Guards the live length-watcher from double-appending while we rebuild the
|
||||
// console from the buffer during (re)hydration. Without it, the async
|
||||
// hydrate fetch and the streamed `logLines` updates race, dropping or
|
||||
// duplicating the earliest startup lines.
|
||||
let hydrating = false
|
||||
let unsubscribeConsoleOutput: (() => void) | null = null
|
||||
const PENDING_CONSOLE_OUTPUT_CAPACITY = 64 * 1024
|
||||
let pendingConsoleOutput = new ServerConsoleBuffer(PENDING_CONSOLE_OUTPUT_CAPACITY)
|
||||
|
||||
function flushConsoleOutput() {
|
||||
for (const data of pendingConsoleOutput.values()) jlineInput.value?.write(data)
|
||||
pendingConsoleOutput = new ServerConsoleBuffer(PENDING_CONSOLE_OUTPUT_CAPACITY)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.server.id,
|
||||
(serverId) => {
|
||||
unsubscribeConsoleOutput?.()
|
||||
unsubscribeConsoleOutput = subscribeServerConsoleOutput(serverId, (data) => {
|
||||
if (jlineInput.value) {
|
||||
jlineInput.value.write(data)
|
||||
} else {
|
||||
pendingConsoleOutput.push(data)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function hydrateAndDisplay() {
|
||||
hydrating = true
|
||||
try {
|
||||
await hydrateLog(props.server.id)
|
||||
const buffer = logLines[props.server.id] ?? []
|
||||
if (buffer.length > 0) await consoleState.addLegacyLog(buffer.join('\n'))
|
||||
consumedLines = buffer.length
|
||||
} finally {
|
||||
hydrating = false
|
||||
}
|
||||
}
|
||||
|
||||
// The per-line `server` events can drop during heavy bursts, so we
|
||||
// periodically reconcile the displayed log against the lossless backend
|
||||
// buffer. This guarantees the console always shows the complete history,
|
||||
// including the server's startup and command responses that arrived in a
|
||||
// single fast burst.
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null
|
||||
function startSync() {
|
||||
stopSync()
|
||||
syncTimer = setInterval(() => {
|
||||
if (hydrating) return
|
||||
if (!props.server.running) {
|
||||
stopSync()
|
||||
return
|
||||
}
|
||||
void hydrateLog(props.server.id)
|
||||
}, 1000)
|
||||
}
|
||||
function stopSync() {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
flushConsoleOutput()
|
||||
await hydrateAndDisplay()
|
||||
loading.value = false
|
||||
startSync()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSync()
|
||||
unsubscribeConsoleOutput?.()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => (logLines[props.server.id] ?? []).length,
|
||||
(count) => {
|
||||
if (loading.value || hydrating) return
|
||||
const lines = logLines[props.server.id] ?? []
|
||||
if (count < consumedLines) {
|
||||
consoleState.clear()
|
||||
consumedLines = 0
|
||||
}
|
||||
const fresh = lines.slice(consumedLines)
|
||||
consumedLines = lines.length
|
||||
if (fresh.length === 0) return
|
||||
for (const line of fresh) {
|
||||
void consoleState.addLegacyLog(line)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function handleSendCommand(command: string) {
|
||||
// The server echoes the command into its own log (e.g. "> time set 0"),
|
||||
// which the console already shows, so we don't echo it a second time here.
|
||||
await sendCommand(props.server.id, command)
|
||||
}
|
||||
|
||||
// Starting a server always resets the console to a clean slate and resumes
|
||||
// bottom-following. The displayed `consoleState` is cleared here, but the
|
||||
// shared `logLines` buffer is intentionally preserved: the global listener may
|
||||
// have already streamed the earliest startup lines, and discarding them (or
|
||||
// letting the async hydrate overwrite them) is what made the launch appear to
|
||||
// have "no startup info". We rebuild the view from whatever `logLines` already
|
||||
// holds, then continue following new lines.
|
||||
const consoleLayout = ref<InstanceType<typeof ConsolePageLayout> | null>(null)
|
||||
watch(
|
||||
() => props.server.running,
|
||||
async (running, previousRunning) => {
|
||||
if (!running) {
|
||||
pendingConsoleOutput = new ServerConsoleBuffer(PENDING_CONSOLE_OUTPUT_CAPACITY)
|
||||
return
|
||||
}
|
||||
if (previousRunning) return
|
||||
await nextTick()
|
||||
flushConsoleOutput()
|
||||
consoleState.clear()
|
||||
consumedLines = 0
|
||||
// Drop the previous run's lines from the shared buffer too; the backend
|
||||
// cleared its own buffer at launch, so without this the old history
|
||||
// would be rehydrated into the fresh console on every restart.
|
||||
logLines[props.server.id] = []
|
||||
await hydrateAndDisplay()
|
||||
consoleLayout.value?.scrollToBottom()
|
||||
},
|
||||
)
|
||||
|
||||
provideConsoleManager({
|
||||
logLines: consoleState.output,
|
||||
sendCommand: (command: string) => void handleSendCommand(command),
|
||||
showCommandInput: computed(() => props.server.running),
|
||||
disableCommandInput: computed(() => !props.server.running),
|
||||
disableCommandInputTooltip: computed(() => formatMessage(messages.notRunning)),
|
||||
loading,
|
||||
emptyStateType: 'server',
|
||||
onClear: () => {
|
||||
consoleState.clear()
|
||||
consumedLines = 0
|
||||
// Drop the shared frontend buffer too, otherwise the next incoming log
|
||||
// line replays the entire pre-clear history back into the console.
|
||||
logLines[props.server.id] = []
|
||||
void servers.clearLog(props.server.id).catch(() => {})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-onboarding-id="server-console"
|
||||
class="flex flex-col pb-3"
|
||||
:class="hasLogs ? 'h-[calc(100dvh-80px)] shrink-0' : 'h-full min-h-[240px]'"
|
||||
>
|
||||
<ConsolePageLayout ref="consoleLayout" :custom-command-input="isForge">
|
||||
<template #command-input="{ disabled }">
|
||||
<JLineCommandInput
|
||||
ref="jlineInput"
|
||||
:disabled="disabled"
|
||||
:placeholder="formatMessage(messages.forgeCommandPlaceholder)"
|
||||
:send-command="handleSendCommand"
|
||||
:send-input="(data) => servers.sendConsoleInput(server.id, data)"
|
||||
:resize-console="(cols, rows) => servers.resizeConsole(server.id, cols, rows)"
|
||||
/>
|
||||
</template>
|
||||
</ConsolePageLayout>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
DownloadIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
LoaderCircleIcon,
|
||||
MoreVerticalIcon,
|
||||
PencilIcon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
||||
import {
|
||||
isServerStatusVisible,
|
||||
SERVER_STATUS_META,
|
||||
} from '@/components/multiplayer/servers/server-status'
|
||||
import ServerConsole from '@/components/multiplayer/servers/ServerConsole.vue'
|
||||
import ServerFilesPanel from '@/components/multiplayer/servers/ServerFilesPanel.vue'
|
||||
import ServerIcon from '@/components/multiplayer/servers/ServerIcon.vue'
|
||||
import ServerSettingsPanel from '@/components/multiplayer/servers/ServerSettingsPanel.vue'
|
||||
import { useMultiplayerSession } from '@/composables/useMultiplayerSession'
|
||||
import { serverSetupStatus } from '@/composables/useServerInstalls'
|
||||
import { useServerLifecycle } from '@/composables/useServerLifecycle'
|
||||
import { useServers } from '@/composables/useServers'
|
||||
import { type PortProcessInfoData, servers as serversApi } from '@/helpers/servers'
|
||||
import { openPath } from '@/helpers/utils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const serverId = route.params.id as string
|
||||
|
||||
const { servers, refresh, stopServer } = useServers()
|
||||
const { eulaModal, eulaText, tryStartServer, acceptEula, declineEula, resumeInstall } =
|
||||
useServerLifecycle()
|
||||
const filePicker = injectFilePicker()
|
||||
const multiplayerSession = useMultiplayerSession()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
console: { id: 'app.servers.detail.console', defaultMessage: 'Console' },
|
||||
files: { id: 'app.servers.detail.files', defaultMessage: 'Files' },
|
||||
settings: { id: 'app.servers.detail.settings', defaultMessage: 'Settings' },
|
||||
back: { id: 'app.servers.detail.back', defaultMessage: 'Servers' },
|
||||
start: { id: 'app.servers.action.start', defaultMessage: 'Start' },
|
||||
stop: { id: 'app.servers.action.stop', defaultMessage: 'Stop' },
|
||||
continueDownload: {
|
||||
id: 'app.servers.action.continue-download',
|
||||
defaultMessage: 'Continue download',
|
||||
},
|
||||
retryDownload: { id: 'app.servers.action.retry-download', defaultMessage: 'Retry download' },
|
||||
downloading: { id: 'app.servers.status.downloading', defaultMessage: 'Downloading' },
|
||||
downloadInterrupted: {
|
||||
id: 'app.servers.status.download-interrupted',
|
||||
defaultMessage: 'Download interrupted',
|
||||
},
|
||||
downloadFailed: { id: 'app.servers.status.download-failed', defaultMessage: 'Download failed' },
|
||||
openFolder: { id: 'app.servers.action.open-folder', defaultMessage: 'Open folder' },
|
||||
share: { id: 'app.servers.action.share', defaultMessage: 'Share online' },
|
||||
notFound: {
|
||||
id: 'app.servers.detail.not-found',
|
||||
defaultMessage: 'This server no longer exists.',
|
||||
},
|
||||
typeLabel: {
|
||||
id: 'app.servers.card.type',
|
||||
defaultMessage: '{type} · {version}',
|
||||
},
|
||||
port: { id: 'app.servers.card.port', defaultMessage: 'Port {port}' },
|
||||
editIcon: { id: 'app.servers.icon.edit', defaultMessage: 'Edit icon' },
|
||||
removeIcon: { id: 'app.servers.icon.remove', defaultMessage: 'Remove icon' },
|
||||
portConflictTitle: {
|
||||
id: 'app.servers.port.conflict-title',
|
||||
defaultMessage: 'Port {port} is already in use',
|
||||
},
|
||||
portConflictDescription: {
|
||||
id: 'app.servers.port.conflict-description',
|
||||
defaultMessage:
|
||||
'{process} is currently occupying this port, so the server cannot start. Change the server port, or force quit the process below.',
|
||||
},
|
||||
portUnknownProcess: {
|
||||
id: 'app.servers.port.unknown-process',
|
||||
defaultMessage: 'Unknown process (PID {pid})',
|
||||
},
|
||||
portForceQuit: {
|
||||
id: 'app.servers.port.force-quit',
|
||||
defaultMessage: 'Force quit process',
|
||||
},
|
||||
portChange: {
|
||||
id: 'app.servers.port.change',
|
||||
defaultMessage: 'Change port',
|
||||
},
|
||||
portRecheck: {
|
||||
id: 'app.servers.port.recheck',
|
||||
defaultMessage: 'Recheck',
|
||||
},
|
||||
portForceQuitFailed: {
|
||||
id: 'app.servers.port.force-quit-failed',
|
||||
defaultMessage: 'Failed to quit the process occupying the port',
|
||||
},
|
||||
})
|
||||
|
||||
const server = computed(() => servers.value.find((entry) => entry.id === serverId))
|
||||
const statusMeta = computed(() => (server.value ? SERVER_STATUS_META[server.value.status] : null))
|
||||
const showStatus = computed(() =>
|
||||
server.value ? isServerStatusVisible(server.value.status) : false,
|
||||
)
|
||||
|
||||
const setupStatus = computed(() => (server.value ? serverSetupStatus(server.value) : null))
|
||||
|
||||
/** Setup states take precedence over the runtime status tag. */
|
||||
const displayTag = computed(() => {
|
||||
switch (setupStatus.value) {
|
||||
case 'installing':
|
||||
return { label: messages.downloading, color: 'text-orange' }
|
||||
case 'interrupted':
|
||||
return { label: messages.downloadInterrupted, color: 'text-orange' }
|
||||
case 'failed':
|
||||
return { label: messages.downloadFailed, color: 'text-red' }
|
||||
default:
|
||||
return showStatus.value && statusMeta.value
|
||||
? { label: statusMeta.value.label, color: statusMeta.value.color }
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
const isLoaded = ref(false)
|
||||
const hasSeenServer = ref(false)
|
||||
|
||||
const DEFAULT_SERVER_PORT = 25565
|
||||
const PORT_CHECK_INTERVAL_MS = 10_000
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const portProcess = ref<PortProcessInfoData | null>(null)
|
||||
const checkingPort = ref(false)
|
||||
const killingPortProcess = ref(false)
|
||||
let portCheckToken = 0
|
||||
|
||||
const effectivePort = computed(() =>
|
||||
server.value ? (server.value.port ?? DEFAULT_SERVER_PORT) : null,
|
||||
)
|
||||
const portConflict = computed(
|
||||
() => !!server.value && server.value.status !== 'running' && !!portProcess.value,
|
||||
)
|
||||
const occupyingProcessLabel = computed(() => {
|
||||
const info = portProcess.value
|
||||
if (!info) return ''
|
||||
return info.name
|
||||
? `${info.name} (PID ${info.pid})`
|
||||
: formatMessage(messages.portUnknownProcess, { pid: info.pid })
|
||||
})
|
||||
|
||||
/** Polls whether something else is listening on the server's port. */
|
||||
async function checkPortOccupation(silent = true) {
|
||||
const port = effectivePort.value
|
||||
if (port == null || server.value?.status === 'running') {
|
||||
portCheckToken++
|
||||
portProcess.value = null
|
||||
return
|
||||
}
|
||||
if (!silent) checkingPort.value = true
|
||||
const token = ++portCheckToken
|
||||
try {
|
||||
const info = await serversApi.portProcess(port)
|
||||
if (token === portCheckToken) portProcess.value = info
|
||||
} catch {
|
||||
if (token === portCheckToken) portProcess.value = null
|
||||
} finally {
|
||||
if (!silent) checkingPort.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function recheckPort() {
|
||||
void checkPortOccupation(false)
|
||||
}
|
||||
|
||||
async function forceQuitPortProcess() {
|
||||
const port = effectivePort.value
|
||||
if (port == null || killingPortProcess.value) return
|
||||
killingPortProcess.value = true
|
||||
try {
|
||||
await serversApi.killPortProcess(port)
|
||||
await checkPortOccupation()
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.portForceQuitFailed),
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
killingPortProcess.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the settings tab and focuses the port field once the properties editor has loaded. */
|
||||
async function goToPortSetting() {
|
||||
tabIndex.value = 2
|
||||
let portField: HTMLElement | null = null
|
||||
for (let i = 0; i < 20 && !portField; i++) {
|
||||
await nextTick()
|
||||
portField = document.getElementById('server-prop-server-port')
|
||||
if (!portField) await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
if (portField) {
|
||||
portField.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
portField.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => server.value?.status, effectivePort], () => void checkPortOccupation(), {
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
const portCheckTimer = setInterval(() => void checkPortOccupation(), PORT_CHECK_INTERVAL_MS)
|
||||
onUnmounted(() => clearInterval(portCheckTimer))
|
||||
|
||||
onMounted(async () => {
|
||||
if (servers.value.length === 0) await refresh().catch(() => {})
|
||||
isLoaded.value = true
|
||||
})
|
||||
|
||||
// A server disappearing after it was loaded means it was deleted: go back to the list
|
||||
// instead of showing a "no longer exists" dead end.
|
||||
watch([server, isLoaded], ([value, loaded]) => {
|
||||
if (value) {
|
||||
hasSeenServer.value = true
|
||||
return
|
||||
}
|
||||
if (loaded && hasSeenServer.value) void router.replace('/multiplayer/servers')
|
||||
})
|
||||
|
||||
const tabIndex = ref(route.query.tab === 'files' ? 1 : route.query.tab === 'settings' ? 2 : 0)
|
||||
const tabLinks = computed(() => [
|
||||
{ label: formatMessage(messages.console), href: 'console', icon: TerminalSquareIcon },
|
||||
{ label: formatMessage(messages.files), href: 'files', icon: FolderOpenIcon },
|
||||
{ label: formatMessage(messages.settings), href: 'settings', icon: WrenchIcon },
|
||||
])
|
||||
|
||||
async function toggleRunning() {
|
||||
if (!server.value) return
|
||||
if (server.value.status === 'running') {
|
||||
await stopServer(server.value.id)
|
||||
} else {
|
||||
await tryStartServer(server.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function setServerIcon() {
|
||||
if (!server.value) return
|
||||
try {
|
||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
||||
if (!picked?.path) return
|
||||
await serversApi.setIcon(server.value.id, picked.path)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function resetServerIcon() {
|
||||
if (!server.value?.iconPath) return
|
||||
try {
|
||||
await serversApi.setIcon(server.value.id, null)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function shareOnline() {
|
||||
if (!server.value?.port) return
|
||||
await router.push({ path: '/multiplayer/rooms' })
|
||||
void multiplayerSession.hostHongshi(server.value.port, null, null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multiplayer-fixed-render flex h-full min-h-0 w-full flex-col gap-3">
|
||||
<div v-if="!server && isLoaded && !hasSeenServer" class="text-secondary">
|
||||
{{ formatMessage(messages.notFound) }}
|
||||
</div>
|
||||
|
||||
<template v-else-if="server">
|
||||
<div class="flex min-w-0 shrink-0 flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.back)"
|
||||
@click="router.push('/multiplayer/servers')"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="group relative shrink-0">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.editIcon)"
|
||||
type="button"
|
||||
class="cursor-pointer rounded-xl transition-transform group-active:scale-95"
|
||||
:aria-label="formatMessage(messages.editIcon)"
|
||||
@click="setServerIcon"
|
||||
>
|
||||
<ServerIcon
|
||||
:icon-path="server.iconPath"
|
||||
:server-type="server.serverType"
|
||||
:server-id="server.id"
|
||||
size="44px"
|
||||
/>
|
||||
</button>
|
||||
<OverflowMenu
|
||||
v-if="server.iconPath"
|
||||
class="absolute -right-1 -top-1 flex size-5 items-center justify-center rounded-full bg-surface-4 text-secondary shadow-md transition-colors hover:text-contrast"
|
||||
:options="[
|
||||
{
|
||||
id: 'remove',
|
||||
color: 'danger',
|
||||
action: () => resetServerIcon(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon class="size-3.5" />
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<h2 class="m-0 truncate text-xl font-semibold text-contrast">
|
||||
{{ server.name }}
|
||||
</h2>
|
||||
<TagItem v-if="displayTag" class="shrink-0">
|
||||
<span :class="`font-semibold ${displayTag.color}`">
|
||||
{{ formatMessage(displayTag.label) }}
|
||||
</span>
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-sm text-secondary">
|
||||
<span class="truncate">
|
||||
{{
|
||||
formatMessage(messages.typeLabel, {
|
||||
type: server.serverType,
|
||||
version: server.gameVersion,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="server.port" class="shrink-0">
|
||||
{{ formatMessage(messages.port, { port: server.port }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled v-if="server.status === 'running'" color="red" type="outlined">
|
||||
<button type="button" @click="toggleRunning">
|
||||
<StopCircleIcon />
|
||||
{{ formatMessage(messages.stop) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="setupStatus === 'installing'" type="outlined">
|
||||
<button type="button" disabled>
|
||||
<LoaderCircleIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.downloading) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="setupStatus === 'interrupted'" color="brand">
|
||||
<button type="button" @click="resumeInstall(server)">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.continueDownload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="setupStatus === 'failed'" color="brand">
|
||||
<button type="button" @click="resumeInstall(server)">
|
||||
<RefreshCwIcon />
|
||||
{{ formatMessage(messages.retryDownload) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="!portConflict" color="brand">
|
||||
<button type="button" @click="toggleRunning">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(messages.start) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="server.status === 'running' && server.port" type="outlined">
|
||||
<button type="button" @click="shareOnline">
|
||||
<GlobeIcon />
|
||||
{{ formatMessage(messages.share) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" @click="openPath(server.path)">
|
||||
<FolderOpenIcon />
|
||||
{{ formatMessage(messages.openFolder) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="portConflict && portProcess"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.portConflictTitle, { port: effectivePort })"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.portConflictDescription, {
|
||||
process: occupyingProcessLabel,
|
||||
})
|
||||
}}
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="killingPortProcess" @click="goToPortSetting">
|
||||
<PencilIcon />
|
||||
{{ formatMessage(messages.portChange) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button type="button" :disabled="killingPortProcess" @click="forceQuitPortProcess">
|
||||
<LoaderCircleIcon v-if="killingPortProcess" class="animate-spin" />
|
||||
<ShieldIcon v-else />
|
||||
{{ formatMessage(messages.portForceQuit) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button type="button" :disabled="checkingPort" @click="recheckPort">
|
||||
<LoaderCircleIcon v-if="checkingPort" class="animate-spin" />
|
||||
<RefreshCwIcon v-else />
|
||||
{{ formatMessage(messages.portRecheck) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<Admonition
|
||||
v-if="setupStatus === 'failed' && server.installError"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.downloadFailed)"
|
||||
>
|
||||
{{ server.installError }}
|
||||
</Admonition>
|
||||
|
||||
<NavTabs
|
||||
mode="local"
|
||||
:active-index="tabIndex"
|
||||
:links="tabLinks"
|
||||
@tab-click="tabIndex = $event"
|
||||
/>
|
||||
|
||||
<div v-if="tabIndex === 0" class="min-h-0 flex-1">
|
||||
<ServerConsole :server="server" />
|
||||
</div>
|
||||
<div v-else-if="tabIndex === 1" class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<ServerFilesPanel :server="server" />
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<ServerSettingsPanel :server="server" @deleted="router.push('/multiplayer/servers')" />
|
||||
</div>
|
||||
|
||||
<EulaModal ref="eulaModal" :text="eulaText" @continue="acceptEula" @decline="declineEula" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* fixed 渲染模式(服务器详情页):控制台/设置区内部滚动。
|
||||
* page-transition-grid 与 page-transition-layer 显式定高(100%)且允许
|
||||
* 收缩(min-height: 0)。grid 必须显式声明 minmax(0, 1fr) 行——隐式 auto 行
|
||||
* 以内容自适应,行高不 definite 时 layer 的百分比高度会退化为 auto,
|
||||
* 整条 h-full 链随之失效,日志一多终端就会把页面撑出视口。
|
||||
* app-viewport 保留 overflow: auto 作为兜底:控制台区块在有日志时固定为
|
||||
* calc(100dvh - 80px),高于可视剩余空间,页面需要可以滚动露出命令输入框;
|
||||
* scrollbar-gutter: auto 避免滚动条出现/消失时布局跳动。
|
||||
*/
|
||||
.app-viewport:has(.multiplayer-fixed-render) {
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.app-viewport:has(.multiplayer-fixed-render) .page-transition-grid,
|
||||
.app-viewport:has(.multiplayer-fixed-render) .page-transition-layer {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-viewport:has(.multiplayer-fixed-render) .page-transition-grid {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { Admonition, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useServers } from '@/composables/useServers'
|
||||
import FileStudio from '@/pages/instance/FileStudio.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
const { servers, refresh } = useServers()
|
||||
const { formatMessage } = useVIntl()
|
||||
const isLoaded = ref(false)
|
||||
|
||||
const messages = defineMessages({
|
||||
notFound: {
|
||||
id: 'app.servers.detail.not-found',
|
||||
defaultMessage: 'This server no longer exists.',
|
||||
},
|
||||
runningTitle: {
|
||||
id: 'app.servers.files.studio-running-title',
|
||||
defaultMessage: 'Stop the server before opening Studio',
|
||||
},
|
||||
runningDescription: {
|
||||
id: 'app.servers.files.busy-tooltip',
|
||||
defaultMessage: 'Stop the server to modify files',
|
||||
},
|
||||
})
|
||||
|
||||
const server = computed(() => servers.value.find((entry) => entry.id === serverId))
|
||||
|
||||
onMounted(async () => {
|
||||
if (servers.value.length === 0) await refresh()
|
||||
isLoaded.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileStudio v-if="server && !server.running" :server="server" />
|
||||
<div v-else-if="server" class="flex size-full items-center justify-center p-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.runningTitle)">
|
||||
{{ formatMessage(messages.runningDescription) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
<div v-else-if="isLoaded" class="flex size-full items-center justify-center text-secondary">
|
||||
{{ formatMessage(messages.notFound) }}
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import { CodeIcon } from '@modrinth/assets'
|
||||
import type { EditingFile, FileItem } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FilePageLayout,
|
||||
injectNotificationManager,
|
||||
provideFileManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readDir,
|
||||
readFile as readFileBytes,
|
||||
readTextFile,
|
||||
remove,
|
||||
rename,
|
||||
stat,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/plugin-fs'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { ServerView } from '@/composables/useServers'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
server: ServerView
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const router = useRouter()
|
||||
|
||||
const messages = defineMessages({
|
||||
saveAs: {
|
||||
id: 'app.servers.files.save-as',
|
||||
defaultMessage: 'Save as...',
|
||||
},
|
||||
busyTooltip: {
|
||||
id: 'app.servers.files.busy-tooltip',
|
||||
defaultMessage: 'Stop the server to modify files',
|
||||
},
|
||||
openStudio: {
|
||||
id: 'app.servers.files.open-studio',
|
||||
defaultMessage: 'Open Studio',
|
||||
},
|
||||
})
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<Error | null>(null)
|
||||
const currentPath = ref('')
|
||||
const editingFile = ref<EditingFile | null>(null)
|
||||
|
||||
const serverRoot = computed(() => props.server.path)
|
||||
const isBusy = computed(() => props.server.running)
|
||||
|
||||
async function resolvePath(relativePath: string): Promise<string> {
|
||||
const clean = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath
|
||||
return clean ? join(serverRoot.value, ...clean.split('/')) : serverRoot.value
|
||||
}
|
||||
|
||||
async function listDirectory(dirPath: string): Promise<FileItem[]> {
|
||||
const absPath = await resolvePath(dirPath)
|
||||
const entries = await readDir(absPath)
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryAbsPath = await join(absPath, entry.name)
|
||||
let metadata
|
||||
try {
|
||||
metadata = await stat(entryAbsPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const item: FileItem = {
|
||||
name: entry.name,
|
||||
type: entry.isDirectory ? 'directory' : 'file',
|
||||
path: dirPath ? `${dirPath}/${entry.name}` : entry.name,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : 0,
|
||||
created: metadata.birthtime ? Math.floor(metadata.birthtime.getTime() / 1000) : 0,
|
||||
}
|
||||
if (!entry.isDirectory) {
|
||||
item.size = metadata.size
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
try {
|
||||
const children = await readDir(entryAbsPath)
|
||||
item.count = children.length
|
||||
} catch {
|
||||
item.count = 0
|
||||
}
|
||||
}
|
||||
return item
|
||||
}),
|
||||
)
|
||||
return results.filter((item): item is FileItem => item !== null)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await listDirectory(currentPath.value)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
currentPath.value = path.startsWith('/') ? path.slice(1) : path
|
||||
void refresh()
|
||||
}
|
||||
|
||||
function startEditing(file: EditingFile) {
|
||||
editingFile.value = file
|
||||
}
|
||||
|
||||
function stopEditing() {
|
||||
editingFile.value = null
|
||||
}
|
||||
|
||||
function notifyFailure(label: string, e: unknown) {
|
||||
addNotification({
|
||||
title: label,
|
||||
text: e instanceof Error ? e.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCreateItem(name: string, type: 'file' | 'directory') {
|
||||
const targetPath = currentPath.value ? `${currentPath.value}/${name}` : name
|
||||
const absPath = await resolvePath(targetPath)
|
||||
try {
|
||||
if (type === 'directory') {
|
||||
await mkdir(absPath)
|
||||
} else {
|
||||
await writeTextFile(absPath, '')
|
||||
}
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
notifyFailure(formatMessage(commonMessages.createFailedLabel), e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameItem(path: string, newName: string) {
|
||||
const oldAbs = await resolvePath(path)
|
||||
const parentDir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : ''
|
||||
const newPath = parentDir ? `${parentDir}/${newName}` : newName
|
||||
try {
|
||||
await rename(oldAbs, await resolvePath(newPath))
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
notifyFailure(formatMessage(commonMessages.renameFailedLabel), e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveItem(source: string, destination: string) {
|
||||
try {
|
||||
await rename(await resolvePath(source), await resolvePath(destination))
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
notifyFailure(formatMessage(commonMessages.moveFailedLabel), e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(path: string, recursive: boolean) {
|
||||
try {
|
||||
await remove(await resolvePath(path), { recursive })
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
notifyFailure(formatMessage(commonMessages.deleteFailedLabel), e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReadFile(path: string): Promise<string> {
|
||||
return await readTextFile(await resolvePath(path))
|
||||
}
|
||||
|
||||
async function handleReadFileAsBlob(path: string): Promise<Blob> {
|
||||
const bytes = await readFileBytes(await resolvePath(path))
|
||||
return new Blob([bytes])
|
||||
}
|
||||
|
||||
async function handleWriteFile(path: string, content: string) {
|
||||
await writeTextFile(await resolvePath(path), content)
|
||||
}
|
||||
|
||||
async function handleDownloadFile(path: string, fileName: string) {
|
||||
const outputPath = await save({ defaultPath: fileName })
|
||||
if (!outputPath) return
|
||||
await copyFile(await resolvePath(path), outputPath)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.server.path,
|
||||
async () => {
|
||||
currentPath.value = ''
|
||||
await refresh()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
provideFileManager({
|
||||
items,
|
||||
loading,
|
||||
error,
|
||||
currentPath,
|
||||
navigateTo,
|
||||
editingFile,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
createItem: handleCreateItem,
|
||||
renameItem: handleRenameItem,
|
||||
moveItem: handleMoveItem,
|
||||
deleteItem: handleDeleteItem,
|
||||
readFile: handleReadFile,
|
||||
readFileAsBlob: handleReadFileAsBlob,
|
||||
writeFile: handleWriteFile,
|
||||
downloadFile: handleDownloadFile,
|
||||
refresh,
|
||||
isBusy,
|
||||
busyTooltip: computed(() => (isBusy.value ? formatMessage(messages.busyTooltip) : undefined)),
|
||||
basePath: serverRoot,
|
||||
openInFolder: (path: string) => highlightInFolder(path),
|
||||
downloadButtonLabel: formatMessage(messages.saveAs),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-0 w-full">
|
||||
<FilePageLayout :show-refresh-button="true">
|
||||
<template #before-refresh>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="isBusy ? formatMessage(messages.busyTooltip) : undefined"
|
||||
type="button"
|
||||
class="!h-10"
|
||||
:disabled="isBusy"
|
||||
@click="router.push({ name: 'MultiplayerServerFileStudio', params: { id: server.id } })"
|
||||
>
|
||||
<CodeIcon class="size-5" />
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ formatMessage(messages.openStudio) }}
|
||||
<span
|
||||
class="rounded bg-orange px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none text-contrast"
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</FilePageLayout>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { ServerTypeId } from '@modrinth/server'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { SERVER_TYPE_META } from '@/components/multiplayer/servers/server-type'
|
||||
import { isBuiltInInstanceIcon } from '@/helpers/instance-icon-frame'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
iconPath?: string | null
|
||||
serverType: ServerTypeId
|
||||
serverId?: string | null
|
||||
size?: string
|
||||
}>(),
|
||||
{
|
||||
iconPath: null,
|
||||
serverId: null,
|
||||
size: '2rem',
|
||||
},
|
||||
)
|
||||
|
||||
const iconUrl = computed(() => (props.iconPath ? convertFileSrc(props.iconPath) : null))
|
||||
|
||||
const typeMeta = computed(() => SERVER_TYPE_META[props.serverType])
|
||||
|
||||
// User-set or built-in icon path takes priority; otherwise fall back to the
|
||||
// per-type brand icon (e.g. Mojang/Forge/Fabric/Paper). Brand icons render frameless.
|
||||
const displayUrl = computed(() => iconUrl.value ?? typeMeta.value.icon ?? null)
|
||||
const frameless = computed(() => {
|
||||
if (props.iconPath) return isBuiltInInstanceIcon(props.iconPath)
|
||||
return !!typeMeta.value.icon
|
||||
})
|
||||
|
||||
// Inline styles instead of Tailwind arbitrary values: underscores inside
|
||||
// `var(--_color)` are converted to spaces by Tailwind's arbitrary-value
|
||||
// parsing, which generates invalid CSS and breaks the production build.
|
||||
const monogramStyle = computed(() => ({
|
||||
color: typeMeta.value.colorVar,
|
||||
backgroundColor: `color-mix(in srgb, ${typeMeta.value.colorVar} 14%, transparent)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar
|
||||
v-if="displayUrl"
|
||||
:src="displayUrl"
|
||||
:size="size"
|
||||
:tint-by="serverId"
|
||||
:class="{ '!border-0 !rounded-none !bg-transparent !shadow-none': frameless }"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex shrink-0 items-center justify-center rounded-lg text-xs font-bold"
|
||||
:style="{
|
||||
'--_size': size,
|
||||
width: 'var(--_size)',
|
||||
height: 'var(--_size)',
|
||||
fontSize: 'calc(var(--_size) * 0.375)',
|
||||
...monogramStyle,
|
||||
}"
|
||||
>
|
||||
{{ typeMeta.monogram }}
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,741 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DropdownIcon,
|
||||
FileTextIcon,
|
||||
GameIcon,
|
||||
GlobeIcon,
|
||||
MapIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageIcon,
|
||||
SettingsIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
configFieldLabel,
|
||||
getConfigFile,
|
||||
parseProperties,
|
||||
type PropertiesEntry,
|
||||
resolveConfigField,
|
||||
type ResolvedConfigField,
|
||||
serializeProperties,
|
||||
setProperty,
|
||||
} from '@modrinth/server'
|
||||
import {
|
||||
Accordion,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
DropdownSelect,
|
||||
injectNotificationManager,
|
||||
type MessageDescriptor,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { type Component, computed, onMounted, ref } from 'vue'
|
||||
|
||||
import StudioEditor from '@/components/instance/studio/StudioEditor.vue'
|
||||
import { servers } from '@/helpers/servers'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.servers.properties.title', defaultMessage: 'Server properties' },
|
||||
formMode: { id: 'app.servers.properties.mode.form', defaultMessage: 'Form' },
|
||||
textMode: { id: 'app.servers.properties.mode.text', defaultMessage: 'Text' },
|
||||
missing: {
|
||||
id: 'app.servers.properties.missing',
|
||||
defaultMessage: 'Start the server once to generate this file.',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'app.servers.properties.load-failed',
|
||||
defaultMessage: 'Failed to load the server configuration.',
|
||||
},
|
||||
})
|
||||
|
||||
const fieldMessages = defineMessages({
|
||||
'server-port': { id: 'app.servers.properties.field.server-port', defaultMessage: 'Server port' },
|
||||
difficulty: { id: 'app.servers.properties.field.difficulty', defaultMessage: 'Difficulty' },
|
||||
gamemode: { id: 'app.servers.properties.field.gamemode', defaultMessage: 'Game mode' },
|
||||
'level-type': { id: 'app.servers.properties.field.level-type', defaultMessage: 'Level type' },
|
||||
'max-players': {
|
||||
id: 'app.servers.properties.field.max-players',
|
||||
defaultMessage: 'Max players',
|
||||
},
|
||||
'view-distance': {
|
||||
id: 'app.servers.properties.field.view-distance',
|
||||
defaultMessage: 'View distance',
|
||||
},
|
||||
'simulation-distance': {
|
||||
id: 'app.servers.properties.field.simulation-distance',
|
||||
defaultMessage: 'Simulation distance',
|
||||
},
|
||||
'max-tick-time': {
|
||||
id: 'app.servers.properties.field.max-tick-time',
|
||||
defaultMessage: 'Max tick time',
|
||||
},
|
||||
'max-world-size': {
|
||||
id: 'app.servers.properties.field.max-world-size',
|
||||
defaultMessage: 'Max world size',
|
||||
},
|
||||
'op-permission-level': {
|
||||
id: 'app.servers.properties.field.op-permission-level',
|
||||
defaultMessage: 'OP permission level',
|
||||
},
|
||||
'function-permission-level': {
|
||||
id: 'app.servers.properties.field.function-permission-level',
|
||||
defaultMessage: 'Function permission level',
|
||||
},
|
||||
'spawn-protection': {
|
||||
id: 'app.servers.properties.field.spawn-protection',
|
||||
defaultMessage: 'Spawn protection',
|
||||
},
|
||||
'player-idle-timeout': {
|
||||
id: 'app.servers.properties.field.player-idle-timeout',
|
||||
defaultMessage: 'Player idle timeout',
|
||||
},
|
||||
'network-compression-threshold': {
|
||||
id: 'app.servers.properties.field.network-compression-threshold',
|
||||
defaultMessage: 'Network compression threshold',
|
||||
},
|
||||
'rate-limit': { id: 'app.servers.properties.field.rate-limit', defaultMessage: 'Rate limit' },
|
||||
'query.port': { id: 'app.servers.properties.field.query.port', defaultMessage: 'Query port' },
|
||||
'rcon.port': { id: 'app.servers.properties.field.rcon.port', defaultMessage: 'RCON port' },
|
||||
'level-name': { id: 'app.servers.properties.field.level-name', defaultMessage: 'Level name' },
|
||||
'level-seed': { id: 'app.servers.properties.field.level-seed', defaultMessage: 'Level seed' },
|
||||
motd: {
|
||||
id: 'app.servers.properties.field.motd',
|
||||
defaultMessage: 'Message of the day (MOTD)',
|
||||
},
|
||||
'resource-pack': {
|
||||
id: 'app.servers.properties.field.resource-pack',
|
||||
defaultMessage: 'Resource pack',
|
||||
},
|
||||
'resource-pack-sha1': {
|
||||
id: 'app.servers.properties.field.resource-pack-sha1',
|
||||
defaultMessage: 'Resource pack SHA-1',
|
||||
},
|
||||
'resource-pack-prompt': {
|
||||
id: 'app.servers.properties.field.resource-pack-prompt',
|
||||
defaultMessage: 'Resource pack prompt',
|
||||
},
|
||||
'rcon.password': {
|
||||
id: 'app.servers.properties.field.rcon.password',
|
||||
defaultMessage: 'RCON password',
|
||||
},
|
||||
'server-ip': { id: 'app.servers.properties.field.server-ip', defaultMessage: 'Server IP' },
|
||||
'text-filtering-config': {
|
||||
id: 'app.servers.properties.field.text-filtering-config',
|
||||
defaultMessage: 'Text filtering config',
|
||||
},
|
||||
'initial-enabled-packs': {
|
||||
id: 'app.servers.properties.field.initial-enabled-packs',
|
||||
defaultMessage: 'Initial enabled packs',
|
||||
},
|
||||
'online-mode': {
|
||||
id: 'app.servers.properties.field.online-mode',
|
||||
defaultMessage: 'Online mode',
|
||||
},
|
||||
'white-list': { id: 'app.servers.properties.field.white-list', defaultMessage: 'Whitelist' },
|
||||
'enforce-whitelist': {
|
||||
id: 'app.servers.properties.field.enforce-whitelist',
|
||||
defaultMessage: 'Enforce whitelist',
|
||||
},
|
||||
'enforce-secure-profile': {
|
||||
id: 'app.servers.properties.field.enforce-secure-profile',
|
||||
defaultMessage: 'Enforce secure profile',
|
||||
},
|
||||
'prevent-proxy-connections': {
|
||||
id: 'app.servers.properties.field.prevent-proxy-connections',
|
||||
defaultMessage: 'Prevent proxy connections',
|
||||
},
|
||||
'allow-flight': {
|
||||
id: 'app.servers.properties.field.allow-flight',
|
||||
defaultMessage: 'Allow flight',
|
||||
},
|
||||
'allow-nether': {
|
||||
id: 'app.servers.properties.field.allow-nether',
|
||||
defaultMessage: 'Allow the Nether',
|
||||
},
|
||||
'spawn-animals': {
|
||||
id: 'app.servers.properties.field.spawn-animals',
|
||||
defaultMessage: 'Spawn animals',
|
||||
},
|
||||
'spawn-monsters': {
|
||||
id: 'app.servers.properties.field.spawn-monsters',
|
||||
defaultMessage: 'Spawn monsters',
|
||||
},
|
||||
'spawn-npcs': { id: 'app.servers.properties.field.spawn-npcs', defaultMessage: 'Spawn NPCs' },
|
||||
pvp: {
|
||||
id: 'app.servers.properties.field.pvp',
|
||||
defaultMessage: 'Player versus player (PvP)',
|
||||
},
|
||||
'enable-command-block': {
|
||||
id: 'app.servers.properties.field.enable-command-block',
|
||||
defaultMessage: 'Enable command blocks',
|
||||
},
|
||||
'enable-status': {
|
||||
id: 'app.servers.properties.field.enable-status',
|
||||
defaultMessage: 'Enable status',
|
||||
},
|
||||
'enable-query': {
|
||||
id: 'app.servers.properties.field.enable-query',
|
||||
defaultMessage: 'Enable query',
|
||||
},
|
||||
'enable-rcon': {
|
||||
id: 'app.servers.properties.field.enable-rcon',
|
||||
defaultMessage: 'Enable RCON',
|
||||
},
|
||||
'enable-jmx-monitoring': {
|
||||
id: 'app.servers.properties.field.enable-jmx-monitoring',
|
||||
defaultMessage: 'Enable JMX monitoring',
|
||||
},
|
||||
'force-gamemode': {
|
||||
id: 'app.servers.properties.field.force-gamemode',
|
||||
defaultMessage: 'Force game mode',
|
||||
},
|
||||
hardcore: { id: 'app.servers.properties.field.hardcore', defaultMessage: 'Hardcore' },
|
||||
'announce-player-achievements': {
|
||||
id: 'app.servers.properties.field.announce-player-achievements',
|
||||
defaultMessage: 'Announce player achievements',
|
||||
},
|
||||
'log-ips': { id: 'app.servers.properties.field.log-ips', defaultMessage: 'Log IP addresses' },
|
||||
'hide-online-players': {
|
||||
id: 'app.servers.properties.field.hide-online-players',
|
||||
defaultMessage: 'Hide online players',
|
||||
},
|
||||
'require-resource-pack': {
|
||||
id: 'app.servers.properties.field.require-resource-pack',
|
||||
defaultMessage: 'Require resource pack',
|
||||
},
|
||||
'sync-chunk-writes': {
|
||||
id: 'app.servers.properties.field.sync-chunk-writes',
|
||||
defaultMessage: 'Sync chunk writes',
|
||||
},
|
||||
'use-native-transport': {
|
||||
id: 'app.servers.properties.field.use-native-transport',
|
||||
defaultMessage: 'Use native transport',
|
||||
},
|
||||
'allow-end': {
|
||||
id: 'app.servers.properties.field.allow-end',
|
||||
defaultMessage: 'Allow the End',
|
||||
},
|
||||
'generate-structures': {
|
||||
id: 'app.servers.properties.field.generate-structures',
|
||||
defaultMessage: 'Generate structures',
|
||||
},
|
||||
'enable-lan': {
|
||||
id: 'app.servers.properties.field.enable-lan',
|
||||
defaultMessage: 'Enable LAN',
|
||||
},
|
||||
'accepts-transfers': {
|
||||
id: 'app.servers.properties.field.accepts-transfers',
|
||||
defaultMessage: 'Accept player transfers',
|
||||
},
|
||||
'broadcast-console-to-ops': {
|
||||
id: 'app.servers.properties.field.broadcast-console-to-ops',
|
||||
defaultMessage: 'Broadcast console to operators',
|
||||
},
|
||||
'broadcast-rcon-to-ops': {
|
||||
id: 'app.servers.properties.field.broadcast-rcon-to-ops',
|
||||
defaultMessage: 'Broadcast RCON to operators',
|
||||
},
|
||||
'bug-report-link': {
|
||||
id: 'app.servers.properties.field.bug-report-link',
|
||||
defaultMessage: 'Bug report link',
|
||||
},
|
||||
'chat-spam-threshold-seconds': {
|
||||
id: 'app.servers.properties.field.chat-spam-threshold-seconds',
|
||||
defaultMessage: 'Chat spam threshold (seconds)',
|
||||
},
|
||||
'command-spam-threshold-seconds': {
|
||||
id: 'app.servers.properties.field.command-spam-threshold-seconds',
|
||||
defaultMessage: 'Command spam threshold (seconds)',
|
||||
},
|
||||
'enable-code-of-conduct': {
|
||||
id: 'app.servers.properties.field.enable-code-of-conduct',
|
||||
defaultMessage: 'Enable code of conduct',
|
||||
},
|
||||
'entity-broadcast-range-percentage': {
|
||||
id: 'app.servers.properties.field.entity-broadcast-range-percentage',
|
||||
defaultMessage: 'Entity broadcast range percentage',
|
||||
},
|
||||
'generator-settings': {
|
||||
id: 'app.servers.properties.field.generator-settings',
|
||||
defaultMessage: 'Generator settings',
|
||||
},
|
||||
'initial-disabled-packs': {
|
||||
id: 'app.servers.properties.field.initial-disabled-packs',
|
||||
defaultMessage: 'Initial disabled packs',
|
||||
},
|
||||
'management-server-allowed-origins': {
|
||||
id: 'app.servers.properties.field.management-server-allowed-origins',
|
||||
defaultMessage: 'Management server allowed origins',
|
||||
},
|
||||
'management-server-enabled': {
|
||||
id: 'app.servers.properties.field.management-server-enabled',
|
||||
defaultMessage: 'Enable management server',
|
||||
},
|
||||
'management-server-host': {
|
||||
id: 'app.servers.properties.field.management-server-host',
|
||||
defaultMessage: 'Management server host',
|
||||
},
|
||||
'management-server-port': {
|
||||
id: 'app.servers.properties.field.management-server-port',
|
||||
defaultMessage: 'Management server port',
|
||||
},
|
||||
'management-server-secret': {
|
||||
id: 'app.servers.properties.field.management-server-secret',
|
||||
defaultMessage: 'Management server secret',
|
||||
},
|
||||
'management-server-tls-enabled': {
|
||||
id: 'app.servers.properties.field.management-server-tls-enabled',
|
||||
defaultMessage: 'Enable management server TLS',
|
||||
},
|
||||
'management-server-tls-keystore': {
|
||||
id: 'app.servers.properties.field.management-server-tls-keystore',
|
||||
defaultMessage: 'Management server TLS keystore',
|
||||
},
|
||||
'management-server-tls-keystore-password': {
|
||||
id: 'app.servers.properties.field.management-server-tls-keystore-password',
|
||||
defaultMessage: 'Management server TLS keystore password',
|
||||
},
|
||||
'max-chained-neighbor-updates': {
|
||||
id: 'app.servers.properties.field.max-chained-neighbor-updates',
|
||||
defaultMessage: 'Max chained neighbor updates',
|
||||
},
|
||||
'pause-when-empty-seconds': {
|
||||
id: 'app.servers.properties.field.pause-when-empty-seconds',
|
||||
defaultMessage: 'Pause when empty (seconds)',
|
||||
},
|
||||
'region-file-compression': {
|
||||
id: 'app.servers.properties.field.region-file-compression',
|
||||
defaultMessage: 'Region file compression',
|
||||
},
|
||||
'resource-pack-id': {
|
||||
id: 'app.servers.properties.field.resource-pack-id',
|
||||
defaultMessage: 'Resource pack ID',
|
||||
},
|
||||
'status-heartbeat-interval': {
|
||||
id: 'app.servers.properties.field.status-heartbeat-interval',
|
||||
defaultMessage: 'Status heartbeat interval',
|
||||
},
|
||||
'text-filtering-version': {
|
||||
id: 'app.servers.properties.field.text-filtering-version',
|
||||
defaultMessage: 'Text filtering version',
|
||||
},
|
||||
})
|
||||
|
||||
const sectionMessages = defineMessages({
|
||||
network: {
|
||||
id: 'app.servers.properties.section.network',
|
||||
defaultMessage: 'Network & Security',
|
||||
},
|
||||
world: { id: 'app.servers.properties.section.world', defaultMessage: 'World' },
|
||||
gameplay: { id: 'app.servers.properties.section.gameplay', defaultMessage: 'Gameplay' },
|
||||
content: { id: 'app.servers.properties.section.content', defaultMessage: 'Content' },
|
||||
advanced: { id: 'app.servers.properties.section.advanced', defaultMessage: 'Advanced' },
|
||||
others: { id: 'app.servers.properties.section.others', defaultMessage: 'Other' },
|
||||
})
|
||||
|
||||
const SECTION_ICONS = {
|
||||
network: GlobeIcon,
|
||||
world: MapIcon,
|
||||
gameplay: GameIcon,
|
||||
content: PackageIcon,
|
||||
advanced: SettingsIcon,
|
||||
others: MoreHorizontalIcon,
|
||||
} as const
|
||||
|
||||
const FIELD_SECTIONS = [
|
||||
{
|
||||
title: sectionMessages.network,
|
||||
icon: SECTION_ICONS.network,
|
||||
openByDefault: true,
|
||||
fields: [
|
||||
'server-port',
|
||||
'server-ip',
|
||||
'motd',
|
||||
'max-players',
|
||||
'online-mode',
|
||||
'white-list',
|
||||
'enforce-whitelist',
|
||||
'enforce-secure-profile',
|
||||
'prevent-proxy-connections',
|
||||
'hide-online-players',
|
||||
'enable-status',
|
||||
'enable-query',
|
||||
'query.port',
|
||||
'enable-rcon',
|
||||
'rcon.port',
|
||||
'rcon.password',
|
||||
'enable-lan',
|
||||
'accepts-transfers',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: sectionMessages.world,
|
||||
icon: SECTION_ICONS.world,
|
||||
openByDefault: true,
|
||||
fields: [
|
||||
'level-name',
|
||||
'level-seed',
|
||||
'level-type',
|
||||
'generator-settings',
|
||||
'generate-structures',
|
||||
'spawn-protection',
|
||||
'allow-nether',
|
||||
'allow-end',
|
||||
'allow-flight',
|
||||
'view-distance',
|
||||
'simulation-distance',
|
||||
'entity-broadcast-range-percentage',
|
||||
'max-world-size',
|
||||
'max-chained-neighbor-updates',
|
||||
'region-file-compression',
|
||||
'sync-chunk-writes',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: sectionMessages.gameplay,
|
||||
icon: SECTION_ICONS.gameplay,
|
||||
openByDefault: true,
|
||||
fields: [
|
||||
'gamemode',
|
||||
'force-gamemode',
|
||||
'difficulty',
|
||||
'hardcore',
|
||||
'pvp',
|
||||
'spawn-animals',
|
||||
'spawn-monsters',
|
||||
'spawn-npcs',
|
||||
'enable-command-block',
|
||||
'announce-player-achievements',
|
||||
'player-idle-timeout',
|
||||
'pause-when-empty-seconds',
|
||||
'max-tick-time',
|
||||
'op-permission-level',
|
||||
'function-permission-level',
|
||||
'network-compression-threshold',
|
||||
'rate-limit',
|
||||
'chat-spam-threshold-seconds',
|
||||
'command-spam-threshold-seconds',
|
||||
'bug-report-link',
|
||||
'use-native-transport',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: sectionMessages.content,
|
||||
icon: SECTION_ICONS.content,
|
||||
openByDefault: false,
|
||||
fields: [
|
||||
'resource-pack',
|
||||
'resource-pack-id',
|
||||
'resource-pack-sha1',
|
||||
'resource-pack-prompt',
|
||||
'require-resource-pack',
|
||||
'initial-enabled-packs',
|
||||
'initial-disabled-packs',
|
||||
'enable-code-of-conduct',
|
||||
'text-filtering-config',
|
||||
'text-filtering-version',
|
||||
'log-ips',
|
||||
'broadcast-console-to-ops',
|
||||
'broadcast-rcon-to-ops',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: sectionMessages.advanced,
|
||||
icon: SECTION_ICONS.advanced,
|
||||
openByDefault: false,
|
||||
fields: [
|
||||
'management-server-enabled',
|
||||
'management-server-host',
|
||||
'management-server-port',
|
||||
'management-server-secret',
|
||||
'management-server-allowed-origins',
|
||||
'management-server-tls-enabled',
|
||||
'management-server-tls-keystore',
|
||||
'management-server-tls-keystore-password',
|
||||
'status-heartbeat-interval',
|
||||
'enable-jmx-monitoring',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const FILE_NAME = 'server.properties'
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isMissing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const mode = ref<'form' | 'text'>('form')
|
||||
const entries = ref<PropertiesEntry[]>([])
|
||||
const rawText = ref('')
|
||||
const baselineText = ref('')
|
||||
const normalizedBaseline = ref('')
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
async function load() {
|
||||
isLoading.value = true
|
||||
isMissing.value = false
|
||||
try {
|
||||
const text = await servers.readFile(props.serverId, FILE_NAME)
|
||||
entries.value = parseProperties(text)
|
||||
rawText.value = text
|
||||
baselineText.value = text
|
||||
normalizedBaseline.value = serializeProperties(entries.value)
|
||||
} catch {
|
||||
isMissing.value = true
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
const definition = computed(() => getConfigFile(FILE_NAME))
|
||||
|
||||
const isDirty = computed(() =>
|
||||
mode.value === 'text'
|
||||
? rawText.value !== baselineText.value
|
||||
: serializeProperties(entries.value) !== normalizedBaseline.value,
|
||||
)
|
||||
|
||||
function fieldLabel(key: string): string {
|
||||
const descriptor = fieldMessages[key as keyof typeof fieldMessages]
|
||||
return descriptor ? formatMessage(descriptor) : configFieldLabel(key)
|
||||
}
|
||||
|
||||
interface FormField {
|
||||
key: string
|
||||
value: string
|
||||
field: ResolvedConfigField
|
||||
}
|
||||
|
||||
const allFormFields = computed<FormField[]>(() =>
|
||||
entries.value
|
||||
.map((entry) => (entry.type === 'pair' ? entry : null))
|
||||
.filter((entry): entry is Extract<PropertiesEntry, { type: 'pair' }> => entry !== null)
|
||||
.map((pair) => ({
|
||||
key: pair.key,
|
||||
value: pair.value,
|
||||
field: definition.value
|
||||
? resolveConfigField(definition.value, pair.key, pair.value)
|
||||
: { key: pair.key, kind: 'string' as const, inferred: true },
|
||||
})),
|
||||
)
|
||||
|
||||
const formSections = computed(() => {
|
||||
const knownKeys = new Set(FIELD_SECTIONS.flatMap((section) => section.fields))
|
||||
const byKey = new Map(allFormFields.value.map((field) => [field.key, field]))
|
||||
const sections: {
|
||||
title: MessageDescriptor
|
||||
icon: Component
|
||||
openByDefault: boolean
|
||||
fields: FormField[]
|
||||
}[] = FIELD_SECTIONS.flatMap((section) => {
|
||||
const fields = section.fields
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((field): field is FormField => field !== undefined)
|
||||
return fields.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: section.title,
|
||||
icon: section.icon,
|
||||
openByDefault: section.openByDefault,
|
||||
fields,
|
||||
},
|
||||
]
|
||||
})
|
||||
const others = allFormFields.value.filter((field) => !knownKeys.has(field.key))
|
||||
if (others.length > 0) {
|
||||
sections.push({
|
||||
title: sectionMessages.others,
|
||||
icon: SECTION_ICONS.others,
|
||||
openByDefault: false,
|
||||
fields: others,
|
||||
})
|
||||
}
|
||||
return sections
|
||||
})
|
||||
|
||||
function setFieldValue(key: string, value: string | number | undefined) {
|
||||
entries.value = setProperty(entries.value, key, value?.toString() ?? '')
|
||||
}
|
||||
|
||||
function switchMode(next: 'form' | 'text') {
|
||||
if (next === 'text' && mode.value === 'form') {
|
||||
rawText.value = serializeProperties(entries.value)
|
||||
} else if (next === 'form' && mode.value === 'text') {
|
||||
entries.value = parseProperties(rawText.value)
|
||||
}
|
||||
mode.value = next
|
||||
}
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (isMissing.value) return true
|
||||
isSaving.value = true
|
||||
try {
|
||||
const text = mode.value === 'text' ? rawText.value : serializeProperties(entries.value)
|
||||
await servers.writeFile(props.serverId, FILE_NAME, text)
|
||||
entries.value = parseProperties(text)
|
||||
rawText.value = text
|
||||
baselineText.value = text
|
||||
normalizedBaseline.value = serializeProperties(entries.value)
|
||||
return true
|
||||
} catch (error) {
|
||||
handleError?.(error)
|
||||
return false
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
entries.value = parseProperties(baselineText.value)
|
||||
rawText.value = baselineText.value
|
||||
}
|
||||
|
||||
defineExpose({ save, cancel, isDirty })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section data-onboarding-id="server-properties" class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-surface-3 text-contrast"
|
||||
>
|
||||
<FileTextIcon class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 truncate text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.title) }}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled :type="mode === 'form' ? 'highlight' : 'transparent'" size="small">
|
||||
<button type="button" @click="switchMode('form')">
|
||||
{{ formatMessage(messages.formMode) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :type="mode === 'text' ? 'highlight' : 'transparent'" size="small">
|
||||
<button type="button" @click="switchMode('text')">
|
||||
{{ formatMessage(messages.textMode) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="isMissing" class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.missing) }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="mode === 'form'">
|
||||
<div class="flex flex-col">
|
||||
<Accordion
|
||||
v-for="section in formSections"
|
||||
:key="section.title.id"
|
||||
:open-by-default="section.openByDefault"
|
||||
overflow-visible
|
||||
:button-class="'group flex min-h-11 w-full cursor-pointer items-center gap-3 bg-transparent px-1 text-left'"
|
||||
class="border-0 border-b border-solid border-surface-4 py-1 last:border-b-0"
|
||||
>
|
||||
<template #button="{ open }">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<span
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-surface-3 text-secondary transition-colors group-hover:text-primary"
|
||||
>
|
||||
<component :is="section.icon" class="size-4" />
|
||||
</span>
|
||||
<span
|
||||
class="min-w-0 flex-1 truncate text-sm font-semibold text-primary group-hover:text-contrast"
|
||||
>
|
||||
{{ formatMessage(section.title) }}
|
||||
</span>
|
||||
</span>
|
||||
<DropdownIcon
|
||||
class="ml-auto size-4 shrink-0 text-secondary transition-transform duration-300 group-hover:text-primary"
|
||||
:class="open && 'rotate-180'"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-x-5 gap-y-3 px-1 pb-4 pt-1 sm:grid-cols-2 xl:grid-cols-3"
|
||||
>
|
||||
<template v-for="item in section.fields" :key="item.key">
|
||||
<div
|
||||
v-if="item.field.kind === 'boolean'"
|
||||
class="flex min-h-9 min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
<label
|
||||
class="truncate text-sm font-medium text-primary"
|
||||
:for="`server-prop-${item.key}`"
|
||||
>
|
||||
<span v-tooltip="item.key">{{ fieldLabel(item.key) }}</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="`server-prop-${item.key}`"
|
||||
:model-value="item.value === 'true'"
|
||||
small
|
||||
@update:model-value="setFieldValue(item.key, $event ? 'true' : 'false')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex min-w-0 flex-col gap-1.5">
|
||||
<label
|
||||
class="truncate text-sm font-medium text-primary"
|
||||
:for="`server-prop-${item.key}`"
|
||||
>
|
||||
<span v-tooltip="item.key">{{ fieldLabel(item.key) }}</span>
|
||||
</label>
|
||||
<StyledInput
|
||||
v-if="item.field.kind === 'integer' || item.field.kind === 'number'"
|
||||
:id="`server-prop-${item.key}`"
|
||||
:model-value="item.value"
|
||||
inputmode="numeric"
|
||||
size="small"
|
||||
wrapper-class="w-full"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
/>
|
||||
|
||||
<DropdownSelect
|
||||
v-else-if="item.field.kind === 'enum'"
|
||||
:model-value="item.value"
|
||||
:options="item.field.options ?? []"
|
||||
:name="`server-prop-${item.key}`"
|
||||
class="!w-full"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
v-else
|
||||
:id="`server-prop-${item.key}`"
|
||||
:model-value="item.value"
|
||||
size="small"
|
||||
wrapper-class="w-full"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="h-[clamp(18rem,60vh,32rem)] overflow-hidden rounded-lg border border-solid border-surface-4"
|
||||
>
|
||||
<StudioEditor
|
||||
file-path="server.properties"
|
||||
language="properties"
|
||||
:content="rawText"
|
||||
:read-only="isSaving"
|
||||
@update:content="rawText = $event"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
import { ImageIcon, SaveIcon, SpinnerIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { requiredJavaMajorVersion } from '@modrinth/server'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Card,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import ServerIcon from '@/components/multiplayer/servers/ServerIcon.vue'
|
||||
import ServerPropertiesEditor from '@/components/multiplayer/servers/ServerPropertiesEditor.vue'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { type ServerView, useServers } from '@/composables/useServers'
|
||||
import { get_jre } from '@/helpers/jre'
|
||||
import { servers as serversApi } from '@/helpers/servers'
|
||||
|
||||
const props = defineProps<{
|
||||
server: ServerView
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deleted: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
general: { id: 'app.servers.settings.general', defaultMessage: 'General' },
|
||||
name: { id: 'app.servers.settings.name', defaultMessage: 'Server name' },
|
||||
icon: { id: 'app.servers.settings.icon', defaultMessage: 'Icon' },
|
||||
selectIcon: { id: 'app.servers.icon.select', defaultMessage: 'Select icon' },
|
||||
changeIcon: { id: 'app.servers.icon.change', defaultMessage: 'Change icon' },
|
||||
removeIcon: { id: 'app.servers.icon.remove', defaultMessage: 'Remove icon' },
|
||||
java: { id: 'app.servers.settings.java', defaultMessage: 'Java' },
|
||||
memory: { id: 'app.servers.settings.memory', defaultMessage: 'Memory (MB)' },
|
||||
jvmArgs: { id: 'app.servers.settings.jvm-args', defaultMessage: 'JVM arguments' },
|
||||
jvmArgsHint: {
|
||||
id: 'app.servers.settings.jvm-args-hint',
|
||||
defaultMessage: 'Space-separated arguments, e.g. -XX:+UseG1GC',
|
||||
},
|
||||
save: { id: 'app.servers.settings.save', defaultMessage: 'Save changes' },
|
||||
saved: { id: 'app.servers.settings.saved', defaultMessage: 'Server settings saved' },
|
||||
cancel: { id: 'app.servers.settings.cancel', defaultMessage: 'Cancel' },
|
||||
deleteTitle: { id: 'app.servers.settings.delete', defaultMessage: 'Delete server' },
|
||||
deleteHint: {
|
||||
id: 'app.servers.settings.delete-hint',
|
||||
defaultMessage: 'Permanently remove this server and all of its files.',
|
||||
},
|
||||
deleteConfirm: {
|
||||
id: 'app.servers.settings.delete-confirm',
|
||||
defaultMessage: 'Delete {name} and all of its files? This cannot be undone.',
|
||||
},
|
||||
deleteProceed: { id: 'app.servers.settings.delete-proceed', defaultMessage: 'Delete' },
|
||||
configFiles: { id: 'app.servers.settings.config', defaultMessage: 'Configuration' },
|
||||
runningTitle: {
|
||||
id: 'app.servers.settings.running-title',
|
||||
defaultMessage: 'Server is running',
|
||||
},
|
||||
runningHint: {
|
||||
id: 'app.servers.settings.running-hint',
|
||||
defaultMessage: 'Your changes will take effect the next time the server starts.',
|
||||
},
|
||||
})
|
||||
|
||||
const { deleteServer, refresh } = useServers()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
|
||||
const name = ref(props.server.name)
|
||||
const iconPath = ref<string | null>(props.server.iconPath ?? null)
|
||||
const javaSelection = ref<{ path: string; version: string }>({
|
||||
path: props.server.javaPath ?? '',
|
||||
version: '',
|
||||
})
|
||||
const memoryMb = ref(props.server.memoryMb ?? 2048)
|
||||
const jvmArgsText = ref((props.server.jvmArgs ?? []).join(' '))
|
||||
const isSaving = ref(false)
|
||||
const deleteModal = useTemplateRef<ComponentExposed<typeof ConfirmModal>>('deleteModal')
|
||||
const editor = useTemplateRef<ComponentExposed<typeof ServerPropertiesEditor>>('editor')
|
||||
|
||||
const requiredJava = computed(() => requiredJavaMajorVersion(props.server.gameVersion))
|
||||
|
||||
// Same busy boundary as the files panel: a running server holds its
|
||||
// configuration in memory and may overwrite external edits, and
|
||||
// manifest changes only take effect after a restart anyway.
|
||||
const isRunning = computed(() => props.server.running)
|
||||
|
||||
const baseline = ref({
|
||||
name: props.server.name,
|
||||
iconPath: props.server.iconPath ?? null,
|
||||
javaPath: props.server.javaPath ?? '',
|
||||
javaVersion: '',
|
||||
memoryMb: props.server.memoryMb ?? 2048,
|
||||
jvmArgs: (props.server.jvmArgs ?? []).join(' '),
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!javaSelection.value.path) return
|
||||
try {
|
||||
const jre = await get_jre(javaSelection.value.path)
|
||||
if (jre) {
|
||||
javaSelection.value.version = jre.version
|
||||
baseline.value.javaVersion = jre.version
|
||||
}
|
||||
} catch {
|
||||
// Keep the path; the selector validates against the required major version.
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.server.iconPath,
|
||||
(value) => {
|
||||
const synced = value ?? null
|
||||
iconPath.value = synced
|
||||
baseline.value.iconPath = synced
|
||||
},
|
||||
)
|
||||
|
||||
const generalDirty = computed(
|
||||
() =>
|
||||
name.value !== baseline.value.name ||
|
||||
iconPath.value !== baseline.value.iconPath ||
|
||||
javaSelection.value.path !== baseline.value.javaPath ||
|
||||
javaSelection.value.version !== baseline.value.javaVersion ||
|
||||
memoryMb.value !== baseline.value.memoryMb ||
|
||||
jvmArgsText.value !== baseline.value.jvmArgs,
|
||||
)
|
||||
|
||||
const isDirty = computed(() => generalDirty.value || (editor.value?.isDirty ?? false))
|
||||
|
||||
async function save() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const jvmArgs = jvmArgsText.value.trim().split(/\s+/).filter(Boolean)
|
||||
const parsedMemory = Number(memoryMb.value)
|
||||
const memoryMbValue =
|
||||
Number.isFinite(parsedMemory) && parsedMemory > 0 ? parsedMemory : baseline.value.memoryMb
|
||||
await serversApi.updateSettings(props.server.id, {
|
||||
name: name.value.trim(),
|
||||
javaPath: javaSelection.value.path,
|
||||
memoryMb: memoryMbValue,
|
||||
jvmArgs,
|
||||
})
|
||||
if (iconPath.value !== baseline.value.iconPath) {
|
||||
await serversApi.setIcon(props.server.id, iconPath.value)
|
||||
}
|
||||
const propsSaved = (await editor.value?.save()) ?? true
|
||||
if (!propsSaved) return
|
||||
name.value = name.value.trim()
|
||||
memoryMb.value = memoryMbValue
|
||||
jvmArgsText.value = jvmArgs.join(' ')
|
||||
baseline.value = {
|
||||
name: name.value,
|
||||
iconPath: iconPath.value,
|
||||
javaPath: javaSelection.value.path,
|
||||
javaVersion: javaSelection.value.version,
|
||||
memoryMb: memoryMbValue,
|
||||
jvmArgs: jvmArgsText.value,
|
||||
}
|
||||
await refresh()
|
||||
addNotification({ type: 'success', title: formatMessage(messages.saved) })
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
name.value = baseline.value.name
|
||||
iconPath.value = baseline.value.iconPath
|
||||
javaSelection.value = { path: baseline.value.javaPath, version: baseline.value.javaVersion }
|
||||
memoryMb.value = baseline.value.memoryMb
|
||||
jvmArgsText.value = baseline.value.jvmArgs
|
||||
editor.value?.cancel()
|
||||
}
|
||||
|
||||
async function pickIcon() {
|
||||
try {
|
||||
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
|
||||
if (picked?.path) iconPath.value = picked.path
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const ok = await deleteServer(props.server.id)
|
||||
if (ok) emit('deleted')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-full flex-col">
|
||||
<div class="flex flex-col gap-6 pb-20">
|
||||
<Admonition v-if="isRunning" type="warning" :header="formatMessage(messages.runningTitle)">
|
||||
{{ formatMessage(messages.runningHint) }}
|
||||
</Admonition>
|
||||
|
||||
<Card data-onboarding-id="server-settings" class="!m-0">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<h3 class="m-0 col-span-full text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.general) }}
|
||||
</h3>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-3 sm:col-span-2 xl:col-span-4">
|
||||
<ServerIcon
|
||||
:icon-path="iconPath"
|
||||
:server-type="server.serverType"
|
||||
:server-id="server.id"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.icon) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="outlined" size="small">
|
||||
<button type="button" @click="pickIcon">
|
||||
<ImageIcon />
|
||||
{{
|
||||
iconPath
|
||||
? formatMessage(messages.changeIcon)
|
||||
: formatMessage(messages.selectIcon)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="iconPath" color="red" type="outlined" size="small">
|
||||
<button type="button" @click="iconPath = null">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.removeIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex min-w-0 flex-col gap-2" for="server-settings-name">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
||||
<StyledInput id="server-settings-name" v-model="name" />
|
||||
</label>
|
||||
|
||||
<label class="flex min-w-0 flex-col gap-2" for="server-settings-memory">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.memory) }}</span>
|
||||
<StyledInput
|
||||
id="server-settings-memory"
|
||||
v-model="memoryMb"
|
||||
inputmode="numeric"
|
||||
wrapper-class="max-w-40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:col-span-2 xl:col-span-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.java) }}</span>
|
||||
<JavaSelector
|
||||
id="server-settings-java"
|
||||
v-model="javaSelection"
|
||||
:version="requiredJava"
|
||||
select-all-versions
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="flex min-w-0 flex-col gap-2 sm:col-span-2 xl:col-span-4"
|
||||
for="server-settings-jvm"
|
||||
>
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.jvmArgs) }}</span>
|
||||
<StyledInput id="server-settings-jvm" v-model="jvmArgsText" />
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.jvmArgsHint) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card class="!m-0">
|
||||
<ServerPropertiesEditor ref="editor" :server-id="server.id" />
|
||||
</Card>
|
||||
|
||||
<Card class="!m-0">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-highlight text-red"
|
||||
>
|
||||
<TrashIcon class="size-4" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.deleteTitle) }}
|
||||
</h3>
|
||||
<p class="mb-0 mt-1 text-sm text-secondary">
|
||||
{{ formatMessage(messages.deleteHint) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button type="button" :disabled="server.running" @click="deleteModal?.show()">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteTitle) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isDirty"
|
||||
class="fixed bottom-4 z-50 flex"
|
||||
:style="{
|
||||
left: 'calc(var(--left-bar-width) + 1.5rem)',
|
||||
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width) - 3rem)',
|
||||
}"
|
||||
>
|
||||
<div class="flex w-full items-center justify-end">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-xl border border-solid border-button-border bg-bg-raised px-3 py-2 shadow-lg"
|
||||
>
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="isSaving" @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button type="button" :disabled="isSaving || isRunning" @click="save">
|
||||
<SpinnerIcon v-if="isSaving" class="animate-spin" />
|
||||
<SaveIcon v-else />
|
||||
{{ formatMessage(messages.save) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
ref="deleteModal"
|
||||
:title="formatMessage(messages.deleteTitle)"
|
||||
:description="formatMessage(messages.deleteConfirm, { name: server.name })"
|
||||
:proceed-label="formatMessage(messages.deleteProceed)"
|
||||
@proceed="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollectionIcon,
|
||||
GridIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ServerIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, EmptyState, PopoutMenu, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import CreateServerModal from '@/components/multiplayer/servers/CreateServerModal.vue'
|
||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
||||
import ServerCard from '@/components/multiplayer/servers/ServerCard.vue'
|
||||
import { useServerLifecycle } from '@/composables/useServerLifecycle'
|
||||
import { type ServerView, useServers } from '@/composables/useServers'
|
||||
import {
|
||||
getLastLibraryDisplayMode,
|
||||
setLastLibraryDisplayMode,
|
||||
} from '@/helpers/library-display-mode'
|
||||
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { servers, isRefreshing, refresh, stopServer } = useServers()
|
||||
const { eulaModal, eulaText, tryStartServer, acceptEula, declineEula, resumeInstall } =
|
||||
useServerLifecycle()
|
||||
const createModal = useTemplateRef<ComponentExposed<typeof CreateServerModal>>('createModal')
|
||||
|
||||
const messages = defineMessages({
|
||||
create: { id: 'app.servers.create.title', defaultMessage: 'Create server' },
|
||||
refresh: { id: 'app.servers.refresh', defaultMessage: 'Refresh' },
|
||||
emptyHeading: {
|
||||
id: 'app.servers.empty.heading',
|
||||
defaultMessage: 'No servers yet',
|
||||
},
|
||||
emptyDescription: {
|
||||
id: 'app.servers.empty.description',
|
||||
defaultMessage: 'Create a server to play with friends, right from the launcher.',
|
||||
},
|
||||
count: {
|
||||
id: 'app.servers.count',
|
||||
defaultMessage: '{count, plural, =0 {No servers yet} one {# server} other {# servers}}',
|
||||
},
|
||||
loading: { id: 'app.servers.loading', defaultMessage: 'Loading servers...' },
|
||||
view: { id: 'app.library.view', defaultMessage: 'View' },
|
||||
standardView: { id: 'app.library.view.standard', defaultMessage: 'Standard grid' },
|
||||
cardsView: { id: 'app.library.view.cards', defaultMessage: 'Library cards' },
|
||||
})
|
||||
|
||||
const displayMode = ref(getLastLibraryDisplayMode())
|
||||
const displayModeOptions = computed(() => [
|
||||
{ id: 'standard' as const, label: formatMessage(messages.standardView), icon: GridIcon },
|
||||
{ id: 'cards' as const, label: formatMessage(messages.cardsView), icon: CollectionIcon },
|
||||
])
|
||||
const currentDisplayMode = computed(() =>
|
||||
displayModeOptions.value.find((option) => option.id === displayMode.value),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void refresh()
|
||||
})
|
||||
|
||||
async function openServer(id: string) {
|
||||
// Refresh first so the freshly created server is present in the shared store
|
||||
// before ServerDetail mounts; otherwise it briefly shows "server not found".
|
||||
await refresh().catch(() => {})
|
||||
void router.push('/multiplayer/servers/' + encodeURIComponent(id))
|
||||
}
|
||||
|
||||
function setDisplayMode(mode: 'standard' | 'cards') {
|
||||
displayMode.value = mode
|
||||
setLastLibraryDisplayMode(mode)
|
||||
}
|
||||
|
||||
async function toggleRunning(server: ServerView) {
|
||||
if (server.status === 'running') {
|
||||
await stopServer(server.id)
|
||||
} else {
|
||||
await tryStartServer(server)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-onboarding-id="servers-overview" class="flex min-h-0 w-full flex-1 flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-2 text-sm text-secondary">
|
||||
<SpinnerIcon v-if="isRefreshing" class="size-4 animate-spin" />
|
||||
<ServerIcon v-else class="size-4" />
|
||||
{{
|
||||
isRefreshing
|
||||
? formatMessage(messages.loading)
|
||||
: formatMessage(messages.count, { count: servers.length })
|
||||
}}
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<PopoutMenu :tooltip="formatMessage(messages.view)" placement="bottom-end">
|
||||
<ButtonStyled circular>
|
||||
<button type="button" :aria-label="formatMessage(messages.view)">
|
||||
<component :is="currentDisplayMode?.icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #menu>
|
||||
<div class="flex w-44 flex-col gap-1 p-1">
|
||||
<ButtonStyled
|
||||
v-for="option in displayModeOptions"
|
||||
:key="option.id"
|
||||
:type="displayMode === option.id ? 'filled' : 'transparent'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||||
:aria-pressed="displayMode === option.id"
|
||||
@click="setDisplayMode(option.id)"
|
||||
>
|
||||
<component :is="option.icon" class="size-4" />
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" :disabled="isRefreshing" @click="refresh()">
|
||||
<RefreshCwIcon :class="{ 'animate-spin': isRefreshing }" />
|
||||
{{ formatMessage(messages.refresh) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
type="button"
|
||||
data-onboarding-id="create-server-button"
|
||||
@click="createModal?.show()"
|
||||
>
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="servers.length === 0 && !isRefreshing"
|
||||
type="empty"
|
||||
:heading="formatMessage(messages.emptyHeading)"
|
||||
:description="formatMessage(messages.emptyDescription)"
|
||||
>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button type="button" @click="createModal?.show()">
|
||||
<ServerIcon />
|
||||
{{ formatMessage(messages.create) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</EmptyState>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full max-w-[72rem] gap-3"
|
||||
:class="{
|
||||
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
|
||||
}"
|
||||
>
|
||||
<ServerCard
|
||||
v-for="entry in servers"
|
||||
:key="entry.id"
|
||||
:server="entry"
|
||||
:variant="displayMode === 'cards' ? 'library' : 'standard'"
|
||||
@open="openServer(entry.id)"
|
||||
@start-stop="toggleRunning(entry)"
|
||||
@resume="resumeInstall(entry)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CreateServerModal ref="createModal" @created="openServer" />
|
||||
<EulaModal ref="eulaModal" :text="eulaText" @continue="acceptEula" @decline="declineEula" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@ -0,0 +1,466 @@
|
||||
import { RefreshCwIcon } from '@modrinth/assets'
|
||||
import {
|
||||
isServerTypeSupported,
|
||||
requiredJavaMajorVersion,
|
||||
SERVER_TYPES,
|
||||
type ServerTypeId,
|
||||
setEulaAccepted,
|
||||
} from '@modrinth/server'
|
||||
import {
|
||||
createContext,
|
||||
defineMessages,
|
||||
type MultiStageModal,
|
||||
type StageConfigInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, markRaw, type Ref, ref } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import {
|
||||
javaMajorFromVersion,
|
||||
toErrorMessage,
|
||||
} from '@/components/multiplayer/servers/server-flow-utils'
|
||||
import { getServerInstallStrategy, runServerInstall } from '@/composables/server-install'
|
||||
import { refresh as refreshServerList } from '@/composables/useServers'
|
||||
import { find_filtered_jres, get_java_default_versions, get_max_memory } from '@/helpers/jre'
|
||||
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
|
||||
import { type ServerManifestData, servers } from '@/helpers/servers'
|
||||
import { injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
import InstallStage from './stages/InstallStage.vue'
|
||||
import SetupStage from './stages/SetupStage.vue'
|
||||
import TypeStage from './stages/TypeStage.vue'
|
||||
|
||||
export type InstallPhase =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'downloading'
|
||||
| 'first-run'
|
||||
| 'eula'
|
||||
| 'error'
|
||||
| 'done'
|
||||
|
||||
export interface JavaSelection {
|
||||
path: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface LoaderVersionOption {
|
||||
id: string
|
||||
stable: boolean
|
||||
}
|
||||
|
||||
export interface CreateServerFlowContext<TCtx extends CreateServerFlowContext<TCtx>> {
|
||||
modal: Ref<ComponentExposed<typeof MultiStageModal> | null>
|
||||
stageConfigs: StageConfigInput<TCtx>[]
|
||||
formatMessage: ReturnType<typeof useVIntl>['formatMessage']
|
||||
|
||||
serverType: Ref<ServerTypeId>
|
||||
availableGameVersions: Ref<string[]>
|
||||
selectedGameVersion: Ref<string>
|
||||
showSnapshots: Ref<boolean>
|
||||
loaderVersions: Ref<LoaderVersionOption[]>
|
||||
selectedLoaderVersion: Ref<string>
|
||||
isVersionsLoading: Ref<boolean>
|
||||
versionsError: Ref<string | null>
|
||||
|
||||
name: Ref<string>
|
||||
selectedJava: Ref<JavaSelection>
|
||||
memoryMb: Ref<number>
|
||||
maxMemoryMb: Ref<number>
|
||||
|
||||
installPhase: Ref<InstallPhase>
|
||||
downloadProgress: Ref<{ downloaded: number; total: number | null } | null>
|
||||
installLog: Ref<string[]>
|
||||
installError: Ref<string | null>
|
||||
eulaText: Ref<string>
|
||||
createdServer: Ref<ServerManifestData | null>
|
||||
showEulaModal: Ref<boolean>
|
||||
|
||||
/** Registered by the configure stage to persist server.properties before finishing. */
|
||||
saveServerProperties: Ref<(() => Promise<boolean>) | null>
|
||||
|
||||
needsLoaderVersion: Ref<boolean>
|
||||
typeSupported: Ref<boolean>
|
||||
canContinueFromType: Ref<boolean>
|
||||
|
||||
loadVersions: () => Promise<void>
|
||||
loadLoaderVersions: () => Promise<void>
|
||||
loadDefaultJava: () => Promise<void>
|
||||
beginInstall: () => Promise<void>
|
||||
retryInstall: () => Promise<void>
|
||||
acceptEula: () => Promise<void>
|
||||
declineEula: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
/** Concrete context used by the vanilla (non-modpack) server creation flow. */
|
||||
export type CreateServerFlowContextValue = CreateServerFlowContext<CreateServerFlowContextValue>
|
||||
|
||||
export const [injectCreateServerFlow, provideCreateServerFlow] =
|
||||
createContext<CreateServerFlowContextValue>('CreateServerFlow')
|
||||
|
||||
export function createCreateServerFlowContext(
|
||||
modal: Ref<ComponentExposed<typeof MultiStageModal> | null>,
|
||||
): CreateServerFlowContextValue {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
// [SERVER-DOWNLOAD-BRIDGE] Capture the download manager once during Vue
|
||||
// setup context. Vue's inject() only works in the synchronous setup
|
||||
// scope — after any `await` the injection context is lost. We store the
|
||||
// reference here and pass it explicitly to the shared download bridge so
|
||||
// the vanilla server download appears in the sidebar like the modpack flow.
|
||||
let downloadManager: ReturnType<typeof injectDownloadManager> | null = null
|
||||
try {
|
||||
downloadManager = injectDownloadManager()
|
||||
} catch {
|
||||
// Not inside a provider tree — server downloads will not appear in sidebar.
|
||||
}
|
||||
|
||||
const wizardMessages = defineMessages({
|
||||
typeStageTitle: { id: 'app.servers.wizard.type-title', defaultMessage: 'Server type' },
|
||||
setupStageTitle: { id: 'app.servers.wizard.setup-title', defaultMessage: 'Setup' },
|
||||
installStageTitle: { id: 'app.servers.wizard.install-title', defaultMessage: 'Install' },
|
||||
configureStageTitle: { id: 'app.servers.wizard.configure-title', defaultMessage: 'Configure' },
|
||||
next: { id: 'app.servers.wizard.next', defaultMessage: 'Next' },
|
||||
retry: { id: 'app.servers.wizard.retry', defaultMessage: 'Retry' },
|
||||
finish: { id: 'app.servers.wizard.finish', defaultMessage: 'Finish' },
|
||||
javaTooOld: {
|
||||
id: 'app.servers.wizard.java-too-old',
|
||||
defaultMessage:
|
||||
'Java {selected} cannot run this game version; Java {required} or newer is required.',
|
||||
},
|
||||
})
|
||||
|
||||
const serverType = ref<ServerTypeId>('vanilla')
|
||||
const availableGameVersions = ref<string[]>([])
|
||||
const selectedGameVersion = ref('')
|
||||
const showSnapshots = ref(false)
|
||||
const loaderVersions = ref<LoaderVersionOption[]>([])
|
||||
const selectedLoaderVersion = ref('')
|
||||
const isVersionsLoading = ref(false)
|
||||
const versionsError = ref<string | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const selectedJava = ref<JavaSelection>({ path: '', version: '' })
|
||||
const memoryMb = ref(2048)
|
||||
const maxMemoryMb = ref(8192)
|
||||
|
||||
const installPhase = ref<InstallPhase>('idle')
|
||||
const downloadProgress = ref<{ downloaded: number; total: number | null } | null>(null)
|
||||
const installLog = ref<string[]>([])
|
||||
const installError = ref<string | null>(null)
|
||||
const eulaText = ref('')
|
||||
const createdServer = ref<ServerManifestData | null>(null)
|
||||
const showEulaModal = ref(false)
|
||||
const saveServerProperties = ref<(() => Promise<boolean>) | null>(null)
|
||||
|
||||
const needsLoaderVersion = computed(
|
||||
() => SERVER_TYPES[serverType.value]?.needsLoaderVersion ?? false,
|
||||
)
|
||||
const typeSupported = computed(() => isServerTypeSupported(serverType.value))
|
||||
|
||||
async function loadVersions() {
|
||||
isVersionsLoading.value = true
|
||||
versionsError.value = null
|
||||
try {
|
||||
const manifest = (await get_game_versions()) as {
|
||||
latest: { release: string }
|
||||
versions: { id: string; type: string; url: string }[]
|
||||
}
|
||||
const all = manifest.versions
|
||||
availableGameVersions.value = all
|
||||
.filter((entry) => (showSnapshots.value ? true : entry.type === 'release'))
|
||||
.map((entry) => entry.id)
|
||||
if (!availableGameVersions.value.includes(selectedGameVersion.value)) {
|
||||
selectedGameVersion.value =
|
||||
manifest.latest.release && availableGameVersions.value.includes(manifest.latest.release)
|
||||
? manifest.latest.release
|
||||
: availableGameVersions.value[0]
|
||||
}
|
||||
await loadLoaderVersions()
|
||||
} catch (error) {
|
||||
versionsError.value = toErrorMessage(error)
|
||||
} finally {
|
||||
isVersionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLoaderVersions() {
|
||||
selectedLoaderVersion.value = ''
|
||||
loaderVersions.value = []
|
||||
if (serverType.value !== 'fabric' || !selectedGameVersion.value) return
|
||||
try {
|
||||
const manifest = (await get_loader_versions('fabric', selectedGameVersion.value)) as {
|
||||
gameVersions: Array<{ id: string; loaders: LoaderVersionOption[] }>
|
||||
}
|
||||
const entry = manifest.gameVersions.find((game) => game.id === selectedGameVersion.value)
|
||||
loaderVersions.value = entry?.loaders ?? []
|
||||
selectedLoaderVersion.value = loaderVersions.value[0]?.id ?? ''
|
||||
} catch {
|
||||
loaderVersions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefills the Java path from the instance-level defaults, falling back to a scan. */
|
||||
async function loadDefaultJava() {
|
||||
if (selectedJava.value.path !== '') return
|
||||
const major = requiredJavaMajorVersion(selectedGameVersion.value || '1.21')
|
||||
try {
|
||||
const defaults = (await get_java_default_versions()) as Array<{
|
||||
parsed_version: number
|
||||
version: string
|
||||
path: string
|
||||
}>
|
||||
const match =
|
||||
defaults.find((entry) => entry.parsed_version === major) ??
|
||||
defaults.find((entry) => entry.parsed_version >= major)
|
||||
if (match) {
|
||||
selectedJava.value = { path: match.path, version: match.version }
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to a filtered scan
|
||||
}
|
||||
try {
|
||||
const javas = (await find_filtered_jres(major)) as JavaSelection[]
|
||||
if (javas.length > 0) selectedJava.value = javas[0]
|
||||
} catch {
|
||||
// Leave empty; the user picks manually in the setup stage
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMaxMemory() {
|
||||
try {
|
||||
const maxKiB = (await get_max_memory()) as number
|
||||
maxMemoryMb.value = Math.max(1024, Math.floor(maxKiB / 1024))
|
||||
} catch {
|
||||
maxMemoryMb.value = 8192
|
||||
}
|
||||
}
|
||||
|
||||
async function beginInstall() {
|
||||
if (installPhase.value === 'downloading' || installPhase.value === 'first-run') return
|
||||
installPhase.value = 'preparing'
|
||||
installError.value = null
|
||||
installLog.value = []
|
||||
downloadProgress.value = null
|
||||
try {
|
||||
const requiredJava = requiredJavaMajorVersion(selectedGameVersion.value)
|
||||
const selectedMajor = javaMajorFromVersion(selectedJava.value.version)
|
||||
if (
|
||||
selectedJava.value.path !== '' &&
|
||||
selectedMajor !== null &&
|
||||
selectedMajor < requiredJava
|
||||
) {
|
||||
throw new Error(
|
||||
formatMessage(wizardMessages.javaTooOld, {
|
||||
selected: selectedMajor,
|
||||
required: requiredJava,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const manifest = await servers.create({
|
||||
name: name.value,
|
||||
serverType: serverType.value,
|
||||
gameVersion: selectedGameVersion.value,
|
||||
loaderVersion: serverType.value === 'fabric' ? selectedLoaderVersion.value : undefined,
|
||||
javaPath: selectedJava.value.path || undefined,
|
||||
memoryMb: memoryMb.value,
|
||||
})
|
||||
createdServer.value = manifest
|
||||
|
||||
// [SERVER-INSTALL] The shared orchestrator owns the sidebar download
|
||||
// job, progress/log event forwarding, and cancellation. Each server
|
||||
// type supplies a `ServerInstallStrategy` that knows how to obtain its
|
||||
// launcher files; vanilla/Fabric/Paper download a jar, Forge runs its
|
||||
// installer. This is the single reuse point for every server type.
|
||||
const strategy = getServerInstallStrategy(serverType.value)
|
||||
await runServerInstall({
|
||||
serverId: manifest.id,
|
||||
name: name.value,
|
||||
inputs: {
|
||||
gameVersion: selectedGameVersion.value,
|
||||
loaderVersion: selectedLoaderVersion.value || undefined,
|
||||
javaPath: selectedJava.value.path || undefined,
|
||||
memoryMb: memoryMb.value,
|
||||
},
|
||||
strategy,
|
||||
downloadManager,
|
||||
onProgress: (progress) => {
|
||||
downloadProgress.value = progress
|
||||
},
|
||||
onLog: (line) => {
|
||||
installLog.value.push(line)
|
||||
if (installLog.value.length > 500) {
|
||||
installLog.value.splice(0, installLog.value.length - 500)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// [SERVER-EULA] Like the modpack flow, the server is not auto-started.
|
||||
// A code-created `eula.txt` (eula=false) is written so the manual start
|
||||
// gate (useServerLifecycle) can offer the EULA without booting the jar.
|
||||
const eula = setEulaAccepted('', false)
|
||||
await servers.writeFile(manifest.id, 'eula.txt', eula).catch(() => {})
|
||||
installPhase.value = 'done'
|
||||
} catch (error) {
|
||||
installPhase.value = 'error'
|
||||
installError.value = toErrorMessage(error)
|
||||
// A half-installed server must not linger in the list; retrying starts over.
|
||||
if (createdServer.value) {
|
||||
const failed = createdServer.value
|
||||
createdServer.value = null
|
||||
await servers.delete(failed.id).catch(() => {})
|
||||
void refreshServerList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retryInstall(): Promise<void> {
|
||||
installPhase.value = 'idle'
|
||||
return beginInstall()
|
||||
}
|
||||
|
||||
async function acceptEula() {
|
||||
if (!createdServer.value) return
|
||||
try {
|
||||
const updated = setEulaAccepted(eulaText.value, true)
|
||||
await servers.writeFile(createdServer.value.id, 'eula.txt', updated)
|
||||
showEulaModal.value = false
|
||||
installPhase.value = 'done'
|
||||
} catch (error) {
|
||||
installError.value = toErrorMessage(error)
|
||||
installPhase.value = 'error'
|
||||
showEulaModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function declineEula() {
|
||||
showEulaModal.value = false
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
serverType.value = 'vanilla'
|
||||
selectedGameVersion.value = ''
|
||||
selectedLoaderVersion.value = ''
|
||||
loaderVersions.value = []
|
||||
name.value = ''
|
||||
selectedJava.value = { path: '', version: '' }
|
||||
memoryMb.value = 2048
|
||||
installPhase.value = 'idle'
|
||||
installLog.value = []
|
||||
installError.value = null
|
||||
downloadProgress.value = null
|
||||
eulaText.value = ''
|
||||
createdServer.value = null
|
||||
showEulaModal.value = false
|
||||
saveServerProperties.value = null
|
||||
void loadVersions()
|
||||
void loadMaxMemory()
|
||||
}
|
||||
|
||||
const canContinueFromType = computed(
|
||||
() =>
|
||||
typeSupported.value &&
|
||||
selectedGameVersion.value !== '' &&
|
||||
(!needsLoaderVersion.value || selectedLoaderVersion.value !== ''),
|
||||
)
|
||||
|
||||
const stageConfigs: StageConfigInput<CreateServerFlowContextValue>[] = [
|
||||
{
|
||||
id: 'type',
|
||||
stageContent: markRaw(TypeStage),
|
||||
title: (ctx) => ctx.formatMessage(wizardMessages.typeStageTitle),
|
||||
cannotNavigateForward: (ctx) => !ctx.canContinueFromType.value,
|
||||
leftButtonConfig: () => null,
|
||||
rightButtonConfig: (ctx) => ({
|
||||
label: ctx.formatMessage(wizardMessages.next),
|
||||
color: 'brand',
|
||||
disabled: !ctx.canContinueFromType.value,
|
||||
onClick: () => ctx.modal.value?.nextStage(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'setup',
|
||||
stageContent: markRaw(SetupStage),
|
||||
title: (ctx) => ctx.formatMessage(wizardMessages.setupStageTitle),
|
||||
cannotNavigateForward: (ctx) => ctx.name.value.trim() === '',
|
||||
leftButtonConfig: () => null,
|
||||
rightButtonConfig: (ctx) => ({
|
||||
label: ctx.formatMessage(wizardMessages.next),
|
||||
color: 'brand',
|
||||
disabled: ctx.name.value.trim() === '',
|
||||
onClick: async () => {
|
||||
await ctx.loadDefaultJava()
|
||||
ctx.modal.value?.nextStage()
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
stageContent: markRaw(InstallStage),
|
||||
title: (ctx) => ctx.formatMessage(wizardMessages.installStageTitle),
|
||||
cannotNavigateForward: (ctx) => ctx.installPhase.value !== 'done',
|
||||
// Downloads continue in the background once the wizard closes; only
|
||||
// the first-run boot locks closing until the server reaches its EULA gate.
|
||||
disableClose: (ctx) => ctx.installPhase.value === 'first-run',
|
||||
leftButtonConfig: () => null,
|
||||
rightButtonConfig: (ctx) => ({
|
||||
label: ctx.formatMessage(
|
||||
ctx.installPhase.value === 'error' ? wizardMessages.retry : wizardMessages.finish,
|
||||
),
|
||||
color: 'brand',
|
||||
icon: ctx.installPhase.value === 'error' ? RefreshCwIcon : null,
|
||||
iconPosition: 'after',
|
||||
disabled: ctx.installPhase.value !== 'done' && ctx.installPhase.value !== 'error',
|
||||
onClick: () => {
|
||||
if (ctx.installPhase.value === 'error') {
|
||||
ctx.retryInstall()
|
||||
return
|
||||
}
|
||||
// Server is ready — close the wizard so the host can navigate to it.
|
||||
ctx.modal.value?.hide()
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
modal,
|
||||
stageConfigs,
|
||||
formatMessage,
|
||||
serverType,
|
||||
availableGameVersions,
|
||||
selectedGameVersion,
|
||||
showSnapshots,
|
||||
loaderVersions,
|
||||
selectedLoaderVersion,
|
||||
isVersionsLoading,
|
||||
versionsError,
|
||||
name,
|
||||
selectedJava,
|
||||
memoryMb,
|
||||
maxMemoryMb,
|
||||
installPhase,
|
||||
downloadProgress,
|
||||
installLog,
|
||||
installError,
|
||||
eulaText,
|
||||
createdServer,
|
||||
showEulaModal,
|
||||
saveServerProperties,
|
||||
needsLoaderVersion,
|
||||
typeSupported,
|
||||
canContinueFromType,
|
||||
loadVersions,
|
||||
loadLoaderVersions,
|
||||
loadDefaultJava,
|
||||
beginInstall,
|
||||
retryInstall,
|
||||
acceptEula,
|
||||
declineEula,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { commonMessages, defineMessages, MultiStageModal } from '@modrinth/ui'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import { provideCreateServerFlow } from '@/components/multiplayer/servers/create-server-flow'
|
||||
import EulaModal from '@/components/multiplayer/servers/EulaModal.vue'
|
||||
import {
|
||||
createModpackServerFlowContext,
|
||||
provideModpackServerFlow,
|
||||
} from '@/components/multiplayer/servers/modpack/create-modpack-server-flow'
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [serverId: string]
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
||||
const eulaModal = useTemplateRef<ComponentExposed<typeof EulaModal>>('eulaModal')
|
||||
|
||||
const ctx = createModpackServerFlowContext(modal)
|
||||
provideCreateServerFlow(ctx)
|
||||
provideModpackServerFlow(ctx)
|
||||
|
||||
const messages = defineMessages({
|
||||
downloadInBackground: {
|
||||
id: 'app.servers.modpack.download-in-background',
|
||||
defaultMessage: 'Download in background',
|
||||
},
|
||||
})
|
||||
|
||||
const wizardShown = ref(false)
|
||||
const wasHiddenDuringInstall = ref(false)
|
||||
const creationReported = ref(false)
|
||||
|
||||
const cancelButton = computed(() => {
|
||||
if (ctx.installPhase.value === 'done') {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
label: ctx.formatMessage(
|
||||
ctx.installPhase.value === 'downloading'
|
||||
? messages.downloadInBackground
|
||||
: commonMessages.cancelButton,
|
||||
),
|
||||
disabled: ctx.installPhase.value === 'first-run',
|
||||
onClick: () => modal.value?.hide(),
|
||||
}
|
||||
})
|
||||
|
||||
watch(ctx.showEulaModal, (visible) => {
|
||||
if (visible) {
|
||||
// When the setup finished in the background, don't pop a EULA dialog over
|
||||
// whatever page the user is on; starting the server gates on it instead.
|
||||
if (wizardShown.value) eulaModal.value?.show()
|
||||
} else {
|
||||
eulaModal.value?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
// The download keeps running in the background even if the wizard is closed.
|
||||
// Report the finished server once the flow reaches a terminal success state.
|
||||
watch(
|
||||
() => ctx.installPhase.value,
|
||||
(phase) => {
|
||||
if (!wasHiddenDuringInstall.value || creationReported.value) return
|
||||
if ((phase === 'done' || phase === 'eula') && ctx.createdServer.value) {
|
||||
creationReported.value = true
|
||||
emit('created', ctx.createdServer.value.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function show(project: Labrinth.Projects.v2.Project, version: Labrinth.Versions.v2.Version) {
|
||||
wizardShown.value = true
|
||||
wasHiddenDuringInstall.value = false
|
||||
creationReported.value = false
|
||||
ctx.reset()
|
||||
ctx.setPack(project, version)
|
||||
modal.value?.setStage(0)
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
wizardShown.value = false
|
||||
if (
|
||||
ctx.createdServer.value &&
|
||||
(ctx.installPhase.value === 'done' || ctx.installPhase.value === 'eula')
|
||||
) {
|
||||
if (!creationReported.value) {
|
||||
creationReported.value = true
|
||||
emit('created', ctx.createdServer.value.id)
|
||||
}
|
||||
} else {
|
||||
wasHiddenDuringInstall.value = true
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, hide: () => modal.value?.hide() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MultiStageModal
|
||||
ref="modal"
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
breadcrumbs
|
||||
:back-button-enabled="
|
||||
(flowCtx) =>
|
||||
flowCtx.installPhase.value !== 'downloading' && flowCtx.installPhase.value !== 'first-run'
|
||||
"
|
||||
:cancel-button="cancelButton"
|
||||
@hide="handleHide"
|
||||
/>
|
||||
<EulaModal
|
||||
ref="eulaModal"
|
||||
:text="ctx.eulaText.value"
|
||||
@continue="ctx.acceptEula"
|
||||
@decline="ctx.declineEula"
|
||||
/>
|
||||
</template>
|
||||
@ -0,0 +1,537 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { RefreshCwIcon } from '@modrinth/assets'
|
||||
import { type ServerTypeId, setEulaAccepted } from '@modrinth/server'
|
||||
import {
|
||||
createContext,
|
||||
defineMessages,
|
||||
type MultiStageModal,
|
||||
type StageConfigInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, markRaw, type Ref, ref } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import { startModpackServerInstall } from '@/composables/useServerInstalls'
|
||||
import { refresh as refreshServerList } from '@/composables/useServers'
|
||||
import { find_filtered_jres, get_java_default_versions, get_max_memory } from '@/helpers/jre'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { serverEventListener, type ServerManifestData, servers } from '@/helpers/servers'
|
||||
import { injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
import type { CreateServerFlowContext, JavaSelection } from '../create-server-flow'
|
||||
import {
|
||||
javaMajorFromVersion,
|
||||
resolveServerLauncher,
|
||||
toErrorMessage,
|
||||
waitForServerStop,
|
||||
} from '../server-flow-utils'
|
||||
import ModpackInstallStage from './stages/ModpackInstallStage.vue'
|
||||
import ModpackSetupStage from './stages/ModpackSetupStage.vue'
|
||||
|
||||
export type ModpackInstallPhase =
|
||||
| 'idle'
|
||||
| 'preparing'
|
||||
| 'downloading'
|
||||
| 'first-run'
|
||||
| 'eula'
|
||||
| 'error'
|
||||
| 'done'
|
||||
|
||||
export interface ModpackServerOptions {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
version: Labrinth.Versions.v2.Version
|
||||
}
|
||||
|
||||
export interface ModpackServerFlowContext extends CreateServerFlowContext<ModpackServerFlowContext> {
|
||||
modpackTitle: Ref<string>
|
||||
modpackVersionNumber: Ref<string>
|
||||
modpackIconUrl: Ref<string | undefined>
|
||||
loaderLabel: Ref<string>
|
||||
loaderSupported: Ref<boolean>
|
||||
gameVersionLabel: Ref<string>
|
||||
setPack: (project: Labrinth.Projects.v2.Project, version: Labrinth.Versions.v2.Version) => void
|
||||
}
|
||||
|
||||
export const [injectModpackServerFlow, provideModpackServerFlow] =
|
||||
createContext<ModpackServerFlowContext>('ModpackServerFlow')
|
||||
|
||||
const MODPACK_SERVER_TYPES: Record<string, { type: ServerTypeId; label: string }> = {
|
||||
fabric: { type: 'fabric', label: 'Fabric' },
|
||||
quilt: { type: 'quilt', label: 'Quilt' },
|
||||
neoforge: { type: 'neoforge', label: 'NeoForge' },
|
||||
forge: { type: 'forge', label: 'Forge' },
|
||||
}
|
||||
|
||||
/** Loaders whose server launcher the app can download and boot directly. */
|
||||
const SUPPORTED_MODPACK_LOADERS: ServerTypeId[] = ['vanilla', 'fabric', 'quilt', 'forge']
|
||||
|
||||
export function resolveModpackLoader(loaders: string[]): { type: ServerTypeId; label: string } {
|
||||
for (const loader of loaders) {
|
||||
const entry = MODPACK_SERVER_TYPES[loader.toLowerCase()]
|
||||
if (entry) return entry
|
||||
}
|
||||
return { type: 'vanilla', label: 'Vanilla' }
|
||||
}
|
||||
|
||||
export function createModpackServerFlowContext(
|
||||
modal: Ref<ComponentExposed<typeof MultiStageModal> | null>,
|
||||
): ModpackServerFlowContext {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
// [SERVER-DOWNLOAD-BRIDGE] Capture the download manager once during Vue
|
||||
// setup context. Vue's inject() only works in the synchronous setup
|
||||
// scope — after any `await` the injection context is lost. We store
|
||||
// the reference here and pass it explicitly to `startModpackServerInstall`.
|
||||
let downloadManager: ReturnType<typeof injectDownloadManager> | null = null
|
||||
try {
|
||||
downloadManager = injectDownloadManager()
|
||||
} catch {
|
||||
// Not inside a provider tree — server downloads will not appear in sidebar.
|
||||
}
|
||||
|
||||
const wizardMessages = defineMessages({
|
||||
setupTitle: { id: 'app.servers.wizard.setup-title', defaultMessage: 'Setup' },
|
||||
installTitle: { id: 'app.servers.wizard.install-title', defaultMessage: 'Install' },
|
||||
configureTitle: { id: 'app.servers.wizard.configure-title', defaultMessage: 'Configure' },
|
||||
next: { id: 'app.servers.wizard.next', defaultMessage: 'Next' },
|
||||
retry: { id: 'app.servers.wizard.retry', defaultMessage: 'Retry' },
|
||||
finish: { id: 'app.servers.wizard.finish', defaultMessage: 'Finish' },
|
||||
javaTooOld: {
|
||||
id: 'app.servers.wizard.java-too-old',
|
||||
defaultMessage:
|
||||
'Java {selected} cannot run this game version; Java {required} or newer is required.',
|
||||
},
|
||||
firstRunCrashed: {
|
||||
id: 'app.servers.modpack.first-run-crashed',
|
||||
defaultMessage:
|
||||
'The server crashed during its first start. Check that your selected Java version is compatible, then try again.',
|
||||
},
|
||||
})
|
||||
|
||||
const project = ref<Labrinth.Projects.v2.Project | null>(null)
|
||||
const version = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
|
||||
const serverType = ref<ServerTypeId>('vanilla')
|
||||
const availableGameVersions = ref<string[]>([])
|
||||
const selectedGameVersion = ref('')
|
||||
const showSnapshots = ref(false)
|
||||
const loaderVersions = ref<{ id: string; stable: boolean }[]>([])
|
||||
const selectedLoaderVersion = ref('')
|
||||
const isVersionsLoading = ref(false)
|
||||
const versionsError = ref<string | null>(null)
|
||||
|
||||
const name = ref('')
|
||||
const selectedJava = ref<JavaSelection>({ path: '', version: '' })
|
||||
const memoryMb = ref(2048)
|
||||
const maxMemoryMb = ref(8192)
|
||||
|
||||
const installPhase = ref<ModpackInstallPhase>('idle')
|
||||
const downloadProgress = ref<{ downloaded: number; total: number | null } | null>(null)
|
||||
const installLog = ref<string[]>([])
|
||||
const installError = ref<string | null>(null)
|
||||
const eulaText = ref('')
|
||||
const createdServer = ref<ServerManifestData | null>(null)
|
||||
const showEulaModal = ref(false)
|
||||
const saveServerProperties = ref<(() => Promise<boolean>) | null>(null)
|
||||
let installSession = 0
|
||||
|
||||
const modpackTitle = ref('')
|
||||
const modpackVersionNumber = ref('')
|
||||
const modpackIconUrl = ref<string | undefined>(undefined)
|
||||
const loaderLabel = ref('')
|
||||
const loaderSupported = ref(false)
|
||||
const gameVersionLabel = ref('')
|
||||
|
||||
const needsLoaderVersion = computed(
|
||||
() => serverType.value === 'fabric' || serverType.value === 'quilt',
|
||||
)
|
||||
const typeSupported = computed(() => loaderSupported.value)
|
||||
const canContinueFromType = computed(() => loaderSupported.value)
|
||||
|
||||
function setPack(
|
||||
packProject: Labrinth.Projects.v2.Project,
|
||||
packVersion: Labrinth.Versions.v2.Version,
|
||||
) {
|
||||
project.value = packProject
|
||||
version.value = packVersion
|
||||
modpackTitle.value = packProject.title
|
||||
modpackVersionNumber.value = packVersion.version_number ?? ''
|
||||
modpackIconUrl.value = packProject.icon_url ?? undefined
|
||||
|
||||
const gameVersion = packVersion.game_versions?.[0] ?? packProject.game_versions?.[0] ?? ''
|
||||
// Merge both the project-level and version-level loader declarations.
|
||||
// Modpack versions frequently leave `version.loaders` empty (the project
|
||||
// field is the reliable source); the authoritative source is the mrpack's
|
||||
// `modrinth.index.json` dependencies, but that is only available after
|
||||
// download. See resolveModpackLoader's fallback note.
|
||||
const loaderCandidates = [...(packProject.loaders ?? []), ...(packVersion.loaders ?? [])]
|
||||
const loader = resolveModpackLoader(loaderCandidates)
|
||||
serverType.value = loader.type
|
||||
loaderLabel.value = loader.label
|
||||
gameVersionLabel.value = gameVersion
|
||||
selectedGameVersion.value = gameVersion
|
||||
availableGameVersions.value = gameVersion ? [gameVersion] : []
|
||||
loaderSupported.value = SUPPORTED_MODPACK_LOADERS.includes(loader.type)
|
||||
|
||||
// Default the server name to `<modpack title> <version number>` so different
|
||||
// versions of the same modpack produce distinct server names instead of
|
||||
// colliding. A short uid is appended only if a name collision remains
|
||||
// (see beginInstall), mirroring the direct-server id style.
|
||||
name.value = `${packProject.title} ${packVersion.version_number ?? ''}`.trim()
|
||||
selectedLoaderVersion.value = ''
|
||||
loaderVersions.value = []
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
// The modpack fixes the game version; nothing to load.
|
||||
}
|
||||
|
||||
async function loadLoaderVersions() {
|
||||
selectedLoaderVersion.value = ''
|
||||
loaderVersions.value = []
|
||||
if (!needsLoaderVersion.value || !selectedGameVersion.value) return
|
||||
try {
|
||||
const manifest = (await get_loader_versions(serverType.value, selectedGameVersion.value)) as {
|
||||
gameVersions: Array<{ id: string; loaders: { id: string; stable: boolean }[] }>
|
||||
}
|
||||
const entry = manifest.gameVersions.find((game) => game.id === selectedGameVersion.value)
|
||||
loaderVersions.value = entry?.loaders ?? []
|
||||
const stable = loaderVersions.value.find((option) => option.stable) ?? loaderVersions.value[0]
|
||||
selectedLoaderVersion.value = stable?.id ?? ''
|
||||
} catch {
|
||||
loaderVersions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDefaultJava() {
|
||||
if (selectedJava.value.path !== '') return
|
||||
const major = javaMajorFromVersion(selectedGameVersion.value || '1.21') ?? 21
|
||||
try {
|
||||
const defaults = (await get_java_default_versions()) as Array<{
|
||||
parsed_version: number
|
||||
version: string
|
||||
path: string
|
||||
}>
|
||||
const match =
|
||||
defaults.find((entry) => entry.parsed_version === major) ??
|
||||
defaults.find((entry) => entry.parsed_version >= major)
|
||||
if (match) {
|
||||
selectedJava.value = { path: match.path, version: match.version }
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to a filtered scan
|
||||
}
|
||||
try {
|
||||
const javas = (await find_filtered_jres(major)) as JavaSelection[]
|
||||
if (javas.length > 0) selectedJava.value = javas[0]
|
||||
} catch {
|
||||
// Leave empty; the user picks manually in the setup stage
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMaxMemory() {
|
||||
try {
|
||||
const maxKiB = (await get_max_memory()) as number
|
||||
maxMemoryMb.value = Math.max(1024, Math.floor(maxKiB / 1024))
|
||||
} catch {
|
||||
maxMemoryMb.value = 8192
|
||||
}
|
||||
}
|
||||
|
||||
async function beginInstall() {
|
||||
if (installPhase.value === 'downloading' || installPhase.value === 'first-run') return
|
||||
if (!project.value || !version.value) return
|
||||
if (!loaderSupported.value) return
|
||||
|
||||
// A closed wizard leaves its install promise running in the background.
|
||||
// Reopening the wizard starts a fresh session; stale sessions must stop
|
||||
// touching the shared state once their token is superseded.
|
||||
const session = ++installSession
|
||||
const isStale = () => installSession !== session
|
||||
|
||||
installPhase.value = 'preparing'
|
||||
installError.value = null
|
||||
installLog.value = []
|
||||
downloadProgress.value = null
|
||||
try {
|
||||
await loadLoaderVersions()
|
||||
if (isStale()) return
|
||||
|
||||
const requiredJava = javaMajorFromVersion(selectedGameVersion.value) ?? 21
|
||||
const selectedMajor = javaMajorFromVersion(selectedJava.value.version)
|
||||
if (
|
||||
selectedJava.value.path !== '' &&
|
||||
selectedMajor !== null &&
|
||||
selectedMajor < requiredJava
|
||||
) {
|
||||
throw new Error(
|
||||
formatMessage(wizardMessages.javaTooOld, {
|
||||
selected: selectedMajor,
|
||||
required: requiredJava,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (!createdServer.value) {
|
||||
// Ensure the chosen name is unique among existing servers. When it
|
||||
// collides we append a short uid (mirroring the direct-server id
|
||||
// style) so duplicate modpack versions stay distinguishable; if the
|
||||
// name is free, no suffix is added.
|
||||
let finalName = name.value.trim()
|
||||
try {
|
||||
const existing = await servers.list()
|
||||
const taken = new Set(existing.map((server) => server.name.trim().toLowerCase()))
|
||||
if (taken.has(finalName.toLowerCase())) {
|
||||
const uid = Math.random().toString(36).slice(2, 6)
|
||||
finalName = `${finalName} ${uid}`
|
||||
}
|
||||
} catch {
|
||||
// Best-effort uniqueness; the backend id already disambiguates.
|
||||
}
|
||||
|
||||
const manifest = await servers.create({
|
||||
name: finalName,
|
||||
serverType: serverType.value,
|
||||
gameVersion: selectedGameVersion.value,
|
||||
loaderVersion: needsLoaderVersion.value ? selectedLoaderVersion.value : undefined,
|
||||
javaPath: selectedJava.value.path || undefined,
|
||||
memoryMb: memoryMb.value,
|
||||
})
|
||||
if (isStale()) {
|
||||
await servers.delete(manifest.id).catch(() => {})
|
||||
void refreshServerList()
|
||||
return
|
||||
}
|
||||
createdServer.value = manifest
|
||||
}
|
||||
const serverId = createdServer.value.id
|
||||
|
||||
// Past this point the server directory exists and the backend tracks
|
||||
// install state on its manifest, so failures leave a retryable entry
|
||||
// instead of being cleaned up. Only pre-install resolution errors
|
||||
// (no launcher, no pack file) still remove the stub.
|
||||
let dispatched = false
|
||||
const unlistenEvents = await serverEventListener((id, payload) => {
|
||||
if (id !== serverId || isStale()) return
|
||||
if (payload.event === 'download_progress') {
|
||||
downloadProgress.value = {
|
||||
downloaded: payload.downloaded,
|
||||
total: payload.total ?? null,
|
||||
}
|
||||
} else if (payload.event === 'log') {
|
||||
installLog.value.push(payload.line)
|
||||
if (installLog.value.length > 500) {
|
||||
installLog.value.splice(0, installLog.value.length - 500)
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
const jar = await resolveServerLauncher(
|
||||
serverType.value,
|
||||
selectedGameVersion.value,
|
||||
selectedLoaderVersion.value,
|
||||
)
|
||||
if (!jar) {
|
||||
throw new Error(
|
||||
`No server launcher available for ${loaderLabel.value} on ${selectedGameVersion.value}`,
|
||||
)
|
||||
}
|
||||
|
||||
const primaryFile =
|
||||
version.value.files.find((file) => file.primary) ?? version.value.files[0]
|
||||
if (!primaryFile?.url) {
|
||||
throw new Error('Modpack has no downloadable file')
|
||||
}
|
||||
|
||||
// The download runs through the shared background runner, so closing
|
||||
// the wizard keeps it going; progress renders from the shared registry.
|
||||
dispatched = true
|
||||
installPhase.value = 'downloading'
|
||||
// [SERVER-DOWNLOAD-BRIDGE] Pass the download manager reference
|
||||
// captured during setup so the synthetic job appears in sidebar.
|
||||
await startModpackServerInstall(
|
||||
serverId,
|
||||
{
|
||||
mrpackUrl: primaryFile.url,
|
||||
mrpackSha1: primaryFile.hashes?.sha1,
|
||||
jarUrl: jar.url,
|
||||
jarFilename: jar.filename,
|
||||
jarSha1: jar.sha1,
|
||||
modpackProjectId: project.value.id,
|
||||
modpackVersionId: version.value.id,
|
||||
modpackTitle: `${modpackTitle.value} ${modpackVersionNumber.value}`.trim(),
|
||||
modpackIconUrl: modpackIconUrl.value,
|
||||
},
|
||||
downloadManager,
|
||||
)
|
||||
if (isStale()) return
|
||||
|
||||
// Modpack installation complete, no auto-start.
|
||||
// User will click "Start" later, which will handle EULA check via tryStartServer.
|
||||
// A code-created `eula.txt` (eula=false) is written so the manual start
|
||||
// gate can offer the EULA without booting the jar.
|
||||
const eula = setEulaAccepted('', false)
|
||||
await servers.writeFile(serverId, 'eula.txt', eula).catch(() => {})
|
||||
installPhase.value = 'done'
|
||||
} catch (error) {
|
||||
if (!dispatched && createdServer.value) {
|
||||
const failed = createdServer.value
|
||||
createdServer.value = null
|
||||
await servers.delete(failed.id).catch(() => {})
|
||||
void refreshServerList()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
unlistenEvents()
|
||||
}
|
||||
} catch (error) {
|
||||
installPhase.value = 'error'
|
||||
installError.value = toErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
function retryInstall(): Promise<void> {
|
||||
installPhase.value = 'idle'
|
||||
return beginInstall()
|
||||
}
|
||||
|
||||
async function acceptEula() {
|
||||
if (!createdServer.value) return
|
||||
try {
|
||||
const updated = setEulaAccepted(eulaText.value, true)
|
||||
await servers.writeFile(createdServer.value.id, 'eula.txt', updated)
|
||||
showEulaModal.value = false
|
||||
installPhase.value = 'done'
|
||||
// Start the server after accepting EULA
|
||||
await servers.start(createdServer.value.id)
|
||||
// Wait for server to stop (crash or normal)
|
||||
const stopped = await waitForServerStop(createdServer.value.id)
|
||||
if (stopped?.event === 'stopped' && stopped.crashed) {
|
||||
throw new Error(formatMessage(wizardMessages.firstRunCrashed))
|
||||
}
|
||||
installPhase.value = 'done'
|
||||
} catch (error) {
|
||||
installError.value = toErrorMessage(error)
|
||||
installPhase.value = 'error'
|
||||
showEulaModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function declineEula() {
|
||||
showEulaModal.value = false
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
installSession++
|
||||
installPhase.value = 'idle'
|
||||
installLog.value = []
|
||||
installError.value = null
|
||||
eulaText.value = ''
|
||||
createdServer.value = null
|
||||
showEulaModal.value = false
|
||||
saveServerProperties.value = null
|
||||
selectedJava.value = { path: '', version: '' }
|
||||
memoryMb.value = 2048
|
||||
void loadMaxMemory()
|
||||
}
|
||||
|
||||
const stageConfigs: StageConfigInput<ModpackServerFlowContext>[] = [
|
||||
{
|
||||
id: 'setup',
|
||||
stageContent: markRaw(ModpackSetupStage),
|
||||
title: (ctx: ModpackServerFlowContext) => ctx.formatMessage(wizardMessages.setupTitle),
|
||||
cannotNavigateForward: (ctx: ModpackServerFlowContext) =>
|
||||
ctx.name.value.trim() === '' || !ctx.canContinueFromType.value,
|
||||
leftButtonConfig: () => null,
|
||||
rightButtonConfig: (ctx: ModpackServerFlowContext) => ({
|
||||
label: ctx.formatMessage(wizardMessages.next),
|
||||
color: 'brand',
|
||||
disabled: ctx.name.value.trim() === '' || !ctx.canContinueFromType.value,
|
||||
onClick: async () => {
|
||||
await ctx.loadDefaultJava()
|
||||
ctx.modal.value?.nextStage()
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
stageContent: markRaw(ModpackInstallStage),
|
||||
title: (ctx: ModpackServerFlowContext) => ctx.formatMessage(wizardMessages.installTitle),
|
||||
cannotNavigateForward: (ctx: ModpackServerFlowContext) => ctx.installPhase.value !== 'done',
|
||||
// Downloads continue in the background once the wizard closes; only
|
||||
// the first-run boot locks closing.
|
||||
disableClose: (ctx: ModpackServerFlowContext) => ctx.installPhase.value === 'first-run',
|
||||
leftButtonConfig: () => null,
|
||||
rightButtonConfig: (ctx: ModpackServerFlowContext) => ({
|
||||
label: ctx.formatMessage(
|
||||
ctx.installPhase.value === 'error'
|
||||
? wizardMessages.retry
|
||||
: ctx.installPhase.value === 'done'
|
||||
? wizardMessages.finish
|
||||
: wizardMessages.next,
|
||||
),
|
||||
color: 'brand',
|
||||
icon: ctx.installPhase.value === 'error' ? RefreshCwIcon : null,
|
||||
iconPosition: 'after',
|
||||
disabled: ctx.installPhase.value !== 'done' && ctx.installPhase.value !== 'error',
|
||||
onClick: () => {
|
||||
if (ctx.installPhase.value === 'error') {
|
||||
void ctx.retryInstall()
|
||||
return
|
||||
}
|
||||
if (ctx.installPhase.value === 'done') {
|
||||
ctx.modal.value?.hide()
|
||||
return
|
||||
}
|
||||
ctx.modal.value?.nextStage()
|
||||
},
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
modal,
|
||||
stageConfigs,
|
||||
formatMessage,
|
||||
serverType,
|
||||
availableGameVersions,
|
||||
selectedGameVersion,
|
||||
showSnapshots,
|
||||
loaderVersions,
|
||||
selectedLoaderVersion,
|
||||
isVersionsLoading,
|
||||
versionsError,
|
||||
name,
|
||||
selectedJava,
|
||||
memoryMb,
|
||||
maxMemoryMb,
|
||||
installPhase,
|
||||
downloadProgress,
|
||||
installLog,
|
||||
installError,
|
||||
eulaText,
|
||||
createdServer,
|
||||
showEulaModal,
|
||||
saveServerProperties,
|
||||
needsLoaderVersion,
|
||||
typeSupported,
|
||||
canContinueFromType,
|
||||
modpackTitle,
|
||||
modpackVersionNumber,
|
||||
modpackIconUrl,
|
||||
loaderLabel,
|
||||
loaderSupported,
|
||||
gameVersionLabel,
|
||||
loadVersions,
|
||||
loadLoaderVersions,
|
||||
loadDefaultJava,
|
||||
beginInstall,
|
||||
retryInstall,
|
||||
acceptEula,
|
||||
declineEula,
|
||||
reset,
|
||||
setPack,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { Admonition, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { injectCreateServerFlow } from '../../create-server-flow'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectCreateServerFlow()
|
||||
|
||||
const messages = defineMessages({
|
||||
downloading: {
|
||||
id: 'app.servers.modpack.downloading',
|
||||
defaultMessage: 'Downloading modpack files...',
|
||||
},
|
||||
preparing: {
|
||||
id: 'app.servers.modpack.preparing',
|
||||
defaultMessage: 'Preparing server...',
|
||||
},
|
||||
done: { id: 'app.servers.modpack.done', defaultMessage: 'Installation complete' },
|
||||
failed: { id: 'app.servers.wizard.failed', defaultMessage: 'Setup failed' },
|
||||
installLog: { id: 'app.servers.wizard.log', defaultMessage: 'Output' },
|
||||
currentFile: {
|
||||
id: 'app.servers.modpack.current-file',
|
||||
defaultMessage: 'Now installing {file}',
|
||||
},
|
||||
backgroundHint: {
|
||||
id: 'app.servers.modpack.background-hint',
|
||||
defaultMessage: 'You can close this window — the download continues in the background.',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (ctx.installPhase.value === 'idle' || ctx.installPhase.value === 'error') {
|
||||
void ctx.beginInstall()
|
||||
}
|
||||
})
|
||||
|
||||
const phaseText = computed(() => {
|
||||
switch (ctx.installPhase.value) {
|
||||
case 'preparing':
|
||||
return formatMessage(messages.preparing)
|
||||
case 'done':
|
||||
return formatMessage(messages.done)
|
||||
case 'error':
|
||||
return formatMessage(messages.failed)
|
||||
default:
|
||||
return formatMessage(messages.downloading)
|
||||
}
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const progress = ctx.downloadProgress.value
|
||||
if (!progress || !progress.total) return 0
|
||||
return Math.min(100, (progress.downloaded / progress.total) * 100)
|
||||
})
|
||||
|
||||
const currentFile = computed(() => {
|
||||
const match = [...ctx.installLog.value]
|
||||
.map((line) => /^Downloading (.+)$/.exec(line)?.[1])
|
||||
.filter(Boolean)
|
||||
.at(-1)
|
||||
return match ?? null
|
||||
})
|
||||
|
||||
const isBusy = computed(
|
||||
() => ctx.installPhase.value === 'preparing' || ctx.installPhase.value === 'downloading',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<SpinnerIcon v-if="isBusy" class="size-6 shrink-0 animate-spin text-orange" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="ctx.installPhase.value === 'done'"
|
||||
class="size-6 shrink-0 text-green"
|
||||
/>
|
||||
<span class="text-lg font-semibold text-contrast">{{ phaseText }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
v-if="ctx.installPhase.value === 'downloading'"
|
||||
full-width
|
||||
:progress="progressPercent"
|
||||
:max="100"
|
||||
:waiting="progressPercent === 0"
|
||||
:label="formatMessage(messages.downloading)"
|
||||
show-progress
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="currentFile && ctx.installPhase.value === 'downloading'"
|
||||
class="m-0 -mt-2 truncate text-xs font-medium text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.currentFile, { file: currentFile }) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="ctx.installPhase.value === 'downloading'"
|
||||
class="m-0 text-xs font-medium text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.backgroundHint) }}
|
||||
</p>
|
||||
|
||||
<Admonition
|
||||
v-if="ctx.installPhase.value === 'error'"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.failed)"
|
||||
>
|
||||
{{ ctx.installError.value }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="ctx.installPhase.value === 'error'" class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.installLog) }}
|
||||
</span>
|
||||
<pre
|
||||
class="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-xl border border-solid border-surface-4 bg-surface-3 p-3 font-mono text-xs leading-relaxed text-primary"
|
||||
>{{ ctx.installLog.value.slice(-40).join('\n') }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { ServerIcon } from '@modrinth/assets'
|
||||
import { requiredJavaMajorVersion } from '@modrinth/server'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
defineMessages,
|
||||
Slider,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
|
||||
import { injectModpackServerFlow } from '../create-modpack-server-flow'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectModpackServerFlow()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: { id: 'app.servers.wizard.name', defaultMessage: 'Server name' },
|
||||
java: { id: 'app.servers.settings.java', defaultMessage: 'Java' },
|
||||
memory: { id: 'app.servers.settings.memory', defaultMessage: 'Memory' },
|
||||
memoryValue: { id: 'app.servers.wizard.memory-value', defaultMessage: '{value} MB' },
|
||||
unsupportedLoaderTitle: {
|
||||
id: 'app.servers.modpack.unsupported-loader-title',
|
||||
defaultMessage: '{loader} servers are not supported yet',
|
||||
},
|
||||
unsupportedLoaderDescription: {
|
||||
id: 'app.servers.modpack.unsupported-loader-description',
|
||||
defaultMessage:
|
||||
'This modpack uses {loader}, but Axolotl can only start modpack servers with vanilla, Fabric, or Quilt. Support for {loader} is coming soon.',
|
||||
},
|
||||
})
|
||||
|
||||
const requiredJava = computed(() =>
|
||||
requiredJavaMajorVersion(ctx.selectedGameVersion.value || '1.21'),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void ctx.loadDefaultJava()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="ctx.modpackIconUrl.value"
|
||||
:alt="ctx.modpackTitle.value"
|
||||
size="56px"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="m-0 truncate text-base font-bold leading-tight text-contrast">
|
||||
{{ ctx.modpackTitle.value }}
|
||||
</p>
|
||||
<p class="m-0 mt-0.5 truncate text-sm font-medium text-secondary">
|
||||
{{ ctx.modpackVersionNumber.value }}
|
||||
</p>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
<TagItem>
|
||||
<span class="font-semibold">{{ ctx.loaderLabel.value }}</span>
|
||||
</TagItem>
|
||||
<TagItem v-if="ctx.gameVersionLabel.value">
|
||||
<span class="font-semibold">{{ ctx.gameVersionLabel.value }}</span>
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Admonition
|
||||
v-if="!ctx.loaderSupported.value"
|
||||
type="critical"
|
||||
:header="
|
||||
formatMessage(messages.unsupportedLoaderTitle, {
|
||||
loader: ctx.loaderLabel.value,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.unsupportedLoaderDescription, {
|
||||
loader: ctx.loaderLabel.value,
|
||||
})
|
||||
}}
|
||||
</Admonition>
|
||||
|
||||
<label class="flex min-w-0 flex-col gap-2" for="modpack-server-name">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
||||
<StyledInput
|
||||
id="modpack-server-name"
|
||||
v-model="ctx.name.value"
|
||||
:icon="ServerIcon"
|
||||
:placeholder="ctx.modpackTitle.value"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.java) }}</span>
|
||||
<JavaSelector
|
||||
id="modpack-java-selector"
|
||||
v-model="ctx.selectedJava.value"
|
||||
:version="requiredJava"
|
||||
select-all-versions
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.memory) }}</span>
|
||||
<span
|
||||
class="rounded-md border border-solid border-surface-5 bg-surface-3 px-2 py-1 text-xs font-semibold leading-none text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.memoryValue, { value: ctx.memoryMb.value }) }}
|
||||
</span>
|
||||
</div>
|
||||
<Slider v-model="ctx.memoryMb.value" :min="1024" :max="ctx.maxMemoryMb.value" :step="512" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,148 @@
|
||||
import {
|
||||
type FabricInstallerVersionsResponse,
|
||||
fabricInstallerVersionsUrl,
|
||||
FORGE_MAVEN_URL,
|
||||
forgePromotionsSlimUrl,
|
||||
latestStablePaperBuild,
|
||||
type PaperBuildsResponse,
|
||||
paperBuildsUrl,
|
||||
quiltInstallerVersionsUrl,
|
||||
resolveServerJar,
|
||||
type ServerJarDownload,
|
||||
type ServerTypeId,
|
||||
type VanillaVersionInfo,
|
||||
} from '@modrinth/server'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { type as osType } from '@tauri-apps/plugin-os'
|
||||
|
||||
import { get_game_versions } from '@/helpers/metadata'
|
||||
import { serverEventListener, type ServerEventPayload } from '@/helpers/servers'
|
||||
|
||||
/** Best-effort conversion of an unknown error into a user-presentable string. */
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object') {
|
||||
const record = error as Record<string, unknown>
|
||||
for (const key of ['message', 'error', 'description'] as const) {
|
||||
if (typeof record[key] === 'string') return record[key]
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error)
|
||||
} catch {
|
||||
return String(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Extracts the Java major version from strings like `17`, `1.8`, or `21.0.1`. */
|
||||
export function javaMajorFromVersion(version: string): number | null {
|
||||
const parts = version
|
||||
.split(/[._]/)
|
||||
.map(Number)
|
||||
.filter((value) => Number.isInteger(value) && value >= 0)
|
||||
if (parts.length === 0) return null
|
||||
if (parts[0] === 1 && parts.length > 1) return parts[1]
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the server to emit a `stopped` event, resolving with the payload
|
||||
* or `null` after a timeout. Used to run the first start during setup and know
|
||||
* when the JVM has exited.
|
||||
*/
|
||||
export async function waitForServerStop(serverId: string): Promise<ServerEventPayload | null> {
|
||||
return new Promise((resolve) => {
|
||||
void serverEventListener((eventServerId, payload) => {
|
||||
if (eventServerId !== serverId || payload.event !== 'stopped') return
|
||||
resolve(payload)
|
||||
}).then((unlisten) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
unlisten()
|
||||
resolve(null)
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let userAgentPromise: Promise<string> | null = null
|
||||
|
||||
/**
|
||||
* Identifying User-Agent, required by services like the PaperMC downloads API.
|
||||
* Mirrors the format used by the Rust backend.
|
||||
*/
|
||||
function launcherUserAgent(): Promise<string> {
|
||||
userAgentPromise ??= Promise.all([getVersion(), osType()]).then(
|
||||
([version, platform]) =>
|
||||
`garbage-human-studio/axolotl/${version} (${platform}; +https://www.ghs.red)`,
|
||||
)
|
||||
userAgentPromise = userAgentPromise.catch(
|
||||
() => 'garbage-human-studio/axolotl (+https://www.ghs.red)',
|
||||
)
|
||||
return userAgentPromise
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await tauriFetch(url, {
|
||||
headers: { 'User-Agent': await launcherUserAgent() },
|
||||
})
|
||||
if (!response.ok) throw new Error('GET ' + url + ' failed: ' + response.status)
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the server launcher jar download for a modpack server. Vanilla
|
||||
* pulls the Mojang server jar; Fabric and Quilt use their meta service launcher
|
||||
* jars with the newest stable installer.
|
||||
*/
|
||||
export async function resolveServerLauncher(
|
||||
type: ServerTypeId,
|
||||
gameVersion: string,
|
||||
loaderVersion?: string,
|
||||
): Promise<ServerJarDownload | null> {
|
||||
switch (type) {
|
||||
case 'vanilla': {
|
||||
const manifest = (await get_game_versions()) as {
|
||||
versions: { id: string; url: string }[]
|
||||
}
|
||||
const entry = manifest.versions.find((v) => v.id === gameVersion)
|
||||
if (!entry) return null
|
||||
const versionInfo = await fetchJson<VanillaVersionInfo>(entry.url)
|
||||
return resolveServerJar('vanilla', { gameVersion, vanillaVersionInfo: versionInfo })
|
||||
}
|
||||
case 'fabric':
|
||||
case 'quilt': {
|
||||
const installers = await fetchJson<FabricInstallerVersionsResponse[]>(
|
||||
type === 'fabric' ? fabricInstallerVersionsUrl() : quiltInstallerVersionsUrl(),
|
||||
)
|
||||
const installerVersion = installers[0]?.version
|
||||
return resolveServerJar(type, { gameVersion, loaderVersion, installerVersion })
|
||||
}
|
||||
case 'paper': {
|
||||
const builds = await fetchJson<PaperBuildsResponse>(paperBuildsUrl(gameVersion))
|
||||
const build = latestStablePaperBuild(builds)
|
||||
if (!build) return null
|
||||
return resolveServerJar(type, { gameVersion, paperBuild: build })
|
||||
}
|
||||
case 'forge': {
|
||||
// The Forge "launcher" is the installer jar; the backend runs it
|
||||
// headlessly (`--installServer`) to materialize the server files.
|
||||
const promos = await fetchJson<{ promos: Record<string, string> }>(forgePromotionsSlimUrl())
|
||||
const build =
|
||||
promos.promos[`${gameVersion}-recommended`] ?? promos.promos[`${gameVersion}-latest`]
|
||||
if (!build) return null
|
||||
const filename = `forge-${gameVersion}-${build}-installer.jar`
|
||||
return {
|
||||
url: `${FORGE_MAVEN_URL}/${gameVersion}-${build}/${filename}`,
|
||||
filename,
|
||||
sha1: undefined,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import type { ServerStatus } from '@modrinth/server'
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
export const serverStatusMessages = defineMessages({
|
||||
created: { id: 'app.servers.status.created', defaultMessage: 'Not set up' },
|
||||
eulaPending: { id: 'app.servers.status.eula-pending', defaultMessage: 'EULA pending' },
|
||||
ready: { id: 'app.servers.status.ready', defaultMessage: 'Ready' },
|
||||
starting: { id: 'app.servers.status.starting', defaultMessage: 'Starting' },
|
||||
running: { id: 'app.servers.status.running', defaultMessage: 'Running' },
|
||||
crashed: { id: 'app.servers.status.crashed', defaultMessage: 'Crashed' },
|
||||
})
|
||||
|
||||
export interface ServerStatusMeta {
|
||||
label: (typeof serverStatusMessages)[keyof typeof serverStatusMessages]
|
||||
color: string
|
||||
}
|
||||
|
||||
export const SERVER_STATUS_META: Record<ServerStatus, ServerStatusMeta> = {
|
||||
created: { label: serverStatusMessages.created, color: 'text-secondary' },
|
||||
eula_pending: { label: serverStatusMessages.eulaPending, color: 'text-orange' },
|
||||
ready: { label: serverStatusMessages.ready, color: 'text-brand' },
|
||||
starting: { label: serverStatusMessages.starting, color: 'text-orange' },
|
||||
running: { label: serverStatusMessages.running, color: 'text-green' },
|
||||
crashed: { label: serverStatusMessages.crashed, color: 'text-red' },
|
||||
}
|
||||
|
||||
/** Idle/closed states that should not render a status tag. */
|
||||
export function isServerStatusVisible(status: ServerStatus): boolean {
|
||||
return status !== 'created' && status !== 'ready'
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import type { ServerTypeId } from '@modrinth/server'
|
||||
|
||||
/** Color, monogram and icon used to badge a server type across cards and the wizard. */
|
||||
export interface ServerTypeMeta {
|
||||
colorVar: string
|
||||
monogram: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const typeIcon = (name: string) =>
|
||||
new URL(`../../../assets/instance-icons/${name}`, import.meta.url).href
|
||||
|
||||
const PLATFORM_ID = (id: ServerTypeId) => `var(--color-platform-${id})`
|
||||
|
||||
export const SERVER_TYPE_META: Record<ServerTypeId, ServerTypeMeta> = {
|
||||
vanilla: { colorVar: 'var(--color-brand)', monogram: 'V', icon: typeIcon('Mojang.svg') },
|
||||
fabric: { colorVar: PLATFORM_ID('fabric'), monogram: 'F', icon: typeIcon('Fabric.png') },
|
||||
paper: { colorVar: PLATFORM_ID('paper'), monogram: 'P', icon: typeIcon('Paper.svg') },
|
||||
forge: { colorVar: PLATFORM_ID('forge'), monogram: 'Fo', icon: typeIcon('Forge.jpeg') },
|
||||
neoforge: { colorVar: PLATFORM_ID('neoforge'), monogram: 'N' },
|
||||
quilt: { colorVar: PLATFORM_ID('quilt'), monogram: 'Q' },
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import ServerPropertiesEditor from '@/components/multiplayer/servers/ServerPropertiesEditor.vue'
|
||||
|
||||
import { injectCreateServerFlow } from '../create-server-flow'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectCreateServerFlow()
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: {
|
||||
id: 'app.servers.wizard.configure-heading',
|
||||
defaultMessage: 'Adjust the server settings, or finish to edit them later.',
|
||||
},
|
||||
})
|
||||
|
||||
const editor = useTemplateRef<ComponentExposed<typeof ServerPropertiesEditor>>('editor')
|
||||
|
||||
onMounted(() => {
|
||||
ctx.saveServerProperties.value = () => editor.value?.save() ?? Promise.resolve(true)
|
||||
})
|
||||
|
||||
const serverId = computed(() => ctx.createdServer.value?.id ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.heading) }}
|
||||
</p>
|
||||
|
||||
<div class="max-h-[32rem] overflow-y-auto pr-2">
|
||||
<ServerPropertiesEditor v-if="serverId !== ''" ref="editor" :server-id="serverId" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { Admonition, defineMessages, ProgressBar, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { injectCreateServerFlow } from '../create-server-flow'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectCreateServerFlow()
|
||||
|
||||
const messages = defineMessages({
|
||||
downloading: {
|
||||
id: 'app.servers.wizard.downloading',
|
||||
defaultMessage: 'Downloading server files...',
|
||||
},
|
||||
firstRun: { id: 'app.servers.wizard.first-run', defaultMessage: 'Running first start...' },
|
||||
eulaWait: {
|
||||
id: 'app.servers.wizard.eula-wait',
|
||||
defaultMessage: 'Waiting for EULA confirmation',
|
||||
},
|
||||
done: { id: 'app.servers.wizard.done', defaultMessage: 'Server ready' },
|
||||
failed: { id: 'app.servers.wizard.failed', defaultMessage: 'Setup failed' },
|
||||
installLog: { id: 'app.servers.wizard.log', defaultMessage: 'Output' },
|
||||
backgroundHint: {
|
||||
id: 'app.servers.wizard.background-hint',
|
||||
defaultMessage: 'You can close this window — the download continues in the background.',
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (ctx.installPhase.value === 'idle' || ctx.installPhase.value === 'error') {
|
||||
void ctx.beginInstall()
|
||||
}
|
||||
})
|
||||
|
||||
const phaseText = computed(() => {
|
||||
switch (ctx.installPhase.value) {
|
||||
case 'first-run':
|
||||
return formatMessage(messages.firstRun)
|
||||
case 'eula':
|
||||
return formatMessage(messages.eulaWait)
|
||||
case 'done':
|
||||
return formatMessage(messages.done)
|
||||
case 'error':
|
||||
return formatMessage(messages.failed)
|
||||
default:
|
||||
return formatMessage(messages.downloading)
|
||||
}
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const progress = ctx.downloadProgress.value
|
||||
if (!progress || !progress.total) return 0
|
||||
return Math.min(100, (progress.downloaded / progress.total) * 100)
|
||||
})
|
||||
|
||||
const isBusy = computed(
|
||||
() =>
|
||||
ctx.installPhase.value === 'preparing' ||
|
||||
ctx.installPhase.value === 'downloading' ||
|
||||
ctx.installPhase.value === 'first-run',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex items-center gap-3">
|
||||
<SpinnerIcon v-if="isBusy" class="size-6 shrink-0 animate-spin text-orange" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="ctx.installPhase.value === 'done'"
|
||||
class="size-6 shrink-0 text-green"
|
||||
/>
|
||||
<span class="text-lg font-semibold text-contrast">{{ phaseText }}</span>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
v-if="ctx.installPhase.value === 'downloading'"
|
||||
full-width
|
||||
:progress="progressPercent"
|
||||
:max="100"
|
||||
:waiting="progressPercent === 0"
|
||||
:label="formatMessage(messages.downloading)"
|
||||
show-progress
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="ctx.installPhase.value === 'downloading'"
|
||||
class="m-0 text-xs font-medium text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.backgroundHint) }}
|
||||
</p>
|
||||
|
||||
<Admonition
|
||||
v-if="ctx.installPhase.value === 'error'"
|
||||
type="critical"
|
||||
:header="formatMessage(messages.failed)"
|
||||
>
|
||||
{{ ctx.installError.value }}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="ctx.installPhase.value === 'error'" class="flex flex-col gap-2">
|
||||
<span class="text-sm font-semibold text-secondary">
|
||||
{{ formatMessage(messages.installLog) }}
|
||||
</span>
|
||||
<pre
|
||||
class="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-xl border border-solid border-surface-4 bg-surface-3 p-3 font-mono text-xs leading-relaxed text-primary"
|
||||
>{{ ctx.installLog.value.slice(-40).join('\n') }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ServerIcon } from '@modrinth/assets'
|
||||
import { requiredJavaMajorVersion } from '@modrinth/server'
|
||||
import { defineMessages, Slider, StyledInput, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
|
||||
import { injectCreateServerFlow } from '../create-server-flow'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectCreateServerFlow()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: { id: 'app.servers.wizard.name', defaultMessage: 'Server name' },
|
||||
namePlaceholder: {
|
||||
id: 'app.servers.wizard.name-placeholder',
|
||||
defaultMessage: 'Survival server',
|
||||
},
|
||||
java: { id: 'app.servers.settings.java', defaultMessage: 'Java' },
|
||||
memory: { id: 'app.servers.settings.memory', defaultMessage: 'Memory' },
|
||||
memoryValue: { id: 'app.servers.wizard.memory-value', defaultMessage: '{value} MB' },
|
||||
})
|
||||
|
||||
const requiredJava = computed(() =>
|
||||
requiredJavaMajorVersion(ctx.selectedGameVersion.value || '1.21'),
|
||||
)
|
||||
|
||||
function suggestName() {
|
||||
const type = ctx.serverType.value
|
||||
const version = ctx.selectedGameVersion.value
|
||||
const flag = Math.random().toString(16).slice(2, 6)
|
||||
const segments = [type, version]
|
||||
if (ctx.selectedLoaderVersion.value) segments.push(ctx.selectedLoaderVersion.value)
|
||||
segments.push(flag)
|
||||
ctx.name.value = segments.filter(Boolean).join('-')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void ctx.loadDefaultJava()
|
||||
if (!ctx.name.value.trim() && ctx.selectedGameVersion.value) {
|
||||
suggestName()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<label class="flex min-w-0 flex-col gap-2" for="wizard-server-name">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.name) }}</span>
|
||||
<StyledInput
|
||||
id="wizard-server-name"
|
||||
v-model="ctx.name.value"
|
||||
:icon="ServerIcon"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.java) }}</span>
|
||||
<JavaSelector
|
||||
id="wizard-java-selector"
|
||||
v-model="ctx.selectedJava.value"
|
||||
:version="requiredJava"
|
||||
select-all-versions
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.memory) }}</span>
|
||||
<span
|
||||
class="rounded-md border border-solid border-surface-5 bg-surface-3 px-2 py-1 text-xs font-semibold leading-none text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.memoryValue, { value: ctx.memoryMb.value }) }}
|
||||
</span>
|
||||
</div>
|
||||
<Slider v-model="ctx.memoryMb.value" :min="1024" :max="ctx.maxMemoryMb.value" :step="512" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
isServerTypeSupported,
|
||||
listServerTypes,
|
||||
type ServerTypeDefinition,
|
||||
type ServerTypeId,
|
||||
} from '@modrinth/server'
|
||||
import { Combobox, type ComboboxOption, defineMessages, Toggle, useVIntl } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { injectCreateServerFlow } from '../create-server-flow'
|
||||
import { SERVER_TYPE_META } from '../server-type'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectCreateServerFlow()
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'app.servers.wizard.type-heading', defaultMessage: 'Choose a server core' },
|
||||
gameVersion: { id: 'app.servers.wizard.game-version', defaultMessage: 'Game version' },
|
||||
loaderVersion: { id: 'app.servers.wizard.loader-version', defaultMessage: 'Loader version' },
|
||||
showSnapshots: { id: 'app.servers.wizard.show-snapshots', defaultMessage: 'Show snapshots' },
|
||||
})
|
||||
|
||||
const typeLabels = defineMessages({
|
||||
vanilla: { id: 'app.servers.type.vanilla', defaultMessage: 'Vanilla' },
|
||||
fabric: { id: 'app.servers.type.fabric', defaultMessage: 'Fabric' },
|
||||
paper: { id: 'app.servers.type.paper', defaultMessage: 'Paper' },
|
||||
forge: { id: 'app.servers.type.forge', defaultMessage: 'Forge' },
|
||||
})
|
||||
|
||||
/** Display order for the wizard's type picker; Forge sits right after Fabric. */
|
||||
const SERVER_TYPE_ORDER: ServerTypeId[] = ['vanilla', 'fabric', 'forge', 'paper']
|
||||
|
||||
function serverTypeLabel(type: ServerTypeDefinition): string {
|
||||
const message = typeLabels[type.id as keyof typeof typeLabels]
|
||||
return message ? formatMessage(message) : type.label
|
||||
}
|
||||
|
||||
const serverTypeOptions = listServerTypes()
|
||||
.filter((type) => isServerTypeSupported(type.id))
|
||||
.sort((a, b) => SERVER_TYPE_ORDER.indexOf(a.id) - SERVER_TYPE_ORDER.indexOf(b.id))
|
||||
|
||||
const gameVersionOptions = computed<ComboboxOption<string>[]>(() =>
|
||||
ctx.availableGameVersions.value.map((version) => ({ value: version, label: version })),
|
||||
)
|
||||
|
||||
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() =>
|
||||
ctx.loaderVersions.value.map((loader) => ({ value: loader.id, label: loader.id })),
|
||||
)
|
||||
|
||||
function selectType(typeId: string) {
|
||||
ctx.serverType.value = typeId as ServerTypeId
|
||||
void ctx.loadLoaderVersions()
|
||||
}
|
||||
|
||||
function selectGameVersion(version: string) {
|
||||
ctx.selectedGameVersion.value = version
|
||||
void ctx.loadLoaderVersions()
|
||||
}
|
||||
|
||||
// Inline styles instead of Tailwind arbitrary values: underscores inside
|
||||
// `var(--_color)` are converted to spaces by Tailwind's arbitrary-value
|
||||
// parsing, which generates invalid CSS and breaks the production build.
|
||||
const monogramStyles = computed<Record<string, string>>(() =>
|
||||
Object.fromEntries(
|
||||
serverTypeOptions.map((type) => [
|
||||
type.id,
|
||||
`color-mix(in srgb, ${SERVER_TYPE_META[type.id].colorVar} 14%, transparent)`,
|
||||
]),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.heading) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="type in serverTypeOptions"
|
||||
:key="type.id"
|
||||
type="button"
|
||||
class="flex items-center gap-2.5 rounded-lg border border-solid px-3 py-2.5 text-left transition-colors"
|
||||
:class="
|
||||
ctx.serverType.value === type.id
|
||||
? 'border-brand bg-brand-highlight'
|
||||
: 'border-surface-4 bg-surface-2 hover:border-surface-5'
|
||||
"
|
||||
@click="selectType(type.id)"
|
||||
>
|
||||
<span
|
||||
v-if="SERVER_TYPE_META[type.id].icon"
|
||||
class="flex size-7 shrink-0 items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="SERVER_TYPE_META[type.id].icon"
|
||||
:alt="serverTypeLabel(type)"
|
||||
class="size-full object-contain"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-md text-xs font-bold"
|
||||
:style="{
|
||||
color: SERVER_TYPE_META[type.id].colorVar,
|
||||
backgroundColor: monogramStyles[type.id],
|
||||
}"
|
||||
>
|
||||
{{ SERVER_TYPE_META[type.id].monogram }}
|
||||
</span>
|
||||
<span class="min-w-0 truncate font-semibold text-contrast">{{
|
||||
serverTypeLabel(type)
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameVersion) }}
|
||||
</span>
|
||||
<Combobox
|
||||
:model-value="ctx.selectedGameVersion.value"
|
||||
:options="gameVersionOptions"
|
||||
:placeholder="formatMessage(messages.gameVersion)"
|
||||
@update:model-value="selectGameVersion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2 pb-2.5">
|
||||
<span class="whitespace-nowrap text-sm text-secondary">
|
||||
{{ formatMessage(messages.showSnapshots) }}
|
||||
</span>
|
||||
<Toggle
|
||||
id="wizard-show-snapshots"
|
||||
:model-value="ctx.showSnapshots.value"
|
||||
small
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
ctx.showSnapshots.value = !!value
|
||||
void ctx.loadVersions()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="ctx.needsLoaderVersion.value" class="flex min-w-0 flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.loaderVersion) }}
|
||||
</span>
|
||||
<Combobox
|
||||
:model-value="ctx.selectedLoaderVersion.value"
|
||||
:options="loaderVersionOptions"
|
||||
:placeholder="formatMessage(messages.loaderVersion)"
|
||||
@update:model-value="(value) => (ctx.selectedLoaderVersion.value = value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
1017
apps/app-frontend/src/components/ui/AboutMergeGame.vue
Normal file
1017
apps/app-frontend/src/components/ui/AboutMergeGame.vue
Normal file
File diff suppressed because it is too large
Load Diff
360
apps/app-frontend/src/components/ui/AboutScene.vue
Normal file
360
apps/app-frontend/src/components/ui/AboutScene.vue
Normal file
@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<canvas id="about_scene" class="size-full" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as THREE from 'three'
|
||||
import { type GLTF, GLTFLoader } from 'three/examples/jsm/Addons.js'
|
||||
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
|
||||
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
const themeStore = useTheming()
|
||||
function isDarkMode() {
|
||||
if (themeStore.selectedTheme == 'system') {
|
||||
return matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
return ['dark', 'oled'].includes(themeStore.selectedTheme)
|
||||
}
|
||||
|
||||
function loadGLTF(url: string): Promise<GLTF> {
|
||||
return new Promise((res, rej) => {
|
||||
const loader = new GLTFLoader()
|
||||
loader.load(
|
||||
url,
|
||||
(data) => {
|
||||
res(data)
|
||||
},
|
||||
undefined,
|
||||
rej,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
|
||||
const tipGeometry = new THREE.SphereGeometry(2)
|
||||
const tipMaterial = new THREE.MeshBasicMaterial({ color })
|
||||
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
|
||||
tipMesh.position.copy(position)
|
||||
return tipMesh
|
||||
}
|
||||
|
||||
function createWaterMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
time: { value: 0 },
|
||||
seed: { value: Math.random() * 83 + 17 },
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define WATER_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define WATER_FRAG
|
||||
uniform float time;
|
||||
uniform float seed;
|
||||
uniform vec3 color;
|
||||
varying vec2 vUv;
|
||||
|
||||
vec2 randomGradient(vec2 p) {
|
||||
float n = sin(dot(p, vec2(127.1, 311.7)));
|
||||
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
|
||||
return vec2(cos(angle), sin(angle));
|
||||
}
|
||||
|
||||
float perlinNoise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
|
||||
vec2 g1 = randomGradient(i);
|
||||
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
|
||||
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
|
||||
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
|
||||
|
||||
vec2 d1 = f;
|
||||
vec2 d2 = f - vec2(1.0, 0.0);
|
||||
vec2 d3 = f - vec2(0.0, 1.0);
|
||||
vec2 d4 = f - vec2(1.0, 1.0);
|
||||
|
||||
float v1 = dot(g1, d1);
|
||||
float v2 = dot(g2, d2);
|
||||
float v3 = dot(g3, d3);
|
||||
float v4 = dot(g4, d4);
|
||||
|
||||
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float height = 0.0;
|
||||
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
|
||||
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
|
||||
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
|
||||
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
|
||||
height = clamp(height, -1.0, 1.0);
|
||||
height = height * 0.8 + 0.6;
|
||||
|
||||
float thickness = 0.008;
|
||||
if(vUv.y < height - thickness) {
|
||||
float scalar = 1.0 - height + vUv.y;
|
||||
scalar = scalar * scalar * scalar * 0.6;
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
} else if(vUv.y > height + thickness) {
|
||||
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
} else {
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(120, 16)
|
||||
const waterMesh = new THREE.Mesh(geometry, material)
|
||||
waterMesh.position.copy(position)
|
||||
return waterMesh
|
||||
}
|
||||
|
||||
function createCircleMaterial(): THREE.ShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
|
||||
},
|
||||
transparent: true,
|
||||
vertexShader: `#define CIRCLE_VERT
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `#define CIRCLE_FRAG
|
||||
varying vec2 vUv;
|
||||
uniform vec3 color;
|
||||
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
|
||||
float t = (v - inMin) / (inMax - inMin);
|
||||
return outMin + (outMax - outMin) * t;
|
||||
}
|
||||
void main() {
|
||||
float dis = distance(vUv, vec2(0.5));
|
||||
float thickness = 0.05;
|
||||
|
||||
gl_FragColor = vec4(0.0);
|
||||
if(dis <= 0.35 && dis >= 0.35 - thickness) {
|
||||
gl_FragColor = vec4(color, 0.8);
|
||||
} else {
|
||||
// emissive
|
||||
float scalar = 0.0;
|
||||
if(dis >= 0.35) {
|
||||
scalar = clamp(0.5 - dis, 0.0, 0.15);
|
||||
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
|
||||
} else {
|
||||
scalar = clamp(0.35 - dis, 0.0, 0.5);
|
||||
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
|
||||
}
|
||||
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
|
||||
gl_FragColor = vec4(color, scalar);
|
||||
}
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
|
||||
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
mesh.position.copy(position)
|
||||
return mesh
|
||||
}
|
||||
|
||||
function main() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
|
||||
if (!canvas) return console.error('No canvas')
|
||||
|
||||
let isUpdating = true
|
||||
|
||||
const canvasSize = new THREE.Vector2(
|
||||
canvas.getBoundingClientRect().width,
|
||||
canvas.getBoundingClientRect().height,
|
||||
)
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
canvas,
|
||||
})
|
||||
renderer.setPixelRatio(devicePixelRatio)
|
||||
renderer.setSize(canvasSize.x, canvasSize.y)
|
||||
|
||||
const deltaClock = new THREE.Clock()
|
||||
const elapseClock = new THREE.Clock()
|
||||
deltaClock.start()
|
||||
elapseClock.start()
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
|
||||
camera.fov *= 0.7
|
||||
camera.position.set(-10, 5, 30)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff)
|
||||
scene.add(ambientLight)
|
||||
|
||||
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
|
||||
dirLight.position.set(-30, 30, 28)
|
||||
scene.add(dirLight)
|
||||
|
||||
scene.add(createTip(dirLight.position, 0xffff00))
|
||||
scene.add(createTip(camera.position))
|
||||
|
||||
const accentColor =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
|
||||
|
||||
const waterMaterial = createWaterMaterial()
|
||||
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 0.6 : 2.4,
|
||||
)
|
||||
// .multiplyScalar(0.6)
|
||||
// .multiplyScalar(2.4)
|
||||
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
|
||||
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
|
||||
|
||||
async function load() {
|
||||
const axlGLTF = await loadGLTF('/models/axolotl.gltf')
|
||||
|
||||
const axlModel = axlGLTF.scene
|
||||
axlModel.scale.multiplyScalar(5)
|
||||
axlModel.rotateY(Math.PI / 2)
|
||||
axlModel.position.add(new THREE.Vector3(0, -2.5, 0))
|
||||
scene.add(axlModel)
|
||||
|
||||
const mixer = new THREE.AnimationMixer(axlModel)
|
||||
const axlSwimAnim = axlGLTF.animations.filter((a) => a.name === 'swim')[0]
|
||||
if (!axlSwimAnim) return console.error('Missing animation swim')
|
||||
mixer.clipAction(axlSwimAnim).play()
|
||||
|
||||
// // Axl Label
|
||||
// const axlLabelGLTF = await loadGLTF('/models/axl_label.glb')
|
||||
// const axlLabel = axlLabelGLTF.scene
|
||||
// axlLabel.scale.multiplyScalar(8)
|
||||
// axlLabel.rotateY(-Math.PI / 2)
|
||||
// axlLabel.position.set(0, 5.2, 0)
|
||||
// scene.add(axlLabel)
|
||||
|
||||
const originAxlModelPosition = axlModel.position.clone()
|
||||
return function (deltaTime: number, elapsedTime: number) {
|
||||
axlModel.position.set(
|
||||
originAxlModelPosition.x,
|
||||
originAxlModelPosition.y + Math.sin(elapsedTime),
|
||||
originAxlModelPosition.z,
|
||||
)
|
||||
axlModel.rotation.y = Math.sin(elapsedTime * 0.3) * 0.2 + (Math.PI * 100) / 180
|
||||
mixer.update(deltaTime)
|
||||
}
|
||||
}
|
||||
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
|
||||
load().then((updateFn) => {
|
||||
if (updateFn) updateGLTF = updateFn
|
||||
})
|
||||
|
||||
const circleMaterial = createCircleMaterial()
|
||||
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
|
||||
isDarkMode() ? 1.2 : 3,
|
||||
)
|
||||
// .multiplyScalar(1.2)
|
||||
// .multiplyScalar(3)
|
||||
|
||||
let circleMeshList: THREE.Mesh[] = []
|
||||
let nextCircleCreateTime = 0.0
|
||||
function updateCircle(deltaTime: number, elapsedTime: number) {
|
||||
circleMeshList = circleMeshList.filter((m) => {
|
||||
m.position.y += deltaTime * 2.0
|
||||
if (m.position.y >= 32) {
|
||||
scene.remove(m)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (elapsedTime >= nextCircleCreateTime) {
|
||||
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
|
||||
const circle = createCircle(
|
||||
circleMaterial,
|
||||
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
|
||||
)
|
||||
scene.add(circle)
|
||||
circleMeshList.push(circle)
|
||||
}
|
||||
}
|
||||
|
||||
function animate(_time: number) {
|
||||
if (isUpdating === false) return
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
const deltaTime = deltaClock.getDelta()
|
||||
const elapsedTime = elapseClock.getElapsedTime()
|
||||
|
||||
updateGLTF(deltaTime, elapsedTime)
|
||||
waterMaterial.uniforms.time.value = elapsedTime
|
||||
|
||||
updateCircle(deltaTime, elapsedTime)
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
animate(Date.now())
|
||||
|
||||
const originCameraPosition = camera.position.clone()
|
||||
function onMouseMove(event: MouseEvent) {
|
||||
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
|
||||
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
|
||||
const newPosition = new THREE.Vector3(
|
||||
originCameraPosition.x + mouseXOffsetRatio,
|
||||
originCameraPosition.y + mouseYOffsetRatio * 0.5,
|
||||
originCameraPosition.z,
|
||||
)
|
||||
camera.position.copy(newPosition)
|
||||
}
|
||||
|
||||
function updateSize() {
|
||||
if (!isUpdating) return
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const w = rect.width
|
||||
const h = rect.height
|
||||
if (w > 0 && h > 0) {
|
||||
renderer.setSize(w, h)
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateSize)
|
||||
resizeObserver.observe(canvas)
|
||||
|
||||
addEventListener('mousemove', onMouseMove)
|
||||
onScopeDispose(() => {
|
||||
isUpdating = false
|
||||
removeEventListener('mousemove', onMouseMove)
|
||||
resizeObserver.disconnect()
|
||||
deltaClock.stop()
|
||||
elapseClock.stop()
|
||||
renderer.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(main)
|
||||
</script>
|
||||
<style>
|
||||
#about_scene {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
|
||||
#00000000 40%
|
||||
);
|
||||
}
|
||||
</style>
|
||||
1237
apps/app-frontend/src/components/ui/AccountsCard.vue
Normal file
1237
apps/app-frontend/src/components/ui/AccountsCard.vue
Normal file
File diff suppressed because it is too large
Load Diff
73
apps/app-frontend/src/components/ui/AddContentButton.vue
Normal file
73
apps/app-frontend/src/components/ui/AddContentButton.vue
Normal file
@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { add_project_from_path } from '@/helpers/instance'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
installContent: { id: 'app.content.install-content', defaultMessage: 'Install content' },
|
||||
addFromFile: { id: 'app.content.add-from-file', defaultMessage: 'Add from file' },
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleAddContentFromFile = async () => {
|
||||
const newProject = await open({ multiple: true })
|
||||
if (!newProject) return
|
||||
|
||||
for (const project of newProject) {
|
||||
await add_project_from_path(props.instance.id, project.path ?? project).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchContent = async () => {
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled>
|
||||
<button @click="handleSearchContent">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.installContent) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'from_file',
|
||||
action: handleAddContentFromFile,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon />
|
||||
<template #from_file>
|
||||
<FolderOpenIcon />
|
||||
<span class="whitespace-nowrap">{{ formatMessage(messages.addFromFile) }}</span>
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
787
apps/app-frontend/src/components/ui/AppActionBar.vue
Normal file
787
apps/app-frontend/src/components/ui/AppActionBar.vue
Normal file
@ -0,0 +1,787 @@
|
||||
<template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Dropdown
|
||||
v-model:shown="notificationCenterShown"
|
||||
placement="bottom-end"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.notifications)"
|
||||
:aria-label="formatMessage(messages.notifications)"
|
||||
class="relative"
|
||||
>
|
||||
<BellIcon />
|
||||
<span
|
||||
v-if="hasUnreadNotifications"
|
||||
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
|
||||
/>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="w-[22rem] max-w-[calc(100vw-2rem)] p-2">
|
||||
<div class="mb-2 flex items-center justify-between px-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.notifications)
|
||||
}}</span>
|
||||
<button
|
||||
v-if="notificationHistory.length"
|
||||
class="text-xs text-secondary hover:text-contrast"
|
||||
@click="clearNotificationHistory"
|
||||
>
|
||||
{{ formatMessage(messages.clearNotifications) }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!notificationHistory.length"
|
||||
class="px-2 py-4 text-center text-sm text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noNotifications) }}
|
||||
</div>
|
||||
<div v-else class="flex max-h-[22rem] flex-col gap-1 overflow-auto">
|
||||
<div
|
||||
v-for="item in notificationHistory"
|
||||
:key="item.key"
|
||||
class="flex items-start gap-2 rounded-lg p-2 hover:bg-button-bg"
|
||||
>
|
||||
<div
|
||||
class="mt-1 size-2 shrink-0 rounded-full"
|
||||
:class="notificationDotClass(item.type)"
|
||||
/>
|
||||
<button class="min-w-0 flex-1 text-left" @click="openNotification(item)">
|
||||
<div class="truncate text-sm font-medium text-contrast">{{ item.title }}</div>
|
||||
<div v-if="item.text" class="line-clamp-2 text-xs text-secondary">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismissNotification)"
|
||||
class="shrink-0 text-secondary hover:text-contrast"
|
||||
@click="dismissNotification(item)"
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<ButtonStyled
|
||||
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
|
||||
color="brand"
|
||||
type="transparent"
|
||||
circular
|
||||
>
|
||||
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="goToDownloads">
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-if="offline" class="flex items-center gap-1">
|
||||
<UnplugIcon class="text-secondary" />
|
||||
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
|
||||
</div>
|
||||
<AppUpdateButton />
|
||||
<div
|
||||
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
|
||||
>
|
||||
<template v-if="selectedProcess">
|
||||
<OnlineIndicatorIcon />
|
||||
<div class="text-contrast flex items-center gap-2">
|
||||
<router-link
|
||||
v-tooltip="formatMessage(messages.viewInstance)"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ selectedProcess.instance.name }}
|
||||
</router-link>
|
||||
<Dropdown
|
||||
v-if="currentProcesses.length > 1"
|
||||
placement="bottom"
|
||||
:triggers="['click']"
|
||||
:hide-triggers="['click']"
|
||||
@show="showInstances = true"
|
||||
@hide="showInstances = false"
|
||||
>
|
||||
<ButtonStyled type="transparent" circular size="small">
|
||||
<button
|
||||
v-tooltip="
|
||||
showInstances
|
||||
? formatMessage(messages.hideMoreRunningInstances)
|
||||
: formatMessage(messages.showMoreRunningInstances)
|
||||
"
|
||||
>
|
||||
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
|
||||
<div
|
||||
v-for="process in currentProcesses"
|
||||
:key="process.uuid"
|
||||
class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
|
||||
>
|
||||
<button
|
||||
v-tooltip.left="
|
||||
process.uuid === selectedProcess.uuid
|
||||
? formatMessage(messages.primaryInstance)
|
||||
: formatMessage(messages.makePrimaryInstance)
|
||||
"
|
||||
class="flex flex-grow items-center gap-2"
|
||||
:class="{
|
||||
'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
|
||||
}"
|
||||
:disabled="process.uuid === selectedProcess.uuid"
|
||||
@click="selectProcess(process)"
|
||||
>
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.instance.name }}
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="stop(process)"
|
||||
>
|
||||
<StopCircleIcon class="text-red size-5" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click.stop="goToTerminal(process.instance.id)"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopInstance)"
|
||||
class="active:scale-95 flex"
|
||||
@click="stop(selectedProcess)"
|
||||
>
|
||||
<StopCircleIcon class="text-red size-5" />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.viewLogs)"
|
||||
class="active:scale-95 flex"
|
||||
@click="goToTerminal()"
|
||||
>
|
||||
<TerminalSquareIcon class="text-secondary size-5" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="size-2 rounded-full bg-secondary" />
|
||||
<span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BellIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
OnlineIndicatorIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UnplugIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
type PopupNotification,
|
||||
type PopupNotificationProgressItem,
|
||||
useVIntl,
|
||||
type WebNotification,
|
||||
} from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { Dropdown } from 'floating-vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
|
||||
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener, process_listener } from '@/helpers/events'
|
||||
import { get_many as getInstances } from '@/helpers/instance'
|
||||
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
|
||||
import type { LoadingBar } from '@/helpers/state'
|
||||
import { progress_bars_list } from '@/helpers/state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
|
||||
|
||||
const notificationManager = injectNotificationManager()
|
||||
const { handleError } = notificationManager
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const downloadManager = injectDownloadManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type NotificationHistoryItem = {
|
||||
key: string
|
||||
createdAt?: number
|
||||
title: string
|
||||
text?: string
|
||||
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
|
||||
collapsed?: boolean
|
||||
expand: () => void
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
const notificationHistory = computed<NotificationHistoryItem[]>(() =>
|
||||
[
|
||||
...notificationManager.getNotifications().map((item: WebNotification) => ({
|
||||
key: `web-${item.id}`,
|
||||
createdAt: item.createdAt,
|
||||
title: item.title ?? formatMessage(messages.notifications),
|
||||
text: item.text,
|
||||
type: item.type,
|
||||
collapsed: item.collapsed,
|
||||
expand: () => notificationManager.expandNotification(item.id),
|
||||
dismiss: () => notificationManager.removeNotification(item.id),
|
||||
})),
|
||||
...popupNotificationManager.getNotifications().map((item: PopupNotification) => ({
|
||||
key: `popup-${item.id}`,
|
||||
createdAt: item.createdAt,
|
||||
title: item.title,
|
||||
text:
|
||||
item.text ??
|
||||
(item.progressItems
|
||||
?.filter((progressItem) => progressItem.text)
|
||||
.map((progressItem) => `${progressItem.title}: ${progressItem.text}`)
|
||||
.join('\n') ||
|
||||
undefined),
|
||||
type: item.type,
|
||||
collapsed: item.collapsed,
|
||||
expand: () => popupNotificationManager.expandNotification(item.id),
|
||||
dismiss: () => popupNotificationManager.removeNotification(item.id),
|
||||
})),
|
||||
].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
|
||||
)
|
||||
|
||||
const hasUnreadNotifications = computed(() =>
|
||||
notificationHistory.value.some(
|
||||
(item) => !item.collapsed && ['error', 'warning'].includes(item.type ?? ''),
|
||||
),
|
||||
)
|
||||
|
||||
function notificationDotClass(type?: NotificationHistoryItem['type']): string {
|
||||
if (type === 'error') return 'bg-red'
|
||||
if (type === 'warning') return 'bg-orange'
|
||||
if (type === 'success') return 'bg-green'
|
||||
if (type === 'download') return 'bg-green'
|
||||
return 'bg-blue'
|
||||
}
|
||||
|
||||
function dismissNotification(item: NotificationHistoryItem) {
|
||||
item.dismiss()
|
||||
}
|
||||
|
||||
async function openNotification(item: NotificationHistoryItem) {
|
||||
item.expand()
|
||||
notificationCenterShown.value = false
|
||||
}
|
||||
|
||||
function clearNotificationHistory() {
|
||||
notificationManager.clearAllNotifications()
|
||||
popupNotificationManager.clearAllNotifications()
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isDownloadsPage = computed(
|
||||
() => route.path === '/downloads' || route.path.startsWith('/downloads/'),
|
||||
)
|
||||
|
||||
const showInstances = ref(false)
|
||||
const notificationCenterShown = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
instance_id: string
|
||||
instance: GameInstance
|
||||
}
|
||||
|
||||
interface LoadingEventPayload {
|
||||
event: LoadingBar['bar_type']
|
||||
loader_uuid: string
|
||||
fraction: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
offline: {
|
||||
id: 'app.action-bar.offline',
|
||||
defaultMessage: 'Offline',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'app.action-bar.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
showMoreRunningInstances: {
|
||||
id: 'app.action-bar.show-more-running-instances',
|
||||
defaultMessage: 'Show more running instances',
|
||||
},
|
||||
hideMoreRunningInstances: {
|
||||
id: 'app.action-bar.hide-more-running-instances',
|
||||
defaultMessage: 'Hide more running instances',
|
||||
},
|
||||
primaryInstance: {
|
||||
id: 'app.action-bar.primary-instance',
|
||||
defaultMessage: 'Primary instance',
|
||||
},
|
||||
makePrimaryInstance: {
|
||||
id: 'app.action-bar.make-primary-instance',
|
||||
defaultMessage: 'Make primary instance',
|
||||
},
|
||||
stopInstance: {
|
||||
id: 'app.action-bar.stop-instance',
|
||||
defaultMessage: 'Stop instance',
|
||||
},
|
||||
viewLogs: {
|
||||
id: 'app.action-bar.view-logs',
|
||||
defaultMessage: 'View logs',
|
||||
},
|
||||
noInstancesRunning: {
|
||||
id: 'app.action-bar.no-instances-running',
|
||||
defaultMessage: 'No instances running',
|
||||
},
|
||||
notifications: {
|
||||
id: 'app.action-bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
},
|
||||
clearNotifications: {
|
||||
id: 'app.action-bar.notifications.clear',
|
||||
defaultMessage: 'Clear all',
|
||||
},
|
||||
noNotifications: {
|
||||
id: 'app.action-bar.notifications.empty',
|
||||
defaultMessage: 'No notifications',
|
||||
},
|
||||
dismissNotification: {
|
||||
id: 'app.action-bar.notifications.dismiss',
|
||||
defaultMessage: 'Dismiss notification',
|
||||
},
|
||||
downloadingJava: {
|
||||
id: 'app.action-bar.downloading-java',
|
||||
defaultMessage: 'Downloading Java {version}',
|
||||
},
|
||||
downloadingModpack: {
|
||||
id: 'app.downloads.phase.downloading-pack-file',
|
||||
defaultMessage: 'Downloading modpack',
|
||||
},
|
||||
downloads: {
|
||||
id: 'app.action-bar.downloads',
|
||||
defaultMessage: 'Downloads',
|
||||
},
|
||||
viewActiveDownloads: {
|
||||
id: 'app.action-bar.view-active-downloads',
|
||||
defaultMessage: 'View active downloads',
|
||||
},
|
||||
exportingModpack: {
|
||||
id: 'app.action-bar.exporting-modpack',
|
||||
defaultMessage: 'Exporting modpack',
|
||||
},
|
||||
})
|
||||
|
||||
const currentProcesses = ref<RunningProcess[]>([])
|
||||
const selectedProcess = ref<RunningProcess | undefined>()
|
||||
|
||||
const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string }>
|
||||
const instanceIds = processes.map((process) => process.instance_id)
|
||||
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
|
||||
currentProcesses.value = processes
|
||||
.map((process) => {
|
||||
const instance = instances.find((item) => process.instance_id === item.id)
|
||||
if (!instance) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...process,
|
||||
instance,
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
|
||||
selectedProcess.value = currentProcesses.value[0]
|
||||
}
|
||||
}
|
||||
|
||||
await refresh()
|
||||
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const unlistenProcess = await process_listener(async () => {
|
||||
await refresh()
|
||||
})
|
||||
|
||||
const stop = async (process: RunningProcess) => {
|
||||
try {
|
||||
await killProcess(process.uuid).catch(handleError)
|
||||
|
||||
trackEvent('InstanceStop', {
|
||||
loader: process.instance.loader,
|
||||
game_version: process.instance.game_version,
|
||||
source: 'AppBar',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
function goToTerminal(instanceId?: string) {
|
||||
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
|
||||
if (!selectedInstanceId) {
|
||||
return
|
||||
}
|
||||
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
|
||||
}
|
||||
|
||||
const currentLoadingBars = ref<LoadingBar[]>([])
|
||||
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
|
||||
const notificationId = ref<string | number | null>(null)
|
||||
const dismissed = ref(false)
|
||||
|
||||
function getLoadingBarKey(loadingBar: LoadingBar): string {
|
||||
return `${loadingBar.loading_bar_uuid ?? loadingBar.id}`
|
||||
}
|
||||
|
||||
function getLoadingProgress(loadingBar: LoadingBar): number {
|
||||
if (!loadingBar.total || loadingBar.total <= 0) {
|
||||
return 0
|
||||
}
|
||||
return Math.max(0, Math.min(1, (loadingBar.current ?? 0) / (loadingBar.total ?? 0)))
|
||||
}
|
||||
|
||||
function getLoadingText(loadingBar: LoadingBar): string {
|
||||
return loadingBar.message ?? ''
|
||||
}
|
||||
|
||||
function getDisplayIconUrl(icon: string | null | undefined): string | null {
|
||||
if (!icon) {
|
||||
return null
|
||||
}
|
||||
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
|
||||
return icon
|
||||
}
|
||||
return convertFileSrc(icon)
|
||||
}
|
||||
|
||||
function getNotification(): PopupNotification | null {
|
||||
if (!notificationId.value) {
|
||||
return null
|
||||
}
|
||||
const notification = popupNotificationManager
|
||||
.getNotifications()
|
||||
.find((notification) => notification.id === notificationId.value)
|
||||
return notification ?? null
|
||||
}
|
||||
|
||||
function collapseNotification(): void {
|
||||
if (!notificationId.value) {
|
||||
return
|
||||
}
|
||||
popupNotificationManager.collapseNotification(notificationId.value)
|
||||
}
|
||||
|
||||
function removeNotification(): void {
|
||||
if (!notificationId.value) {
|
||||
return
|
||||
}
|
||||
popupNotificationManager.removeNotification(notificationId.value)
|
||||
notificationId.value = null
|
||||
}
|
||||
|
||||
function buildDownloadItems(): PopupNotificationProgressItem[] {
|
||||
return [
|
||||
...installJobNotifications.progressItems.value,
|
||||
...currentLoadingBars.value.map((bar) => {
|
||||
const isPackDownload = bar.bar_type?.type === 'pack_download'
|
||||
return {
|
||||
id: getLoadingBarKey(bar),
|
||||
title: bar.title ?? '',
|
||||
text: getLoadingText(bar),
|
||||
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
|
||||
progress: getLoadingProgress(bar),
|
||||
waiting: !bar.total || bar.total <= 0,
|
||||
// Pack downloads report file counts, so prefer count UI over raw percentage.
|
||||
progressType: isPackDownload ? 'count' : 'percentage',
|
||||
progressCurrent: bar.current,
|
||||
progressTotal: bar.total,
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
const hasVisibleActiveDownloadToasts = computed(() => {
|
||||
const notification = getNotification()
|
||||
return !!notification && !notification.collapsed
|
||||
})
|
||||
const hasActiveDownloads = computed(
|
||||
() =>
|
||||
installJobNotifications.active.value ||
|
||||
currentLoadingBars.value.some((bar) => downloadBarTypes.has(bar.bar_type?.type ?? '')),
|
||||
)
|
||||
const hasDownloadNotificationItems = computed(
|
||||
() => installJobNotifications.hasItems.value || currentLoadingBars.value.length > 0,
|
||||
)
|
||||
|
||||
function updateNotification(resummon = false): void {
|
||||
const shouldResummon = resummon && !isDownloadsPage.value
|
||||
if (shouldResummon) {
|
||||
dismissed.value = false
|
||||
}
|
||||
|
||||
if (!hasDownloadNotificationItems.value) {
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (notificationId.value && !getNotification()) {
|
||||
notificationId.value = null
|
||||
dismissed.value = true
|
||||
}
|
||||
|
||||
if (dismissed.value && !shouldResummon) {
|
||||
return
|
||||
}
|
||||
|
||||
let notif = getNotification()
|
||||
if (notif?.collapsed && shouldResummon) {
|
||||
notif.collapsed = false
|
||||
}
|
||||
const progressItems = buildDownloadItems()
|
||||
|
||||
if (notif) {
|
||||
notif.title = installJobNotifications.hasItems.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads)
|
||||
notif.text = undefined
|
||||
notif.progressItems = progressItems
|
||||
notif.buttons = installJobNotifications.buttons.value
|
||||
notif.onClick = hasDownloadNotificationItems.value ? goToDownloads : undefined
|
||||
notif.progress = undefined
|
||||
notif.waiting = undefined
|
||||
notif.autoCloseMs =
|
||||
progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)
|
||||
? 30 * 1000
|
||||
: null
|
||||
if (!notif.collapsed) popupNotificationManager.setNotificationTimer(notif)
|
||||
} else {
|
||||
notif = popupNotificationManager.addPopupNotification({
|
||||
title: installJobNotifications.hasItems.value
|
||||
? installJobNotifications.title.value
|
||||
: formatMessage(messages.downloads),
|
||||
type: 'download',
|
||||
autoCloseMs: null,
|
||||
progressItems,
|
||||
buttons: installJobNotifications.buttons.value,
|
||||
onClick: hasDownloadNotificationItems.value ? goToDownloads : undefined,
|
||||
})
|
||||
notificationId.value = notif.id
|
||||
if (isDownloadsPage.value) {
|
||||
popupNotificationManager.collapseNotification(notif.id)
|
||||
}
|
||||
if (progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)) {
|
||||
notif.autoCloseMs = 30 * 1000
|
||||
popupNotificationManager.setNotificationTimer(notif)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
|
||||
const formatted = { ...loadingBar }
|
||||
if (formatted.bar_type?.type === 'java_download') {
|
||||
formatted.title = formatMessage(messages.downloadingJava, {
|
||||
version: formatted.bar_type.version,
|
||||
})
|
||||
}
|
||||
if (formatted.bar_type?.type === 'pack_file_download') {
|
||||
formatted.message = formatMessage(messages.downloadingModpack)
|
||||
}
|
||||
if (formatted.bar_type?.instance_id) {
|
||||
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
|
||||
}
|
||||
if (formatted.bar_type?.type === 'zip_extract') {
|
||||
formatted.title = formatMessage(messages.exportingModpack)
|
||||
}
|
||||
if (formatted.bar_type?.pack_name) {
|
||||
formatted.title = formatted.bar_type.pack_name
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
|
||||
return (
|
||||
loadingBar.bar_type?.type !== 'launcher_update' &&
|
||||
[
|
||||
'java_download',
|
||||
'pack_file_download',
|
||||
'pack_download',
|
||||
'minecraft_download',
|
||||
'copy_instance',
|
||||
'zip_extract',
|
||||
].includes(loadingBar.bar_type?.type ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
function applyLoadingEvent(payload: LoadingEventPayload): boolean {
|
||||
const key = payload.loader_uuid
|
||||
const index = currentLoadingBars.value.findIndex((bar) => getLoadingBarKey(bar) === key)
|
||||
|
||||
if (payload.fraction === null) {
|
||||
if (index >= 0) {
|
||||
currentLoadingBars.value.splice(index, 1)
|
||||
const { [key]: _removedIcon, ...remainingIcons } = currentLoadingBarIconUrls.value
|
||||
currentLoadingBarIconUrls.value = remainingIcons
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const loadingBar = formatLoadingBars({
|
||||
loading_bar_uuid: payload.loader_uuid,
|
||||
message: payload.message,
|
||||
current: payload.fraction,
|
||||
total: 1,
|
||||
bar_type: payload.event,
|
||||
})
|
||||
if (!isVisibleLoadingBar(loadingBar)) return false
|
||||
|
||||
if (index >= 0) {
|
||||
currentLoadingBars.value.splice(index, 1, loadingBar)
|
||||
} else {
|
||||
currentLoadingBars.value.push(loadingBar)
|
||||
}
|
||||
currentLoadingBarIconUrls.value[key] = getDisplayIconUrl(payload.event?.icon)
|
||||
return index < 0
|
||||
}
|
||||
|
||||
async function refreshLoadingBars() {
|
||||
const bars: Record<string, LoadingBar> = await progress_bars_list().catch((error) => {
|
||||
handleError(error)
|
||||
return {}
|
||||
})
|
||||
|
||||
currentLoadingBars.value = Object.values(bars).map(formatLoadingBars).filter(isVisibleLoadingBar)
|
||||
|
||||
const instanceIds = Array.from(
|
||||
new Set(
|
||||
currentLoadingBars.value
|
||||
.map((bar) => bar.bar_type?.instance_id)
|
||||
.filter((instanceId): instanceId is string => !!instanceId),
|
||||
),
|
||||
)
|
||||
const instances = instanceIds.length
|
||||
? await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
: []
|
||||
const instanceIconUrls = new Map(
|
||||
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
|
||||
)
|
||||
currentLoadingBarIconUrls.value = Object.fromEntries(
|
||||
currentLoadingBars.value.map((bar) => {
|
||||
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
|
||||
const instanceIconUrl = bar.bar_type?.instance_id
|
||||
? instanceIconUrls.get(bar.bar_type.instance_id)
|
||||
: null
|
||||
return [getLoadingBarKey(bar), barIconUrl ?? instanceIconUrl ?? null]
|
||||
}),
|
||||
)
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
|
||||
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
|
||||
return aKey.localeCompare(bKey)
|
||||
})
|
||||
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
const installJobNotifications = await useInstallJobNotifications({
|
||||
router,
|
||||
manager: downloadManager,
|
||||
handleError,
|
||||
onChange: updateNotification,
|
||||
})
|
||||
|
||||
await refreshLoadingBars()
|
||||
|
||||
let newBarDuringWindow = false
|
||||
let loadingNotificationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
|
||||
const isNewBar = applyLoadingEvent(payload)
|
||||
if (isNewBar) {
|
||||
newBarDuringWindow = true
|
||||
}
|
||||
if (loadingNotificationTimer !== null) {
|
||||
return
|
||||
}
|
||||
loadingNotificationTimer = setTimeout(() => {
|
||||
loadingNotificationTimer = null
|
||||
if (newBarDuringWindow) {
|
||||
newBarDuringWindow = false
|
||||
if (isDownloadsPage.value) {
|
||||
updateNotification()
|
||||
} else {
|
||||
removeNotification()
|
||||
updateNotification(true)
|
||||
}
|
||||
} else {
|
||||
updateNotification()
|
||||
}
|
||||
}, 250)
|
||||
})
|
||||
|
||||
function goToDownloads() {
|
||||
router.push('/downloads')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
if (isDownloadsPage.value) {
|
||||
collapseNotification()
|
||||
}
|
||||
updateNotification()
|
||||
},
|
||||
)
|
||||
|
||||
function selectProcess(process: RunningProcess) {
|
||||
selectedProcess.value = process
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (loadingNotificationTimer !== null) {
|
||||
clearTimeout(loadingNotificationTimer)
|
||||
loadingNotificationTimer = null
|
||||
}
|
||||
removeNotification()
|
||||
dismissed.value = false
|
||||
unlistenProcess()
|
||||
unlistenLoading()
|
||||
installJobNotifications.dispose()
|
||||
})
|
||||
</script>
|
||||
14
apps/app-frontend/src/components/ui/AxolotlLogo.vue
Normal file
14
apps/app-frontend/src/components/ui/AxolotlLogo.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div aria-label="Axolotl" class="flex h-full items-center gap-2 font-extrabold text-contrast">
|
||||
<img aria-hidden="true" class="aspect-square h-full object-contain" :src="axolotlVisual" />
|
||||
<span v-if="!iconOnly" class="hidden text-sm tracking-wide xl:inline">Axolotl</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axolotlVisual from '@modrinth/assets/branding/axolotl.png'
|
||||
|
||||
defineProps<{
|
||||
iconOnly?: boolean
|
||||
}>()
|
||||
</script>
|
||||
343
apps/app-frontend/src/components/ui/Breadcrumbs.vue
Normal file
343
apps/app-frontend/src/components/ui/Breadcrumbs.vue
Normal file
@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div
|
||||
ref="outerRef"
|
||||
data-tauri-drag-region
|
||||
class="min-w-0 overflow-hidden pl-3"
|
||||
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
|
||||
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<div
|
||||
ref="innerRef"
|
||||
data-tauri-drag-region
|
||||
class="flex w-fit items-center gap-1"
|
||||
:class="{ 'breadcrumbs-scroll': isAnimating }"
|
||||
@animationiteration="onAnimationIteration"
|
||||
>
|
||||
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.name">
|
||||
<router-link
|
||||
v-if="breadcrumb.link"
|
||||
:to="{
|
||||
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
|
||||
query: breadcrumb.query,
|
||||
}"
|
||||
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-primary"
|
||||
>
|
||||
<Avatar
|
||||
v-if="resolveIconUrl(breadcrumb)"
|
||||
:src="resolveIconUrl(breadcrumb)"
|
||||
:alt="resolveLabel(breadcrumb.name)"
|
||||
size="20px"
|
||||
no-shadow
|
||||
raised
|
||||
class="shrink-0 !rounded-md"
|
||||
/>
|
||||
<component
|
||||
:is="resolveIcon(breadcrumb)"
|
||||
v-else-if="resolveIcon(breadcrumb)"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</router-link>
|
||||
<span
|
||||
v-else
|
||||
data-tauri-drag-region
|
||||
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
|
||||
>
|
||||
<Avatar
|
||||
v-if="resolveIconUrl(breadcrumb)"
|
||||
:src="resolveIconUrl(breadcrumb)"
|
||||
:alt="resolveLabel(breadcrumb.name)"
|
||||
size="20px"
|
||||
no-shadow
|
||||
raised
|
||||
class="shrink-0 !rounded-md"
|
||||
/>
|
||||
<component
|
||||
:is="resolveIcon(breadcrumb)"
|
||||
v-else-if="resolveIcon(breadcrumb)"
|
||||
class="size-5 shrink-0 text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ resolveLabel(breadcrumb.name) }}
|
||||
</span>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbs.length - 1"
|
||||
data-tauri-drag-region
|
||||
class="w-5 h-5 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
ChevronRightIcon,
|
||||
CodeIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
FolderIcon,
|
||||
GlobeIcon,
|
||||
HeartIcon,
|
||||
HomeIcon,
|
||||
ImagesIcon,
|
||||
LibraryIcon,
|
||||
MapIcon,
|
||||
PackageIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { type Component, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { resolveBreadcrumbLabel } from '@/helpers/breadcrumb-label'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
|
||||
interface Breadcrumb {
|
||||
name: string
|
||||
link?: string
|
||||
query?: Record<string, string>
|
||||
iconUrl?: string | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbData = useBreadcrumbs()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
home: { id: 'app.navigation.home', defaultMessage: 'Home' },
|
||||
worlds: { id: 'app.navigation.worlds', defaultMessage: 'Worlds' },
|
||||
discoverContent: {
|
||||
id: 'app.navigation.discover-content',
|
||||
defaultMessage: 'Discover content',
|
||||
},
|
||||
skinSelector: { id: 'app.navigation.skin-selector', defaultMessage: 'Skin selector' },
|
||||
multiplayer: { id: 'app.navigation.multiplayer', defaultMessage: 'Multiplayer' },
|
||||
library: { id: 'app.navigation.library', defaultMessage: 'Library' },
|
||||
downloads: { id: 'app.navigation.downloads', defaultMessage: 'Downloads' },
|
||||
lab: { id: 'app.navigation.lab', defaultMessage: 'Lab' },
|
||||
gradientText: {
|
||||
id: 'app.lab.gradient-text.title',
|
||||
defaultMessage: 'Gradient text generator',
|
||||
},
|
||||
seedMap: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
|
||||
schematicWorkshop: {
|
||||
id: 'app.lab.schematic-preview.title',
|
||||
defaultMessage: 'Schematic workshop',
|
||||
},
|
||||
modTranslation: {
|
||||
id: 'app.lab.mod-translation.title',
|
||||
defaultMessage: 'Mod translation',
|
||||
},
|
||||
skinEditor: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
|
||||
content: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
|
||||
files: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
|
||||
studio: { id: 'instance.files.studio.title', defaultMessage: 'Studio' },
|
||||
logs: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
|
||||
editWorld: { id: 'app.navigation.edit-world', defaultMessage: 'Edit world' },
|
||||
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
|
||||
})
|
||||
|
||||
const staticLabels = {
|
||||
Home: messages.home,
|
||||
Worlds: messages.worlds,
|
||||
'Discover content': messages.discoverContent,
|
||||
'Skin selector': messages.skinSelector,
|
||||
Multiplayer: messages.multiplayer,
|
||||
Library: messages.library,
|
||||
Downloads: messages.downloads,
|
||||
Settings: commonMessages.settingsLabel,
|
||||
Lab: messages.lab,
|
||||
'Gradient text generator': messages.gradientText,
|
||||
'Seed map': messages.seedMap,
|
||||
'Schematic workshop': messages.schematicWorkshop,
|
||||
'Mod translation': messages.modTranslation,
|
||||
'Skin editor': messages.skinEditor,
|
||||
Content: messages.content,
|
||||
Files: messages.files,
|
||||
Studio: messages.studio,
|
||||
Logs: messages.logs,
|
||||
'Edit world': messages.editWorld,
|
||||
Upgrade: messages.upgradeInstance,
|
||||
}
|
||||
|
||||
const staticIcons: Record<string, Component> = {
|
||||
Home: HomeIcon,
|
||||
Worlds: GlobeIcon,
|
||||
'Discover content': CompassIcon,
|
||||
'Skin selector': ChangeSkinIcon,
|
||||
Multiplayer: ServerIcon,
|
||||
Library: LibraryIcon,
|
||||
Downloads: DownloadIcon,
|
||||
Settings: SettingsIcon,
|
||||
Lab: FlaskConicalIcon,
|
||||
'Gradient text generator': FlaskConicalIcon,
|
||||
'Seed map': MapIcon,
|
||||
'Schematic workshop': CodeIcon,
|
||||
'Mod translation': CodeIcon,
|
||||
'Skin editor': PencilIcon,
|
||||
Content: PackageIcon,
|
||||
Files: FolderIcon,
|
||||
Studio: CodeIcon,
|
||||
Logs: FileTextIcon,
|
||||
'Edit world': PencilIcon,
|
||||
Upgrade: ArrowBigUpDashIcon,
|
||||
Favorites: HeartIcon,
|
||||
Versions: PackageIcon,
|
||||
Gallery: ImagesIcon,
|
||||
Screenshots: ImagesIcon,
|
||||
'Drop help': FileTextIcon,
|
||||
'Recipe generator': FlaskConicalIcon,
|
||||
Downloaded: DownloadIcon,
|
||||
Modpacks: PackageIcon,
|
||||
LibraryServers: ServerIcon,
|
||||
Custom: PackageIcon,
|
||||
Shared: PackageIcon,
|
||||
Saved: HeartIcon,
|
||||
}
|
||||
|
||||
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
||||
const additionalContext =
|
||||
route.meta.useContext === true
|
||||
? breadcrumbData.context
|
||||
: route.meta.useRootContext === true
|
||||
? breadcrumbData.rootContext
|
||||
: null
|
||||
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
|
||||
if (
|
||||
additionalContext?.name.startsWith('?') &&
|
||||
crumbs.some((crumb) => crumb.name === additionalContext.name)
|
||||
) {
|
||||
return crumbs
|
||||
}
|
||||
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
|
||||
})
|
||||
|
||||
function resolveLabel(name: string): string {
|
||||
return resolveBreadcrumbLabel(
|
||||
name,
|
||||
(key) => breadcrumbData.getName(key),
|
||||
staticLabels,
|
||||
(message) => formatMessage(message),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveIcon(breadcrumb: Breadcrumb): Component | undefined {
|
||||
if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined
|
||||
const dynamicIcons: Record<string, Component> = {
|
||||
'?Project': PackageIcon,
|
||||
'?Version': PackageIcon,
|
||||
'?BrowseTitle': CompassIcon,
|
||||
'?FavoritesTitle': HeartIcon,
|
||||
}
|
||||
if (dynamicIcons[breadcrumb.name]) return dynamicIcons[breadcrumb.name]
|
||||
const key = breadcrumb.name.startsWith('?') ? resolveLabel(breadcrumb.name) : breadcrumb.name
|
||||
return staticIcons[key]
|
||||
}
|
||||
|
||||
function resolveIconUrl(breadcrumb: Breadcrumb): string | null {
|
||||
return (
|
||||
breadcrumb.iconUrl ??
|
||||
(breadcrumb.name.startsWith('?') ? breadcrumbData.getIcon(breadcrumb.name.slice(1)) : null)
|
||||
)
|
||||
}
|
||||
|
||||
// Overflow detection
|
||||
const outerRef = ref<HTMLDivElement | null>(null)
|
||||
const innerRef = ref<HTMLDivElement | null>(null)
|
||||
const isOverflowing = ref(false)
|
||||
const isAnimating = ref(false)
|
||||
const overflowAmount = ref(0)
|
||||
|
||||
let hovered = false
|
||||
let stopping = false
|
||||
|
||||
function checkOverflow() {
|
||||
if (!outerRef.value || !innerRef.value) return
|
||||
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
|
||||
isOverflowing.value = overflow > 0
|
||||
overflowAmount.value = overflow + 12
|
||||
}
|
||||
|
||||
function onMouseEnter() {
|
||||
hovered = true
|
||||
stopping = false
|
||||
if (isOverflowing.value) {
|
||||
isAnimating.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovered = false
|
||||
if (isAnimating.value) {
|
||||
stopping = true
|
||||
}
|
||||
}
|
||||
|
||||
function onAnimationIteration() {
|
||||
if (stopping && !hovered) {
|
||||
isAnimating.value = false
|
||||
stopping = false
|
||||
}
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
checkOverflow()
|
||||
resizeObserver = new ResizeObserver(checkOverflow)
|
||||
if (outerRef.value) resizeObserver.observe(outerRef.value)
|
||||
if (innerRef.value) resizeObserver.observe(innerRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(
|
||||
breadcrumbs,
|
||||
() => {
|
||||
breadcrumbData.resetToNames(breadcrumbs.value)
|
||||
requestAnimationFrame(checkOverflow)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-fade-mask {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px,
|
||||
black calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.breadcrumbs-scroll {
|
||||
animation: breadcrumb-scroll 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breadcrumb-scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
35%,
|
||||
65% {
|
||||
transform: translateX(var(--scroll-distance));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,725 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, Checkbox, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getActiveDependencyConflictIdentities } from '@/providers/content-selection-logic'
|
||||
|
||||
export interface ContentInstallPreviewDependency {
|
||||
id: string
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
description?: string
|
||||
projectUrl?: string
|
||||
requiredBy: string[]
|
||||
alreadyInstalled: boolean
|
||||
status?: 'installed' | 'included'
|
||||
versionMismatch?: boolean
|
||||
selectionReason?: string
|
||||
required?: boolean
|
||||
requiredByKeys?: string[]
|
||||
}
|
||||
|
||||
export interface ContentInstallPreviewSkipped {
|
||||
id: string
|
||||
title: string
|
||||
reason: string
|
||||
requiredByKeys?: string[]
|
||||
}
|
||||
|
||||
export interface ContentInstallPreviewData {
|
||||
primary?: ContentInstallPreviewPrimary
|
||||
primaries?: ContentInstallPreviewPrimary[]
|
||||
instanceName: string
|
||||
installDependencies: boolean
|
||||
dependencies: ContentInstallPreviewDependency[]
|
||||
skipped: ContentInstallPreviewSkipped[]
|
||||
}
|
||||
|
||||
export interface ContentInstallPreviewPrimary {
|
||||
key?: string
|
||||
title: string
|
||||
iconUrl?: string | null
|
||||
versionNumber?: string
|
||||
provider?: string
|
||||
contentType?: string
|
||||
error?: string
|
||||
conflictIdentities?: string[]
|
||||
removable?: boolean
|
||||
}
|
||||
|
||||
export interface ContentInstallBatchPreviewResult {
|
||||
approvedIds: string[]
|
||||
primaryKeys: string[]
|
||||
}
|
||||
|
||||
export interface ContentInstallConflictPrompt {
|
||||
candidate: {
|
||||
title: string
|
||||
provider: string
|
||||
contentType: string
|
||||
iconUrl?: string | null
|
||||
}
|
||||
existing: Array<{
|
||||
title: string
|
||||
provider: string
|
||||
fileName?: string
|
||||
}>
|
||||
source: 'heuristic'
|
||||
confidence: 'high' | 'possible'
|
||||
}
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.content-install.preview.header',
|
||||
defaultMessage: 'Confirm installation',
|
||||
},
|
||||
description: {
|
||||
id: 'app.content-install.preview.description',
|
||||
defaultMessage:
|
||||
'{count, plural, one {# dependency will be installed automatically} other {# dependencies will be installed automatically}} for {project} in {instance}.',
|
||||
},
|
||||
batchDescription: {
|
||||
id: 'app.content-install.preview.batch-description',
|
||||
defaultMessage:
|
||||
'Review {projectCount, plural, one {# project} other {# projects}} and {dependencyCount, plural, one {# dependency} other {# dependencies}} for {instance}.',
|
||||
},
|
||||
selectedContentHeader: {
|
||||
id: 'app.content-install.preview.selected-content-header',
|
||||
defaultMessage: 'Selected content',
|
||||
},
|
||||
removeProject: {
|
||||
id: 'app.content-install.preview.remove-project',
|
||||
defaultMessage: 'Remove {project} from this installation',
|
||||
},
|
||||
dependenciesHeader: {
|
||||
id: 'app.content-install.preview.dependencies-header',
|
||||
defaultMessage: 'Dependencies',
|
||||
},
|
||||
dependenciesCount: {
|
||||
id: 'app.content-install.preview.dependencies-count',
|
||||
defaultMessage: '{count, plural, one {# dependency} other {# dependencies}}',
|
||||
},
|
||||
requiredDependenciesHeader: {
|
||||
id: 'app.content-install.preview.required-dependencies-header',
|
||||
defaultMessage: 'Required dependencies',
|
||||
},
|
||||
optionalDependenciesHeader: {
|
||||
id: 'app.content-install.preview.optional-dependencies-header',
|
||||
defaultMessage: 'Optional dependencies',
|
||||
},
|
||||
requiredBy: {
|
||||
id: 'app.content-install.preview.required-by',
|
||||
defaultMessage: 'Required by {projects}',
|
||||
},
|
||||
alreadyInstalled: {
|
||||
id: 'app.content-install.preview.already-installed',
|
||||
defaultMessage: 'Already installed',
|
||||
},
|
||||
alreadyIncluded: {
|
||||
id: 'app.content-install.preview.already-included',
|
||||
defaultMessage: 'Already included',
|
||||
},
|
||||
versionMismatch: {
|
||||
id: 'app.content-install.preview.version-mismatch',
|
||||
defaultMessage: 'Version may not match this instance',
|
||||
},
|
||||
skippedHeader: {
|
||||
id: 'app.content-install.preview.skipped-header',
|
||||
defaultMessage: 'Skipped',
|
||||
},
|
||||
onlyChecked: {
|
||||
id: 'app.content-install.preview.only-checked',
|
||||
defaultMessage: 'Only checked dependencies will be installed.',
|
||||
},
|
||||
selectAll: {
|
||||
id: 'app.content-install.preview.select-all',
|
||||
defaultMessage: 'Select all',
|
||||
},
|
||||
clearAll: {
|
||||
id: 'app.content-install.preview.clear-all',
|
||||
defaultMessage: 'Clear all',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.content-install.preview.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
viewDetails: {
|
||||
id: 'app.content-install.preview.view-details',
|
||||
defaultMessage: 'View details',
|
||||
},
|
||||
openProjectPage: {
|
||||
id: 'app.content-install.preview.open-project-page',
|
||||
defaultMessage: 'Open project page',
|
||||
},
|
||||
descriptionUnavailable: {
|
||||
id: 'app.content-install.preview.description-unavailable',
|
||||
defaultMessage: 'No description available.',
|
||||
},
|
||||
install: {
|
||||
id: 'app.content-install.preview.install',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
installResolved: {
|
||||
id: 'app.content-install.preview.install-resolved',
|
||||
defaultMessage: 'Install resolved content',
|
||||
},
|
||||
conflictHeader: {
|
||||
id: 'app.content-install.preview.conflict-header',
|
||||
defaultMessage: 'Possible duplicate content',
|
||||
},
|
||||
conflictDescription: {
|
||||
id: 'app.content-install.preview.conflict-description',
|
||||
defaultMessage:
|
||||
'{candidate} may be the same content as an installed or selected project. Continue anyway?',
|
||||
},
|
||||
continueAnyway: {
|
||||
id: 'app.content-install.preview.continue-anyway',
|
||||
defaultMessage: 'Install anyway',
|
||||
},
|
||||
existingContent: {
|
||||
id: 'app.content-install.preview.existing-content',
|
||||
defaultMessage: 'Existing content',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const data = ref<ContentInstallPreviewData | null>(null)
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const expandedDependencyIds = ref<Set<string>>(new Set())
|
||||
const removedPrimaryKeys = ref<Set<string>>(new Set())
|
||||
let settled = false
|
||||
let batchMode = false
|
||||
let conflictMode = false
|
||||
const conflictPrompt = ref<ContentInstallConflictPrompt | null>(null)
|
||||
let resolveShow:
|
||||
| ((result: string[] | ContentInstallBatchPreviewResult | boolean | null) => void)
|
||||
| null = null
|
||||
|
||||
const primaries = computed(() => {
|
||||
if (!data.value) return []
|
||||
if (data.value.primaries?.length) return data.value.primaries
|
||||
return data.value.primary ? [data.value.primary] : []
|
||||
})
|
||||
const visiblePrimaries = computed(() =>
|
||||
primaries.value.filter((primary) => !primary.key || !removedPrimaryKeys.value.has(primary.key)),
|
||||
)
|
||||
const visiblePrimaryKeys = computed(() =>
|
||||
visiblePrimaries.value.map((primary) => primary.key).filter((key): key is string => !!key),
|
||||
)
|
||||
const visiblePrimaryKeySet = computed(() => new Set(visiblePrimaryKeys.value))
|
||||
const visibleDependencies = computed(
|
||||
() =>
|
||||
data.value?.dependencies.filter(
|
||||
(dependency) =>
|
||||
!dependency.requiredByKeys?.length ||
|
||||
dependency.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
|
||||
) ?? [],
|
||||
)
|
||||
const activeConflictIdentities = computed(() =>
|
||||
getActiveDependencyConflictIdentities(data.value?.dependencies ?? [], visiblePrimaryKeySet.value),
|
||||
)
|
||||
function primaryError(primary: ContentInstallPreviewPrimary) {
|
||||
if (!primary.error) return null
|
||||
if (!primary.conflictIdentities?.length) return primary.error
|
||||
return primary.conflictIdentities.some((identity) => activeConflictIdentities.value.has(identity))
|
||||
? primary.error
|
||||
: null
|
||||
}
|
||||
const visibleSkipped = computed(
|
||||
() =>
|
||||
data.value?.skipped.filter(
|
||||
(skipped) =>
|
||||
!skipped.requiredByKeys?.length ||
|
||||
skipped.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
|
||||
) ?? [],
|
||||
)
|
||||
const hasBlockingPrimary = computed(() =>
|
||||
visiblePrimaries.value.some((primary) => !!primaryError(primary)),
|
||||
)
|
||||
|
||||
const installableDependencies = computed(() => visibleDependencies.value)
|
||||
const dependencyGroups = computed(() =>
|
||||
[
|
||||
{
|
||||
id: 'required',
|
||||
header: messages.requiredDependenciesHeader,
|
||||
dependencies: visibleDependencies.value.filter((dependency) => dependency.required !== false),
|
||||
},
|
||||
{
|
||||
id: 'optional',
|
||||
header: messages.optionalDependenciesHeader,
|
||||
dependencies: visibleDependencies.value.filter((dependency) => dependency.required === false),
|
||||
},
|
||||
].filter((group) => group.dependencies.length > 0),
|
||||
)
|
||||
const selectedInstallableCount = computed(
|
||||
() =>
|
||||
installableDependencies.value.filter((dependency) => selectedIds.value.has(dependency.id))
|
||||
.length,
|
||||
)
|
||||
const hasUnresolvedDependencies = computed(() => visibleSkipped.value.length > 0)
|
||||
|
||||
function toggleDependency(id: string, value: boolean) {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (value) next.add(id)
|
||||
else next.delete(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function toggleAll(value: boolean) {
|
||||
const next = new Set(selectedIds.value)
|
||||
for (const dependency of installableDependencies.value) {
|
||||
if (value) next.add(dependency.id)
|
||||
else next.delete(dependency.id)
|
||||
}
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
function hasDependencyDetails(dependency: ContentInstallPreviewDependency) {
|
||||
return !!dependency.description || !!dependency.projectUrl
|
||||
}
|
||||
|
||||
function toggleDependencyDetails(id: string) {
|
||||
const next = new Set(expandedDependencyIds.value)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
expandedDependencyIds.value = next
|
||||
}
|
||||
|
||||
async function openDependencyPage(dependency: ContentInstallPreviewDependency) {
|
||||
if (!dependency.projectUrl) return
|
||||
await openUrl(dependency.projectUrl)
|
||||
}
|
||||
|
||||
function initialSelectedIds(value: ContentInstallPreviewData) {
|
||||
if (!value.installDependencies) return new Set<string>()
|
||||
return new Set(
|
||||
value.dependencies
|
||||
.filter((dependency) => !dependency.alreadyInstalled && dependency.required !== false)
|
||||
.map((dependency) => dependency.id),
|
||||
)
|
||||
}
|
||||
|
||||
function finish(result: string[] | ContentInstallBatchPreviewResult | boolean | null) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
const resolve = resolveShow
|
||||
resolveShow = null
|
||||
if (resolve) resolve(result)
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (conflictMode) {
|
||||
finish(true)
|
||||
return
|
||||
}
|
||||
if (hasBlockingPrimary.value || visiblePrimaries.value.length === 0) return
|
||||
const approvedIds = visibleDependencies.value
|
||||
.filter((dependency) => selectedIds.value.has(dependency.id))
|
||||
.map((dependency) => dependency.id)
|
||||
finish(batchMode ? { approvedIds, primaryKeys: visiblePrimaryKeys.value } : approvedIds)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
finish(null)
|
||||
}
|
||||
|
||||
function show(value: ContentInstallPreviewData): Promise<string[] | null> {
|
||||
resolveShow?.(null)
|
||||
resolveShow = null
|
||||
data.value = value
|
||||
batchMode = false
|
||||
conflictMode = false
|
||||
conflictPrompt.value = null
|
||||
removedPrimaryKeys.value = new Set()
|
||||
selectedIds.value = initialSelectedIds(value)
|
||||
expandedDependencyIds.value = new Set()
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
resolveShow = (result) => resolve(Array.isArray(result) ? result : null)
|
||||
})
|
||||
}
|
||||
|
||||
function showBatch(
|
||||
value: ContentInstallPreviewData,
|
||||
): Promise<ContentInstallBatchPreviewResult | null> {
|
||||
resolveShow?.(null)
|
||||
resolveShow = null
|
||||
data.value = value
|
||||
batchMode = true
|
||||
conflictMode = false
|
||||
conflictPrompt.value = null
|
||||
removedPrimaryKeys.value = new Set()
|
||||
selectedIds.value = initialSelectedIds(value)
|
||||
expandedDependencyIds.value = new Set()
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise<ContentInstallBatchPreviewResult | null>((resolve) => {
|
||||
resolveShow = (result) =>
|
||||
resolve(result && !Array.isArray(result) && typeof result !== 'boolean' ? result : null)
|
||||
})
|
||||
}
|
||||
|
||||
function showConflict(value: ContentInstallConflictPrompt): Promise<boolean> {
|
||||
resolveShow?.(null)
|
||||
resolveShow = null
|
||||
data.value = null
|
||||
batchMode = false
|
||||
conflictMode = true
|
||||
conflictPrompt.value = value
|
||||
settled = false
|
||||
modal.value?.show()
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveShow = (result) => resolve(result === true)
|
||||
})
|
||||
}
|
||||
|
||||
function removePrimary(key: string) {
|
||||
removedPrimaryKeys.value = new Set([...removedPrimaryKeys.value, key])
|
||||
}
|
||||
|
||||
defineExpose({ show, showBatch, showConflict })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(conflictMode ? messages.conflictHeader : messages.header)"
|
||||
scrollable
|
||||
max-content-height="70vh"
|
||||
width="40rem"
|
||||
max-width="40rem"
|
||||
:on-hide="hide"
|
||||
>
|
||||
<div v-if="conflictPrompt" class="flex flex-col gap-4">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-solid border-warning bg-warning-bg p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="conflictPrompt.candidate.iconUrl"
|
||||
:alt="conflictPrompt.candidate.title"
|
||||
size="2.5rem"
|
||||
:tint-by="conflictPrompt.candidate.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate font-semibold text-contrast">{{
|
||||
conflictPrompt.candidate.title
|
||||
}}</span>
|
||||
<span class="block truncate text-sm text-secondary">
|
||||
{{
|
||||
[conflictPrompt.candidate.provider, conflictPrompt.candidate.contentType].join(' · ')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-primary">
|
||||
{{
|
||||
formatMessage(messages.conflictDescription, {
|
||||
candidate: conflictPrompt.candidate.title,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.existingContent)
|
||||
}}</span>
|
||||
<div
|
||||
v-for="item in conflictPrompt.existing"
|
||||
:key="`${item.provider}:${item.title}:${item.fileName ?? ''}`"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-contrast">{{ item.title }}</span>
|
||||
<span class="shrink-0 text-sm text-secondary">{{ item.provider }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex min-w-0 flex-col gap-4">
|
||||
<div v-if="batchMode" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.selectedContentHeader)
|
||||
}}</span>
|
||||
<div
|
||||
v-for="primary in visiblePrimaries"
|
||||
:key="primary.key ?? primary.title"
|
||||
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="primary.iconUrl"
|
||||
:alt="primary.title"
|
||||
size="2.5rem"
|
||||
:tint-by="primary.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ primary.title }}</span>
|
||||
<span class="truncate text-sm text-secondary">
|
||||
{{
|
||||
[primary.versionNumber, primary.provider, primary.contentType]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
}}
|
||||
</span>
|
||||
<span v-if="primaryError(primary)" class="text-sm text-red">
|
||||
{{ primaryError(primary) }}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled v-if="primary.removable && primary.key" circular type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.removeProject, { project: primary.title })"
|
||||
@click="removePrimary(primary.key)"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="visiblePrimaries[0]"
|
||||
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
|
||||
>
|
||||
<Avatar
|
||||
:src="visiblePrimaries[0].iconUrl"
|
||||
:alt="visiblePrimaries[0].title"
|
||||
size="2.5rem"
|
||||
:tint-by="visiblePrimaries[0].title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-semibold text-contrast">{{ visiblePrimaries[0].title }}</span>
|
||||
<span v-if="visiblePrimaries[0].versionNumber" class="truncate text-sm text-secondary">
|
||||
{{ visiblePrimaries[0].versionNumber }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-primary">
|
||||
{{
|
||||
batchMode
|
||||
? formatMessage(messages.batchDescription, {
|
||||
projectCount: visiblePrimaries.length,
|
||||
dependencyCount: visibleDependencies.length,
|
||||
instance: data.instanceName,
|
||||
})
|
||||
: formatMessage(messages.description, {
|
||||
count: selectedInstallableCount,
|
||||
project: visiblePrimaries[0]?.title ?? '',
|
||||
instance: data.instanceName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div v-if="visibleDependencies.length > 0" class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.dependenciesHeader) }}
|
||||
<span
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.dependenciesCount, { count: visibleDependencies.length }) }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonStyled v-if="installableDependencies.length > 1" size="small" type="transparent">
|
||||
<button @click="toggleAll(selectedInstallableCount !== installableDependencies.length)">
|
||||
{{
|
||||
selectedInstallableCount === installableDependencies.length
|
||||
? formatMessage(messages.clearAll)
|
||||
: formatMessage(messages.selectAll)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3"
|
||||
:class="{ 'sm:grid-cols-2': dependencyGroups.length > 1 }"
|
||||
>
|
||||
<div
|
||||
v-for="group in dependencyGroups"
|
||||
:key="group.id"
|
||||
class="flex min-w-0 flex-col gap-2"
|
||||
>
|
||||
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||
{{ formatMessage(group.header) }}
|
||||
<span
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
|
||||
>
|
||||
{{ group.dependencies.length }}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
v-for="dependency in group.dependencies"
|
||||
:key="dependency.id"
|
||||
class="flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-solid border-surface-4 bg-surface-2"
|
||||
:class="{ 'opacity-60': dependency.alreadyInstalled }"
|
||||
>
|
||||
<div class="flex items-start gap-3 p-3">
|
||||
<Checkbox
|
||||
:model-value="selectedIds.has(dependency.id)"
|
||||
class="mt-2 shrink-0"
|
||||
@update:model-value="(value) => toggleDependency(dependency.id, value)"
|
||||
/>
|
||||
<Avatar
|
||||
:src="dependency.iconUrl"
|
||||
:alt="dependency.title"
|
||||
size="2.5rem"
|
||||
:tint-by="dependency.title"
|
||||
no-shadow
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<button
|
||||
v-if="hasDependencyDetails(dependency)"
|
||||
type="button"
|
||||
class="group flex w-full min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
|
||||
:aria-expanded="expandedDependencyIds.has(dependency.id)"
|
||||
:aria-label="formatMessage(messages.viewDetails, { project: dependency.title })"
|
||||
@click="toggleDependencyDetails(dependency.id)"
|
||||
>
|
||||
<span
|
||||
v-tooltip="
|
||||
dependency.description
|
||||
? {
|
||||
content: dependency.description,
|
||||
placement: 'top',
|
||||
popperClass: 'preview-dependency-tooltip',
|
||||
}
|
||||
: null
|
||||
"
|
||||
class="min-w-0 truncate font-semibold text-contrast group-hover:underline"
|
||||
>
|
||||
{{ dependency.title }}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
class="shrink-0 text-secondary transition-transform duration-150"
|
||||
:class="{
|
||||
'rotate-180': expandedDependencyIds.has(dependency.id),
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="truncate font-semibold text-contrast">
|
||||
{{ dependency.title }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.versionNumber"
|
||||
class="min-w-0 truncate text-sm text-secondary"
|
||||
>
|
||||
{{ dependency.versionNumber }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.requiredBy.length > 0"
|
||||
class="min-w-0 truncate text-sm text-secondary"
|
||||
>
|
||||
{{
|
||||
formatMessage(messages.requiredBy, {
|
||||
projects: dependency.requiredBy.join(', '),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
<span
|
||||
v-if="dependency.versionMismatch"
|
||||
class="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-medium text-warning-text"
|
||||
>
|
||||
{{ formatMessage(messages.versionMismatch) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.selectionReason"
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
|
||||
>
|
||||
{{ dependency.selectionReason }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dependency.alreadyInstalled"
|
||||
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
|
||||
>
|
||||
{{
|
||||
formatMessage(
|
||||
dependency.status === 'included'
|
||||
? messages.alreadyIncluded
|
||||
: messages.alreadyInstalled,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="expandedDependencyIds.has(dependency.id)"
|
||||
class="mb-3 flex w-auto min-w-0 flex-col gap-2.5 rounded-lg bg-surface-1 px-3 py-2.5 mx-3"
|
||||
>
|
||||
<p
|
||||
v-if="dependency.description"
|
||||
class="m-0 w-full min-w-0 text-sm leading-relaxed text-secondary [overflow-wrap:anywhere]"
|
||||
>
|
||||
{{ dependency.description }}
|
||||
</p>
|
||||
<p v-else class="m-0 w-full min-w-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.descriptionUnavailable) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="dependency.projectUrl" class="self-start" type="outlined">
|
||||
<button type="button" @click="openDependencyPage(dependency)">
|
||||
{{ formatMessage(messages.openProjectPage) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-secondary">{{ formatMessage(messages.onlyChecked) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSkipped.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.skippedHeader) }}</span>
|
||||
<div
|
||||
v-for="skipped in visibleSkipped"
|
||||
:key="skipped.id"
|
||||
class="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate font-medium text-contrast">
|
||||
{{ skipped.title }}
|
||||
</span>
|
||||
<span class="shrink-0 text-sm text-secondary">{{ skipped.reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="hide">{{ formatMessage(messages.cancel) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!conflictMode && (hasBlockingPrimary || visiblePrimaries.length === 0)"
|
||||
@click="confirm"
|
||||
>
|
||||
{{
|
||||
conflictMode
|
||||
? formatMessage(messages.continueAnyway)
|
||||
: hasUnresolvedDependencies
|
||||
? formatMessage(messages.installResolved)
|
||||
: formatMessage(messages.install)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.preview-dependency-tooltip.v-popper--theme-tooltip .v-popper__inner {
|
||||
max-width: 22rem;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
181
apps/app-frontend/src/components/ui/ContextMenu.vue
Normal file
181
apps/app-frontend/src/components/ui/ContextMenu.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="shown"
|
||||
ref="contextMenu"
|
||||
class="context-menu"
|
||||
:style="{
|
||||
left: left,
|
||||
top: top,
|
||||
}"
|
||||
>
|
||||
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
|
||||
<hr v-if="option.type === 'divider'" class="divider" />
|
||||
<div
|
||||
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
|
||||
class="item clickable"
|
||||
:class="[option.color ?? 'base']"
|
||||
>
|
||||
<slot :name="option.name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['menu-closed', 'option-clicked'])
|
||||
|
||||
const item = ref(null)
|
||||
const contextMenu = ref(null)
|
||||
const options = ref([])
|
||||
const left = ref('0px')
|
||||
const top = ref('0px')
|
||||
const shown = ref(false)
|
||||
|
||||
defineExpose({
|
||||
showMenu: (event, passedItem, passedOptions) => {
|
||||
item.value = passedItem
|
||||
options.value = passedOptions
|
||||
|
||||
// show to get dimensions
|
||||
shown.value = true
|
||||
|
||||
// then, adjust position if overflowing
|
||||
nextTick(() => {
|
||||
const menuWidth = contextMenu.value?.clientWidth || 200
|
||||
const menuHeight = contextMenu.value?.clientHeight || 100
|
||||
const minFromEdge = 10
|
||||
|
||||
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
|
||||
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
|
||||
} else {
|
||||
left.value = event.pageX + minFromEdge + 'px'
|
||||
}
|
||||
|
||||
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
|
||||
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
|
||||
} else {
|
||||
top.value = event.pageY + minFromEdge + 'px'
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const isInstanceLink = (item) => {
|
||||
if (item.instance != undefined && item.instance.link) {
|
||||
return true
|
||||
} else if (item != undefined && item.link) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const hideContextMenu = () => {
|
||||
shown.value = false
|
||||
emit('menu-closed')
|
||||
}
|
||||
|
||||
const optionClicked = (option) => {
|
||||
emit('option-clicked', {
|
||||
item: item.value,
|
||||
option: option,
|
||||
})
|
||||
hideContextMenu()
|
||||
}
|
||||
|
||||
const onEscKeyRelease = (event) => {
|
||||
if (event.keyCode === 27) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
const elements = document.elementsFromPoint(event.clientX, event.clientY)
|
||||
if (
|
||||
contextMenu.value &&
|
||||
contextMenu.value.$el !== event.target &&
|
||||
!elements.includes(contextMenu.value.$el)
|
||||
) {
|
||||
hideContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
document.body.addEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
document.body.removeEventListener('keyup', onEscKeyRelease)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.context-menu {
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-floating);
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
z-index: 1000000;
|
||||
overflow: hidden;
|
||||
padding: var(--gap-sm);
|
||||
|
||||
.item {
|
||||
align-items: center;
|
||||
color: var(--color-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
padding: var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
&.base {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: var(--color-red);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&.contrast {
|
||||
background-color: var(--color-orange);
|
||||
color: var(--color-accent-contrast);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: 1px solid var(--color-divider);
|
||||
margin: var(--gap-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { explain_crash_with_ai } from '@/helpers/logs'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const loading = ref(false)
|
||||
const output = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.crash-analysis.ai.title', defaultMessage: 'AI crash explanation' },
|
||||
disclaimer: {
|
||||
id: 'app.crash-analysis.ai.disclaimer',
|
||||
defaultMessage:
|
||||
'A sanitized and shortened crash context is sent directly to the AI provider configured in this launcher. AI output may be inaccurate.',
|
||||
},
|
||||
analyzing: { id: 'app.crash-analysis.ai.analyzing', defaultMessage: 'Explaining the crash...' },
|
||||
error: { id: 'app.crash-analysis.ai.error', defaultMessage: 'AI explanation failed: {message}' },
|
||||
copy: { id: 'app.crash-analysis.ai.copy', defaultMessage: 'Copy explanation' },
|
||||
copied: {
|
||||
id: 'app.crash-analysis.ai.copied',
|
||||
defaultMessage: 'AI explanation copied to your clipboard',
|
||||
},
|
||||
close: { id: 'app.crash-analysis.ai.close', defaultMessage: 'Close' },
|
||||
})
|
||||
|
||||
const renderedOutput = computed(() => renderHighlightedString(output.value))
|
||||
|
||||
async function show(instanceId: string): Promise<void> {
|
||||
output.value = ''
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
modal.value?.show()
|
||||
try {
|
||||
const result = await explain_crash_with_ai(instanceId)
|
||||
output.value = result.content
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(output.value)
|
||||
addNotification({ title: formatMessage(messages.copied), type: 'success' })
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="720px">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.title)">
|
||||
{{ formatMessage(messages.disclaimer) }}
|
||||
</Admonition>
|
||||
<div v-if="loading" class="text-secondary">{{ formatMessage(messages.analyzing) }}</div>
|
||||
<div v-else-if="errorMessage" class="rounded-lg bg-red-500/10 p-3 text-secondary">
|
||||
{{ formatMessage(messages.error, { message: errorMessage }) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="output"
|
||||
class="markdown-body max-h-[55vh] overflow-y-auto rounded-lg bg-surface-2 p-4"
|
||||
v-html="renderedOutput"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<ButtonStyled v-if="output" type="outlined">
|
||||
<button @click="copy">{{ formatMessage(messages.copy) }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loading" @click="modal?.hide()">
|
||||
{{ formatMessage(messages.close) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
167
apps/app-frontend/src/components/ui/CrashModChangesModal.vue
Normal file
167
apps/app-frontend/src/components/ui/CrashModChangesModal.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { CrashAnalysisResult } from '@/composables/useCrashAnalysis'
|
||||
import { refresh_content } from '@/helpers/instance'
|
||||
import { undo_added_mod } from '@/helpers/logs'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const analysis = ref<CrashAnalysisResult | null>(null)
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const busy = ref<string | null>(null)
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.title',
|
||||
defaultMessage: 'Mod changes since the last successful launch',
|
||||
},
|
||||
description: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.description',
|
||||
defaultMessage: 'This comparison does not restore or modify any files.',
|
||||
},
|
||||
added: { id: 'app.minecraft-crash.mod-changes-modal.added', defaultMessage: 'Added ({count})' },
|
||||
removed: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.removed',
|
||||
defaultMessage: 'Removed ({count})',
|
||||
},
|
||||
modified: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.modified',
|
||||
defaultMessage: 'Modified ({count})',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.empty',
|
||||
defaultMessage: 'No Mod file changes were detected.',
|
||||
},
|
||||
close: { id: 'app.minecraft-crash.mod-changes-modal.close', defaultMessage: 'Close' },
|
||||
undo: { id: 'app.minecraft-crash.mod-changes-modal.undo', defaultMessage: 'Undo added Mod' },
|
||||
undone: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.undone',
|
||||
defaultMessage: 'Added Mod removed',
|
||||
},
|
||||
undoConfirm: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.undo-confirm',
|
||||
defaultMessage: 'Remove {name}? Only this unchanged file will be deleted.',
|
||||
},
|
||||
undoFailed: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.undo-failed',
|
||||
defaultMessage: 'Could not undo this Mod change',
|
||||
},
|
||||
refreshFailed: {
|
||||
id: 'app.minecraft-crash.mod-changes-modal.refresh-failed',
|
||||
defaultMessage: 'Mod removed, but the content list could not be refreshed.',
|
||||
},
|
||||
})
|
||||
|
||||
const groups = computed(() =>
|
||||
(['added', 'removed', 'modified'] as const).map((kind) => ({
|
||||
kind,
|
||||
items: (analysis.value?.mod_changes ?? []).filter((change) => change.kind === kind),
|
||||
})),
|
||||
)
|
||||
const groupMessages = {
|
||||
added: messages.added,
|
||||
removed: messages.removed,
|
||||
modified: messages.modified,
|
||||
} as const
|
||||
|
||||
function show(nextAnalysis: CrashAnalysisResult): void {
|
||||
analysis.value = nextAnalysis
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
async function undo(change: (typeof groups.value)[number]['items'][number]): Promise<void> {
|
||||
if (change.kind !== 'added' || busy.value) return
|
||||
const currentAnalysis = analysis.value
|
||||
if (!currentAnalysis || !change.current_sha256) return
|
||||
const name = change.project_title || change.filename
|
||||
if (!window.confirm(formatMessage(messages.undoConfirm, { name }))) return
|
||||
busy.value = change.filename
|
||||
let removed = false
|
||||
try {
|
||||
await undo_added_mod(currentAnalysis.instance_id, change.filename, change.current_sha256)
|
||||
removed = true
|
||||
currentAnalysis.mod_changes = currentAnalysis.mod_changes.filter(
|
||||
(item) => item.filename !== change.filename,
|
||||
)
|
||||
addNotification({ title: formatMessage(messages.undone), type: 'success' })
|
||||
} catch {
|
||||
addNotification({ title: formatMessage(messages.undoFailed), type: 'error' })
|
||||
} finally {
|
||||
busy.value = null
|
||||
}
|
||||
if (!removed) return
|
||||
try {
|
||||
await refresh_content(currentAnalysis.instance_id)
|
||||
} catch {
|
||||
addNotification({ title: formatMessage(messages.refreshFailed), type: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="680px">
|
||||
<div class="flex max-h-[65vh] flex-col gap-4 overflow-y-auto">
|
||||
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
|
||||
<p v-if="!analysis?.mod_changes.length" class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
<section v-for="group in groups" v-else :key="group.kind" class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(groupMessages[group.kind], { count: group.items.length }) }}
|
||||
</h3>
|
||||
<ul v-if="group.items.length" class="m-0 flex list-none flex-col gap-1 p-0">
|
||||
<li
|
||||
v-for="change in group.items"
|
||||
:key="`${group.kind}:${change.filename}`"
|
||||
class="rounded-md bg-surface-2 px-3 py-2 text-sm text-secondary"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="size-8 shrink-0 overflow-hidden rounded bg-surface-3">
|
||||
<img
|
||||
v-if="change.icon_url"
|
||||
:src="change.icon_url"
|
||||
:alt="change.project_title || ''"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
v-if="change.project_title && change.project_title !== change.filename"
|
||||
class="truncate font-sans text-sm text-contrast"
|
||||
>
|
||||
{{ change.project_title || change.filename }}
|
||||
</div>
|
||||
<div class="truncate text-xs text-secondary">
|
||||
{{ change.version_number ? `v${change.version_number} · ` : ''
|
||||
}}{{ change.filename }}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled v-if="change.kind === 'added'" type="outlined">
|
||||
<button :disabled="busy === change.filename" @click="undo(change)">
|
||||
{{ formatMessage(messages.undo) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
507
apps/app-frontend/src/components/ui/ErrorModal.vue
Normal file
507
apps/app-frontend/src/components/ui/ErrorModal.vue
Normal file
@ -0,0 +1,507 @@
|
||||
<script setup>
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
HammerIcon,
|
||||
LogInIcon,
|
||||
UpdatedIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Collapsible,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { AxolotlBrandConfig } from '@/config'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { install_existing_instance } from '@/helpers/install'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { exportErrorLogs } from '@/helpers/utils'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
genericTitle: { id: 'app.error.generic-title', defaultMessage: 'An error occurred' },
|
||||
minecraftAuthTitle: {
|
||||
id: 'app.error.minecraft-auth-title',
|
||||
defaultMessage: 'Unable to sign in to Minecraft',
|
||||
},
|
||||
minecraftSignInTitle: {
|
||||
id: 'app.error.minecraft-sign-in-title',
|
||||
defaultMessage: 'Sign in to Minecraft',
|
||||
},
|
||||
directoryTitle: {
|
||||
id: 'app.error.directory-title',
|
||||
defaultMessage: 'Could not change app directory',
|
||||
},
|
||||
loaderTitle: { id: 'app.error.loader-title', defaultMessage: 'No loader selected' },
|
||||
stateTitle: {
|
||||
id: 'app.error.state-title',
|
||||
defaultMessage: 'Error initializing Axolotl Launcher',
|
||||
},
|
||||
networkIssues: { id: 'app.error.network-issues', defaultMessage: 'Network issues' },
|
||||
networkDescription: {
|
||||
id: 'app.error.network-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher had trouble connecting to Microsoft services. This is often caused by a poor connection. Try again, and use our support article if the issue persists.',
|
||||
},
|
||||
hostsDescription: {
|
||||
id: 'app.error.hosts-description',
|
||||
defaultMessage:
|
||||
'The connection to Microsoft, Xbox, or Minecraft services was rejected. These services may be blocked by your hosts file. See our support article for steps to fix the issue.',
|
||||
},
|
||||
supportArticle: { id: 'app.error.support-article', defaultMessage: 'Support article' },
|
||||
tryAnotherAccount: {
|
||||
id: 'app.error.try-another-account',
|
||||
defaultMessage: 'Try another Microsoft account',
|
||||
},
|
||||
accountDescription: {
|
||||
id: 'app.error.account-description',
|
||||
defaultMessage:
|
||||
'Check that you signed in with the correct account. You may own Minecraft on another Microsoft account.',
|
||||
},
|
||||
tryAnotherAccountButton: {
|
||||
id: 'app.error.try-another-account-button',
|
||||
defaultMessage: 'Try another account',
|
||||
},
|
||||
officialLauncherTitle: {
|
||||
id: 'app.error.official-launcher-title',
|
||||
defaultMessage: 'Using PC Game Pass, coming from Bedrock, or just bought the game?',
|
||||
},
|
||||
officialLauncherBefore: {
|
||||
id: 'app.error.official-launcher-before',
|
||||
defaultMessage: 'Try signing in with the',
|
||||
},
|
||||
officialLauncher: {
|
||||
id: 'app.error.official-launcher',
|
||||
defaultMessage: 'official Minecraft Launcher',
|
||||
},
|
||||
officialLauncherAfter: {
|
||||
id: 'app.error.official-launcher-after',
|
||||
defaultMessage: 'first. When that is complete, return here and sign in.',
|
||||
},
|
||||
tryAgain: { id: 'app.error.try-sign-in-again', defaultMessage: 'Try signing in again' },
|
||||
permissionsTitle: {
|
||||
id: 'app.error.permissions-title',
|
||||
defaultMessage: 'Change directory permissions',
|
||||
},
|
||||
permissionsDescription: {
|
||||
id: 'app.error.permissions-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher cannot write to the selected directory. Adjust its permissions and try again, or cancel the directory change.',
|
||||
},
|
||||
spaceTitle: { id: 'app.error.space-title', defaultMessage: 'Not enough space' },
|
||||
spaceDescription: {
|
||||
id: 'app.error.space-description',
|
||||
defaultMessage:
|
||||
'The disk containing the selected directory does not have enough free space. Free some space and try again, or cancel the directory change.',
|
||||
},
|
||||
directoryDescription: {
|
||||
id: 'app.error.directory-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher cannot migrate to the selected directory. Contact support for help or cancel the directory change.',
|
||||
},
|
||||
retryDirectory: {
|
||||
id: 'app.error.retry-directory',
|
||||
defaultMessage: 'Retry directory change',
|
||||
},
|
||||
cancelDirectory: {
|
||||
id: 'app.error.cancel-directory',
|
||||
defaultMessage: 'Cancel directory change',
|
||||
},
|
||||
minecraftRequired: {
|
||||
id: 'app.error.minecraft-required',
|
||||
defaultMessage:
|
||||
'You are not logged in to any account. Please log in below. If you do not have a licensed account, you can create an offline account or log in with a third-party service in the sidebar.',
|
||||
},
|
||||
stateDescription: {
|
||||
id: 'app.error.state-description',
|
||||
defaultMessage:
|
||||
'Axolotl Launcher failed to load correctly. A file may be corrupted or an essential file may be missing.',
|
||||
},
|
||||
stateFixIntro: {
|
||||
id: 'app.error.state-fix-intro',
|
||||
defaultMessage: 'Try one of the following:',
|
||||
},
|
||||
stateFixInternet: {
|
||||
id: 'app.error.state-fix-internet',
|
||||
defaultMessage: 'Check your internet connection, then restart the app.',
|
||||
},
|
||||
stateFixRedownload: {
|
||||
id: 'app.error.state-fix-redownload',
|
||||
defaultMessage: 'Download and install the app again.',
|
||||
},
|
||||
loaderDescription: {
|
||||
id: 'app.error.loader-description',
|
||||
defaultMessage: 'Axolotl Launcher could not find a loader version for this instance.',
|
||||
},
|
||||
loaderFix: {
|
||||
id: 'app.error.loader-fix',
|
||||
defaultMessage: 'Repair the instance using the button below.',
|
||||
},
|
||||
repairInstance: { id: 'app.error.repair-instance', defaultMessage: 'Repair instance' },
|
||||
supportDescription: {
|
||||
id: 'app.error.support-description',
|
||||
defaultMessage:
|
||||
'If you still need help, visit our support page and provide the following debug information.',
|
||||
},
|
||||
getSupport: { id: 'app.error.get-support', defaultMessage: 'Get support' },
|
||||
debugInformation: { id: 'app.error.debug-information', defaultMessage: 'Debug information' },
|
||||
copyDebugInfo: { id: 'app.error.copy-debug-info', defaultMessage: 'Copy debug information' },
|
||||
exportLogs: { id: 'app.error.export-logs', defaultMessage: 'Export error logs' },
|
||||
noErrorMessage: { id: 'app.error.no-error-message', defaultMessage: 'No error message.' },
|
||||
})
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
const closable = ref(true)
|
||||
const errorCollapsed = ref(false)
|
||||
|
||||
const title = ref(formatMessage(messages.genericTitle))
|
||||
const errorType = ref('unknown')
|
||||
const supportLink = ref(AxolotlBrandConfig.supportUrl)
|
||||
const metadata = ref({})
|
||||
|
||||
defineExpose({
|
||||
async show(errorVal, context, canClose = true, source = null) {
|
||||
console.log(errorVal, context, canClose, source)
|
||||
closable.value = canClose
|
||||
|
||||
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
|
||||
title.value = formatMessage(messages.minecraftAuthTitle)
|
||||
errorType.value = 'minecraft_auth'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
|
||||
if (
|
||||
errorVal.message.includes('existing connection was forcibly closed') ||
|
||||
errorVal.message.includes('error sending request for url')
|
||||
) {
|
||||
metadata.value.network = true
|
||||
}
|
||||
if (errorVal.message.includes('because the target machine actively refused it')) {
|
||||
metadata.value.hostsFile = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
|
||||
title.value = formatMessage(messages.minecraftSignInTitle)
|
||||
errorType.value = 'minecraft_sign_in'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
|
||||
title.value = formatMessage(messages.directoryTitle)
|
||||
errorType.value = 'directory_move'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
|
||||
if (errorVal.message.includes('directory is not writable')) {
|
||||
metadata.value.readOnly = true
|
||||
}
|
||||
|
||||
if (errorVal.message.includes('Not enough space')) {
|
||||
metadata.value.notEnoughSpace = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('No loader version selected for')) {
|
||||
title.value = formatMessage(messages.loaderTitle)
|
||||
errorType.value = 'no_loader_version'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
metadata.value.instanceId = context.instanceId
|
||||
} else if (source === 'state_init') {
|
||||
title.value = formatMessage(messages.stateTitle)
|
||||
errorType.value = 'state_init'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
} else {
|
||||
title.value = formatMessage(messages.genericTitle)
|
||||
errorType.value = 'unknown'
|
||||
supportLink.value = AxolotlBrandConfig.supportUrl
|
||||
metadata.value = {}
|
||||
}
|
||||
|
||||
error.value = errorVal
|
||||
errorModal.value.show()
|
||||
},
|
||||
})
|
||||
|
||||
const loadingMinecraft = ref(false)
|
||||
async function loginMinecraft() {
|
||||
try {
|
||||
loadingMinecraft.value = true
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.profile.id).catch(handleError)
|
||||
}
|
||||
|
||||
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
|
||||
loadingMinecraft.value = false
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
loadingMinecraft.value = false
|
||||
handleSevereError(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelDirectoryChange() {
|
||||
try {
|
||||
await cancel_directory_change()
|
||||
window.location.reload()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
}
|
||||
}
|
||||
|
||||
function retryDirectoryChange() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const loadingRepair = ref(false)
|
||||
async function repairInstance() {
|
||||
loadingRepair.value = true
|
||||
try {
|
||||
await install_existing_instance(metadata.value.instanceId, false)
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
handleSevereError(err)
|
||||
}
|
||||
loadingRepair.value = false
|
||||
}
|
||||
|
||||
const hasDebugInfo = computed(
|
||||
() =>
|
||||
errorType.value === 'directory_move' ||
|
||||
errorType.value === 'minecraft_auth' ||
|
||||
errorType.value === 'state_init' ||
|
||||
errorType.value === 'no_loader_version',
|
||||
)
|
||||
|
||||
const debugInfo = computed(
|
||||
() => error.value.message ?? error.value ?? formatMessage(messages.noErrorMessage),
|
||||
)
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const exportingLogs = ref(false)
|
||||
|
||||
async function exportLogs() {
|
||||
exportingLogs.value = true
|
||||
try {
|
||||
await exportErrorLogs(debugInfo.value)
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
} finally {
|
||||
exportingLogs.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper ref="errorModal" :header="title" :closable="closable">
|
||||
<div class="modal-body flex flex-col gap-3 max-w-[550px]">
|
||||
<div class="markdown-body">
|
||||
<template v-if="errorType === 'minecraft_auth'">
|
||||
<template v-if="metadata.network">
|
||||
<h3>{{ formatMessage(messages.networkIssues) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.networkDescription) }}
|
||||
<a :href="AxolotlBrandConfig.supportUrl">
|
||||
{{ formatMessage(messages.supportArticle) }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="metadata.hostsFile">
|
||||
<h3>{{ formatMessage(messages.networkIssues) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.hostsDescription) }}
|
||||
<a :href="AxolotlBrandConfig.supportUrl">
|
||||
{{ formatMessage(messages.supportArticle) }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>{{ formatMessage(messages.tryAnotherAccount) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.accountDescription) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> {{ formatMessage(messages.tryAnotherAccountButton) }}
|
||||
</button>
|
||||
</div>
|
||||
<h3>{{ formatMessage(messages.officialLauncherTitle) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.officialLauncherBefore) }}
|
||||
<a href="https://www.minecraft.net/en-us/download">
|
||||
{{ formatMessage(messages.officialLauncher) }}
|
||||
</a>
|
||||
{{ formatMessage(messages.officialLauncherAfter) }}
|
||||
</p>
|
||||
</template>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> {{ formatMessage(messages.tryAgain) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="errorType === 'directory_move'">
|
||||
<template v-if="metadata.readOnly">
|
||||
<h3>{{ formatMessage(messages.permissionsTitle) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.permissionsDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="metadata.notEnoughSpace">
|
||||
<h3>{{ formatMessage(messages.spaceTitle) }}</h3>
|
||||
<p>
|
||||
{{ formatMessage(messages.spaceDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>
|
||||
{{ formatMessage(messages.directoryDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn" @click="retryDirectoryChange">
|
||||
<UpdatedIcon /> {{ formatMessage(messages.retryDirectory) }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="cancelDirectoryChange">
|
||||
<XIcon /> {{ formatMessage(messages.cancelDirectory) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="errorType === 'minecraft_sign_in'">
|
||||
<p>
|
||||
{{ formatMessage(messages.minecraftRequired) }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> {{ formatMessage(messages.minecraftSignInTitle) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="errorType === 'state_init'">
|
||||
<p>
|
||||
{{ formatMessage(messages.stateDescription) }}
|
||||
</p>
|
||||
<p>{{ formatMessage(messages.stateFixIntro) }}</p>
|
||||
<ul>
|
||||
<li>{{ formatMessage(messages.stateFixInternet) }}</li>
|
||||
<li>{{ formatMessage(messages.stateFixRedownload) }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template v-else-if="errorType === 'no_loader_version'">
|
||||
<p>{{ formatMessage(messages.loaderDescription) }}</p>
|
||||
<p>{{ formatMessage(messages.loaderFix) }}</p>
|
||||
<div class="flex items-center justify-center p-2 gap-2">
|
||||
<button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance">
|
||||
<HammerIcon /> {{ formatMessage(messages.repairInstance) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ debugInfo }}
|
||||
</template>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="w-full h-[1px] bg-surface-5 mb-3"></div>
|
||||
<p>
|
||||
{{ formatMessage(messages.supportDescription) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<a :href="supportLink" @click="errorModal.hide()">
|
||||
<ChatIcon /> {{ formatMessage(messages.getSupport) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="exportingLogs" @click="exportLogs">
|
||||
<DownloadIcon /> {{ formatMessage(messages.exportLogs) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="closable">
|
||||
<button @click="errorModal.hide()">
|
||||
<XIcon /> {{ formatMessage(commonMessages.closeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full h-[1px] bg-surface-5"></div>
|
||||
|
||||
<div class="overflow-clip">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
|
||||
@click="errorCollapsed = !errorCollapsed"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
|
||||
<WrenchIcon class="h-4 w-4" />
|
||||
{{ formatMessage(messages.debugInformation) }}
|
||||
</span>
|
||||
<DropdownIcon
|
||||
class="h-5 w-5 text-secondary transition-transform"
|
||||
:class="{ 'rotate-180': !errorCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="errorCollapsed">
|
||||
<div
|
||||
class="p-3 bg-surface-2 rounded-2xl text-xs grid grid-cols-[1fr_auto] max-w-full items-start"
|
||||
>
|
||||
<div
|
||||
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
|
||||
>
|
||||
{{ debugInfo }}
|
||||
</div>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyDebugInfo)"
|
||||
:disabled="copied"
|
||||
@click="copyToClipboard(debugInfo)"
|
||||
>
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
|
||||
<template v-else> <CopyIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.light-mode {
|
||||
--color-orange-bg: rgba(255, 163, 71, 0.2);
|
||||
}
|
||||
|
||||
.dark-mode,
|
||||
.oled-mode {
|
||||
--color-orange-bg: rgba(224, 131, 37, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.markdown-body {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
335
apps/app-frontend/src/components/ui/ExportModal.vue
Normal file
335
apps/app-frontend/src/components/ui/ExportModal.vue
Normal file
@ -0,0 +1,335 @@
|
||||
<script setup>
|
||||
import { FolderOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
FileTreeSelect,
|
||||
injectNotificationManager,
|
||||
injectPopupNotificationManager,
|
||||
NewModal,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { save } from '@tauri-apps/plugin-dialog'
|
||||
import { join } from '@tauri-apps/api/path'
|
||||
import { readDir, stat } from '@tauri-apps/plugin-fs'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { PackageIcon } from '@/assets/icons'
|
||||
import {
|
||||
export_instance_mrpack,
|
||||
get_full_path,
|
||||
get_pack_export_candidates,
|
||||
} from '@/helpers/instance'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const popupNotificationManager = injectPopupNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
|
||||
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
|
||||
modpackNamePlaceholder: {
|
||||
id: 'app.export-modal.modpack-name-placeholder',
|
||||
defaultMessage: 'Modpack name',
|
||||
},
|
||||
versionNumberLabel: {
|
||||
id: 'app.export-modal.version-number-label',
|
||||
defaultMessage: 'Version number',
|
||||
},
|
||||
versionNumberPlaceholder: {
|
||||
id: 'app.export-modal.version-number-placeholder',
|
||||
defaultMessage: '1.0.0',
|
||||
},
|
||||
descriptionPlaceholder: {
|
||||
id: 'app.export-modal.description-placeholder',
|
||||
defaultMessage: 'Enter modpack description...',
|
||||
},
|
||||
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
|
||||
exportComplete: {
|
||||
id: 'app.export-modal.export-complete',
|
||||
defaultMessage: 'Export complete',
|
||||
},
|
||||
exportCompleteDescription: {
|
||||
id: 'app.export-modal.export-complete-description',
|
||||
defaultMessage: '{name} was exported successfully.',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
resetExportState()
|
||||
exportModal.value.show()
|
||||
void initFiles().catch(handleError)
|
||||
},
|
||||
})
|
||||
|
||||
const exportModal = ref(null)
|
||||
const nameInput = ref(props.instance.name)
|
||||
const exportDescription = ref('')
|
||||
const versionInput = ref('1.0.0')
|
||||
const files = ref([])
|
||||
const selectedFilePaths = ref([])
|
||||
const fileTreeKey = ref(0)
|
||||
const filesLoadId = ref(0)
|
||||
const instanceRoot = ref('')
|
||||
const loadedDirectories = ref(new Set())
|
||||
|
||||
async function initFiles() {
|
||||
const loadId = ++filesLoadId.value
|
||||
const [filePaths, root] = await Promise.all([
|
||||
get_pack_export_candidates(props.instance.id),
|
||||
get_full_path(props.instance.id),
|
||||
])
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
instanceRoot.value = root
|
||||
const exportCandidates = await Promise.all(
|
||||
filePaths.map((path) => buildExportCandidateItem(root, path)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
files.value = exportCandidates
|
||||
selectedFilePaths.value = files.value
|
||||
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
|
||||
.map((file) => file.path)
|
||||
}
|
||||
|
||||
const exportPack = async () => {
|
||||
const outputPath = await save({
|
||||
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
|
||||
filters: [
|
||||
{
|
||||
name: 'Modrinth Modpack',
|
||||
extensions: ['mrpack'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (outputPath) {
|
||||
exportModal.value.hide()
|
||||
|
||||
try {
|
||||
await export_instance_mrpack(
|
||||
props.instance.id,
|
||||
outputPath,
|
||||
selectedFilePaths.value,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value,
|
||||
)
|
||||
|
||||
const fileName = outputPath.split(/[\\/]/).pop() ?? outputPath
|
||||
popupNotificationManager.addPopupNotification({
|
||||
title: formatMessage(messages.exportComplete),
|
||||
text: formatMessage(messages.exportCompleteDescription, { name: fileName }),
|
||||
type: 'success',
|
||||
buttons: [
|
||||
{
|
||||
label: formatMessage(commonMessages.openInFolderButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => highlightInFolder(outputPath).catch(handleError),
|
||||
},
|
||||
],
|
||||
})
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resetExportState() {
|
||||
nameInput.value = props.instance.name
|
||||
exportDescription.value = ''
|
||||
versionInput.value = '1.0.0'
|
||||
files.value = []
|
||||
selectedFilePaths.value = []
|
||||
fileTreeKey.value += 1
|
||||
instanceRoot.value = ''
|
||||
loadedDirectories.value = new Set()
|
||||
}
|
||||
|
||||
async function loadExportDirectory(path) {
|
||||
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
|
||||
|
||||
const loadId = filesLoadId.value
|
||||
loadedDirectories.value.add(path)
|
||||
|
||||
try {
|
||||
const entries = await readDir(await join(instanceRoot.value, ...path.split('/')))
|
||||
const childItems = await Promise.all(
|
||||
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
|
||||
)
|
||||
if (loadId !== filesLoadId.value) return
|
||||
|
||||
appendExportItems(childItems)
|
||||
} catch {
|
||||
loadedDirectories.value.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExportCandidateItem(instanceRoot, path) {
|
||||
try {
|
||||
const entries = await readDir(await join(instanceRoot, ...path.split('/')))
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
modified: metadata.modified,
|
||||
count: entries.length,
|
||||
}
|
||||
} catch {
|
||||
return buildExportFileItem(instanceRoot, path)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
|
||||
const path = `${parentPath}/${entry.name}`
|
||||
if (entry.isDirectory) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'directory',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
modified: metadata.modified,
|
||||
}
|
||||
}
|
||||
|
||||
return buildExportFileItem(instanceRoot, path)
|
||||
}
|
||||
|
||||
async function buildExportFileItem(instanceRoot, path) {
|
||||
const metadata = await getExportCandidateMetadata(instanceRoot, path)
|
||||
return {
|
||||
path,
|
||||
type: 'file',
|
||||
disabled: isExportCandidateDisabled(path),
|
||||
size: metadata.size,
|
||||
modified: metadata.modified,
|
||||
}
|
||||
}
|
||||
|
||||
function appendExportItems(items) {
|
||||
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
|
||||
for (const item of items) {
|
||||
nextFiles.set(normalizeExportPath(item.path), item)
|
||||
}
|
||||
files.value = [...nextFiles.values()]
|
||||
}
|
||||
|
||||
async function getExportCandidateMetadata(instanceRoot, path) {
|
||||
try {
|
||||
const metadata = await stat(await join(instanceRoot, ...path.split('/')))
|
||||
return {
|
||||
size: metadata.size,
|
||||
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExportPath(path) {
|
||||
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
|
||||
}
|
||||
|
||||
function isDefaultSelectedExportCandidate(path) {
|
||||
return (
|
||||
path.startsWith('mods') ||
|
||||
path.startsWith('datapacks') ||
|
||||
path.startsWith('resourcepacks') ||
|
||||
path.startsWith('shaderpacks') ||
|
||||
path.startsWith('config')
|
||||
)
|
||||
}
|
||||
|
||||
function isExportCandidateDisabled(path) {
|
||||
return (
|
||||
path === 'profile.json' ||
|
||||
path.startsWith('modrinth_logs') ||
|
||||
path.startsWith('.fabric') ||
|
||||
path.startsWith('__MACOSX')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="exportModal"
|
||||
:header="formatMessage(messages.header)"
|
||||
scrollable
|
||||
width="46rem"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
|
||||
<StyledInput
|
||||
v-model="nameInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_input w-full">
|
||||
<p class="text-contrast font-semibold">
|
||||
{{ formatMessage(messages.versionNumberLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="versionInput"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="m-0 text-contrast font-semibold">
|
||||
{{ formatMessage(commonMessages.descriptionLabel) }}
|
||||
</p>
|
||||
<StyledInput
|
||||
v-model="exportDescription"
|
||||
multiline
|
||||
:placeholder="formatMessage(messages.descriptionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<FileTreeSelect
|
||||
:key="fileTreeKey"
|
||||
v-model="selectedFilePaths"
|
||||
class="min-w-0"
|
||||
:items="files"
|
||||
@navigate="loadExportDirectory"
|
||||
/>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="exportModal.hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="exportPack">
|
||||
<PackageIcon />
|
||||
{{ formatMessage(messages.exportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user