feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -10,14 +10,18 @@
<slot name="title" :open="isOpen" />
<DropdownIcon
v-if="!forceOpen"
class="ml-auto size-5 transition-transform duration-300 shrink-0"
class="ml-auto size-5 transition-transform duration-300 shrink-0 text-contrast"
:class="{ 'rotate-180': isOpen }"
/>
</div>
</slot>
<slot name="summary" />
</button>
<div class="accordion-content" :class="{ open: isOpen }">
<div
class="accordion-content"
:class="{ open: isOpen, 'overflow-visible': overflowVisible && showOverflow }"
@transitionend="onTransitionEnd"
>
<div>
<div :class="contentClass ? contentClass : ''" :inert="!isOpen">
<slot />
@@ -39,6 +43,7 @@ const props = withDefaults(
contentClass?: string
titleWrapperClass?: string
forceOpen?: boolean
overflowVisible?: boolean
}>(),
{
type: 'standard',
@@ -47,11 +52,13 @@ const props = withDefaults(
contentClass: null,
titleWrapperClass: null,
forceOpen: false,
overflowVisible: false,
},
)
const toggledOpen = ref(props.openByDefault)
const isOpen = computed(() => toggledOpen.value || props.forceOpen)
const showOverflow = ref(props.openByDefault)
const emit = defineEmits(['onOpen', 'onClose'])
const slots = useSlots()
@@ -71,9 +78,15 @@ function open() {
emit('onOpen')
}
function close() {
showOverflow.value = false
toggledOpen.value = false
emit('onClose')
}
function onTransitionEnd() {
if (isOpen.value) {
showOverflow.value = true
}
}
defineExpose({
open,
@@ -105,4 +118,8 @@ defineOptions({
.accordion-content > div {
overflow: hidden;
}
.accordion-content.overflow-visible > div {
overflow: visible;
}
</style>

View File

@@ -1,11 +1,28 @@
<template>
<div
:class="[
'flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
'relative flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
typeClasses[type],
]"
>
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<ButtonStyled
v-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div
:class="[
'flex gap-2 items-start',
(header || $slots.header) && 'flex-col',
dismissible && 'pr-8',
]"
>
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component
@@ -28,7 +45,10 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { getSeverityIcon } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'
withDefaults(
defineProps<{
@@ -36,15 +56,21 @@ withDefaults(
header?: string
body?: string
showActionsUnderneath?: boolean
dismissible?: boolean
}>(),
{
type: 'info',
header: '',
body: '',
showActionsUnderneath: false,
dismissible: false,
},
)
defineEmits<{
dismiss: []
}>()
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
@@ -56,4 +82,10 @@ const iconClasses = {
warning: 'text-brand-orange',
critical: 'text-brand-red',
}
const buttonColors: Record<string, 'blue' | 'orange' | 'red'> = {
info: 'blue',
warning: 'orange',
critical: 'red',
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<button
class="group flex w-full hover:cursor-pointer items-center gap-3 rounded-[20px] p-3 text-left transition-all hover:brightness-110 active:scale-[0.98] border-none"
:class="selected ? 'bg-brand-highlight' : 'bg-surface-4'"
@click="$emit('click')"
>
<div
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-solid"
:class="selected ? 'border-brand' : 'border-surface-5'"
>
<component
:is="icon"
class="size-8 text-secondary"
:class="selected ? '!stroke-brand' : ''"
stroke-width="1.5"
/>
</div>
<div class="flex flex-1 flex-col gap-1">
<span class="text-base font-semibold text-contrast">{{ title }}</span>
<span class="text-sm font-medium text-primary">{{ description }}</span>
</div>
<ChevronRightIcon
class="size-5 shrink-0 text-secondary opacity-0 transition-opacity duration-100 group-hover:opacity-100"
/>
</button>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import type { Component } from 'vue'
defineProps<{
icon: Component
title: string
description: string
selected?: boolean
}>()
defineEmits<{
(e: 'click'): void
}>()
</script>

View File

@@ -1,8 +1,12 @@
<template>
<div class="chips">
<div class="chips" role="radiogroup" :aria-label="ariaLabel">
<Button
v-for="item in items"
:key="formatLabel(item)"
v-tooltip="isDisabled(item) ? disabledTooltip : undefined"
role="radio"
:aria-checked="selected === item"
:disabled="isDisabled(item)"
class="btn !brightness-100 hover:!brightness-125"
:class="{
selected: selected === item,
@@ -29,6 +33,9 @@ const props = withDefaults(
neverEmpty?: boolean
capitalize?: boolean
size?: 'standard' | 'small'
ariaLabel?: string
disabledItems?: T[]
disabledTooltip?: string
}>(),
{
neverEmpty: true,
@@ -46,7 +53,12 @@ if (props.items.length > 0 && props.neverEmpty && !selected.value) {
selected.value = props.items[0]
}
function isDisabled(item: T): boolean {
return props.disabledItems?.includes(item) ?? false
}
function toggleItem(item: T) {
if (isDisabled(item)) return
if (selected.value === item && !props.neverEmpty) {
selected.value = null
} else {

View File

@@ -1,5 +1,17 @@
<template>
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
<div
class="accordion-content"
:class="[
baseClass ?? '',
{
open: isOpen,
'no-transition': !shouldAnimate,
'overflow-visible': overflowVisible && isFullyOpen,
},
]"
:style="isHidden ? { display: 'none' } : {}"
@transitionend="onTransitionEnd"
>
<div v-bind="$attrs" :inert="collapsed">
<slot />
</div>
@@ -7,15 +19,78 @@
</template>
<script setup lang="ts">
defineProps<{
import { nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
baseClass?: string
collapsed: boolean
overflowVisible?: boolean
}>()
defineOptions({
inheritAttrs: false,
})
const shouldAnimate = ref(false)
const isHidden = ref(props.collapsed)
const isOpen = ref(!props.collapsed)
const isFullyOpen = ref(!props.collapsed)
onMounted(() => {
requestAnimationFrame(() => {
shouldAnimate.value = true
})
})
watch(
() => props.collapsed,
async (collapsed) => {
if (!collapsed) {
// Opening
isHidden.value = false
isFullyOpen.value = false
if (!shouldAnimate.value) {
isOpen.value = true
isFullyOpen.value = true
return
}
// Wait for display: none removal to take effect, then animate open
await nextTick()
requestAnimationFrame(() => {
isOpen.value = true
})
} else {
// Closing
// Remove overflow-visible so content is clipped during animation
isFullyOpen.value = false
if (!shouldAnimate.value) {
isOpen.value = false
isHidden.value = true
return
}
// Wait a frame for overflow: hidden to apply, THEN start closing
await nextTick()
requestAnimationFrame(() => {
isOpen.value = false
})
}
},
)
function onTransitionEnd(e: TransitionEvent) {
if (e.target !== e.currentTarget) return
if (props.collapsed) {
isHidden.value = true
} else {
isFullyOpen.value = true
}
}
</script>
<style scoped>
.accordion-content {
display: grid;
@@ -23,6 +98,10 @@ defineOptions({
transition: grid-template-rows 0.3s ease-in-out;
}
.accordion-content.no-transition {
transition: none !important;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
@@ -36,4 +115,8 @@ defineOptions({
.accordion-content > div {
overflow: hidden;
}
.accordion-content.overflow-visible > div {
overflow: visible;
}
</style>

View File

@@ -1,24 +1,49 @@
<template>
<div ref="containerRef" class="relative inline-block w-full">
<!-- Searchable mode: input trigger -->
<StyledInput
v-if="searchable"
ref="searchTriggerRef"
v-model="searchQuery"
:icon="showSearchIcon ? SearchIcon : undefined"
type="text"
:placeholder="searchPlaceholder || placeholder"
:disabled="disabled"
wrapper-class="w-full"
:input-class="showChevron ? '!pr-9' : undefined"
class="relative"
@input="handleSearchInput"
@keydown="handleSearchKeydown"
@focus="handleSearchFocus"
@click="handleSearchClick"
>
<template v-if="showChevron" #right>
<ChevronLeftIcon
class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</template>
</StyledInput>
<!-- Standard mode: button trigger -->
<span
v-else
ref="triggerRef"
role="button"
tabindex="0"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text"
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2.5 text-left transition-all duration-200 text-button-text"
:class="[
triggerClasses,
props.triggerClass,
{
'z-[9999]': isOpen,
'rounded-b-none': shouldRoundBottomCorners,
'rounded-t-none': shouldRoundTopCorners,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:bg-button-bgHover active:bg-button-bgActive': !disabled,
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
},
]"
:aria-expanded="isOpen"
:aria-haspopup="listbox ? 'listbox' : 'menu'"
:aria-disabled="disabled || undefined"
@click="handleTriggerClick"
@click="handleTriggerClick($event)"
@keydown="handleTriggerKeydown"
>
<div class="flex items-center gap-2">
@@ -36,95 +61,89 @@
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
class="size-5 shrink-0 transition-transform duration-300"
class="size-5 shrink-0 transition-transform duration-150"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</span>
<Teleport to="#teleports">
<div
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 !border-solid border-0 shadow-2xl"
:class="[
shouldRoundBottomCorners
? 'rounded-t-none !border-t-[1px] !border-t-surface-5'
: 'rounded-b-none !border-b-[1px] !border-b-surface-5',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div v-if="searchable" class="p-4">
<StyledInput
ref="searchInputRef"
v-model="searchQuery"
:icon="SearchIcon"
type="text"
:placeholder="searchPlaceholder"
wrapper-class="w-full"
input-class="!border !border-solid !border-surface-5"
@keydown.stop="handleSearchKeydown"
@input="emit('searchInput', searchQuery)"
/>
</div>
<div v-if="searchable && filteredOptions.length > 0" class="h-px bg-surface-5"></div>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
:class="[
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="group/option flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<div class="flex w-full items-center justify-between gap-2">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<div class="flex flex-col gap-1.5">
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
<span
v-if="item.subLabel"
class="text-sm"
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
>
{{ item.subLabel }}
</span>
</div>
</div>
<slot name="option-suffix" :item="item"></slot>
</div>
</div>
</slot>
</component>
</template>
</div>
</slot>
</component>
</template>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
{{ noOptionsMessage }}
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
{{ noOptionsMessage }}
</div>
<slot name="dropdown-footer"></slot>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -132,16 +151,7 @@
<script setup lang="ts" generic="T">
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import {
type Component,
computed,
nextTick,
onMounted,
onUnmounted,
ref,
useSlots,
watch,
} from 'vue'
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import StyledInput from './StyledInput.vue'
@@ -160,6 +170,7 @@ export interface ComboboxOption<T> {
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DROPDOWN_GAP = 12
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
@@ -185,11 +196,14 @@ const props = withDefaults(
showIconInSelected?: boolean
maxHeight?: number
displayValue?: string
extraPosition?: 'top' | 'bottom'
triggerClass?: string
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
disableSearchFilter?: boolean
/** Keep the selected option's label in the input after selection, and show all options on focus */
syncWithSelection?: boolean
/** Show a search icon in the searchable input */
showSearchIcon?: boolean
}>(),
{
placeholder: 'Select an option',
@@ -200,8 +214,9 @@ const props = withDefaults(
showChevron: true,
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
noOptionsMessage: 'No results found',
syncWithSelection: true,
showSearchIcon: false,
},
)
@@ -213,19 +228,25 @@ const emit = defineEmits<{
searchInput: [query: string]
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const userHasTyped = ref(false)
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const searchTriggerRef = ref<InstanceType<typeof StyledInput>>()
const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const rafId = ref<number | null>(null)
const effectiveTriggerEl = computed(() => {
if (props.searchable && searchTriggerRef.value) {
return (searchTriggerRef.value as unknown as { $el: HTMLElement }).$el as HTMLElement
}
return triggerRef.value
})
const dropdownStyle = ref({
top: '0px',
left: '0px',
@@ -234,18 +255,6 @@ const dropdownStyle = ref({
const openDirection = ref<'down' | 'up'>('down')
const triggerClasses = computed(() => {
const classes = [props.triggerClass]
if (isOpen.value) {
if (props.extraPosition === 'bottom' && slots?.extra) {
classes.push('!rounded-b-none')
} else if (props.extraPosition === 'top' && slots?.extra) {
classes.push('!rounded-t-none')
}
}
return classes
})
const selectedOption = computed<ComboboxOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is ComboboxOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
@@ -266,7 +275,7 @@ const optionsWithKeys = computed(() => {
})
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable || props.disableSearchFilter) {
if (!searchQuery.value || !props.searchable || props.disableSearchFilter || !userHasTyped.value) {
return optionsWithKeys.value
}
@@ -279,9 +288,6 @@ const filteredOptions = computed(() => {
})
})
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
return [
item.class,
@@ -308,12 +314,6 @@ function setInitialFocus() {
}
}
function focusSearchInput() {
if (props.searchable && searchInputRef.value) {
searchInputRef.value.focus()
}
}
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
@@ -324,8 +324,10 @@ function determineOpenDirection(
}
const hasSpaceBelow =
triggerRect.bottom + dropdownRect.height + DROPDOWN_VIEWPORT_MARGIN <= viewportHeight
const hasSpaceAbove = triggerRect.top - dropdownRect.height - DROPDOWN_VIEWPORT_MARGIN > 0
triggerRect.bottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <=
viewportHeight
const hasSpaceAbove =
triggerRect.top - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > 0
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
@@ -335,7 +337,9 @@ function calculateVerticalPosition(
dropdownRect: DOMRect,
direction: 'up' | 'down',
): number {
return direction === 'up' ? triggerRect.top - dropdownRect.height : triggerRect.bottom
return direction === 'up'
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
: triggerRect.bottom + DROPDOWN_GAP
}
function calculateHorizontalPosition(
@@ -356,11 +360,11 @@ function calculateHorizontalPosition(
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
if (!effectiveTriggerEl.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
@@ -382,15 +386,12 @@ async function openDropdown() {
if (props.disabled || isOpen.value) return
isOpen.value = true
searchQuery.value = ''
emit('open')
await nextTick()
await updateDropdownPosition()
setInitialFocus()
focusSearchInput()
startPositionTracking()
}
@@ -399,16 +400,22 @@ function closeDropdown() {
stopPositionTracking()
isOpen.value = false
searchQuery.value = ''
userHasTyped.value = false
focusedIndex.value = -1
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
if (!props.searchable) {
nextTick(() => {
triggerRef.value?.focus()
})
}
}
function handleTriggerClick() {
function handleTriggerClick(event: MouseEvent) {
// Ignore synthetic clicks generated by keyboard (Enter/Space on role="button")
// since handleTriggerKeydown already handles keyboard interaction
if (event.detail === 0) return
if (isOpen.value) {
closeDropdown()
} else {
@@ -432,6 +439,9 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) {
emit('select', option)
if (option.type !== 'link') {
if (props.searchable) {
searchQuery.value = props.syncWithSelection ? option.label : ''
}
closeDropdown()
}
}
@@ -456,7 +466,6 @@ function focusOption(index: number) {
if (isDivider(option) || option.disabled) return
focusedIndex.value = index
optionRefs.value[index]?.focus()
optionRefs.value[index]?.scrollIntoView({ block: 'nearest' })
}
@@ -471,13 +480,14 @@ function focusPreviousOption() {
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (isOpen.value) {
handleDropdownKeydown(event)
return
}
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault()
openDropdown()
break
case 'ArrowUp':
event.preventDefault()
openDropdown()
@@ -526,10 +536,51 @@ function handleSearchKeydown(event: KeyboardEvent) {
closeDropdown()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
if (!isOpen.value) {
openDropdown()
}
focusNextOption()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (!isOpen.value) {
openDropdown()
}
focusPreviousOption()
} else if (event.key === 'Enter') {
event.preventDefault()
if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (option && !isDivider(option)) {
handleOptionClick(option, focusedIndex.value)
}
}
} else if (event.key === 'Tab' && isOpen.value) {
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
}
}
function handleSearchInput() {
userHasTyped.value = true
emit('searchInput', searchQuery.value)
if (!isOpen.value) {
openDropdown()
}
}
function handleSearchFocus() {
if (!isOpen.value) {
openDropdown()
}
}
function handleSearchClick() {
if (!isOpen.value) {
openDropdown()
}
}
@@ -559,7 +610,7 @@ onClickOutside(
() => {
closeDropdown()
},
{ ignore: [triggerRef] },
{ ignore: [triggerRef, containerRef] },
)
onMounted(() => {
@@ -582,4 +633,15 @@ watch(filteredOptions, () => {
updateDropdownPosition()
}
})
watch(
[() => props.modelValue, () => props.options],
([val]) => {
if (props.searchable && props.syncWithSelection && !isOpen.value) {
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
}
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="mx-auto flex flex-col items-center p-6 text-center">
<component :is="illustration" v-if="illustration" class="h-[200px] w-auto" />
<div class="flex flex-col items-center gap-1.5">
<span class="text-2xl font-semibold text-contrast">
<slot name="heading">{{ heading }}</slot>
</span>
<span v-if="$slots.description || description" class="text-secondary">
<slot name="description">{{ description }}</slot>
</span>
</div>
<div v-if="$slots.actions" class="mt-8 flex gap-2">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import {
DoneIllustration,
EmptyIllustration,
EmptyInboxIllustration,
ErrorIllustration,
NoConnectionIllustration,
NoCreditCardIllustration,
NoDocumentsIllustration,
NoGPSIllustration,
NoImagesIllustration,
NoItemsCartIllustration,
NoMessagesIllustration,
NoSearchResultIllustration,
NoTasksIllustration,
} from '@modrinth/assets'
import type { Component } from 'vue'
import { computed } from 'vue'
const illustrationMap: Record<string, Component> = {
done: DoneIllustration,
empty: EmptyIllustration,
'empty-inbox': EmptyInboxIllustration,
error: ErrorIllustration,
'no-connection': NoConnectionIllustration,
'no-credit-card': NoCreditCardIllustration,
'no-documents': NoDocumentsIllustration,
'no-gps': NoGPSIllustration,
'no-images': NoImagesIllustration,
'no-items-cart': NoItemsCartIllustration,
'no-messages': NoMessagesIllustration,
'no-search-result': NoSearchResultIllustration,
'no-tasks': NoTasksIllustration,
}
const props = defineProps<{
type?: keyof typeof illustrationMap
heading?: string
description?: string
}>()
const illustration = computed(() => (props.type ? illustrationMap[props.type] : undefined))
</script>

View File

@@ -3,6 +3,7 @@ import { onUnmounted, watch } from 'vue'
const props = defineProps<{
shown: boolean
ariaLabel?: string
}>()
watch(
@@ -20,9 +21,15 @@ onUnmounted(() => {
<template>
<Transition name="floating-action-bar" appear>
<div v-if="shown" class="floating-action-bar fixed w-full z-10 left-0 p-10 bottom-0">
<div
v-if="shown"
class="floating-action-bar drop-shadow-2xl fixed z-10 p-4 bottom-0"
aria-live="polite"
>
<div
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[75rem] p-4"
role="toolbar"
:aria-label="ariaLabel"
class="relative overflow-clip flex items-center gap-2 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto max-w-[60vw] px-4 py-3 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
>
<slot />
</div>
@@ -32,6 +39,8 @@ onUnmounted(() => {
<style scoped>
.floating-action-bar {
left: var(--left-bar-width, 0px);
right: var(--right-bar-width, 0px);
transition: bottom 0.25s ease-in-out;
}
@@ -67,3 +76,9 @@ onUnmounted(() => {
}
}
</style>
<style>
.intercom-lightweight-app-launcher {
z-index: 9 !important;
}
</style>

View File

@@ -5,7 +5,7 @@
max-content-height="72vh"
:on-hide="onModalHide"
:closable="true"
:close-on-click-outside="false"
:close-on-click-outside="closeOnClickOutside"
:width="resolvedMaxWidth"
:fade="fade"
:disable-close="resolveCtxFn(currentStage.disableClose, context)"
@@ -59,7 +59,7 @@
</template>
<progress
v-if="nonProgressStage !== true"
v-if="nonProgressStage !== true && !disableProgress"
:value="progressValue"
max="100"
class="w-full h-1 appearance-none border-none absolute top-0 left-0"
@@ -74,7 +74,7 @@
>
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
class="!border-surface-5"
class="!border-surface-5 !shadow-none"
:class="leftButtonConfig.buttonClass"
:disabled="leftButtonConfig.disabled"
@click="leftButtonConfig.onClick"
@@ -85,19 +85,28 @@
</ButtonStyled>
<ButtonStyled v-if="rightButtonConfig" :color="rightButtonConfig.color">
<button
:disabled="rightButtonConfig.disabled"
class="!shadow-none"
:class="rightButtonConfig.buttonClass"
:disabled="rightButtonConfig.disabled || rightButtonConfig.loading"
@click="rightButtonConfig.onClick"
>
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'before'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'before'"
v-else-if="rightButtonConfig.iconPosition === 'before'"
:class="rightButtonConfig.iconClass"
/>
{{ rightButtonConfig.label }}
<SpinnerIcon
v-if="rightButtonConfig.loading && rightButtonConfig.iconPosition === 'after'"
class="animate-spin"
/>
<component
:is="rightButtonConfig.icon"
v-if="rightButtonConfig.iconPosition === 'after'"
v-else-if="rightButtonConfig.iconPosition === 'after'"
:class="rightButtonConfig.iconClass"
/>
</button>
@@ -108,7 +117,7 @@
</template>
<script lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { ChevronRightIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import type { Component } from 'vue'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
@@ -119,6 +128,7 @@ export interface StageButtonConfig {
iconPosition?: 'before' | 'after'
color?: InstanceType<typeof ButtonStyled>['$props']['color']
disabled?: boolean
loading?: boolean
iconClass?: string | null
buttonClass?: string | null
onClick?: () => void
@@ -148,13 +158,20 @@ export function resolveCtxFn<T, R>(value: MaybeCtxFn<T, R>, ctx: T): R {
</script>
<script setup lang="ts" generic="T">
const props = defineProps<{
stages: StageConfigInput<T>[]
context: T
breadcrumbs?: boolean
fitContent?: boolean
fade?: 'standard' | 'warning' | 'danger'
}>()
const props = withDefaults(
defineProps<{
stages: StageConfigInput<T>[]
context: T
breadcrumbs?: boolean
fitContent?: boolean
fade?: 'standard' | 'warning' | 'danger'
disableProgress?: boolean
closeOnClickOutside?: boolean
}>(),
{
closeOnClickOutside: true,
},
)
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const currentStageIndex = ref<number>(0)

View File

@@ -66,11 +66,19 @@ const percent = computed(() => props.progress / props.max)
<div v-if="showProgress" class="flex items-center gap-1 text-sm text-secondary">
<span>{{ Math.round(percent * 100) }}%</span>
<slot name="progress-icon">
<SpinnerIcon class="size-5 animate-spin" />
<SpinnerIcon class="size-5 animate-spin" aria-hidden="true" />
</slot>
</div>
</div>
<div class="flex h-2 w-full overflow-hidden rounded-full" :class="[colors[props.color].bg]">
<div
role="progressbar"
:aria-valuenow="waiting ? undefined : Math.round(percent * 100)"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="label || undefined"
class="flex h-2 w-full overflow-hidden rounded-full"
:class="[colors[props.color].bg]"
>
<div
class="rounded-full progress-bar"
:class="[

View File

@@ -5,6 +5,7 @@ export { default as AutoBrandIcon } from './AutoBrandIcon.vue'
export { default as AutoLink } from './AutoLink.vue'
export { default as Avatar } from './Avatar.vue'
export { default as Badge } from './Badge.vue'
export { default as BigOptionButton } from './BigOptionButton.vue'
export { default as BulletDivider } from './BulletDivider.vue'
export { default as Button } from './Button.vue'
export { default as ButtonStyled } from './ButtonStyled.vue'
@@ -21,6 +22,7 @@ export { default as DoubleIcon } from './DoubleIcon.vue'
export { default as DropArea } from './DropArea.vue'
export { default as DropdownSelect } from './DropdownSelect.vue'
export { default as DropzoneFileInput } from './DropzoneFileInput.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as EnvironmentIndicator } from './EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'

View File

@@ -0,0 +1,422 @@
<template>
<div class="space-y-6">
<!-- Instance-specific: Icon upload -->
<div v-if="ctx.flowType === 'instance'" class="flex items-center gap-4">
<Avatar :src="ctx.instanceIconUrl.value ?? undefined" size="5rem" />
<div class="flex flex-col gap-2">
<ButtonStyled type="outlined">
<button class="!border-surface-5" @click="triggerIconInput">
<UploadIcon />
Select icon
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button class="!border-surface-5" :disabled="!ctx.instanceIcon.value" @click="removeIcon">
<XIcon />
Remove icon
</button>
</ButtonStyled>
</div>
</div>
<!-- Instance-specific: Name field -->
<div v-if="ctx.flowType === 'instance'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Name</span>
<StyledInput v-model="ctx.instanceName.value" placeholder="Enter instance name" />
</div>
<!-- Loader chips -->
<div v-if="!hideLoaderChips" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
ctx.flowType === 'instance' ? 'Loader' : 'Content loader'
}}</span>
<Chips
v-model="selectedLoader"
:items="effectiveLoaders"
:format-label="formatLoaderLabel"
:never-empty="false"
/>
</div>
<!-- Game version -->
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Game version</span>
<Combobox
v-model="selectedGameVersion"
:options="gameVersionOptions"
searchable
sync-with-selection
placeholder="Select game version"
search-placeholder="Search game version..."
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="ctx.showSnapshots.value = !ctx.showSnapshots.value"
>
<EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }}
</button>
</template>
</Combobox>
</div>
<!-- Loader version -->
<template v-if="!hideLoaderVersion">
<Collapsible :collapsed="!selectedLoader || !selectedGameVersion" overflow-visible>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
isPaperLike ? 'Build number' : 'Loader version'
}}</span>
<Chips
v-if="!isPaperLike"
v-model="loaderVersionType"
:items="loaderVersionTypeItems"
:format-label="capitalize"
/>
<div v-if="isPaperLike || loaderVersionType === 'other'">
<Combobox
v-model="selectedLoaderVersion"
:options="loaderVersionOptions"
:no-options-message="loaderVersionsLoading ? 'Loading...' : 'No versions available'"
searchable
sync-with-selection
:placeholder="isPaperLike ? 'Select build number' : 'Select loader version'"
:search-placeholder="
isPaperLike ? 'Search build number...' : 'Search loader version...'
"
/>
</div>
</div>
</Collapsible>
</template>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, EyeOffIcon, UploadIcon, XIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker, injectTags } from '../../../../providers'
import Avatar from '../../../base/Avatar.vue'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Chips from '../../../base/Chips.vue'
import Collapsible from '../../../base/Collapsible.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
import StyledInput from '../../../base/StyledInput.vue'
import type { LoaderVersionType } from '../creation-flow-context'
import { injectCreationFlowContext } from '../creation-flow-context'
import { capitalize, formatLoaderLabel } from '../shared'
const debug = useDebugLogger('CustomSetupStage')
const ctx = injectCreationFlowContext()
const {
selectedLoader,
selectedGameVersion,
loaderVersionType,
selectedLoaderVersion,
hideLoaderChips,
hideLoaderVersion,
} = ctx
// For instance flow, prepend 'vanilla' to available loaders.
// For server flows, vanilla is a separate option in the setup type stage, so exclude it here.
const effectiveLoaders = computed(() => {
if (ctx.flowType === 'instance') {
return ['vanilla', ...ctx.availableLoaders.filter((l) => l !== 'vanilla')]
}
if (ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server') {
return ctx.availableLoaders.filter((l) => l !== 'vanilla')
}
return ctx.availableLoaders
})
// Pre-select loader and game version from initial values
onMounted(() => {
debug('mounted, initialLoader:', ctx.initialLoader, 'initialGameVersion:', ctx.initialGameVersion)
if (!selectedLoader.value) {
if (ctx.initialLoader) {
selectedLoader.value = ctx.initialLoader
} else {
selectedLoader.value = 'fabric'
}
}
if (ctx.initialGameVersion && !selectedGameVersion.value) {
selectedGameVersion.value = ctx.initialGameVersion
}
debug('after init:', { loader: selectedLoader.value, gameVersion: selectedGameVersion.value })
})
const tags = injectTags()
const loaderVersionTypeItems: LoaderVersionType[] = ['stable', 'latest', 'other']
const isPaperLike = computed(
() => selectedLoader.value === 'paper' || selectedLoader.value === 'purpur',
)
// Icon upload handling
const filePicker = injectFilePicker()
async function triggerIconInput() {
const picked = await filePicker.pickImage()
if (picked) {
ctx.instanceIcon.value = picked.file
ctx.instanceIconUrl.value = picked.previewUrl
ctx.instanceIconPath.value = picked.path ?? null
}
}
function removeIcon() {
ctx.instanceIcon.value = null
ctx.instanceIconUrl.value = null
ctx.instanceIconPath.value = null
}
// Loader versions fetched from launcher-meta
interface LoaderVersionEntry {
id: string
stable: boolean
}
const loaderVersionsLoading = ref(false)
const loaderVersionsData = ref<LoaderVersionEntry[]>([])
const loaderVersionsCache = ref<Record<string, { id: string; loaders: LoaderVersionEntry[] }[]>>({})
// Paper/Purpur build caches
const paperVersions = ref<Record<string, number[]>>({})
const purpurVersions = ref<Record<string, string[]>>({})
// Paper/Purpur supported game version sets (for filtering the game version combobox)
const paperSupportedVersions = ref<Set<string> | null>(null)
const purpurSupportedVersions = ref<Set<string> | null>(null)
// Game versions from tags provider, filtered by loader support
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
const versions = ctx.showSnapshots.value
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
// For loaders with per-version data, only show game versions that have builds
if (selectedLoader.value && selectedLoader.value !== 'vanilla') {
if (selectedLoader.value === 'paper' && paperSupportedVersions.value) {
return versions
.filter((v) => paperSupportedVersions.value!.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
if (selectedLoader.value === 'purpur' && purpurSupportedVersions.value) {
return versions
.filter((v) => purpurSupportedVersions.value!.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
let apiLoader = selectedLoader.value
if (apiLoader === 'neoforge') apiLoader = 'neo'
const manifest = loaderVersionsCache.value[apiLoader]
if (manifest) {
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (!hasPlaceholder) {
const supportedVersions = new Set(
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id),
)
return versions
.filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
}
}
return versions.map((v) => ({ value: v.version, label: v.version }))
})
// Auto-select latest game version when options change and current selection is missing or invalid
watch(
gameVersionOptions,
(options) => {
if (options.length === 0) return
if (!selectedGameVersion.value || !options.some((o) => o.value === selectedGameVersion.value)) {
selectedGameVersion.value = options[0].value
}
},
{ immediate: true },
)
async function fetchLoaderManifest(loader: string) {
let apiLoader = loader
if (apiLoader === 'neoforge') apiLoader = 'neo'
if (loaderVersionsCache.value[apiLoader]) return
try {
const res = await fetch(`https://launcher-meta.modrinth.com/${apiLoader}/v0/manifest.json`)
const data = (await res.json()) as {
gameVersions: { id: string; loaders: LoaderVersionEntry[] }[]
}
loaderVersionsCache.value[apiLoader] = data.gameVersions
} catch {
loaderVersionsCache.value[apiLoader] = []
}
}
async function fetchPaperSupportedVersions() {
if (paperSupportedVersions.value) return
try {
const res = await fetch('https://api.papermc.io/v2/projects/paper')
const data = (await res.json()) as { versions: string[] }
paperSupportedVersions.value = new Set(data.versions)
} catch {
paperSupportedVersions.value = new Set()
}
}
async function fetchPurpurSupportedVersions() {
if (purpurSupportedVersions.value) return
try {
const res = await fetch('https://api.purpurmc.org/v2/purpur')
const data = (await res.json()) as { versions: string[] }
purpurSupportedVersions.value = new Set(data.versions)
} catch {
purpurSupportedVersions.value = new Set()
}
}
async function fetchPaperVersions(mcVersion: string) {
if (paperVersions.value[mcVersion]) return
try {
const res = await fetch(`https://fill.papermc.io/v3/projects/paper/versions/${mcVersion}`)
const data = (await res.json()) as { builds: number[] }
paperVersions.value[mcVersion] = data.builds.sort((a, b) => b - a)
} catch {
paperVersions.value[mcVersion] = []
}
}
async function fetchPurpurVersions(mcVersion: string) {
if (purpurVersions.value[mcVersion]) return
try {
const res = await fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
const data = (await res.json()) as { builds: { all: string[] } }
purpurVersions.value[mcVersion] = data.builds.all.sort((a, b) => parseInt(b) - parseInt(a))
} catch {
purpurVersions.value[mcVersion] = []
}
}
function getLoaderVersionsForGameVersion(
loader: string,
gameVersion: string,
): LoaderVersionEntry[] {
let apiLoader = loader
if (apiLoader === 'neoforge') apiLoader = 'neo'
const manifest = loaderVersionsCache.value[apiLoader]
if (!manifest) return []
// Some loaders (e.g. Fabric) list all versions under a placeholder entry
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
if (placeholder) return placeholder.loaders
const entry = manifest.find((x) => x.id === gameVersion)
return entry?.loaders ?? []
}
// Fetch version data when loader changes so game versions can be filtered
watch(
() => selectedLoader.value,
async (loader) => {
if (!loader || loader === 'vanilla') return
if (loader === 'paper') {
await fetchPaperSupportedVersions()
return
}
if (loader === 'purpur') {
await fetchPurpurSupportedVersions()
return
}
await fetchLoaderManifest(loader)
},
{ immediate: true },
)
// Watch loader + game version to resolve loader versions
watch(
[() => selectedLoader.value, () => selectedGameVersion.value],
async ([loader, gameVersion]) => {
loaderVersionsData.value = []
selectedLoaderVersion.value = null
if (!loader || !gameVersion || loader === 'vanilla') return
loaderVersionsLoading.value = true
if (loader === 'paper') {
await fetchPaperVersions(gameVersion)
loaderVersionsLoading.value = false
// Auto-select latest build
const builds = paperVersions.value[gameVersion]
if (builds?.length) {
selectedLoaderVersion.value = `${builds[0]}`
}
return
}
if (loader === 'purpur') {
await fetchPurpurVersions(gameVersion)
loaderVersionsLoading.value = false
// Auto-select latest build
const builds = purpurVersions.value[gameVersion]
if (builds?.length) {
selectedLoaderVersion.value = builds[0]
}
return
}
await fetchLoaderManifest(loader)
loaderVersionsData.value = getLoaderVersionsForGameVersion(loader, gameVersion)
loaderVersionsLoading.value = false
// Auto-select based on loaderVersionType
autoSelectLoaderVersion()
},
)
watch(
() => loaderVersionType.value,
() => autoSelectLoaderVersion(),
)
function autoSelectLoaderVersion() {
if (loaderVersionType.value === 'stable') {
const stable = loaderVersionsData.value.find((v) => v.stable)
selectedLoaderVersion.value = stable?.id ?? loaderVersionsData.value[0]?.id ?? null
} else if (loaderVersionType.value === 'latest') {
selectedLoaderVersion.value = loaderVersionsData.value[0]?.id ?? null
} else if (loaderVersionType.value === 'other' && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = loaderVersionsData.value[0]?.id ?? null
}
debug('autoSelectLoaderVersion:', selectedLoaderVersion.value, 'type:', loaderVersionType.value)
}
const loaderVersionOptions = computed<ComboboxOption<string>[]>(() => {
if (selectedLoader.value === 'paper' && selectedGameVersion.value) {
const builds = paperVersions.value[selectedGameVersion.value] ?? []
return builds.map((b) => ({ value: `${b}`, label: `Build ${b}` }))
}
if (selectedLoader.value === 'purpur' && selectedGameVersion.value) {
const builds = purpurVersions.value[selectedGameVersion.value] ?? []
return builds.map((b) => ({ value: b, label: `Build ${b}` }))
}
return loaderVersionsData.value.map((v) => ({
value: v.id,
label: v.stable ? `${v.id} (stable)` : v.id,
}))
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div class="space-y-6">
<div
v-if="ctx.flowType !== 'server-onboarding' && ctx.flowType !== 'reset-server'"
class="flex flex-col gap-2"
>
<span class="font-semibold text-contrast">World name</span>
<StyledInput v-model="worldName" placeholder="Enter world name" />
</div>
<div v-if="ctx.setupType.value === 'vanilla'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Game version</span>
<Combobox
v-model="selectedGameVersion"
:options="gameVersionOptions"
searchable
sync-with-selection
placeholder="Select game version"
>
<template v-if="ctx.showSnapshotToggle" #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="ctx.showSnapshots.value = !ctx.showSnapshots.value"
>
<EyeOffIcon v-if="ctx.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ ctx.showSnapshots.value ? 'Hide snapshots' : 'Show all versions' }}
</button>
</template>
</Combobox>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Gamemode</span>
<Chips v-model="gamemode" :items="gamemodeItems" :format-label="capitalize" />
</div>
<div v-if="gamemode !== 'hardcore'" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Difficulty</span>
<Chips v-model="difficulty" :items="difficultyItems" :format-label="capitalize" />
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">World type</span>
<Combobox
v-model="worldTypeOption"
:options="worldTypeOptions"
placeholder="Select world type"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"
>World seed <span class="text-secondary font-normal">(Optional)</span></span
>
<StyledInput v-model="worldSeed" placeholder="Enter world seed" />
<span class="text-sm text-secondary">Leave blank for a random seed.</span>
</div>
<div class="h-px w-full bg-surface-5" />
<Accordion overflow-visible button-class="w-full bg-transparent m-0 p-0 border-none">
<template #title>
<SettingsIcon class="size-4 shrink-0 text-primary" />
<span class="font-semibold text-contrast text-lg">Additional settings</span>
</template>
<div class="flex flex-col gap-4 pt-4">
<div class="flex w-full flex-row items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<span class="font-semibold text-contrast">Generate structures</span>
<span class="text-sm text-secondary">
Controls whether villages, strongholds, and other structures generate in new chunks.
</span>
</div>
<Toggle v-model="generateStructures" small class="shrink-0" />
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Generator settings</span>
<Combobox
v-model="generatorSettingsMode"
:options="generatorSettingsOptions"
placeholder="Select generator settings"
/>
<StyledInput
v-if="generatorSettingsMode === 'custom'"
v-model="generatorSettingsCustom"
multiline
:rows="4"
placeholder="Enter generator settings JSON"
input-class="font-mono"
/>
<span class="text-sm text-secondary">
Used for advanced world customization such as custom Superflat layers.
</span>
</div>
</div>
</Accordion>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, EyeOffIcon, SettingsIcon } from '@modrinth/assets'
import { computed, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectTags } from '../../../../providers'
import Accordion from '../../../base/Accordion.vue'
import Chips from '../../../base/Chips.vue'
import Combobox, { type ComboboxOption } from '../../../base/Combobox.vue'
import StyledInput from '../../../base/StyledInput.vue'
import Toggle from '../../../base/Toggle.vue'
import type { Difficulty, Gamemode, GeneratorSettingsMode } from '../creation-flow-context'
import { injectCreationFlowContext } from '../creation-flow-context'
import { capitalize } from '../shared'
const debug = useDebugLogger('FinalConfigStage')
const ctx = injectCreationFlowContext()
const {
worldName,
gamemode,
difficulty,
worldTypeOption,
worldSeed,
generateStructures,
generatorSettingsMode,
generatorSettingsCustom,
selectedGameVersion,
} = ctx
debug(
'mounted, setupType:',
ctx.setupType.value,
'loader:',
ctx.selectedLoader.value,
'gameVersion:',
ctx.selectedGameVersion.value,
'loaderVersion:',
ctx.selectedLoaderVersion.value,
)
// Game version options for vanilla flow
const tags = injectTags()
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
const versions = ctx.showSnapshots.value
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
return versions.map((v) => ({ value: v.version, label: v.version }))
})
// Auto-select latest game version for vanilla
watch(
gameVersionOptions,
(options) => {
if (!selectedGameVersion.value && options.length > 0) {
selectedGameVersion.value = options[0].value
}
},
{ immediate: true },
)
// Hardcore locks difficulty to hard
let previousDifficulty: Difficulty = difficulty.value
watch(gamemode, (mode) => {
if (mode === 'hardcore') {
previousDifficulty = difficulty.value
difficulty.value = 'hard'
} else {
difficulty.value = previousDifficulty
}
})
const gamemodeItems: Gamemode[] = ['survival', 'creative', 'hardcore']
const difficultyItems: Difficulty[] = ['peaceful', 'easy', 'normal', 'hard']
const worldTypeOptions: ComboboxOption<string>[] = [
{ value: 'minecraft:normal', label: 'Default' },
{ value: 'minecraft:flat', label: 'Superflat' },
{ value: 'minecraft:large_biomes', label: 'Large Biomes' },
{ value: 'minecraft:amplified', label: 'Amplified' },
{ value: 'minecraft:single_biome_surface', label: 'Single Biome' },
]
const generatorSettingsOptions: ComboboxOption<GeneratorSettingsMode>[] = [
{ value: 'default', label: 'Default' },
{ value: 'flat', label: 'Flat' },
{ value: 'custom', label: 'Custom' },
]
</script>

View File

@@ -0,0 +1,281 @@
<template>
<div class="flex flex-col gap-2">
<!-- Header -->
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">Launcher instances</span>
<ButtonStyled
type="transparent"
size="small"
:class="{ invisible: totalSelectedCount === 0 }"
>
<button @click="clearAll">Clear all</button>
</ButtonStyled>
</div>
<template v-if="loading">
<div class="flex items-center justify-center py-8 text-secondary text-sm">
Detecting launcher instances...
</div>
</template>
<template v-else>
<!-- Search -->
<StyledInput
v-if="ctx.importLaunchers.value.length > 0"
v-model="ctx.importSearchQuery.value"
:icon="SearchIcon"
placeholder="Search instance name"
/>
<!-- Launcher sections -->
<div v-if="ctx.importLaunchers.value.length > 0" class="flex flex-col gap-2">
<div
v-for="launcher in visibleLaunchers"
:key="launcher.name"
class="flex flex-col rounded-[20px] border border-solid border-surface-4 shadow-sm overflow-clip"
>
<!-- Launcher header -->
<button
class="flex w-full cursor-pointer items-center gap-3 border-none bg-surface-3 p-3 text-left transition-colors"
@click="toggleLauncherExpanded(launcher.name)"
>
<ChevronRightIcon
class="size-5 shrink-0 text-secondary transition-transform"
:class="{ 'rotate-90': expandedLaunchers.has(launcher.name) }"
/>
<Checkbox
:model-value="getLauncherCheckState(launcher)"
:indeterminate="getLauncherIndeterminate(launcher)"
@update:model-value="toggleLauncherAll(launcher, $event)"
@click.stop
/>
<span class="font-semibold text-contrast">{{ launcher.name }}</span>
</button>
<!-- Instance list (expanded) -->
<Collapsible :collapsed="!expandedLaunchers.has(launcher.name)">
<div class="flex flex-col">
<template v-for="(instance, i) in filteredInstances(launcher)" :key="instance">
<div
class="flex items-center gap-3 border-0 border-t border-solid border-surface-4 py-3 pr-3"
:class="i % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
style="padding-left: 2.75rem"
>
<Checkbox
:model-value="isInstanceSelected(launcher.name, instance)"
@update:model-value="toggleInstance(launcher.name, instance, $event)"
/>
<span class="text-sm">{{ instance }}</span>
</div>
</template>
</div>
</Collapsible>
</div>
</div>
<!-- Add launcher path -->
<div v-if="!showAddPath">
<ButtonStyled>
<button class="w-full !shadow-none" @click="showAddPath = true">Add launcher path</button>
</ButtonStyled>
</div>
<div v-else class="flex items-center gap-2">
<ButtonStyled icon-only
><button class="!shadow-none" @click="browseForLauncherPath">
<FolderSearchIcon class="size-5" /></button
></ButtonStyled>
<StyledInput v-model="newLauncherPath" placeholder="Path to launcher..." class="flex-1" />
<ButtonStyled>
<button class="!shadow-none" :disabled="!newLauncherPath.trim()" @click="addLauncherPath">
Add
</button>
</ButtonStyled>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
import { injectInstanceImport } from '../../../../providers'
import type { ImportableLauncher } from '../../../../providers/instance-import'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Checkbox from '../../../base/Checkbox.vue'
import Collapsible from '../../../base/Collapsible.vue'
import StyledInput from '../../../base/StyledInput.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const ctx = injectCreationFlowContext()
const importProvider = injectInstanceImport()
const loading = ref(false)
const expandedLaunchers = ref(new Set<string>())
const expandedBeforeSearch = ref<Set<string> | null>(null)
const showAddPath = ref(false)
const newLauncherPath = ref('')
// Load detected launchers on mount
onMounted(async () => {
if (ctx.importLaunchers.value.length > 0) return // Already loaded
loading.value = true
try {
ctx.importLaunchers.value = await importProvider.getDetectedLaunchers()
// Auto-expand launchers that have instances
for (const launcher of ctx.importLaunchers.value) {
if (launcher.instances.length > 0) {
expandedLaunchers.value.add(launcher.name)
}
}
} catch {
ctx.importLaunchers.value = []
}
loading.value = false
})
// Filter instances by search query
function filteredInstances(launcher: ImportableLauncher): string[] {
const query = ctx.importSearchQuery.value.toLowerCase().trim()
if (!query) return launcher.instances
return launcher.instances.filter((name) => name.toLowerCase().includes(query))
}
// Hide launchers with no matching instances when searching
const visibleLaunchers = computed(() => {
const query = ctx.importSearchQuery.value.toLowerCase().trim()
if (!query) return ctx.importLaunchers.value
return ctx.importLaunchers.value.filter((launcher) => filteredInstances(launcher).length > 0)
})
// Auto-expand launchers with matching results when searching
watch(
() => ctx.importSearchQuery.value,
(query) => {
const trimmed = query.trim()
if (trimmed) {
// Save current state before search overrides it
if (!expandedBeforeSearch.value) {
expandedBeforeSearch.value = new Set(expandedLaunchers.value)
}
// Expand all launchers that have matching instances
const newExpanded = new Set(expandedLaunchers.value)
for (const launcher of ctx.importLaunchers.value) {
if (filteredInstances(launcher).length > 0) {
newExpanded.add(launcher.name)
}
}
expandedLaunchers.value = newExpanded
} else if (expandedBeforeSearch.value) {
// Restore pre-search state
expandedLaunchers.value = expandedBeforeSearch.value
expandedBeforeSearch.value = null
}
},
)
// Selection helpers
function isInstanceSelected(launcherName: string, instance: string): boolean {
return ctx.importSelectedInstances.value[launcherName]?.has(instance) ?? false
}
function toggleInstance(launcherName: string, instance: string, selected: boolean) {
if (!ctx.importSelectedInstances.value[launcherName]) {
ctx.importSelectedInstances.value[launcherName] = new Set()
}
if (selected) {
ctx.importSelectedInstances.value[launcherName].add(instance)
} else {
ctx.importSelectedInstances.value[launcherName].delete(instance)
}
// Trigger reactivity
ctx.importSelectedInstances.value = { ...ctx.importSelectedInstances.value }
}
function getLauncherCheckState(launcher: ImportableLauncher): boolean {
const set = ctx.importSelectedInstances.value[launcher.name]
if (!set || set.size === 0) return false
const visible = filteredInstances(launcher)
return visible.length > 0 && visible.every((i) => set.has(i))
}
function getLauncherIndeterminate(launcher: ImportableLauncher): boolean {
const set = ctx.importSelectedInstances.value[launcher.name]
if (!set || set.size === 0) return false
const visible = filteredInstances(launcher)
const selectedVisible = visible.filter((i) => set.has(i))
return selectedVisible.length > 0 && selectedVisible.length < visible.length
}
function toggleLauncherAll(launcher: ImportableLauncher, selected: boolean) {
if (!ctx.importSelectedInstances.value[launcher.name]) {
ctx.importSelectedInstances.value[launcher.name] = new Set()
}
const visible = filteredInstances(launcher)
for (const instance of visible) {
if (selected) {
ctx.importSelectedInstances.value[launcher.name].add(instance)
} else {
ctx.importSelectedInstances.value[launcher.name].delete(instance)
}
}
// Trigger reactivity
ctx.importSelectedInstances.value = { ...ctx.importSelectedInstances.value }
}
function toggleLauncherExpanded(name: string) {
if (expandedLaunchers.value.has(name)) {
expandedLaunchers.value.delete(name)
} else {
expandedLaunchers.value.add(name)
}
expandedLaunchers.value = new Set(expandedLaunchers.value)
}
const totalSelectedCount = computed(() => {
let count = 0
for (const set of Object.values(ctx.importSelectedInstances.value)) {
count += set.size
}
return count
})
function clearAll() {
ctx.importSelectedInstances.value = {}
}
async function browseForLauncherPath() {
const path = await importProvider.selectDirectory()
if (path) {
newLauncherPath.value = path
}
}
async function addLauncherPath() {
const path = newLauncherPath.value.trim()
if (!path) return
try {
const instances = await importProvider.getImportableInstances('Custom', path)
const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path,
instances,
}
ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
expandedLaunchers.value.add(launcher.name)
expandedLaunchers.value = new Set(expandedLaunchers.value)
} catch {
// Failed to load — still add with empty instances
const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path,
instances: [],
}
ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
}
newLauncherPath.value = ''
showAddPath.value = false
}
</script>

View File

@@ -0,0 +1,174 @@
<template>
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Already know the modpack you want to install?</span>
<Combobox
v-model="ctx.modpackSearchProjectId.value"
:options="ctx.modpackSearchOptions.value"
searchable
search-placeholder="Search for modpack"
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
:disable-search-filter="true"
@search-input="(query) => handleSearch(query)"
>
<template #option-suffix>
<RightArrowIcon
class="size-5 shrink-0 text-secondary opacity-0 transition-opacity group-hover/option:opacity-100 group-data-[focused=true]/option:opacity-100"
/>
</template>
</Combobox>
<div class="flex items-center gap-3">
<div class="h-[1px] w-full flex-1 bg-surface-5" />
<span class="text-sm text-secondary">or</span>
<div class="h-[1px] w-full flex-1 bg-surface-5" />
</div>
<div class="flex gap-3">
<ButtonStyled type="outlined">
<button class="flex-1 !border-surface-4" @click="triggerFileInput">
<ImportIcon />
Import modpack
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button class="flex-1" @click="ctx.browseModpacks()">
<CompassIcon />
Browse modpacks
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { CompassIcon, ImportIcon, RightArrowIcon } from '@modrinth/assets'
import { defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { injectFilePicker } from '../../../../providers'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Combobox from '../../../base/Combobox.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('ModpackStage')
const ctx = injectCreationFlowContext()
const filePicker = injectFilePicker()
const searchLoading = ref(false)
function proceedWithModpack() {
debug('proceedWithModpack:', {
flowType: ctx.flowType,
modpackSelection: ctx.modpackSelection.value,
})
if (ctx.flowType === 'instance') {
ctx.finish()
} else {
ctx.modal.value?.setStage('final-config')
}
}
const search = async (query: string) => {
query = query.trim()
debug('search() called:', { query, trimmed: query })
try {
debug('search() calling API...', {
query: query || undefined,
facets: [['project_type:modpack']],
limit: 10,
})
const results = await ctx.searchModpacks(query, 10)
debug('search() API returned:', {
totalHits: results.total_hits,
hitCount: results.hits.length,
firstHit: results.hits[0]?.title,
})
ctx.modpackSearchHits.value = {}
for (const hit of results.hits) {
ctx.modpackSearchHits.value[hit.project_id] = {
title: hit.title,
iconUrl: hit.icon_url,
latestVersion: hit.latest_version,
}
}
ctx.modpackSearchOptions.value = results.hits.map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
debug('search() options set:', {
optionCount: ctx.modpackSearchOptions.value.length,
labels: ctx.modpackSearchOptions.value.map((o) => o.label),
})
} catch (err) {
debug('search() ERROR:', err)
ctx.modpackSearchOptions.value = []
}
searchLoading.value = false
debug('search() done, searchLoading:', searchLoading.value)
}
const handleSearch = async (query: string) => {
debug('handleSearch() called:', { query })
searchLoading.value = true
await search(query)
}
onMounted(() => {
debug('onMounted() firing, resetting and calling search("")')
ctx.modpackSearchProjectId.value = undefined
search('')
})
// When a project is selected via search, fetch its latest version and auto-proceed
watch(
() => ctx.modpackSearchProjectId.value,
async (projectId, oldProjectId) => {
if (projectId === oldProjectId) return
ctx.modpackSearchVersionId.value = undefined
ctx.modpackVersionOptions.value = []
if (!projectId) return
const hit = ctx.modpackSearchHits.value[projectId]
// Always fetch the actual latest version from the API since search index can be stale
try {
const versions = await ctx.getProjectVersions(projectId)
if (versions.length > 0) {
const version = versions[0]
ctx.modpackSelection.value = {
projectId,
versionId: version.id,
name: hit?.title ?? '',
iconUrl: hit?.iconUrl,
}
proceedWithModpack()
}
} catch {
// Failed to fetch versions — do nothing
}
},
)
async function triggerFileInput() {
const picked = await filePicker.pickModpackFile()
if (picked) {
ctx.modpackFile.value = picked.file
ctx.modpackFilePath.value = picked.path ?? null
proceedWithModpack()
}
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">
{{
ctx.flowType === 'instance'
? 'Choose instance type'
: ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server'
? 'Select installation type'
: 'Select world type'
}}
</span>
<!-- Instance flow options -->
<template v-if="ctx.flowType === 'instance'">
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="BoxesIcon"
title="Custom setup"
description="Start from scratch by picking a loader and game version."
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="PackageIcon"
title="Modpack base"
description="Use a popular modpack as your starting point."
@click="setSetupType('modpack')"
/>
<BigOptionButton
:icon="BoxImportIcon"
title="Import instance"
description="Import an instance from Prism, CurseForge, or similar."
@click="ctx.setImportMode()"
/>
</div>
<span class="text-sm text-secondary">
An instance is a Minecraft setup with a specific loader, version, and mods.
</span>
</template>
<!-- World / Server onboarding flow options -->
<template v-else>
<div class="flex flex-col gap-3">
<BigOptionButton
:icon="PackageIcon"
title="Modpack base"
description="Use a popular modpack as your starting point."
@click="setSetupType('modpack')"
/>
<BigOptionButton
:icon="BoxesIcon"
title="Custom setup"
description="Start from scratch by picking a loader and game version."
@click="setSetupType('custom')"
/>
<BigOptionButton
:icon="BoxIcon"
title="Vanilla Minecraft"
description="Classic Minecraft with no mods or plugins."
@click="setSetupType('vanilla')"
/>
</div>
<InlineBackupCreator v-if="ctx.flowType === 'reset-server'" backup-name="Before reinstall" />
</template>
</div>
</template>
<script setup lang="ts">
import { BoxesIcon, BoxIcon, BoxImportIcon, PackageIcon } from '@modrinth/assets'
import { useDebugLogger } from '#ui/composables/debug-logger'
import InlineBackupCreator from '../../../../layouts/shared/content-tab/components/modals/InlineBackupCreator.vue'
import BigOptionButton from '../../../base/BigOptionButton.vue'
import { injectCreationFlowContext } from '../creation-flow-context'
const debug = useDebugLogger('SetupTypeStage')
const ctx = injectCreationFlowContext()
const { setSetupType: _setSetupType } = ctx
function setSetupType(type: 'modpack' | 'custom' | 'vanilla') {
debug('selected:', type)
_setSetupType(type)
}
</script>

View File

@@ -0,0 +1,396 @@
import type { Archon } from '@modrinth/api-client'
import { computed, type ComputedRef, type Ref, ref, type ShallowRef, watch } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { createContext } from '../../../providers'
import type { ImportableLauncher } from '../../../providers/instance-import'
import type { MultiStageModal, StageConfigInput } from '../../base'
import type { ComboboxOption } from '../../base/Combobox.vue'
import { stageConfigs } from './stages'
export type FlowType = 'world' | 'server-onboarding' | 'reset-server' | 'instance'
export type SetupType = 'modpack' | 'custom' | 'vanilla'
export type Gamemode = 'survival' | 'creative' | 'hardcore'
export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard'
export type LoaderVersionType = 'stable' | 'latest' | 'other'
export type GeneratorSettingsMode = 'default' | 'flat' | 'custom'
export interface ModpackSelection {
projectId: string
versionId: string
name: string
iconUrl?: string
}
export interface ModpackSearchHit {
title: string
iconUrl?: string
latestVersion?: string
}
export interface ModpackSearchResult {
hits: {
project_id: string
title: string
icon_url: string
latest_version?: string
}[]
total_hits: number
offset: number
limit: number
}
export const flowTypeHeadings: Record<FlowType, string> = {
world: 'Create world',
'server-onboarding': 'Set up server',
'reset-server': 'Reset server',
instance: 'Create instance',
}
export interface CreationFlowContextValue {
// Flow
flowType: FlowType
// Configuration
availableLoaders: string[]
showSnapshotToggle: boolean
disableClose: boolean
isInitialSetup: boolean
// Initial values
initialLoader: string | null
initialGameVersion: string | null
// State
setupType: Ref<SetupType | null>
isImportMode: Ref<boolean>
worldName: Ref<string>
gamemode: Ref<Gamemode>
difficulty: Ref<Difficulty>
worldSeed: Ref<string>
worldTypeOption: Ref<string>
generateStructures: Ref<boolean>
generatorSettingsMode: Ref<GeneratorSettingsMode>
generatorSettingsCustom: Ref<string>
// Instance-specific state
instanceName: Ref<string>
instanceIcon: Ref<File | null>
instanceIconUrl: Ref<string | null>
instanceIconPath: Ref<string | null>
// Loader/version state (custom setup)
selectedLoader: Ref<string | null>
selectedGameVersion: Ref<string | null>
loaderVersionType: Ref<LoaderVersionType>
selectedLoaderVersion: Ref<string | null>
hideLoaderChips: ComputedRef<boolean>
hideLoaderVersion: ComputedRef<boolean>
showSnapshots: Ref<boolean>
// Modpack state
modpackSelection: Ref<ModpackSelection | null>
modpackFile: Ref<File | null>
modpackFilePath: Ref<string | null>
// Modpack search state (persisted across stage navigation)
modpackSearchProjectId: Ref<string | undefined>
modpackSearchVersionId: Ref<string | undefined>
modpackSearchOptions: Ref<ComboboxOption<string>[]>
modpackVersionOptions: Ref<ComboboxOption<string>[]>
modpackSearchHits: Ref<Record<string, ModpackSearchHit>>
// Import state (instance flow only)
importLaunchers: Ref<ImportableLauncher[]>
importSelectedInstances: Ref<Record<string, Set<string>>>
importSearchQuery: Ref<string>
// Confirm stage
hardReset: Ref<boolean>
// Loading state (set when finish() is called, cleared on reset)
loading: Ref<boolean>
// Modal
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
stageConfigs: StageConfigInput<CreationFlowContextValue>[]
// Callbacks
onBack: (() => void) | null
// Methods
reset: (instanceCount?: number) => void
setSetupType: (type: SetupType) => void
setImportMode: () => void
browseModpacks: () => void
finish: () => void
buildProperties: () => Archon.Content.v1.PropertiesFields
// Platform-provided search
searchModpacks: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions: (projectId: string) => Promise<{ id: string }[]>
}
export const [injectCreationFlowContext, provideCreationFlowContext] =
createContext<CreationFlowContextValue>('CreationFlowModal')
// TODO: replace with actual world count from the world list once available
let worldCounter = 0
let instanceCounter = 0
export interface CreationFlowOptions {
availableLoaders?: string[]
showSnapshotToggle?: boolean
disableClose?: boolean
isInitialSetup?: boolean
initialLoader?: string
initialGameVersion?: string
onBack?: () => void
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
}
export function createCreationFlowContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
flowType: FlowType,
emit: {
browseModpacks: () => void
create: (config: CreationFlowContextValue) => void
},
options: CreationFlowOptions = {},
): CreationFlowContextValue {
const debug = useDebugLogger('CreationFlow')
const availableLoaders = options.availableLoaders ?? ['fabric', 'neoforge', 'forge', 'quilt']
const showSnapshotToggle = options.showSnapshotToggle ?? false
const disableClose = options.disableClose ?? false
const isInitialSetup = options.isInitialSetup ?? false
const initialLoader = options.initialLoader ?? null
const initialGameVersion = options.initialGameVersion ?? null
const onBack = options.onBack ?? null
const setupType = ref<SetupType | null>(null)
const isImportMode = ref(false)
const worldName = ref('')
const gamemode = ref<Gamemode>('survival')
const difficulty = ref<Difficulty>('normal')
const worldSeed = ref('')
const worldTypeOption = ref('minecraft:normal')
const generateStructures = ref(true)
const generatorSettingsMode = ref<GeneratorSettingsMode>('default')
const generatorSettingsCustom = ref('')
// Instance-specific state
const instanceName = ref('')
const instanceIcon = ref<File | null>(null)
const instanceIconUrl = ref<string | null>(null)
const instanceIconPath = ref<string | null>(null)
// Revoke old object URL when icon is cleared to avoid memory leaks
watch(instanceIconUrl, (_newUrl, oldUrl) => {
if (oldUrl && oldUrl.startsWith('blob:')) {
URL.revokeObjectURL(oldUrl)
}
})
const selectedLoader = ref<string | null>(null)
const selectedGameVersion = ref<string | null>(null)
const loaderVersionType = ref<LoaderVersionType>('stable')
const selectedLoaderVersion = ref<string | null>(null)
const showSnapshots = ref(false)
const modpackSelection = ref<ModpackSelection | null>(null)
const modpackFile = ref<File | null>(null)
const modpackFilePath = ref<string | null>(null)
// Modpack search state (persisted across stage navigation)
const modpackSearchProjectId = ref<string | undefined>()
const modpackSearchVersionId = ref<string | undefined>()
const modpackSearchOptions = ref<ComboboxOption<string>[]>([])
const modpackVersionOptions = ref<ComboboxOption<string>[]>([])
const modpackSearchHits = ref<Record<string, ModpackSearchHit>>({})
// Import state (instance flow only)
const importLaunchers = ref<ImportableLauncher[]>([])
const importSelectedInstances = ref<Record<string, Set<string>>>({})
const importSearchQuery = ref('')
const hardReset = ref(isInitialSetup)
const loading = ref(false)
// hideLoaderChips: hides the entire loader chips section (only for vanilla world type in world/server flows)
const hideLoaderChips = computed(() => setupType.value === 'vanilla')
// hideLoaderVersion: hides the loader version section (vanilla world type OR vanilla selected as loader chip)
const hideLoaderVersion = computed(
() => setupType.value === 'vanilla' || selectedLoader.value === 'vanilla',
)
function reset(instanceCount?: number) {
setupType.value = null
isImportMode.value = false
worldCounter++
worldName.value = flowType === 'world' ? `World ${worldCounter}` : ''
if (instanceCount != null) {
instanceCounter = instanceCount
}
instanceCounter++
gamemode.value = 'survival'
difficulty.value = 'normal'
worldSeed.value = ''
worldTypeOption.value = 'minecraft:normal'
generateStructures.value = true
generatorSettingsMode.value = 'default'
generatorSettingsCustom.value = ''
// Instance-specific
instanceName.value = flowType === 'instance' ? `New instance (${instanceCounter})` : ''
instanceIconUrl.value = null
instanceIcon.value = null
instanceIconPath.value = null
selectedLoader.value = null
selectedGameVersion.value = null
loaderVersionType.value = 'stable'
selectedLoaderVersion.value = null
showSnapshots.value = false
modpackSelection.value = null
modpackFile.value = null
modpackFilePath.value = null
modpackSearchProjectId.value = undefined
modpackSearchVersionId.value = undefined
modpackSearchOptions.value = []
modpackVersionOptions.value = []
modpackSearchHits.value = {}
// Import state
importLaunchers.value = []
importSelectedInstances.value = {}
importSearchQuery.value = ''
hardReset.value = isInitialSetup
loading.value = false
}
function setSetupType(type: SetupType) {
debug('setSetupType:', type)
isImportMode.value = false
setupType.value = type
if (type === 'modpack') {
modal.value?.setStage('modpack')
} else {
// both custom and vanilla go to custom-setup
// vanilla just hides loader chips via hideLoaderChips computed
modal.value?.setStage('custom-setup')
}
}
function setImportMode() {
isImportMode.value = true
setupType.value = null
modal.value?.setStage('import-instance')
}
function browseModpacks() {
modal.value?.hide()
emit.browseModpacks()
}
function finish() {
debug('finish() called, state:', {
setupType: setupType.value,
selectedLoader: selectedLoader.value,
selectedGameVersion: selectedGameVersion.value,
selectedLoaderVersion: selectedLoaderVersion.value,
modpackSelection: modpackSelection.value,
hasModpackFile: !!modpackFile.value,
})
loading.value = true
emit.create(contextValue)
}
function buildProperties(): Archon.Content.v1.PropertiesFields {
const isHardcore = gamemode.value === 'hardcore'
const known: Archon.Content.v1.KnownPropertiesFields = {
gamemode: isHardcore ? 'survival' : gamemode.value,
hardcore: isHardcore ? 'true' : 'false',
difficulty: difficulty.value,
level_seed: worldSeed.value || null,
level_type: worldTypeOption.value,
generate_structures: String(generateStructures.value),
}
if (generatorSettingsMode.value === 'flat') {
known.generator_settings = ''
} else if (generatorSettingsMode.value === 'custom' && generatorSettingsCustom.value) {
known.generator_settings = generatorSettingsCustom.value
}
return { known }
}
const searchModpacks = options.searchModpacks!
const getProjectVersions = options.getProjectVersions!
const resolvedStageConfigs = disableClose
? stageConfigs.map((stage) => ({ ...stage, disableClose: true }))
: stageConfigs
const contextValue: CreationFlowContextValue = {
flowType,
availableLoaders,
showSnapshotToggle,
disableClose,
isInitialSetup,
initialLoader,
initialGameVersion,
setupType,
isImportMode,
worldName,
gamemode,
difficulty,
worldSeed,
worldTypeOption,
generateStructures,
generatorSettingsMode,
generatorSettingsCustom,
instanceName,
instanceIcon,
instanceIconUrl,
instanceIconPath,
selectedLoader,
selectedGameVersion,
loaderVersionType,
selectedLoaderVersion,
hideLoaderChips,
hideLoaderVersion,
showSnapshots,
modpackSelection,
modpackFile,
modpackFilePath,
modpackSearchProjectId,
modpackSearchVersionId,
modpackSearchOptions,
modpackVersionOptions,
modpackSearchHits,
importLaunchers,
importSelectedInstances,
importSearchQuery,
hardReset,
loading,
modal,
stageConfigs: resolvedStageConfigs,
onBack,
reset,
setSetupType,
setImportMode,
browseModpacks,
finish,
buildProperties,
searchModpacks,
getProjectVersions,
}
return contextValue
}

View File

@@ -0,0 +1,90 @@
<template>
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:fade="fade"
disable-progress
@hide="$emit('hide')"
/>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import MultiStageModal from '../../base/MultiStageModal.vue'
import {
createCreationFlowContext,
type CreationFlowContextValue,
type FlowType,
type ModpackSearchResult,
provideCreationFlowContext,
} from './creation-flow-context'
const props = withDefaults(
defineProps<{
type?: FlowType
availableLoaders?: string[]
showSnapshotToggle?: boolean
disableClose?: boolean
isInitialSetup?: boolean
initialLoader?: string
initialGameVersion?: string
onBack?: (() => void) | null
fade?: 'standard' | 'warning' | 'danger'
searchModpacks?: (query: string, limit?: number) => Promise<ModpackSearchResult>
getProjectVersions?: (projectId: string) => Promise<{ id: string }[]>
}>(),
{
type: 'world',
availableLoaders: () => ['fabric', 'neoforge', 'forge', 'quilt'],
showSnapshotToggle: false,
disableClose: false,
isInitialSetup: false,
initialLoader: undefined,
initialGameVersion: undefined,
onBack: null,
},
)
const emit = defineEmits<{
(e: 'hide' | 'browse-modpacks'): void
(e: 'create', config: CreationFlowContextValue): void
}>()
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const ctx = createCreationFlowContext(
modal,
props.type,
{
browseModpacks: () => emit('browse-modpacks'),
create: (config) => emit('create', config),
},
{
availableLoaders: props.availableLoaders,
showSnapshotToggle: props.showSnapshotToggle,
disableClose: props.disableClose,
isInitialSetup: props.isInitialSetup,
initialLoader: props.initialLoader,
initialGameVersion: props.initialGameVersion,
onBack: props.onBack ?? undefined,
searchModpacks: props.searchModpacks,
getProjectVersions: props.getProjectVersions,
},
)
provideCreationFlowContext(ctx)
function show(instanceCount?: number) {
ctx.reset(instanceCount)
modal.value?.setStage(0)
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide, ctx })
</script>

View File

@@ -0,0 +1,3 @@
export { formatLoaderLabel, loaderDisplayNames } from '#ui/utils/loaders'
export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1)

View File

@@ -0,0 +1,71 @@
import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import CustomSetupStage from '../components/CustomSetupStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
if (ctx.flowType === 'instance' && !ctx.instanceName.value?.trim()) return true
if (!ctx.selectedGameVersion.value) return true
if (!ctx.hideLoaderChips.value && !ctx.selectedLoader.value) return true
if (
!ctx.hideLoaderVersion.value &&
ctx.loaderVersionType.value === 'other' &&
!ctx.selectedLoaderVersion.value
)
return true
return false
}
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'custom-setup',
title: (ctx) => flowTypeHeadings[ctx.flowType],
stageContent: markRaw(CustomSetupStage),
skip: (ctx) =>
ctx.setupType.value === 'modpack' ||
ctx.setupType.value === 'vanilla' ||
ctx.isImportMode.value,
cannotNavigateForward: isForwardBlocked,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'),
}),
rightButtonConfig: (ctx) => {
const isInstance = ctx.flowType === 'instance'
const goesToNextStage =
ctx.flowType === 'world' ||
ctx.flowType === 'server-onboarding' ||
ctx.flowType === 'reset-server'
const disabled = isForwardBlocked(ctx)
if (isInstance) {
return {
label: 'Create instance',
icon: PlusIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled,
loading: ctx.loading.value,
onClick: () => ctx.finish(),
}
}
return {
label: goesToNextStage ? 'Continue' : 'Finish',
icon: goesToNextStage ? RightArrowIcon : null,
iconPosition: 'after' as const,
color: goesToNextStage ? undefined : ('brand' as const),
disabled,
onClick: () => {
if (goesToNextStage) {
ctx.modal.value?.nextStage()
} else {
ctx.finish()
}
},
}
},
maxWidth: '520px',
}

View File

@@ -0,0 +1,59 @@
import { LeftArrowIcon, PlusIcon, RightArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import FinalConfigStage from '../components/FinalConfigStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
function isForwardBlocked(ctx: CreationFlowContextValue): boolean {
if (ctx.flowType === 'world' && !ctx.worldName.value.trim()) return true
if (ctx.setupType.value === 'vanilla' && !ctx.selectedGameVersion.value) return true
return false
}
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'final-config',
title: (ctx) => flowTypeHeadings[ctx.flowType],
stageContent: markRaw(FinalConfigStage),
skip: (ctx) => ctx.flowType === 'instance' || ctx.isImportMode.value,
cannotNavigateForward: isForwardBlocked,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => {
if (ctx.onBack) {
ctx.onBack()
} else {
ctx.modal.value?.prevStage()
}
},
}),
rightButtonConfig: (ctx) => {
const isWorld = ctx.flowType === 'world'
const isOnboarding = ctx.flowType === 'server-onboarding'
const isReset = ctx.flowType === 'reset-server'
const isFinish = isWorld || isOnboarding || isReset
return {
label: isWorld
? 'Create world'
: isReset
? 'Reset server'
: isOnboarding
? 'Setup server'
: 'Continue',
icon: isFinish ? PlusIcon : RightArrowIcon,
iconPosition: isFinish ? ('before' as const) : ('after' as const),
color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined,
disabled: isForwardBlocked(ctx),
loading: isFinish && ctx.loading.value,
onClick: () => {
if (isFinish) {
ctx.finish()
} else {
ctx.modal.value?.nextStage()
}
},
}
},
maxWidth: '520px',
}

View File

@@ -0,0 +1,41 @@
import { DownloadIcon, LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import ImportInstanceStage from '../components/ImportInstanceStage.vue'
import type { CreationFlowContextValue } from '../creation-flow-context'
function getSelectedCount(ctx: CreationFlowContextValue): number {
let count = 0
for (const set of Object.values(ctx.importSelectedInstances.value)) {
count += set.size
}
return count
}
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'import-instance',
title: 'Import instance',
stageContent: markRaw(ImportInstanceStage),
skip: (ctx) => !ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => {
ctx.isImportMode.value = false
ctx.modal.value?.setStage('setup-type')
},
}),
rightButtonConfig: (ctx) => {
const count = getSelectedCount(ctx)
return {
label: count > 0 ? `Import ${count} instance${count !== 1 ? 's' : ''}` : 'Import',
icon: DownloadIcon,
iconPosition: 'before' as const,
color: 'brand' as const,
disabled: count === 0,
onClick: () => ctx.finish(),
}
},
maxWidth: '520px',
}

View File

@@ -0,0 +1,15 @@
import type { StageConfigInput } from '../../../base'
import type { CreationFlowContextValue } from '../creation-flow-context'
import { stageConfig as customSetupStageConfig } from './custom-setup-stage'
import { stageConfig as finalConfigStageConfig } from './final-config-stage'
import { stageConfig as importInstanceStageConfig } from './import-instance-stage'
import { stageConfig as modpackStageConfig } from './modpack-stage'
import { stageConfig as setupTypeStageConfig } from './setup-type-stage'
export const stageConfigs: StageConfigInput<CreationFlowContextValue>[] = [
setupTypeStageConfig,
modpackStageConfig,
importInstanceStageConfig,
customSetupStageConfig,
finalConfigStageConfig,
]

View File

@@ -0,0 +1,20 @@
import { LeftArrowIcon } from '@modrinth/assets'
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import ModpackStage from '../components/ModpackStage.vue'
import type { CreationFlowContextValue } from '../creation-flow-context'
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'modpack',
title: 'Choose modpack',
stageContent: markRaw(ModpackStage),
skip: (ctx) => ctx.setupType.value !== 'modpack' || ctx.isImportMode.value,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('setup-type'),
}),
rightButtonConfig: null,
maxWidth: '520px',
}

View File

@@ -0,0 +1,14 @@
import { markRaw } from 'vue'
import type { StageConfigInput } from '../../../base'
import SetupTypeStage from '../components/SetupTypeStage.vue'
import { type CreationFlowContextValue, flowTypeHeadings } from '../creation-flow-context'
export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
id: 'setup-type',
title: (ctx) => flowTypeHeadings[ctx.flowType],
stageContent: markRaw(SetupTypeStage),
leftButtonConfig: null,
rightButtonConfig: null,
maxWidth: '520px',
}

View File

@@ -5,7 +5,6 @@ export * from './brand'
export * from './changelog'
export * from './chart'
export * from './content'
export * from './instances'
export * from './modal'
export * from './nav'
export * from './page'

View File

@@ -1,243 +0,0 @@
<script setup lang="ts">
import { DownloadIcon, MoreVerticalIcon, OrganizationIcon, TrashIcon } from '@modrinth/assets'
import { computed, getCurrentInstance, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import { truncatedTooltip } from '../../utils/truncate'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import BulletDivider from '../base/BulletDivider.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
import Toggle from '../base/Toggle.vue'
import TeleportOverflowMenu from '../servers/files/explorer/TeleportOverflowMenu.vue'
import type { ContentCardProject, ContentCardVersion, ContentOwner } from './types'
const { formatMessage } = useVIntl()
interface Props {
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
enabled?: boolean
hasUpdate?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
versionLink: undefined,
owner: undefined,
enabled: undefined,
hasUpdate: false,
overflowOptions: undefined,
disabled: false,
showCheckbox: false,
hideDelete: false,
hideActions: false,
})
const selected = defineModel<boolean>('selected')
const emit = defineEmits<{
'update:enabled': [value: boolean]
delete: []
update: []
}>()
const instance = getCurrentInstance()
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const versionNumberRef = ref<HTMLElement | null>(null)
const fileNameRef = ref<HTMLElement | null>(null)
</script>
<template>
<div
class="flex h-[74px] items-center justify-between gap-4 px-3"
:class="{ 'opacity-50': disabled }"
>
<div
class="flex min-w-0 items-center gap-4"
:class="
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
"
>
<Checkbox
v-if="showCheckbox"
:model-value="selected ?? false"
:disabled="disabled"
class="shrink-0"
@update:model-value="selected = $event"
/>
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="3rem"
no-shadow
class="shrink-0 rounded-2xl border border-surface-5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<AutoLink
:target="
typeof projectLink === 'string' && projectLink.startsWith('http')
? '_blank'
: undefined
"
:to="projectLink"
class="truncate font-semibold leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': projectLink }"
>
{{ project.title }}
</AutoLink>
<div class="flex min-w-0 items-center gap-1">
<AutoLink
v-if="owner"
:target="
typeof owner.link === 'string' && owner.link.startsWith('http')
? '_blank'
: undefined
"
:to="owner.link"
class="flex shrink-0 items-center gap-1 !decoration-secondary"
:class="{ 'hover:underline': owner.link }"
>
<OrganizationIcon
v-if="owner.type === 'organization'"
class="size-4 text-secondary"
/>
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="1.5rem"
:circle="owner.type === 'user'"
no-shadow
class="shrink-0"
/>
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
</AutoLink>
<template v-if="version">
<BulletDivider class="shrink-0 @[800px]:hidden" />
<AutoLink
:target="
typeof versionLink === 'string' && versionLink.startsWith('http')
? '_blank'
: undefined
"
:to="versionLink"
class="truncate text-sm leading-5 text-secondary !decoration-secondary @[800px]:hidden"
:class="{ 'hover:underline': versionLink }"
>
{{ version.version_number }}
</AutoLink>
</template>
</div>
</div>
</div>
</div>
<div
class="hidden flex-col gap-0.5 @[800px]:flex"
:class="hideActions ? 'flex-1' : 'w-[335px] min-w-0'"
>
<template v-if="version">
<AutoLink
v-tooltip="truncatedTooltip(versionNumberRef, version.version_number)"
:target="
typeof versionLink === 'string' && versionLink.startsWith('http') ? '_blank' : undefined
"
:to="versionLink"
class="inline-flex min-w-0 font-medium leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': versionLink, 'cursor-pointer': versionLink }"
>
<span ref="versionNumberRef" class="truncate">{{
version.version_number.slice(0, Math.ceil(version.version_number.length / 2))
}}</span>
<span class="shrink-0">{{
version.version_number.slice(Math.ceil(version.version_number.length / 2))
}}</span>
</AutoLink>
<span
v-tooltip="truncatedTooltip(fileNameRef, version.file_name)"
class="flex min-w-0 leading-6 text-secondary"
>
<span ref="fileNameRef" class="truncate">{{
version.file_name.slice(0, Math.ceil(version.file_name.length / 2))
}}</span>
<span class="shrink-0">{{
version.file_name.slice(Math.ceil(version.file_name.length / 2))
}}</span>
</span>
</template>
</div>
<div v-if="!hideActions" class="flex min-w-[160px] shrink-0 items-center justify-end gap-2">
<slot name="additionalButtonsLeft" />
<!-- Fixed width container to reserve space for update button -->
<div v-if="hasUpdateListener" class="flex w-8 items-center justify-center">
<ButtonStyled
v-if="hasUpdate"
circular
type="transparent"
color="green"
color-fill="text"
hover-color-fill="background"
>
<button
v-tooltip="formatMessage(commonMessages.updateAvailableLabel)"
:disabled="disabled"
@click="emit('update')"
>
<DownloadIcon class="size-5" />
</button>
</ButtonStyled>
</div>
<Toggle
v-if="enabled !== undefined"
:model-value="enabled"
:disabled="disabled"
small
class="mr-2 my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
/>
<ButtonStyled v-if="hasDeleteListener && !props.hideDelete" circular type="transparent">
<button
v-tooltip="formatMessage(commonMessages.deleteLabel)"
:disabled="disabled"
@click="emit('delete')"
>
<TrashIcon class="size-5 text-secondary" />
</button>
</ButtonStyled>
<slot name="additionalButtonsRight" />
<ButtonStyled circular type="transparent">
<TeleportOverflowMenu
v-if="overflowOptions?.length"
:options="overflowOptions"
:disabled="disabled"
>
<MoreVerticalIcon class="size-5" />
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</template>

View File

@@ -1,304 +0,0 @@
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed, getCurrentInstance, ref, toRef } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { useStickyObserver } from '../../composables/sticky-observer'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import { commonMessages } from '../../utils/common-messages'
import Checkbox from '../base/Checkbox.vue'
import ContentCardItem from './ContentCardItem.vue'
import type {
ContentCardTableItem,
ContentCardTableSortColumn,
ContentCardTableSortDirection,
} from './types'
const { formatMessage } = useVIntl()
interface Props {
items: ContentCardTableItem[]
showSelection?: boolean
sortable?: boolean
sortBy?: ContentCardTableSortColumn
sortDirection?: ContentCardTableSortDirection
virtualized?: boolean
hideDelete?: boolean
hideHeader?: boolean
flat?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showSelection: false,
sortable: false,
sortBy: undefined,
sortDirection: 'asc',
virtualized: true,
hideDelete: false,
hideHeader: false,
flat: false,
})
const stickyHeaderRef = ref<HTMLElement | null>(null)
const { isStuck } = useStickyObserver(stickyHeaderRef, 'ContentCardTable')
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const emit = defineEmits<{
'update:enabled': [id: string, value: boolean]
delete: [id: string]
update: [id: string]
sort: [column: ContentCardTableSortColumn, direction: ContentCardTableSortDirection]
}>()
// Check if any actions are available
const instance = getCurrentInstance()
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const hasEnabledListener = computed(
() => typeof instance?.vnode.props?.['onUpdate:enabled'] === 'function',
)
const hasAnyActions = computed(() => {
// Check if there are listeners for actions
const hasListeners =
(hasDeleteListener.value && !props.hideDelete) ||
hasUpdateListener.value ||
hasEnabledListener.value
// Check if any items have overflow options or updates
const hasItemActions = props.items.some(
(item) =>
(item.overflowOptions && item.overflowOptions.length > 0) ||
item.hasUpdate ||
item.enabled !== undefined,
)
return hasListeners || hasItemActions
})
// Virtualization
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
toRef(props, 'items'),
{
itemHeight: 74,
bufferSize: 5,
enabled: toRef(props, 'virtualized'),
},
)
// Expose for perf monitoring
defineExpose({
visibleRange,
visibleItems,
})
// Selection logic
const allSelected = computed(() => {
if (props.items.length === 0) return false
return props.items.every((item) => selectedIds.value.includes(item.id))
})
const someSelected = computed(() => {
return props.items.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
})
function toggleSelectAll() {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = props.items.map((item) => item.id)
}
}
function toggleItemSelection(itemId: string, selected: boolean) {
if (selected) {
if (!selectedIds.value.includes(itemId)) {
selectedIds.value = [...selectedIds.value, itemId]
}
} else {
selectedIds.value = selectedIds.value.filter((id) => id !== itemId)
}
}
function isItemSelected(itemId: string): boolean {
return selectedIds.value.includes(itemId)
}
function handleSort(column: ContentCardTableSortColumn) {
if (!props.sortable) return
const newDirection: ContentCardTableSortDirection =
props.sortBy === column && props.sortDirection === 'asc' ? 'desc' : 'asc'
emit('sort', column, newDirection)
}
</script>
<template>
<div
class="@container border border-solid border-surface-4 shadow-sm overflow-clip"
:class="[flat ? '' : 'rounded-[20px]', isStuck || hideHeader ? 'border-t-0' : '']"
>
<div
v-if="!hideHeader"
ref="stickyHeaderRef"
class="sticky top-0 z-10 flex h-12 items-center justify-between gap-4 bg-surface-3 px-3"
:class="[
flat || isStuck ? 'rounded-none' : 'rounded-t-[20px]',
isStuck
? 'transition-[border-radius] duration-100 border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
: '',
]"
>
<div
class="flex min-w-0 items-center gap-4"
:class="
hasAnyActions
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
"
>
<Checkbox
v-if="showSelection"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<button
v-if="sortable"
class="flex items-center gap-1.5 font-semibold text-secondary"
@click="handleSort('project')"
>
{{ formatMessage(commonMessages.projectLabel) }}
<ChevronUpIcon v-if="sortBy === 'project' && sortDirection === 'asc'" class="size-4" />
<ChevronDownIcon
v-else-if="sortBy === 'project' && sortDirection === 'desc'"
class="size-4"
/>
</button>
<span v-else class="font-semibold text-secondary">{{
formatMessage(commonMessages.projectLabel)
}}</span>
</div>
<div class="hidden @[800px]:flex" :class="hasAnyActions ? 'w-[335px] min-w-0' : 'flex-1'">
<button
v-if="sortable"
class="flex items-center gap-1.5 font-semibold text-secondary"
@click="handleSort('version')"
>
{{ formatMessage(commonMessages.versionLabel) }}
<ChevronUpIcon v-if="sortBy === 'version' && sortDirection === 'asc'" class="size-4" />
<ChevronDownIcon
v-else-if="sortBy === 'version' && sortDirection === 'desc'"
class="size-4"
/>
</button>
<span v-else class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
}}</span>
</div>
<div v-if="hasAnyActions" class="min-w-[160px] shrink-0 text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
</div>
</div>
<div
v-if="items.length > 0 && virtualized"
ref="listContainer"
class="relative w-full"
:class="flat ? '' : 'rounded-b-[20px]'"
:style="{ minHeight: `${totalHeight}px` }"
>
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
<ContentCardItem
v-for="(item, idx) in visibleItems"
:key="item.id"
data-content-card-item
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
(visibleRange.start + idx) % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
'border-0 border-t border-solid border-surface-4',
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="emit('delete', item.id)"
@update="emit('update', item.id)"
>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />
</template>
<template #additionalButtonsRight>
<slot name="itemButtonsRight" :item="item" :index="visibleRange.start + idx" />
</template>
</ContentCardItem>
</div>
</div>
<div v-else-if="items.length > 0" ref="listContainer" :class="flat ? '' : 'rounded-b-[20px]'">
<ContentCardItem
v-for="(item, index) in items"
:key="item.id"
data-content-card-item
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:version-link="item.versionLink"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
:hide-delete="hideDelete"
:hide-actions="!hasAnyActions"
:selected="isItemSelected(item.id)"
:class="[
index % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
'border-0 border-t border-solid border-surface-4',
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="emit('delete', item.id)"
@update="emit('update', item.id)"
>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="index" />
</template>
<template #additionalButtonsRight>
<slot name="itemButtonsRight" :item="item" :index="index" />
</template>
</ContentCardItem>
</div>
<div
v-else
class="flex items-center justify-center py-12"
:class="flat ? '' : 'rounded-b-[20px]'"
>
<slot name="empty">
<span class="text-secondary">{{ formatMessage(commonMessages.noItemsLabel) }}</span>
</slot>
</div>
</div>
</template>

View File

@@ -1,180 +0,0 @@
<script setup lang="ts">
import {
ClockIcon,
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
OrganizationIcon,
UnlinkIcon,
} from '@modrinth/assets'
import { computed, getCurrentInstance } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useCompactNumber } from '../../composables'
import { useRelativeTime } from '../../composables/how-ago'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import BulletDivider from '../base/BulletDivider.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
import TagItem from '../base/TagItem.vue'
import type {
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from './types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
unlinkModpack: {
id: 'instances.modpack-card.unlink',
defaultMessage: 'Unlink modpack',
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
version?: ContentModpackCardVersion
owner?: ContentOwner
categories?: ContentModpackCardCategory[]
disabled?: boolean
overflowOptions?: OverflowMenuOption[]
}
withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
owner: undefined,
categories: undefined,
disabled: false,
overflowOptions: undefined,
})
const emit = defineEmits<{
update: []
content: []
unlink: []
}>()
const instance = getCurrentInstance()
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const hasContentListener = computed(() => typeof instance?.vnode.props?.onContent === 'function')
const hasUnlinkListener = computed(() => typeof instance?.vnode.props?.onUnlink === 'function')
const formatTimeAgo = useRelativeTime()
const { formatCompactNumber } = useCompactNumber()
</script>
<template>
<div
class="flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
:class="{ 'opacity-50': disabled }"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="5rem"
no-shadow
raised
class="shrink-0"
/>
<div class="flex flex-col gap-1.5">
<AutoLink
:to="projectLink"
class="text-2xl font-semibold leading-8 text-contrast hover:underline"
>
{{ project.title }}
</AutoLink>
<div class="flex flex-wrap items-center gap-2 text-secondary">
<template v-if="owner">
<AutoLink :to="owner.link" class="flex items-center gap-1.5 hover:underline">
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="2rem"
:circle="owner.type === 'user'"
no-shadow
/>
<OrganizationIcon v-if="owner.type === 'organization'" class="size-4" />
<span class="font-medium">{{ owner.name }}</span>
</AutoLink>
</template>
<template v-if="owner && version">
<BulletDivider />
</template>
<template v-if="version">
<span class="font-medium">v{{ version.version_number }}</span>
</template>
<template v-if="version?.date_published">
<BulletDivider />
<div class="flex items-center gap-2">
<ClockIcon class="size-5" />
<span>{{ formatTimeAgo(new Date(version.date_published)) }}</span>
</div>
</template>
</div>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<ButtonStyled v-if="hasUpdateListener" type="transparent" color="green" color-fill="text">
<button class="flex items-center gap-2" @click="emit('update')">
<DownloadIcon class="!text-green size-5" />
<span class="font-semibold">{{ formatMessage(commonMessages.updateButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-if="hasContentListener">
<button class="!shadow-none" @click="emit('content')">
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="hasUnlinkListener" circular type="outlined">
<button
v-tooltip="formatMessage(messages.unlinkModpack)"
class="!border-surface-4 !border-[1px]"
@click="emit('unlink')"
>
<UnlinkIcon class="size-5" />
</button>
</ButtonStyled>
<ButtonStyled v-if="overflowOptions?.length" circular type="transparent">
<OverflowMenu :options="overflowOptions">
<MoreVerticalIcon class="size-5" />
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<span v-if="project.description" class="text-secondary">
{{ project.description }}
</span>
<div class="flex flex-wrap items-center gap-3">
<div v-if="project.downloads !== undefined" class="flex items-center gap-2 text-secondary">
<DownloadIcon class="size-5" />
<span class="font-medium">{{ formatCompactNumber(project.downloads) }}</span>
</div>
<div v-if="project.followers !== undefined" class="flex items-center gap-2 text-secondary">
<HeartIcon class="size-5" />
<span class="font-medium">{{ formatCompactNumber(project.followers) }}</span>
</div>
<div v-if="categories?.length" class="flex flex-wrap gap-2">
<TagItem v-for="cat in categories" :key="cat.name" :action="cat.action">
{{ cat.name }}
</TagItem>
</div>
</div>
</div>
</template>

View File

@@ -1,19 +0,0 @@
export { default as ContentCardItem } from './ContentCardItem.vue'
export { default as ContentCardTable } from './ContentCardTable.vue'
/**
* @deprecated Use `ContentCardTable` with `ContentCardItem` instead.
* This alias is kept for backwards compatibility and will be removed in a future version.
*/
export { default as ContentCard } from './ContentCardItem.vue'
export { default as ContentModpackCard } from './ContentModpackCard.vue'
// export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue'
export { default as ModpackContentModal } from './modals/ModpackContentModal.vue'
export type {
ContentCardProject,
ContentCardTableItem,
ContentCardVersion,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from './types'

View File

@@ -1,389 +0,0 @@
<template>
<NewModal ref="modal" :max-width="'90vw'" :width="'90vw'" no-padding>
<template #title>
<Avatar v-if="projectIconUrl" :src="projectIconUrl" size="3rem" :tint-by="projectName" />
<span class="text-lg font-extrabold text-contrast">{{
header ?? formatMessage(messages.updateVersionHeader)
}}</span>
</template>
<div class="flex h-[550px] border-solid border-transparent border-[1px] border-b-surface-4">
<div class="w-[300px] flex flex-col relative">
<div class="p-4 pb-2">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchVersionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-16">
<div class="flex flex-col gap-1.5">
<button
v-for="version in filteredVersions"
:key="version.id"
class="flex items-center h-10 px-4 py-2.5 rounded-xl border-none cursor-pointer transition-colors"
:class="[
selectedVersion?.id === version.id
? 'bg-brand-highlight'
: 'bg-transparent hover:bg-button-bg',
]"
@click="selectedVersion = version"
>
<div class="flex items-center justify-between w-full">
<span
v-tooltip="'v' + version.version_number"
class="font-semibold text-contrast truncate"
>
v{{ version.version_number }}
</span>
<span
class="px-2.5 py-0.5 rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
:class="getBadgeClasses(version)"
>
{{ getBadgeLabel(version) }}
</span>
</div>
</button>
</div>
<div v-if="filteredVersions.length === 0" class="p-4 text-center text-secondary text-sm">
{{ formatMessage(messages.noVersionsFound) }}
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 pointer-events-none">
<div class="h-14 bg-gradient-to-t from-bg-raised to-transparent" />
<div class="bg-bg-raised pb-5 flex justify-center pointer-events-auto">
<ButtonStyled type="transparent" :circular="true">
<button
class="flex items-center gap-1.5"
@click="hideIncompatibleState = !hideIncompatibleState"
>
<EyeIcon v-if="hideIncompatibleState" class="h-6 w-6" />
<EyeOffIcon v-else class="h-6 w-6" />
<span class="font-medium">{{
hideIncompatibleState
? formatMessage(messages.showIncompatible)
: formatMessage(messages.hideIncompatible)
}}</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="w-px bg-divider" />
<div class="flex-1 flex flex-col min-w-0 relative">
<template v-if="selectedVersion">
<div class="bg-bg p-4">
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-semibold text-xl text-contrast">
v{{ selectedVersion.version_number }}
</span>
<span
class="px-2.5 py-0.5 rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
:class="getBadgeClasses(selectedVersion)"
>
{{ getBadgeLabel(selectedVersion) }}
</span>
</div>
<span class="font-medium text-primary">
{{ formatLongDate(selectedVersion.date_published) }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 rounded-xl">
<FileTextIcon class="h-6 w-6 text-primary" />
<span class="font-medium text-primary">{{
formatMessage(commonMessages.changelogLabel)
}}</span>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-divider" />
<span class="font-medium text-primary">
{{ formatLoaderGameVersion(selectedVersion) }}
</span>
</div>
</div>
</div>
<div class="h-px bg-divider" />
<div class="flex-1 bg-bg p-4 overflow-y-auto">
<div
v-if="selectedVersion.changelog"
class="markdown"
v-html="renderHighlightedString(selectedVersion.changelog)"
/>
<div v-else class="text-secondary italic">
{{ formatMessage(messages.noChangelog) }}
</div>
</div>
<div
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-bg to-transparent pointer-events-none"
/>
</template>
<div v-else class="flex-1 flex items-center justify-center text-secondary bg-bg">
{{ formatMessage(messages.selectVersionPrompt) }}
</div>
</div>
</div>
<div
class="bg-highlight-orange h-9 text-orange p-2 border-solid border-x-0 border-[1px] flex flex-row gap-2"
>
<TriangleAlertIcon class="size-4" />
<span>{{
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
}}</span>
</div>
<div class="w-full flex flex-row gap-2 justify-end p-4">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-4" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!selectedVersion || selectedVersion.id === currentVersionId"
@click="handleUpdate"
>
<DownloadIcon />
{{
formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, {
version: selectedVersion?.version_number ?? '...',
})
}}
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DownloadIcon,
EyeIcon,
EyeOffIcon,
FileTextIcon,
SearchIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { formatMessage } = useVIntl()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const messages = defineMessages({
updateVersionHeader: {
id: 'instances.updater-modal.header',
defaultMessage: 'Update version',
},
searchVersionPlaceholder: {
id: 'instances.updater-modal.search-placeholder',
defaultMessage: 'Search version...',
},
noVersionsFound: {
id: 'instances.updater-modal.no-versions',
defaultMessage: 'No versions found',
},
showIncompatible: {
id: 'instances.updater-modal.show-incompatible',
defaultMessage: 'Show incompatible',
},
hideIncompatible: {
id: 'instances.updater-modal.hide-incompatible',
defaultMessage: 'Hide incompatible',
},
noChangelog: {
id: 'instances.updater-modal.no-changelog',
defaultMessage: 'No changelog provided for this version.',
},
selectVersionPrompt: {
id: 'instances.updater-modal.select-version',
defaultMessage: 'Select a version to view its changelog',
},
updateWarningApp: {
id: 'instances.updater-modal.warning.app',
defaultMessage:
"We can't guarantee updates are safe for your instance. Review the changelog for all intermediate versions and consider a backup.",
},
updateWarningWeb: {
id: 'instances.updater-modal.warning.web',
defaultMessage:
"We can't guarantee updates are safe for your worlds. Review the changelog for all intermediate versions and consider a backup.",
},
downgradeToVersion: {
id: 'instances.updater-modal.downgrade-to',
defaultMessage: 'Downgrade to v{version}',
},
updateToVersion: {
id: 'instances.updater-modal.update-to',
defaultMessage: 'Update to v{version}',
},
currentBadge: {
id: 'instances.updater-modal.badge.current',
defaultMessage: 'Current',
},
incompatibleBadge: {
id: 'instances.updater-modal.badge.incompatible',
defaultMessage: 'Incompatible',
},
})
const props = withDefaults(
defineProps<{
versions: Labrinth.Versions.v2.Version[]
currentGameVersion: string
currentLoader: string
currentVersionId: string
isApp: boolean
projectIconUrl?: string
projectName?: string
header?: string
}>(),
{
projectIconUrl: undefined,
projectName: undefined,
header: undefined,
},
)
const emit = defineEmits<{
update: [version: Labrinth.Versions.v2.Version]
cancel: []
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const searchQuery = ref('')
const hideIncompatibleState = ref(true)
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
const hasGameVersion = version.game_versions.includes(props.currentGameVersion)
const hasLoader = version.loaders.some(
(loader) => loader.toLowerCase() === props.currentLoader.toLowerCase(),
)
return hasGameVersion && hasLoader
}
const currentVersion = computed(() => props.versions.find((v) => v.id === props.currentVersionId))
const isDowngrade = computed(() => {
if (!selectedVersion.value || !currentVersion.value) return false
return (
new Date(selectedVersion.value.date_published) < new Date(currentVersion.value.date_published)
)
})
const filteredVersions = computed(() => {
let versions = [...props.versions]
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
versions = versions.filter(
(v) => v.name.toLowerCase().includes(query) || v.version_number.toLowerCase().includes(query),
)
}
// Filter by compatibility
if (hideIncompatibleState.value) {
versions = versions.filter(isVersionCompatible)
}
return versions
})
function getBadgeLabel(version: Labrinth.Versions.v2.Version): string {
if (version.id === props.currentVersionId) return formatMessage(messages.currentBadge)
if (!isVersionCompatible(version)) return formatMessage(messages.incompatibleBadge)
return capitalizeString(version.version_type)
}
function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
// Current badge
if (version.id === props.currentVersionId) {
return 'bg-surface-4 border-surface-5 text-primary'
}
// Incompatible badge (takes precedence over version type)
if (!isVersionCompatible(version)) {
return 'bg-highlight-orange border-brand-orange text-brand-orange'
}
// Version type badges
switch (version.version_type) {
case 'release':
return 'bg-highlight-green border-brand text-brand'
case 'beta':
return 'bg-highlight-blue border-brand-blue text-brand-blue'
case 'alpha':
return 'bg-highlight-purple border-brand-purple text-brand-purple'
default:
return 'bg-surface-4 border-surface-5 text-primary'
}
}
function formatLongDate(dateString: string): string {
return formatDate(new Date(dateString))
}
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
const loader = capitalizeString(version.loaders[0] || '')
const gameVersion = version.game_versions[0] || ''
return `${loader} ${gameVersion}`
}
function handleUpdate() {
if (selectedVersion.value) {
emit('update', selectedVersion.value)
hide()
}
}
function handleCancel() {
emit('cancel')
hide()
}
function show(initialVersionId?: string) {
searchQuery.value = ''
hideIncompatibleState.value = true
// Pre-select a version
if (initialVersionId) {
selectedVersion.value = props.versions.find((v) => v.id === initialVersionId) ?? null
} else if (props.versions.length > 0) {
// Default to first version if none specified
selectedVersion.value = props.versions[0]
} else {
selectedVersion.value = null
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,505 +0,0 @@
<script setup lang="ts">
import {
BoxIcon,
FilterIcon,
GlassesIcon,
PaintbrushIcon,
PowerIcon,
PowerOffIcon,
SearchIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { formatProjectType } from '@modrinth/utils'
import Fuse from 'fuse.js'
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import BulletDivider from '../../base/BulletDivider.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import Checkbox from '../../base/Checkbox.vue'
import FloatingActionBar from '../../base/FloatingActionBar.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
import ContentCardTable from '../ContentCardTable.vue'
import type { ContentCardTableItem, ContentItem } from '../types'
const { formatMessage } = useVIntl()
interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
}
const props = withDefaults(defineProps<Props>(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
})
const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
}>()
const messages = defineMessages({
header: {
id: 'instances.modpack-content-modal.header',
defaultMessage: 'Modpack content',
},
searchPlaceholder: {
id: 'instances.modpack-content-modal.search-placeholder',
defaultMessage: 'Search {count, number} {count, plural, one {project} other {projects}}',
},
loading: {
id: 'instances.modpack-content-modal.loading',
defaultMessage: 'Loading content...',
},
emptyTitle: {
id: 'instances.modpack-content-modal.empty-title',
defaultMessage: 'No content found',
},
emptyDescription: {
id: 'instances.modpack-content-modal.empty-description',
defaultMessage: 'This modpack does not include any additional content.',
},
noResults: {
id: 'instances.modpack-content-modal.no-results',
defaultMessage: 'No projects match your search.',
},
backButton: {
id: 'instances.modpack-content-modal.back-button',
defaultMessage: 'Back',
},
allFilter: {
id: 'instances.modpack-content-modal.filter-all',
defaultMessage: 'All',
},
copyLink: {
id: 'instances.modpack-content-modal.copy-link',
defaultMessage: 'Copy link',
},
selectedCount: {
id: 'instances.modpack-content-modal.selected-count',
defaultMessage: '{count, number} selected',
},
enable: {
id: 'instances.modpack-content-modal.enable',
defaultMessage: 'Enable',
},
disable: {
id: 'instances.modpack-content-modal.disable',
defaultMessage: 'Disable',
},
})
export interface ModpackContentModalState {
items: ContentItem[]
searchQuery: string
selectedFilters: string[]
scrollTop: number
}
const modal = ref<InstanceType<typeof NewModal>>()
const scrollContainer = ref<HTMLElement | null>(null)
const items = ref<ContentItem[]>([])
const loading = ref(false)
const searchQuery = ref('')
const selectedFilters = ref<string[]>([])
const selectedIds = ref<string[]>([])
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.file_name)),
)
const allSelected = computed(() => {
if (filteredItems.value.length === 0) return false
return filteredItems.value.every((item) => selectedIds.value.includes(item.file_name))
})
const someSelected = computed(() => {
return (
filteredItems.value.some((item) => selectedIds.value.includes(item.file_name)) &&
!allSelected.value
)
})
function toggleSelectAll() {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = filteredItems.value.map((item) => item.file_name)
}
}
const fuse = new Fuse<ContentItem>([], {
keys: ['project.title', 'owner.name', 'file_name'],
threshold: 0.4,
distance: 100,
})
watchSyncEffect(() => fuse.setCollection(items.value))
const filterOptions = computed(() => {
const frequency = items.value.reduce(
(map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
},
{} as Record<string, number>,
)
// Sort by frequency (most common first)
return Object.entries(frequency)
.sort(([, a], [, b]) => b - a)
.map(([type]) => ({
id: type,
label: formatProjectType(type) + 's',
}))
})
const stats = computed(() => {
const counts: Record<string, number> = {}
for (const item of items.value) {
counts[item.project_type] = (counts[item.project_type] || 0) + 1
}
return counts
})
function toggleFilter(filterId: string) {
const index = selectedFilters.value.indexOf(filterId)
if (index === -1) {
selectedFilters.value.push(filterId)
} else {
selectedFilters.value.splice(index, 1)
}
}
const typeFilteredCount = computed(() => {
if (selectedFilters.value.length === 0) return items.value.length
return items.value.filter((item) => selectedFilters.value.includes(item.project_type)).length
})
const filteredItems = computed(() => {
const query = searchQuery.value.trim()
let result: ContentItem[]
if (query) {
result = fuse.search(query).map(({ item }) => item)
} else {
result = [...items.value].sort((a, b) => {
const nameA = a.project?.title ?? a.file_name
const nameB = b.project?.title ?? b.file_name
return nameA.toLowerCase().localeCompare(nameB.toLowerCase())
})
}
// Apply type filters
if (selectedFilters.value.length > 0) {
result = result.filter((item) => selectedFilters.value.includes(item.project_type))
}
return result
})
const tableItems = computed<ContentCardTableItem[]>(() =>
filteredItems.value.map((item) => ({
id: item.file_name,
project: item.project ?? {
id: item.file_name,
slug: null,
title: item.file_name,
icon_url: null,
},
projectLink: item.project?.id ? `/project/${item.project.id}` : undefined,
version: item.version ?? {
id: item.file_name,
version_number: 'Unknown',
file_name: item.file_name,
},
owner: item.owner
? {
...item.owner,
link: `https://modrinth.com/${item.owner.type}/${item.owner.id}`,
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
})),
)
function getTypeIcon(type: string) {
switch (type) {
case 'mod':
return BoxIcon
case 'shaderpack':
case 'shader':
return GlassesIcon
case 'resourcepack':
return PaintbrushIcon
default:
return BoxIcon
}
}
function handleEnabledChange(fileName: string, value: boolean) {
const item = items.value.find((i) => i.file_name === fileName)
if (!item) return
emit('update:enabled', item, value)
}
function bulkEnable() {
for (const item of selectedItems.value) {
emit('update:enabled', item, true)
}
selectedIds.value = []
}
function bulkDisable() {
for (const item of selectedItems.value) {
emit('update:enabled', item, false)
}
selectedIds.value = []
}
function show(contentItems: ContentItem[]) {
items.value = contentItems
searchQuery.value = ''
selectedFilters.value = []
selectedIds.value = []
loading.value = false
modal.value?.show()
}
function showLoading() {
items.value = []
searchQuery.value = ''
selectedFilters.value = []
selectedIds.value = []
loading.value = true
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function getState(): ModpackContentModalState | null {
if (!items.value.length) return null
return {
items: items.value,
searchQuery: searchQuery.value,
selectedFilters: [...selectedFilters.value],
scrollTop: scrollContainer.value?.scrollTop ?? 0,
}
}
async function restore(state: ModpackContentModalState) {
items.value = state.items
searchQuery.value = state.searchQuery
selectedFilters.value = state.selectedFilters
loading.value = false
modal.value?.show()
await nextTick()
if (scrollContainer.value) {
scrollContainer.value.scrollTop = state.scrollTop
}
}
defineExpose({ show, showLoading, hide, getState, restore })
</script>
<template>
<NewModal
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
no-padding
>
<template #title>
<Avatar
v-if="props.modpackIconUrl"
:src="props.modpackIconUrl"
size="3rem"
:tint-by="props.modpackName"
/>
<span class="text-lg font-extrabold text-contrast">
{{ formatMessage(messages.header) }}
</span>
</template>
<div class="flex flex-col h-[min(600px,calc(95vh-10rem))]">
<div class="flex flex-col gap-4 px-6 py-4 border-b border-solid border-0 border-surface-4">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder, { count: typeFilteredCount })"
clearable
/>
<!-- Filters -->
<div v-if="filterOptions.length > 1" class="flex items-center gap-2">
<FilterIcon class="size-5 text-secondary shrink-0" />
<div class="flex flex-wrap items-center gap-1.5">
<button
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.length === 0
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
@click="selectedFilters = []"
>
{{ formatMessage(messages.allFilter) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.includes(option.id)
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<!-- Content area -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Loading state -->
<div
v-if="loading"
class="flex flex-col items-center justify-center flex-1 gap-2 text-secondary"
>
<SpinnerIcon class="size-8 animate-spin" />
<span class="text-sm">{{ formatMessage(messages.loading) }}</span>
</div>
<!-- Empty state -->
<div
v-else-if="items.length === 0"
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
>
<span class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.emptyTitle) }}
</span>
<span class="text-secondary">{{ formatMessage(messages.emptyDescription) }}</span>
</div>
<!-- No search results -->
<div
v-else-if="filteredItems.length === 0"
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
>
<span class="text-secondary">{{ formatMessage(messages.noResults) }}</span>
</div>
<!-- Content table -->
<div v-else class="@container flex-1 min-h-0 flex flex-col">
<div
class="flex h-12 shrink-0 items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-3 px-3"
>
<div
class="flex min-w-0 items-center gap-4"
:class="
props.enableToggle
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
"
>
<Checkbox
v-if="props.enableToggle"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.projectLabel)
}}</span>
</div>
<div
class="hidden @[800px]:flex"
:class="props.enableToggle ? 'w-[335px] min-w-0' : 'flex-1'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
}}</span>
</div>
<div v-if="props.enableToggle" class="min-w-[160px] shrink-0 text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
</div>
</div>
<div ref="scrollContainer" class="flex-1 min-h-0 overflow-y-auto">
<ContentCardTable
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="props.enableToggle"
hide-delete
hide-header
flat
@update:enabled="(id, val) => handleEnabledChange(id, val)"
/>
</div>
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-between px-6 py-4 border-t border-solid border-0 border-surface-4 shrink-0"
>
<!-- Stats -->
<div class="flex items-center gap-2">
<template v-for="(count, type, idx) in stats" :key="type">
<BulletDivider v-if="idx > 0" />
<div class="flex items-center gap-1.5">
<component :is="getTypeIcon(type as string)" class="size-5 text-secondary" />
<span class="font-medium text-primary">
{{ count }} {{ formatProjectType(type as string) }}{{ count !== 1 ? 's' : '' }}
</span>
</div>
</template>
</div>
</div>
</div>
<FloatingActionBar
v-if="props.enableToggle"
:shown="selectedItems.length > 0"
style="--left-bar-width: 0px; --right-bar-width: 0px"
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast">
{{ formatMessage(messages.selectedCount, { count: selectedItems.length }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button class="!text-primary" @click="selectedIds = []">
{{ formatMessage(commonMessages.clearButton) }}
</button>
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled v-if="selectedItems.every((m) => !m.enabled)" type="transparent">
<button @click="bulkEnable">
<PowerIcon />
{{ formatMessage(messages.enable) }}
</button>
</ButtonStyled>
<ButtonStyled v-else type="transparent">
<button @click="bulkDisable">
<PowerOffIcon />
{{ formatMessage(messages.disable) }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</NewModal>
</template>

View File

@@ -1,67 +0,0 @@
import type { Labrinth } from '@modrinth/api-client'
import type { RouteLocationRaw } from 'vue-router'
import type { Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
export type ContentCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url'
>
export type ContentCardVersion = Pick<Labrinth.Versions.v2.Version, 'id' | 'version_number'> & {
file_name: string
date_published?: string
}
export interface ContentOwner {
id: string
name: string
avatar_url?: string
type: 'user' | 'organization'
link?: string | RouteLocationRaw
}
export interface ContentCardTableItem {
id: string
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
hasUpdate?: boolean
overflowOptions?: OverflowMenuOption[]
}
export type ContentCardTableSortColumn = 'project' | 'version'
export type ContentCardTableSortDirection = 'asc' | 'desc'
/** Content item returned from the app backend API - maps to ContentCardTableItem for display */
export interface ContentItem extends Omit<
ContentCardTableItem,
'id' | 'projectLink' | 'disabled' | 'overflowOptions'
> {
file_name: string
file_path?: string
hash?: string
size?: number
project_type: string
has_update: boolean
update_version_id: string | null
date_added?: string
}
export type ContentModpackCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url' | 'description' | 'downloads' | 'followers'
>
export type ContentModpackCardVersion = Pick<
Labrinth.Versions.v2.Version,
'id' | 'version_number' | 'date_published'
>
export type ContentModpackCardCategory = Labrinth.Tags.v2.Category & {
action?: (event: MouseEvent) => void
}

View File

@@ -0,0 +1,111 @@
<template>
<NewModal ref="modal" header="Install to play" :closable="true">
<div class="flex flex-col gap-4 max-w-[500px]">
<Admonition type="info" header="Shared server instance">
This server requires modded content to play. Accept to install the needed files from
Modrinth.
</Admonition>
<div v-if="sharedBy?.name" class="flex items-center gap-2 text-sm text-secondary">
<Avatar
v-if="sharedBy?.icon_url"
:src="sharedBy.icon_url"
:alt="sharedBy.name"
size="24px"
/>
<span>
<span class="font-semibold text-contrast">{{ sharedBy.name }}</span>
shared this instance with you today.
</span>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Shared instance</span>
<div class="flex items-center gap-3 rounded-xl bg-surface-4 p-3">
<Avatar :src="project.icon_url" :alt="project.title" size="48px" />
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
<template v-if="modCount"> · {{ modCount }} mods </template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
Decline
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<CheckIcon />
Accept
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import type { Project } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { useVIntl } from '../../composables'
import { formatLoader } from '../../utils'
import Admonition from '../base/Admonition.vue'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import NewModal from './NewModal.vue'
const props = defineProps<{
project: Project
sharedBy?: {
name: string
icon_url?: string
}
modCount?: number
}>()
const emit = defineEmits<{
accept: []
decline: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const loaderDisplay = computed(() => {
const loader = props.project.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
function handleAccept() {
// TODO: Implement accept logic
emit('accept')
modal.value?.hide()
}
function handleDecline() {
emit('decline')
modal.value?.hide()
}
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -14,7 +14,7 @@
'modal-overlay',
{
shown: visible,
noblur: props.noblur,
noblur: effectiveNoblur,
},
computedFade,
]"
@@ -29,7 +29,12 @@
}"
>
<div
ref="modalBodyRef"
role="dialog"
aria-modal="true"
:aria-labelledby="headerId"
class="modal-body flex flex-col bg-bg-raised rounded-2xl border border-solid border-surface-5"
@keydown="handleKeyDown"
>
<div
v-if="!hideHeader"
@@ -38,13 +43,18 @@
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
<slot name="title">
<span v-if="header" class="text-lg font-extrabold text-contrast">
<span v-if="header" :id="headerId" class="text-2xl font-semibold text-contrast">
{{ header }}
</span>
</slot>
</div>
<ButtonStyled v-if="closable" circular>
<button v-tooltip="'Close'" aria-label="Close" :disabled="disableClose" @click="hide">
<button
v-tooltip="closeLabel"
:aria-label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
@@ -55,7 +65,12 @@
class="absolute top-4 right-4 z-10"
circular
>
<button v-tooltip="'Close'" aria-label="Close" :disabled="disableClose" @click="hide">
<button
v-tooltip="closeLabel"
:aria-label="closeLabel"
:disabled="disableClose"
@click="hide"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
@@ -64,14 +79,14 @@
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-24 bg-gradient-to-b from-bg-raised to-transparent"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-6 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
@@ -91,14 +106,14 @@
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
enter-to-class="opacity-100 max-h-6"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-from-class="opacity-100 max-h-6"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-24 bg-gradient-to-t from-bg-raised to-transparent"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-6 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
@@ -113,7 +128,7 @@
<slot> You just lost the game.</slot>
</div>
<div v-if="$slots.actions" class="p-4">
<div v-if="$slots.actions" class="p-4 pt-0">
<slot name="actions" />
</div>
</div>
@@ -123,11 +138,25 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { computed, ref } from 'vue'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { useModalStack } from '../../composables/modal-stack'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import { injectModalBehavior } from '../../providers'
import { commonMessages } from '../../utils/common-messages'
import ButtonStyled from '../base/ButtonStyled.vue'
const { formatMessage } = useVIntl()
const modalBehavior = injectModalBehavior(null)
const {
push: pushModal,
pop: popModal,
isTopmost: isTopmostModal,
stackSize: modalStackSize,
} = useModalStack()
const props = withDefaults(
defineProps<{
noblur?: boolean
@@ -156,6 +185,7 @@ const props = withDefaults(
}>(),
{
type: true,
noblur: undefined,
closable: true,
danger: false,
fade: undefined,
@@ -177,25 +207,45 @@ const props = withDefaults(
},
)
const effectiveNoblur = computed(() => props.noblur ?? modalBehavior?.noblur.value ?? false)
const computedFade = computed(() => {
if (props.fade) return props.fade
if (props.danger) return 'danger'
return 'standard'
})
const modalId = `modal-${Math.random().toString(36).slice(2, 9)}`
const headerId = `${modalId}-header`
const closeLabel = computed(() => formatMessage(commonMessages.closeButton))
const open = ref(false)
const visible = ref(false)
const modalBodyRef = ref<HTMLElement | null>(null)
let previousFocusEl: Element | null = null
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
function getFocusableElements(): HTMLElement[] {
if (!modalBodyRef.value) return []
return Array.from(modalBodyRef.value.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
}
function show(event?: MouseEvent) {
props.onShow?.()
const wasEmpty = modalStackSize() === 0
open.value = true
previousFocusEl = document.activeElement
pushModal()
if (wasEmpty) modalBehavior?.onShow?.()
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleWindowKeyDown)
window.addEventListener('mousedown', updateMousePosition)
window.addEventListener('keydown', handleKeyDown)
if (event) {
updateMousePosition(event)
} else {
@@ -204,6 +254,14 @@ function show(event?: MouseEvent) {
}
setTimeout(() => {
visible.value = true
nextTick(() => {
const focusable = getFocusableElements()
if (focusable.length > 0) {
focusable[0].focus()
} else {
modalBodyRef.value?.focus()
}
})
}, 50)
}
@@ -211,9 +269,17 @@ function hide() {
if (props.disableClose) return
props.onHide?.()
visible.value = false
document.body.style.overflow = ''
popModal()
if (modalStackSize() === 0) {
modalBehavior?.onHide?.()
document.body.style.overflow = ''
}
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
window.removeEventListener('keydown', handleKeyDown)
if (previousFocusEl instanceof HTMLElement) {
previousFocusEl.focus()
}
previousFocusEl = null
setTimeout(() => {
open.value = false
}, 300)
@@ -233,13 +299,48 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
mouseY.value = event.clientY
}
function handleKeyDown(event: KeyboardEvent) {
onUnmounted(() => {
if (open.value) {
popModal()
window.removeEventListener('keydown', handleWindowKeyDown)
window.removeEventListener('mousedown', updateMousePosition)
if (modalStackSize() === 0) {
document.body.style.overflow = ''
modalBehavior?.onHide?.()
}
}
})
function handleWindowKeyDown(event: KeyboardEvent) {
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
if (!isTopmostModal()) return
hide()
mouseX.value = window.innerWidth / 2
mouseY.value = window.innerHeight / 2
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Tab') {
const focusable = getFocusableElements()
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey) {
if (document.activeElement === first) {
event.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
}
}
</script>
<style lang="scss" scoped>

View File

@@ -75,13 +75,15 @@ defineExpose({ selectedTab, setTab })
<div
ref="scrollContainer"
class="w-[600px] h-[500px] overflow-y-auto px-4"
class="min-w-[400px] h-[500px] overflow-y-auto px-4"
@scroll="checkScrollState"
>
<component
:is="visibleTabs[selectedTab].content"
v-bind="visibleTabs[selectedTab].props ?? {}"
/>
<Suspense>
<component
:is="visibleTabs[selectedTab].content"
v-bind="visibleTabs[selectedTab].props ?? {}"
/>
</Suspense>
</div>
<Transition

View File

@@ -1,4 +1,5 @@
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
export { default as Modal } from './Modal.vue'
export { default as NewModal } from './NewModal.vue'
export type { ServerProject as OpenInAppModalServerProject } from './OpenInAppModal.vue'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import { capitalizeString } from '../../../../../utils'
import { useCompactNumber, useVIntl } from '../../../composables'
import { commonMessages } from '../../../utils'

View File

@@ -0,0 +1,122 @@
<template>
<Admonition type="info" show-actions-underneath>
<template #icon>
<slot name="icon">
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
</slot>
</template>
<template #header>We're preparing your server!</template>
<template v-if="progress">{{ phaseLabel }}</template>
<div v-else class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in tickerMessages"
:key="message"
class="ticker-item"
:class="{ active: index === currentIndex % tickerMessages.length }"
>
{{ message }}
</div>
</div>
</div>
<template #actions>
<ProgressBar
v-if="progress"
:progress="progress.percent"
:max="100"
color="blue"
full-width
/>
<ProgressBar v-else :progress="0" :max="1" color="blue" full-width waiting />
</template>
</Admonition>
</template>
<script setup lang="ts">
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Admonition from '../base/Admonition.vue'
import ProgressBar from '../base/ProgressBar.vue'
export interface SyncProgress {
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
percent: number
}
const props = defineProps<{
progress?: SyncProgress | null
}>()
const phaseLabel = computed(() => {
switch (props.progress?.phase) {
case 'InstallingLoader':
return 'Installing platform...'
case 'InstallingPack':
return 'Installing modpack...'
case 'Addons':
return 'Installing addons...'
default:
return 'Installing...'
}
})
const tickerMessages = [
'Organizing files...',
'Downloading mods...',
'Configuring server...',
'Setting up environment...',
'Adding Java...',
]
const currentIndex = ref(0)
let intervalId: ReturnType<typeof setInterval> | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % tickerMessages.length
}, 3000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
white-space: nowrap;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>

View File

@@ -203,8 +203,8 @@ async function dataURLToBlob(dataURL: string): Promise<Blob> {
const { data: image } = useQuery({
queryKey: ['server-icon', props.server_id] as const,
queryFn: async (): Promise<string | undefined> => {
if (!props.server_id || props.status !== 'available') return undefined
queryFn: async (): Promise<string | null> => {
if (!props.server_id || props.status !== 'available') return null
try {
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
@@ -244,8 +244,10 @@ const { data: image } = useQuery({
}
} catch (error) {
console.debug('Icon processing failed:', error)
return undefined
return null
}
return null
},
enabled: computed(() => !!props.server_id && props.status === 'available'),
})

View File

@@ -0,0 +1,254 @@
<template>
<CreationFlowModal
ref="creationFlowRef"
type="reset-server"
:available-loaders="serverLoaders"
:show-snapshot-toggle="true"
:disable-close="props.initialSetup"
:is-initial-setup="props.initialSetup"
:initial-loader="initialLoader"
:initial-game-version="initialGameVersion"
:fade="props.initialSetup ? undefined : 'danger'"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
@create="onFlowComplete"
@hide="$emit('hide')"
@browse-modpacks="$emit('browse-modpacks')"
/>
<NewModal
ref="uploadModal"
:header="formatMessage(messages.uploadingModpackHeader)"
:closable="false"
>
<div class="flex flex-col gap-4 md:w-[400px]">
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.uploadWarningText) }}</p>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Archon, ModrinthApiError } from '@modrinth/api-client'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers/api-client'
import { injectModrinthServerContext } from '../../providers/server-context'
import { injectNotificationManager } from '../../providers/web-notifications'
import { AppearingProgressBar } from '../base'
import type { CreationFlowContextValue } from '../flows/creation-flow-modal/creation-flow-context'
import CreationFlowModal from '../flows/creation-flow-modal/index.vue'
import { NewModal } from '../modal'
const { formatMessage } = useVIntl()
const messages = defineMessages({
uploadingModpackHeader: {
id: 'servers.setup.uploading-modpack.header',
defaultMessage: 'Uploading modpack',
},
uploadWarningText: {
id: 'servers.setup.upload-warning',
defaultMessage: "Please don't close this page while uploading.",
},
rateLimitTitle: {
id: 'servers.setup.rate-limit.title',
defaultMessage: 'Cannot reinstall server',
},
rateLimitText: {
id: 'servers.setup.rate-limit.text',
defaultMessage: 'You are being rate limited. Please try again later.',
},
reinstallFailedTitle: {
id: 'servers.setup.reinstall-failed.title',
defaultMessage: 'Reinstall Failed',
},
reinstallFailedText: {
id: 'servers.setup.reinstall-failed.text',
defaultMessage: 'An unexpected error occurred while reinstalling. Please try again later.',
},
})
const debug = useDebugLogger('ServerSetupModal')
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const serverLoaders = ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']
async function searchModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
query: query || undefined,
facets: [['project_type:modpack'], ['client_side:required'], ['server_side:required']],
limit,
})
}
async function getProjectVersions(projectId: string) {
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
return versions.map((v) => ({ id: v.id }))
}
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
if (loader === 'neoforge') return 'neo_forge'
return loader as Archon.Content.v1.Modloader
}
const props = defineProps<{
initialSetup?: boolean
}>()
const emit = defineEmits<{
reinstall: [args?: { loader: string; lVersion: string; mVersion: string | null }]
hide: []
'browse-modpacks': []
}>()
const initialLoader = computed(() => {
const loader = serverContext.server.value.loader
if (!loader || loader === 'Vanilla') return undefined
return loader.toLowerCase()
})
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
const uploadModal = useTemplateRef<InstanceType<typeof NewModal>>('uploadModal')
const uploadedBytes = ref(0)
const totalBytes = ref(0)
async function onFlowComplete(ctx: CreationFlowContextValue) {
debug('onFlowComplete:', {
setupType: ctx.setupType.value,
hasModpackFile: !!ctx.modpackFile.value,
modpackSelection: ctx.modpackSelection.value,
selectedLoader: ctx.selectedLoader.value,
selectedGameVersion: ctx.selectedGameVersion.value,
selectedLoaderVersion: ctx.selectedLoaderVersion.value,
worldId: serverContext.worldId.value,
})
try {
if (ctx.setupType.value === 'modpack' && ctx.modpackFile.value) {
debug('onFlowComplete: mrpack upload path')
await handleMrpackUpload(ctx.modpackFile.value, ctx.buildProperties())
} else if (ctx.setupType.value === 'modpack' && ctx.modpackSelection.value) {
debug('onFlowComplete: modpack selection path, calling installContent')
await client.archon.content_v1.installContent(
serverContext.serverId,
serverContext.worldId.value!,
{
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: ctx.modpackSelection.value.projectId,
version_id: ctx.modpackSelection.value.versionId,
},
soft_override: false,
properties: ctx.buildProperties(),
},
)
debug('onFlowComplete: modpack installContent returned, emitting reinstall')
emitReinstall()
} else {
const loader = ctx.selectedLoader.value
const loaderVersion =
!loader || loader === 'vanilla' ? '' : (ctx.selectedLoaderVersion.value ?? '')
debug('onFlowComplete: bare install path', {
loader,
loaderVersion,
gameVersion: ctx.selectedGameVersion.value,
apiLoader: toApiLoader(loader ?? 'vanilla'),
})
await client.archon.content_v1.installContent(
serverContext.serverId,
serverContext.worldId.value!,
{
content_variant: 'bare',
loader: toApiLoader(loader ?? 'vanilla'),
version: loaderVersion,
game_version: ctx.selectedGameVersion.value ?? undefined,
soft_override: false,
properties: ctx.buildProperties(),
},
)
debug('onFlowComplete: bare installContent returned, emitting reinstall')
emitReinstall({
loader: loader ?? 'vanilla',
lVersion: loaderVersion,
mVersion: ctx.selectedGameVersion.value,
})
}
creationFlowRef.value?.hide()
} catch (error) {
debug('onFlowComplete: ERROR', error)
if ((error as ModrinthApiError).statusCode === 429) {
addNotification({
title: formatMessage(messages.rateLimitTitle),
text: formatMessage(messages.rateLimitText),
type: 'error',
})
} else {
addNotification({
title: formatMessage(messages.reinstallFailedTitle),
text: formatMessage(messages.reinstallFailedText),
type: 'error',
})
}
} finally {
ctx.loading.value = false
}
}
async function handleMrpackUpload(file: File, properties: Archon.Content.v1.PropertiesFields) {
uploadedBytes.value = 0
totalBytes.value = file.size
creationFlowRef.value?.hide()
await nextTick()
uploadModal.value?.show()
try {
const handle = client.kyros.content_v1.uploadModpackFile(
serverContext.worldId.value!,
file,
properties,
{
softOverride: false,
onProgress: ({ loaded, total }) => {
uploadedBytes.value = loaded
totalBytes.value = total
},
},
)
await handle.promise
emitReinstall()
} finally {
uploadModal.value?.hide()
}
}
function emitReinstall(args?: { loader: string; lVersion: string; mVersion: string | null }) {
debug('emitReinstall:', args)
emit('reinstall', args)
}
function show() {
creationFlowRef.value?.show()
}
function hide() {
creationFlowRef.value?.hide()
}
defineExpose({ show, hide, ctx: computed(() => creationFlowRef.value?.ctx) })
</script>

View File

@@ -90,7 +90,8 @@ const props = defineProps<{
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const createMutation = useMutation({
mutationFn: (name: string) => client.archon.backups_v0.create(ctx.serverId, { name }),
mutationFn: (name: string) =>
client.archon.backups_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})

View File

@@ -71,7 +71,7 @@ const backupsQueryKey = ['backups', 'list', ctx.serverId]
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v0.rename(ctx.serverId, backupId, { name }),
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})

View File

@@ -58,7 +58,8 @@ const ctx = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const restoreMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.restore(ctx.serverId, backupId),
mutationFn: (backupId: string) =>
client.archon.backups_v1.restore(ctx.serverId, ctx.worldId.value!, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})

View File

@@ -0,0 +1,160 @@
<template>
<div class="experimental-styles-within flex w-full flex-col items-center gap-2">
<ManySelect
v-model="selectedPlatforms"
:tooltip="
filterOptions.platform.length < 2 && !disabled ? 'No other platforms available' : undefined
"
:options="filterOptions.platform"
:dropdown-id="`${baseId}-platform`"
search
show-always
class="w-full"
:disabled="disabled || filterOptions.platform.length < 2"
:dropdown-class="'w-full'"
@change="updateFilters"
>
<slot name="platform">
<FilterIcon class="h-5 w-5 text-secondary" />
Platform
</slot>
<template #option="{ option }">
{{ formatLoader(formatMessage, String(option)) }}
</template>
<template v-if="hasAnyUnsupportedPlatforms" #footer>
<Checkbox
v-model="showSupportedPlatformsOnly"
class="mx-1"
:label="`Show ${type?.toLowerCase()} platforms only`"
/>
</template>
</ManySelect>
<ManySelect
v-model="selectedGameVersions"
:tooltip="
filterOptions.gameVersion.length < 2 && !disabled
? 'No other game versions available'
: undefined
"
:options="filterOptions.gameVersion"
:dropdown-id="`${baseId}-game-version`"
search
show-always
class="w-full"
:disabled="disabled || filterOptions.gameVersion.length < 2"
:dropdown-class="'w-full'"
@change="updateFilters"
>
<slot name="game-versions">
<FilterIcon class="h-5 w-5 text-secondary" />
Game versions
</slot>
<template v-if="hasAnySnapshots" #footer>
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
</template>
</ManySelect>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FilterIcon } from '@modrinth/assets'
import { formatLoader, useVIntl } from '@modrinth/ui'
import Checkbox from '@modrinth/ui/src/components/base/Checkbox.vue'
import ManySelect from '@modrinth/ui/src/components/base/ManySelect.vue'
import { computed, ref } from 'vue'
const { formatMessage } = useVIntl()
export type ListedGameVersion = {
name: string
release: boolean
}
export type ListedPlatform = {
name: string
isType: boolean
}
const props = defineProps<{
versions: Labrinth.Versions.v2.Version[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
listedGameVersions: ListedGameVersion[]
listedPlatforms: ListedPlatform[]
baseId?: string
type: 'Mod' | 'Plugin'
platformTags: {
name: string
supported_project_types: string[]
}[]
disabled?: boolean
}>()
const emit = defineEmits(['update:query'])
const showSnapshots = ref(false)
const hasAnySnapshots = computed(() => {
return props.versions.some((x) =>
props.gameVersions.some(
(y) => y.version_type !== 'release' && x.game_versions.includes(y.version),
),
)
})
const hasOnlySnapshots = computed(() => {
return props.versions.every((version) => {
return version.game_versions.every((gv) => {
const matched = props.gameVersions.find((tag) => tag.version === gv)
return matched && matched.version_type !== 'release'
})
})
})
const hasAnyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.some((x) => !x.isType)
})
const hasOnlyUnsupportedPlatforms = computed(() => {
return props.listedPlatforms.every((x) => !x.isType)
})
const showSupportedPlatformsOnly = ref(true)
const filterOptions = computed<Record<'gameVersion' | 'platform', string[]>>(() => {
const filters: Record<'gameVersion' | 'platform', string[]> = {
gameVersion: [],
platform: [],
}
filters.gameVersion = props.listedGameVersions
.filter((x) => {
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release
})
.map((x) => x.name)
filters.platform = props.listedPlatforms
.filter((x) => {
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
? true
: x.isType
})
.map((x) => x.name)
return filters
})
const selectedGameVersions = ref<string[]>([])
const selectedPlatforms = ref<string[]>([])
function updateFilters() {
emit('update:query', {
g: selectedGameVersions.value,
l: selectedPlatforms.value,
})
}
defineExpose({
selectedGameVersions,
selectedPlatforms,
})
</script>

View File

@@ -97,6 +97,8 @@
position="bottom"
direction="left"
aria-label="Create new..."
:disabled="disabled"
:tooltip="disabled ? disabledTooltip : undefined"
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
:options="[
{ id: 'file', action: () => $emit('create', 'file') },
@@ -191,6 +193,8 @@ const props = defineProps<{
searchQuery: string
showRefreshButton?: boolean
baseId: string
disabled?: boolean
disabledTooltip?: string
}>()
defineEmits<{

View File

@@ -99,6 +99,8 @@ interface FileItemProps {
index: number
isLast: boolean
selected: boolean
writeDisabled?: boolean
writeDisabledTooltip?: string
}
const props = defineProps<FileItemProps>()
@@ -145,35 +147,48 @@ const fileExtension = computed(() => getFileExtension(props.name))
const isZip = computed(() => fileExtension.value === 'zip')
const menuOptions = computed(() => [
{
id: 'extract',
shown: isZip.value,
action: () => emit('extract', { name: props.name, type: props.type, path: props.path }),
},
{
divider: true,
shown: isZip.value,
},
{
id: 'rename',
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
},
{
id: 'move',
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
},
{
id: 'download',
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
shown: props.type !== 'directory',
},
{
id: 'delete',
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
color: 'red' as const,
},
])
const menuOptions = computed(() => {
const item = { name: props.name, type: props.type, path: props.path }
const wd = props.writeDisabled
const wdTooltip = props.writeDisabledTooltip
return [
{
id: 'extract',
shown: isZip.value,
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('extract', item),
},
{
divider: true,
shown: isZip.value,
},
{
id: 'rename',
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('rename', item),
},
{
id: 'move',
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('move', item),
},
{
id: 'download',
action: () => emit('download', item),
shown: props.type !== 'directory',
},
{
id: 'delete',
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('delete', item),
color: 'red' as const,
},
]
})
const iconComponent = computed(() => {
if (props.type === 'directory') {

View File

@@ -29,6 +29,8 @@
:index="visibleRange.start + idx"
:is-last="visibleRange.start + idx === props.items.length - 1"
:selected="selectedItems.has(item.path)"
:write-disabled="writeDisabled"
:write-disabled-tooltip="writeDisabledTooltip"
@delete="$emit('delete', item)"
@rename="$emit('rename', item)"
@extract="$emit('extract', item)"
@@ -47,13 +49,16 @@
<script setup lang="ts">
import type { Kyros } from '@modrinth/api-client'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { toRef } from 'vue'
import { useVirtualScroll } from '../../../../composables/virtual-scroll'
import FileItem from './FileItem.vue'
const props = defineProps<{
items: Kyros.Files.v0.DirectoryItem[]
selectedItems: Set<string>
writeDisabled?: boolean
writeDisabledTooltip?: string
}>()
const emit = defineEmits<{
@@ -70,64 +75,12 @@ const emit = defineEmits<{
'toggle-select': [path: string]
}>()
const ITEM_HEIGHT = 61
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
const visibleRange = computed(() => {
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
}
})
const visibleTop = computed(() => {
return visibleRange.value.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
})
function handleScroll() {
windowScrollY.value = window.scrollY
if (!listContainer.value) return
const containerBottom = listContainer.value.getBoundingClientRect().bottom
const remainingScroll = containerBottom - window.innerHeight
if (remainingScroll < windowHeight.value * 0.2) {
emit('loadMore')
}
}
function handleResize() {
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
toRef(props, 'items'),
{
itemHeight: 61,
bufferSize: 5,
onNearEnd: () => emit('loadMore'),
},
)
</script>

View File

@@ -3,6 +3,7 @@
<button
ref="triggerRef"
class="teleport-overflow-menu-trigger"
:class="btnClass"
:aria-expanded="isOpen"
:aria-haspopup="true"
@mousedown="handleMouseDown"
@@ -45,6 +46,8 @@
if (el) menuItemsRef[index] = el as HTMLElement
}
"
v-tooltip="option.tooltip"
:disabled="option.disabled"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@@ -92,6 +95,8 @@ interface Option {
action?: (() => void) | string
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
disabled?: boolean
tooltip?: string
}
type Divider = {
@@ -109,14 +114,17 @@ const props = withDefaults(
defineProps<{
options: Item[]
hoverable?: boolean
btnClass?: string | string[] | Record<string, boolean>
}>(),
{
hoverable: false,
btnClass: undefined,
},
)
const emit = defineEmits<{
select: [option: Option]
open: []
}>()
const isOpen = ref(false)
@@ -187,6 +195,7 @@ const toggleMenu = (event: MouseEvent) => {
const openMenu = () => {
isOpen.value = true
emit('open')
disableBodyScroll()
nextTick(() => {
menuStyle.value = calculateMenuPosition()
@@ -255,6 +264,7 @@ const handleMouseMove = (event: MouseEvent) => {
}
const handleItemClick = (option: Option, index: number) => {
if (option.disabled) return
selectedIndex.value = index
selectOption(option)
}

View File

@@ -0,0 +1,11 @@
export type {
CreationFlowContextValue,
CreationFlowOptions,
Difficulty,
FlowType,
Gamemode,
GeneratorSettingsMode,
LoaderVersionType,
SetupType,
} from '../../flows/creation-flow-modal/creation-flow-context'
export { default as CreationFlowModal } from '../../flows/creation-flow-modal/index.vue'

View File

@@ -1,8 +1,11 @@
export * from './backups'
export * from './files'
export * from './flows'
export * from './icons'
export { default as InstallingBanner } from './InstallingBanner.vue'
export * from './labels'
export * from './marketing'
export type { PendingChange } from './ServerListing.vue'
export { default as ServerListing } from './ServerListing.vue'
export { default as ServerSetupModal } from './ServerSetupModal.vue'
export { default as ServersPromo } from './ServersPromo.vue'

View File

@@ -1,20 +1,38 @@
<script setup lang="ts">
import type { VersionChannel } from '@modrinth/utils'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
withDefaults(
const props = withDefaults(
defineProps<{
channel: VersionChannel
/** @deprecated Use size="lg" instead */
large?: boolean
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(),
{
large: false,
size: 'md',
},
)
const sizeClasses = computed(() => {
if (props.large) return 'text-lg w-[2.625rem] h-[2.625rem]'
switch (props.size) {
case 'xs':
return 'text-xs w-5 h-5'
case 'sm':
return 'text-xs w-7 h-7'
case 'lg':
return 'text-lg w-[2.625rem] h-[2.625rem]'
default:
return 'text-sm w-9 h-9'
}
})
const messages = defineMessages({
releaseSymbol: {
id: 'project.versions.channel.release.symbol',
@@ -33,7 +51,15 @@ const messages = defineMessages({
<template>
<div
:class="`flex ${large ? 'text-lg w-[2.625rem] h-[2.625rem]' : 'text-sm w-9 h-9'} font-bold justify-center items-center rounded-full ${channel === 'release' ? 'bg-bg-green text-brand-green' : channel === 'beta' ? 'bg-bg-orange text-brand-orange' : 'bg-bg-red text-brand-red'}`"
:class="[
'flex font-bold justify-center items-center rounded-full',
sizeClasses,
channel === 'release'
? 'bg-bg-green text-brand-green'
: channel === 'beta'
? 'bg-bg-orange text-brand-orange'
: 'bg-bg-red text-brand-red',
]"
>
{{ channel ? formatMessage(messages[`${channel}Symbol`]) : '?' }}
</div>