feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}

View 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',
)
})

View 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,
},
}
}),
}
}

View 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)
})

View 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

View File

@ -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,
)
})

View File

@ -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,
),
),
),
}
}

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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),
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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,
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -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'
}

View File

@ -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' },
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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