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

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

View File

@@ -7,3 +7,5 @@ export * from './how-ago'
export * from './i18n'
export * from './i18n-debug'
export * from './scroll-indicator'
export * from './sticky-observer'
export * from './virtual-scroll'

View File

@@ -0,0 +1,27 @@
const isClient = typeof window !== 'undefined'
const stack: symbol[] = []
export function useModalStack() {
const id = Symbol()
function push() {
if (isClient && !stack.includes(id)) stack.push(id)
}
function pop() {
if (!isClient) return
const idx = stack.indexOf(id)
if (idx !== -1) stack.splice(idx, 1)
}
function isTopmost() {
if (!isClient) return true
return stack.length === 0 || stack[stack.length - 1] === id
}
function stackSize() {
return isClient ? stack.length : 0
}
return { push, pop, isTopmost, stackSize }
}

View File

@@ -78,10 +78,10 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
)
function checkNearEnd() {
if (!onNearEnd || !listContainer.value) return
if (!onNearEnd || !listContainer.value || !viewportHeight.value) return
const containerBottom = listContainer.value.getBoundingClientRect().bottom
const remainingScroll = containerBottom - window.innerHeight
const remainingScroll = containerBottom - viewportHeight.value
if (remainingScroll < viewportHeight.value * nearEndThreshold) {
onNearEnd()
@@ -102,6 +102,8 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
}
watchEffect((onCleanup) => {
if (typeof window === 'undefined') return
const listEl = listContainer.value
if (!listEl) return

View File

@@ -0,0 +1,3 @@
export * from './shared/content-tab'
export * from './shared/installation-settings'
export * from './wrapped'

View File

@@ -1,20 +1,28 @@
<script setup lang="ts">
import { DownloadIcon, MoreVerticalIcon, OrganizationIcon, TrashIcon } from '@modrinth/assets'
import {
DownloadIcon,
MoreVerticalIcon,
OrganizationIcon,
TrashIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
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'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { truncatedTooltip } from '#ui/utils/truncate'
import type { ContentCardProject, ContentCardVersion, ContentOwner } from '../types'
const { formatMessage } = useVIntl()
@@ -26,6 +34,7 @@ interface Props {
owner?: ContentOwner
enabled?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
showCheckbox?: boolean
@@ -40,6 +49,7 @@ const props = withDefaults(defineProps<Props>(), {
owner: undefined,
enabled: undefined,
hasUpdate: false,
isClientOnly: false,
overflowOptions: undefined,
disabled: false,
showCheckbox: false,
@@ -65,6 +75,7 @@ const fileNameRef = ref<HTMLElement | null>(null)
<template>
<div
role="row"
class="flex h-[74px] items-center justify-between gap-4 px-3"
:class="{ 'opacity-50': disabled }"
>
@@ -78,6 +89,7 @@ const fileNameRef = ref<HTMLElement | null>(null)
v-if="showCheckbox"
:model-value="selected ?? false"
:disabled="disabled"
:aria-label="`Select ${project.title}`"
class="shrink-0"
@update:model-value="selected = $event"
/>
@@ -91,18 +103,28 @@ const fileNameRef = ref<HTMLElement | null>(null)
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
: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>
<Tooltip v-if="isClientOnly">
<TriangleAlertIcon class="size-4 shrink-0 text-orange" />
<template #popper>
<div class="max-w-[18rem] text-sm">
{{ formatMessage(commonMessages.clientOnlyWarning) }}
</div>
</template>
</Tooltip>
</div>
<div class="flex min-w-0 items-center gap-1">
<AutoLink
@@ -152,7 +174,7 @@ const fileNameRef = ref<HTMLElement | null>(null)
<div
class="hidden flex-col gap-0.5 @[800px]:flex"
:class="hideActions ? 'flex-1' : 'w-[335px] min-w-0'"
:class="hideActions ? 'flex-1' : 'flex-1 min-w-0'"
>
<template v-if="version">
<AutoLink
@@ -212,6 +234,7 @@ const fileNameRef = ref<HTMLElement | null>(null)
v-if="enabled !== undefined"
:model-value="enabled"
:disabled="disabled"
:aria-label="project.title"
small
class="mr-2 my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"

View File

@@ -2,17 +2,18 @@
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 Checkbox from '#ui/components/base/Checkbox.vue'
import { useVIntl } from '#ui/composables/i18n'
import { useStickyObserver } from '#ui/composables/sticky-observer'
import { useVirtualScroll } from '#ui/composables/virtual-scroll'
import { commonMessages } from '#ui/utils/common-messages'
import type {
ContentCardTableItem,
ContentCardTableSortColumn,
ContentCardTableSortDirection,
} from './types'
} from '../types'
import ContentCardItem from './ContentCardItem.vue'
const { formatMessage } = useVIntl()
@@ -137,12 +138,14 @@ function handleSort(column: ContentCardTableSortColumn) {
<template>
<div
role="table"
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"
role="rowgroup"
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]',
@@ -152,6 +155,7 @@ function handleSort(column: ContentCardTableSortColumn) {
]"
>
<div
role="row"
class="flex min-w-0 items-center gap-4"
:class="
hasAnyActions
@@ -163,12 +167,17 @@ function handleSort(column: ContentCardTableSortColumn) {
v-if="showSelection"
:model-value="allSelected"
:indeterminate="someSelected"
:aria-label="formatMessage(commonMessages.selectAllLabel)"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<button
v-if="sortable"
role="columnheader"
:aria-sort="
sortBy === 'project' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
"
class="flex items-center gap-1.5 font-semibold text-secondary"
@click="handleSort('project')"
>
@@ -179,14 +188,18 @@ function handleSort(column: ContentCardTableSortColumn) {
class="size-4"
/>
</button>
<span v-else class="font-semibold text-secondary">{{
<span v-else role="columnheader" class="font-semibold text-secondary">{{
formatMessage(commonMessages.projectLabel)
}}</span>
</div>
<div class="hidden @[800px]:flex" :class="hasAnyActions ? 'w-[335px] min-w-0' : 'flex-1'">
<div class="hidden @[800px]:flex" :class="hasAnyActions ? 'flex-1 min-w-0' : 'flex-1'">
<button
v-if="sortable"
role="columnheader"
:aria-sort="
sortBy === 'version' ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
"
class="flex items-center gap-1.5 font-semibold text-secondary"
@click="handleSort('version')"
>
@@ -197,12 +210,12 @@ function handleSort(column: ContentCardTableSortColumn) {
class="size-4"
/>
</button>
<span v-else class="font-semibold text-secondary">{{
<span v-else role="columnheader" class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
}}</span>
</div>
<div v-if="hasAnyActions" class="min-w-[160px] shrink-0 text-right">
<div v-if="hasAnyActions" role="columnheader" class="min-w-[160px] shrink-0 text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
@@ -212,9 +225,10 @@ function handleSort(column: ContentCardTableSortColumn) {
<div
v-if="items.length > 0 && virtualized"
ref="listContainer"
role="rowgroup"
class="relative w-full"
:class="flat ? '' : 'rounded-b-[20px]'"
:style="{ minHeight: `${totalHeight}px` }"
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
>
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
<ContentCardItem
@@ -228,6 +242,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
@@ -254,7 +269,12 @@ function handleSort(column: ContentCardTableSortColumn) {
</div>
</div>
<div v-else-if="items.length > 0" ref="listContainer" :class="flat ? '' : 'rounded-b-[20px]'">
<div
v-else-if="items.length > 0"
ref="listContainer"
role="rowgroup"
:class="flat ? '' : 'rounded-b-[20px]'"
>
<ContentCardItem
v-for="(item, index) in items"
:key="item.id"

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
import {
BoxesIcon,
ClockIcon,
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
SettingsIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import OverflowMenu, {
type Option as OverflowMenuOption,
} from '#ui/components/base/OverflowMenu.vue'
import TagItem from '#ui/components/base/TagItem.vue'
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type {
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '../types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
updating: {
id: 'content.modpack-card.updating',
defaultMessage: 'Updating...',
},
contentHintTitle: {
id: 'content.modpack-card.content-hint-title',
defaultMessage: 'Modpack content moved',
},
contentHintDescription: {
id: 'content.modpack-card.content-hint-description',
defaultMessage: "Your modpack's content can now be found here!",
},
dismissHint: {
id: 'content.modpack-card.dismiss-hint',
defaultMessage: "Don't show again",
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
version?: ContentModpackCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
categories?: ContentModpackCardCategory[]
disabled?: boolean
overflowOptions?: OverflowMenuOption[]
hasUpdate?: boolean
disabledText?: string
showContentHint?: boolean
}
withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
versionLink: undefined,
owner: undefined,
categories: undefined,
disabled: false,
overflowOptions: undefined,
hasUpdate: false,
disabledText: undefined,
showContentHint: false,
})
const emit = defineEmits<{
update: []
content: []
settings: []
'dismiss-content-hint': []
}>()
const instance = getCurrentInstance()
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const hasContentListener = computed(() => typeof instance?.vnode.props?.onContent === 'function')
const hasSettingsListener = computed(() => typeof instance?.vnode.props?.onSettings === 'function')
const formatTimeAgo = useRelativeTime()
const formatCompact = (n: number | undefined) => {
if (n === undefined) return ''
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(n)
}
const collapsedOptions = computed(() => {
const options: {
id: string
action: () => void
color?: 'standard' | 'red' | 'brand' | 'orange' | 'green' | 'blue' | 'purple'
}[] = []
if (hasContentListener.value) {
options.push({
id: 'content',
action: () => emit('content'),
})
}
if (hasSettingsListener.value) {
options.push({
id: 'settings',
action: () => emit('settings'),
})
}
return options
})
const containerRef = ref<HTMLElement | null>(null)
const isExpanded = ref(true)
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
isExpanded.value = entry.contentRect.width >= 700
}
})
onMounted(() => {
if (containerRef.value) observer.observe(containerRef.value)
})
onUnmounted(() => {
observer.disconnect()
})
</script>
<template>
<div
ref="containerRef"
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
:class="{ 'opacity-50': disabled }"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 flex-1 items-start gap-4">
<AutoLink :to="projectLink" class="shrink-0">
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised />
</AutoLink>
<div class="flex flex-col gap-1.5">
<AutoLink
:to="projectLink"
class="text-xl font-semibold leading-8 text-contrast hover:underline"
>
{{ project.title }}
</AutoLink>
<div class="flex flex-nowrap items-center gap-2 overflow-hidden text-secondary">
<AutoLink
v-if="owner"
:to="owner.link"
class="flex shrink-0 items-center gap-1.5 hover:underline"
>
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="2rem"
:circle="owner.type === 'user'"
no-shadow
/>
<span class="font-medium whitespace-nowrap">{{ owner.name }}</span>
</AutoLink>
<template v-if="version">
<BulletDivider v-if="owner" />
<AutoLink
:to="versionLink"
class="shrink-0 font-medium text-secondary !decoration-secondary whitespace-nowrap"
:class="versionLink ? 'hover:underline' : ''"
>
{{ version.version_number }}
</AutoLink>
</template>
<template v-if="version?.date_published">
<BulletDivider />
<div class="flex shrink-0 items-center gap-2 whitespace-nowrap">
<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">
<template v-if="disabled">
<div class="flex items-center gap-2 text-secondary">
<SpinnerIcon class="animate-spin" />
<span class="font-semibold">{{
disabledText ?? formatMessage(messages.updating)
}}</span>
</div>
</template>
<template v-else>
<!-- Expanded actions visible at >= 700px -->
<div class="hidden @[700px]:flex items-center gap-2">
<ButtonStyled
v-if="hasUpdateListener && hasUpdate"
type="transparent"
color="green"
color-fill="text"
>
<button class="flex items-center gap-2" @click="emit('update')">
<DownloadIcon class="!text-green" />
<span class="font-semibold">{{ formatMessage(commonMessages.updateButton) }}</span>
</button>
</ButtonStyled>
<Tooltip
v-if="hasContentListener"
theme="dismissable-prompt"
:triggers="[]"
:shown="showContentHint && isExpanded"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled>
<button
class="!shadow-none"
@click="
() => {
emit('content')
emit('dismiss-content-hint')
}
"
>
<BoxesIcon />
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<template #popper>
<div class="experimental-styles-within grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.contentHintTitle) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dismissHint)"
@click="emit('dismiss-content-hint')"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.contentHintDescription) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
<button
class="!border !border-surface-4"
@click="
() => {
emit('settings')
emit('dismiss-content-hint')
}
"
>
<SettingsIcon />
</button>
</ButtonStyled>
</div>
<!-- Collapsed actions visible at < 700px -->
<div v-if="hasUpdate && hasUpdateListener" class="flex @[700px]:hidden">
<ButtonStyled circular type="transparent" color="green" color-fill="text">
<button
v-tooltip="formatMessage(commonMessages.updateButton)"
@click="emit('update')"
>
<DownloadIcon class="size-5" />
</button>
</ButtonStyled>
</div>
<Tooltip
v-if="collapsedOptions.length"
theme="dismissable-prompt"
:triggers="[]"
:shown="showContentHint && !isExpanded"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled circular type="outlined"
><TeleportOverflowMenu
:options="collapsedOptions"
class="flex @[700px]:hidden"
btn-class="!border-surface-4 !border"
@open="emit('dismiss-content-hint')"
>
<MoreVerticalIcon class="size-5" />
<template #content>
<BoxesIcon class="size-5" />
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<SettingsIcon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
</template>
</TeleportOverflowMenu></ButtonStyled
>
<template #popper>
<div class="experimental-styles-within grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.contentHintTitle) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dismissHint)"
@click="emit('dismiss-content-hint')"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.contentHintDescription) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled
v-if="overflowOptions?.length"
circular
type="transparent"
class="hidden @[700px]:flex"
>
<OverflowMenu :options="overflowOptions">
<MoreVerticalIcon class="size-5" />
</OverflowMenu>
</ButtonStyled>
</template>
</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">{{ formatCompact(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">{{ formatCompact(project.followers) }}</span>
</div>
<div v-if="categories?.length" class="flex flex-wrap items-center gap-1">
<TagItem v-for="cat in categories" :key="cat.name" :action="cat.action">
{{ cat.name }}
</TagItem>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import { PowerIcon, PowerOffIcon } from '@modrinth/assets'
import { computed } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type { BulkOperationType } from '../composables/bulk-operations'
import type { ContentItem } from '../types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectedCount: {
id: 'content.selection-bar.selected-count',
defaultMessage: '{count} {contentType} selected',
},
selectedCountSimple: {
id: 'content.selection-bar.selected-count-simple',
defaultMessage: '{count, number} selected',
},
enable: {
id: 'content.selection-bar.enable',
defaultMessage: 'Enable',
},
disable: {
id: 'content.selection-bar.disable',
defaultMessage: 'Disable',
},
bulkEnabling: {
id: 'content.selection-bar.bulk.enabling',
defaultMessage: 'Enabling {progress}/{total} {contentType}...',
},
bulkEnablingWaiting: {
id: 'content.selection-bar.bulk.enabling-waiting',
defaultMessage: 'Enabling {contentType}...',
},
bulkDisabling: {
id: 'content.selection-bar.bulk.disabling',
defaultMessage: 'Disabling {progress}/{total} {contentType}...',
},
bulkDisablingWaiting: {
id: 'content.selection-bar.bulk.disabling-waiting',
defaultMessage: 'Disabling {contentType}...',
},
bulkUpdating: {
id: 'content.selection-bar.bulk.updating',
defaultMessage: 'Updating {progress}/{total} {contentType}...',
},
bulkUpdatingWaiting: {
id: 'content.selection-bar.bulk.updating-waiting',
defaultMessage: 'Updating {contentType}...',
},
bulkDeleting: {
id: 'content.selection-bar.bulk.deleting',
defaultMessage: 'Deleting {progress}/{total} {contentType}...',
},
bulkDeletingWaiting: {
id: 'content.selection-bar.bulk.deleting-waiting',
defaultMessage: 'Deleting {contentType}...',
},
})
interface Props {
selectedItems: ContentItem[]
contentTypeLabel?: string
isBusy?: boolean
isBulkOperating?: boolean
bulkOperation?: BulkOperationType | null
bulkProgress?: number
bulkTotal?: number
bulkWaiting?: boolean
ariaLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
contentTypeLabel: undefined,
isBusy: false,
isBulkOperating: false,
bulkOperation: null,
bulkProgress: 0,
bulkTotal: 0,
bulkWaiting: false,
ariaLabel: undefined,
})
const emit = defineEmits<{
clear: []
enable: []
disable: []
}>()
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
const selectedCountText = computed(() => {
const count = props.selectedItems.length || props.bulkTotal
if (props.contentTypeLabel) {
return formatMessage(messages.selectedCount, {
count,
contentType: `${props.contentTypeLabel}${count === 1 ? '' : 's'}`,
})
}
return formatMessage(messages.selectedCountSimple, { count })
})
const bulkProgressMessage = computed(() => {
if (!props.bulkOperation) return ''
const contentType = props.contentTypeLabel
? `${props.contentTypeLabel}${props.bulkTotal === 1 ? '' : 's'}`
: 'items'
const messageMap = {
enable: props.bulkWaiting ? messages.bulkEnablingWaiting : messages.bulkEnabling,
disable: props.bulkWaiting ? messages.bulkDisablingWaiting : messages.bulkDisabling,
update: props.bulkWaiting ? messages.bulkUpdatingWaiting : messages.bulkUpdating,
delete: props.bulkWaiting ? messages.bulkDeletingWaiting : messages.bulkDeleting,
}
return formatMessage(messageMap[props.bulkOperation], {
progress: props.bulkProgress,
total: props.bulkTotal,
contentType,
})
})
</script>
<template>
<FloatingActionBar :shown="shown" :aria-label="ariaLabel">
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ selectedCountText }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
<button
class="!text-primary"
:disabled="isBulkOperating"
:class="{ 'opacity-60 pointer-events-none': isBulkOperating }"
@click="emit('clear')"
>
{{ formatMessage(commonMessages.clearButton) }}
</button>
</ButtonStyled>
</div>
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
<slot name="actions" />
<ButtonStyled v-if="allDisabled" type="transparent">
<button :disabled="isBusy" @click="emit('enable')">
<PowerIcon />
{{ formatMessage(messages.enable) }}
</button>
</ButtonStyled>
<ButtonStyled v-else type="transparent">
<button :disabled="isBusy" @click="emit('disable')">
<PowerOffIcon />
{{ formatMessage(messages.disable) }}
</button>
</ButtonStyled>
<slot name="actions-end" />
</div>
<div v-else class="ml-auto flex items-center" aria-live="polite">
<span class="px-4 py-2.5 text-base font-semibold text-secondary tabular-nums">
{{ bulkProgressMessage }}
</span>
</div>
<div v-if="isBulkOperating" class="absolute bottom-0 left-0 right-0 h-1">
<div
class="h-full rounded-l-full bg-brand transition-[width] duration-200 ease-in-out"
:class="{ 'animate-indeterminate': bulkWaiting }"
:style="
!bulkWaiting
? { width: `${bulkTotal > 0 ? (bulkProgress / bulkTotal) * 100 : 0}%` }
: undefined
"
role="progressbar"
:aria-valuenow="bulkWaiting ? undefined : bulkProgress"
:aria-valuemin="0"
:aria-valuemax="bulkTotal"
style="box-shadow: 0px -2px 4px 0px rgba(27, 217, 106, 0.1)"
/>
</div>
</FloatingActionBar>
</template>
<style scoped>
@keyframes indeterminate {
0% {
width: 20%;
margin-left: -20%;
}
100% {
width: 60%;
margin-left: 100%;
}
}
.animate-indeterminate {
animation: indeterminate 1.5s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { count }) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before bulk update"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="confirm">
<DownloadIcon />
{{ formatMessage(messages.updateButton, { count }) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.confirm-bulk-update.header',
defaultMessage: 'Update projects',
},
admonitionHeader: {
id: 'content.confirm-bulk-update.admonition-header',
defaultMessage: 'Update warning',
},
admonitionBody: {
id: 'content.confirm-bulk-update.admonition-body',
defaultMessage:
"Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one.",
},
updateButton: {
id: 'content.confirm-bulk-update.update-button',
defaultMessage: 'Update {count, plural, one {# project} other {# projects}}',
},
})
defineProps<{
count: number
server?: boolean
}>()
const emit = defineEmits<{
(e: 'update'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('update')
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,107 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header, { count, itemType })"
:fade="variant === 'server' ? 'warning' : 'danger'"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition
:type="variant === 'server' ? 'warning' : 'critical'"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before deletion"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled :color="variant === 'server' ? 'orange' : 'red'">
<button :disabled="buttonsDisabled" @click="confirm">
<TrashIcon />
{{ formatMessage(messages.deleteButton, { count, itemType }) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.confirm-deletion.header',
defaultMessage: 'Delete {itemType}{count, plural, one {} other {s}}',
},
admonitionHeader: {
id: 'content.confirm-deletion.admonition-header',
defaultMessage: 'Deletion warning',
},
admonitionBody: {
id: 'content.confirm-deletion.admonition-body',
defaultMessage:
'Deleting a mod can permanently affect your world and may cause missing content or unexpected issues when it loads again.',
},
deleteButton: {
id: 'content.confirm-deletion.delete-button',
defaultMessage: 'Delete {count} {itemType}{count, plural, one {} other {s}}',
},
})
withDefaults(
defineProps<{
count: number
itemType: string
variant?: 'instance' | 'server'
}>(),
{
variant: 'instance',
},
)
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('delete')
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,91 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.leavePageTitle)"
fade="warning"
max-width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.uploadInProgress)">
{{ formatMessage(messages.leavePageBody) }}
</Admonition>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="cancel">
<XIcon />
{{ formatMessage(messages.stayOnPageButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="leave">
<RightArrowIcon />
{{ formatMessage(messages.leavePageButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const { formatMessage } = useVIntl()
const messages = defineMessages({
leavePageTitle: {
id: 'instances.confirm-leave-modal.title',
defaultMessage: 'Leave page?',
},
uploadInProgress: {
id: 'instances.confirm-leave-modal.upload-in-progress',
defaultMessage: 'Upload in progress',
},
leavePageBody: {
id: 'instances.confirm-leave-modal.body',
defaultMessage:
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
},
stayOnPageButton: {
id: 'instances.confirm-leave-modal.stay',
defaultMessage: 'Stay on page',
},
leavePageButton: {
id: 'instances.confirm-leave-modal.leave',
defaultMessage: 'Leave page',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
let resolvePromise: ((value: boolean) => void) | null = null
function prompt(): Promise<boolean> {
return new Promise((resolve) => {
resolvePromise = resolve
modal.value?.show()
})
}
function leave() {
modal.value?.hide()
resolvePromise?.(true)
resolvePromise = null
}
function cancel() {
modal.value?.hide()
resolvePromise?.(false)
resolvePromise = null
}
defineExpose({ prompt })
</script>

View File

@@ -0,0 +1,109 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header, { action: downgrade ? 'downgrade' : 'update' })"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition
type="warning"
:header="
formatMessage(messages.admonitionHeader, { action: downgrade ? 'downgrade' : 'update' })
"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="downgrade ? 'Before modpack downgrade' : 'Before modpack update'"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="handleConfirm">
<DownloadIcon />
{{
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
}}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
defineProps<{
downgrade?: boolean
server?: boolean
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.confirm-modpack-update.header',
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
},
admonitionHeader: {
id: 'content.confirm-modpack-update.admonition-header',
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} warning',
},
admonitionBody: {
id: 'content.confirm-modpack-update.admonition-body',
defaultMessage: 'Any mods or content you added on top of the modpack will be deleted.',
},
confirmButton: {
id: 'content.confirm-modpack-update.confirm-button',
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
},
})
const emit = defineEmits<{
(e: 'confirm' | 'cancel'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
modal.value?.show()
}
function handleConfirm() {
modal.value?.hide()
emit('confirm')
}
function handleCancel() {
modal.value?.hide()
emit('cancel')
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="danger"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before reinstall"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="buttonsDisabled" @click="confirm">
<DownloadIcon />
{{ formatMessage(messages.reinstallButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'instance.confirm-reinstall.header',
defaultMessage: 'Reinstall modpack',
},
admonitionHeader: {
id: 'instance.confirm-reinstall.admonition-header',
defaultMessage: 'Reinstallation warning',
},
admonitionBody: {
id: 'instance.confirm-reinstall.admonition-body',
defaultMessage:
'Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation.',
},
reinstallButton: {
id: 'instance.confirm-reinstall.reinstall-button',
defaultMessage: 'Reinstall modpack',
},
})
defineProps<{
server?: boolean
}>()
const emit = defineEmits<{
(e: 'reinstall'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('reinstall')
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,79 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header, { type: server ? 'server' : 'instance' })"
max-width="500px"
>
<span class="text-primary">
{{ formatMessage(messages.body, { type: server ? 'server' : 'instance' }) }}
</span>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="green">
<button @click="confirm">
<HammerIcon />
{{ formatMessage(messages.repairButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { HammerIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
defineProps<{
server?: boolean
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'instance.confirm-repair.header',
defaultMessage: 'Repair {type, select, server {server} other {instance}}',
},
body: {
id: 'instance.confirm-repair.body',
defaultMessage:
'Repairing reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your {type, select, server {server is not starting correctly} other {game is not launching due to launcher-related errors}}.',
},
repairButton: {
id: 'instance.confirm-repair.repair-button',
defaultMessage: 'Repair',
},
})
const emit = defineEmits<{
(e: 'repair'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('repair')
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,97 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="warning"
max-width="500px"
:on-hide="() => backupCreator?.cancelBackup()"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before unlink"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled" @click="confirm">
<UnlinkIcon />
{{ formatMessage(server ? messages.header : messages.unlinkButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { UnlinkIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from './InlineBackupCreator.vue'
defineProps<{
server?: boolean
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'content.confirm-unlink.header',
defaultMessage: 'Unlink modpack',
},
admonitionHeader: {
id: 'content.confirm-unlink.admonition-header',
defaultMessage: 'Unlinking modpack',
},
admonitionBody: {
id: 'content.confirm-unlink.admonition-body',
defaultMessage:
'Mods and content will be merged with what you added on top of the modpack, and it will stop receiving updates.',
},
unlinkButton: {
id: 'content.confirm-unlink.unlink-button',
defaultMessage: 'Unlink',
},
})
const emit = defineEmits<{
(e: 'unlink'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('unlink')
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,460 @@
<template>
<NewModal ref="modal" no-padding scrollable max-width="560px" width="560px" :on-hide="handleHide">
<template #title>
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(messages.header) }}
</span>
</template>
<div class="flex flex-col gap-2.5 p-6">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.instanceType) }}
</span>
<Chips v-model="tab" :items="tabs" :format-label="formatTabLabel" :never-empty="true" />
</div>
<div class="h-px bg-divider" />
<!-- Existing instance tab -->
<div
v-if="tab === 'existing'"
class="flex flex-col gap-3 bg-surface-2 py-4"
style="height: 400px; overflow-y: auto"
>
<div class="flex items-start gap-3 px-6">
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="flex-1"
/>
<ButtonStyled type="outlined" circular>
<button
v-tooltip="`${hideUninstallable ? 'Show' : 'Hide'} unavailable`"
class="!border-surface-4 !border"
@click="hideUninstallable = !hideUninstallable"
>
<EyeIcon v-if="hideUninstallable" />
<EyeOffIcon v-else />
</button>
</ButtonStyled>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingIndicator />
</div>
<div
v-else-if="filteredInstances.length === 0"
class="flex items-center justify-center py-12 text-secondary"
>
{{ formatMessage(messages.noInstances) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="inst in filteredInstances"
:key="inst.id"
class="flex items-center justify-between px-6 py-1.5"
:class="
!inst.compatible ? 'opacity-40' : inst.installed ? 'opacity-60' : 'hover:bg-surface-3'
"
>
<button
v-tooltip="
!inst.compatible ? 'This instance is not compatible with this project' : undefined
"
class="flex min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden border-0 bg-transparent p-0 text-left"
@click="emit('navigate', inst)"
>
<Avatar :src="inst.iconUrl ?? undefined" size="2rem" rounded="md" />
<span class="truncate font-semibold text-contrast hover:underline">{{
inst.name
}}</span>
</button>
<ButtonStyled v-if="inst.installed" :disabled="true">
<button>
<CheckIcon />
{{ formatMessage(messages.installedBadge) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="inst.compatible" :disabled="inst.installing">
<button @click="emit('install', inst)">
{{
inst.installing
? formatMessage(messages.installingLabel)
: formatMessage(messages.installButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</div>
<!-- New instance tab -->
<div v-else class="flex flex-col gap-6 p-6">
<div class="flex items-center gap-4">
<Avatar :src="iconPreviewUrl ?? undefined" size="5rem" rounded="2xl" />
<div class="flex flex-col gap-2">
<ButtonStyled type="outlined">
<button class="!border-surface-4 !border" @click="selectIcon">
<UploadIcon />
{{ formatMessage(messages.selectIcon) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
class="!border-surface-4 !border"
:disabled="!iconPreviewUrl"
@click="removeIcon"
>
<XIcon />
{{ formatMessage(messages.removeIcon) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
</span>
<StyledInput
v-model="instanceName"
:placeholder="formatMessage(messages.namePlaceholder)"
/>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.loaderLabel) }}
</span>
<Chips
v-model="selectedLoader"
:items="compatibleLoaders"
:format-label="formatLoaderLabel"
:never-empty="true"
/>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.gameVersionLabel) }}
</span>
<Combobox
v-model="selectedGameVersion"
:options="gameVersionOptions"
searchable
sync-with-selection
:placeholder="formatMessage(messages.gameVersionPlaceholder)"
>
<template v-if="hasReleaseData" #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="showSnapshots = !showSnapshots"
>
<EyeOffIcon v-if="showSnapshots" class="size-4" />
<EyeIcon v-else class="size-4" />
{{
showSnapshots
? formatMessage(messages.hideSnapshots)
: formatMessage(messages.showAllVersions)
}}
</button>
</template>
</Combobox>
</div>
</div>
<template #actions>
<div v-if="tab === 'existing'" class="flex items-center justify-between pt-5 pb-1 px-4">
<div class="flex items-center gap-1.5">
<BoxIcon class="size-5" />
<span>
{{ formatMessage(messages.compatibleCount, { count: compatibleCount }) }}
</span>
</div>
<ButtonStyled type="outlined">
<button class="!border-surface-4 !border" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
<div v-else class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border-surface-4 !border" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!instanceName" @click="handleCreateAndInstall">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
BoxIcon,
CheckIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
SearchIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { computed, ref } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectFilePicker } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'instances.content-install.header',
defaultMessage: 'Install project',
},
instanceType: {
id: 'instances.content-install.instance-type',
defaultMessage: 'Instance type',
},
existingTab: {
id: 'instances.content-install.existing-tab',
defaultMessage: 'Existing instance',
},
newTab: {
id: 'instances.content-install.new-tab',
defaultMessage: 'New instance',
},
searchPlaceholder: {
id: 'instances.content-install.search-placeholder',
defaultMessage: 'Search instance',
},
installedBadge: {
id: 'instances.content-install.installed-badge',
defaultMessage: 'Installed',
},
installingLabel: {
id: 'instances.content-install.installing-label',
defaultMessage: 'Installing...',
},
installButton: {
id: 'instances.content-install.install-button',
defaultMessage: 'Install',
},
selectIcon: {
id: 'instances.content-install.select-icon',
defaultMessage: 'Select icon',
},
removeIcon: {
id: 'instances.content-install.remove-icon',
defaultMessage: 'Remove icon',
},
nameLabel: {
id: 'instances.content-install.name-label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'instances.content-install.name-placeholder',
defaultMessage: 'Enter instance name',
},
loaderLabel: {
id: 'instances.content-install.loader-label',
defaultMessage: 'Loader',
},
gameVersionLabel: {
id: 'instances.content-install.game-version-label',
defaultMessage: 'Game version',
},
gameVersionPlaceholder: {
id: 'instances.content-install.game-version-placeholder',
defaultMessage: 'Select game version',
},
compatibleCount: {
id: 'instances.content-install.compatible-count',
defaultMessage: '{count} compatible {count, plural, one {instance} other {instances}}',
},
noInstances: {
id: 'instances.content-install.no-instances',
defaultMessage: 'No compatible instances found',
},
showAllVersions: {
id: 'instances.content-install.show-all-versions',
defaultMessage: 'Show all versions',
},
hideSnapshots: {
id: 'instances.content-install.hide-snapshots',
defaultMessage: 'Hide snapshots',
},
})
export interface ContentInstallInstance {
id: string
name: string
iconUrl?: string | null
installed: boolean
compatible: boolean
installing?: boolean
}
const props = defineProps<{
instances: ContentInstallInstance[]
compatibleLoaders: string[]
gameVersions: string[]
releaseGameVersions?: Set<string>
loading?: boolean
defaultTab?: 'existing' | 'new'
preferredLoader?: string | null
preferredGameVersion?: string | null
}>()
const emit = defineEmits<{
install: [instance: ContentInstallInstance]
'create-and-install': [
data: {
name: string
iconPath: string | null
iconPreviewUrl: string | null
loader: string
gameVersion: string
},
]
navigate: [instance: ContentInstallInstance]
cancel: []
}>()
const modal = ref<InstanceType<typeof NewModal>>()
type Tab = 'existing' | 'new'
const tabs = computed<Tab[]>(() =>
props.compatibleLoaders.length > 0 ? ['existing', 'new'] : ['existing'],
)
const tab = ref<Tab>('existing')
const tabLabels: Record<Tab, () => string> = {
existing: () => formatMessage(messages.existingTab),
new: () => formatMessage(messages.newTab),
}
const formatTabLabel = (item: Tab) => tabLabels[item]()
const searchFilter = ref('')
const hideUninstallable = ref(true)
const filteredInstances = computed(() => {
let list = props.instances
if (hideUninstallable.value) list = list.filter((i) => i.compatible && !i.installed)
if (searchFilter.value) {
const query = searchFilter.value.toLowerCase()
list = list.filter((i) => i.name.toLowerCase().includes(query))
}
const score = (i: ContentInstallInstance) => (!i.compatible ? 2 : i.installed ? 1 : 0)
return list.slice().sort((a, b) => {
const diff = score(a) - score(b)
if (diff !== 0) return diff
return a.name.localeCompare(b.name)
})
})
const compatibleCount = computed(() => props.instances.filter((i) => i.compatible).length)
const instanceName = ref('')
const selectedLoader = ref<string | null>(null)
const selectedGameVersion = ref<string | null>(null)
const iconPath = ref<string | null>(null)
const iconPreviewUrl = ref<string | null>(null)
const showSnapshots = ref(false)
const hasReleaseData = computed(
() => props.releaseGameVersions && props.releaseGameVersions.size > 0,
)
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
const versions =
showSnapshots.value || !hasReleaseData.value
? props.gameVersions
: props.gameVersions.filter((v) => props.releaseGameVersions!.has(v))
return versions.map((v) => ({ value: v, label: v }))
})
const filePicker = injectFilePicker(null)
async function selectIcon() {
if (!filePicker) return
const picked = await filePicker.pickImage()
if (picked) {
iconPath.value = picked.path ?? null
iconPreviewUrl.value = picked.previewUrl
}
}
function removeIcon() {
iconPath.value = null
iconPreviewUrl.value = null
}
function resetState() {
tab.value = props.defaultTab ?? 'existing'
searchFilter.value = ''
hideUninstallable.value = true
instanceName.value = `New instance (${props.instances.length + 1})`
iconPath.value = null
iconPreviewUrl.value = null
selectedLoader.value = props.preferredLoader ?? props.compatibleLoaders[0] ?? null
const preferred = props.preferredGameVersion
const isSnapshot = preferred && hasReleaseData.value && !props.releaseGameVersions!.has(preferred)
showSnapshots.value = !!isSnapshot
const defaultVersion = hasReleaseData.value
? (props.gameVersions.find((v) => props.releaseGameVersions!.has(v)) ??
props.gameVersions[0] ??
null)
: (props.gameVersions[0] ?? null)
selectedGameVersion.value = preferred ?? defaultVersion
}
function handleHide() {
resetState()
emit('cancel')
}
function show() {
resetState()
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function handleCreateAndInstall() {
if (!instanceName.value || !selectedLoader.value || !selectedGameVersion.value) return
emit('create-and-install', {
name: instanceName.value,
iconPath: iconPath.value,
iconPreviewUrl: iconPreviewUrl.value,
loader: selectedLoader.value,
gameVersion: selectedGameVersion.value,
})
hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1,546 @@
<template>
<NewModal
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
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(
isModpack ? messages.switchModpackVersionHeader : messages.updateVersionHeader,
)
}}</span>
</template>
<div
class="flex h-[min(550px,calc(95vh-10rem))] border-solid border-transparent border-[1px] border-b-surface-4"
>
<div class="w-[300px] flex flex-col relative bg-surface-3">
<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 v-if="loading" class="flex flex-col items-center justify-center h-full gap-2">
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
<span class="text-sm text-secondary">{{
formatMessage(messages.loadingVersions)
}}</span>
</div>
<template v-else>
<div class="flex flex-col gap-1.5" role="listbox">
<button
v-for="version in filteredVersions"
:key="version.id"
role="option"
:aria-selected="selectedVersion?.id === 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',
]"
@mouseenter="handleVersionMouseEnter(version)"
@mouseleave="handleVersionMouseLeave"
@focus="emit('versionHover', version)"
@click="handleVersionSelect(version)"
>
<div class="flex items-center justify-between w-full gap-2">
<div class="flex items-center gap-2 min-w-0">
<VersionChannelIndicator
:channel="version.version_type"
size="sm"
class="shrink-0"
/>
<span
v-tooltip="version.version_number"
class="font-semibold text-contrast truncate"
>
{{ version.version_number }}
</span>
</div>
<span
v-if="shouldShowBadge(version)"
class="rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
:class="[
getBadgeClasses(version),
isVersionCompatible(version) ? 'px-2.5 py-0.5' : 'p-1',
]"
>
<CircleAlertIcon
v-if="!isVersionCompatible(version)"
v-tooltip="formatMessage(messages.incompatibleBadge)"
class="size-4"
/>
<template v-else>{{ getBadgeLabel(version) }}</template>
</span>
</div>
</button>
</div>
<div
v-if="filteredVersions.length === 0"
class="p-4 text-center text-secondary text-sm"
>
{{ formatMessage(messages.noVersionsFound) }}
</div>
</template>
</div>
<div
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
>
<div class="pointer-events-auto">
<ButtonStyled type="transparent" :circular="true">
<button
class="flex items-center gap-1.5"
:aria-label="
hideIncompatibleState
? formatMessage(messages.showIncompatible)
: formatMessage(messages.hideIncompatible)
"
@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 bg-surface-1" aria-live="polite">
<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-2">
<span class="font-semibold text-xl text-contrast">
{{ 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="getVersionTypeBadgeClasses(selectedVersion)"
>
{{ capitalizeString(selectedVersion.version_type) }}
</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="loadingChangelog"
class="flex flex-col items-center justify-center h-full gap-2"
>
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
<span class="text-sm text-secondary">{{
formatMessage(messages.loadingChangelog)
}}</span>
</div>
<div
v-else-if="selectedVersion.changelog"
class="markdown [&_img]:max-w-full [&_img]:h-auto"
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="w-full flex flex-row items-center gap-4 p-4 border-solid border-x-0 border-b-0 border-t border-surface-4"
>
<div class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto">
<TriangleAlertIcon class="size-6 shrink-0" />
<span>{{
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
}}</span>
</div>
<div class="flex flex-row gap-2 shrink-0">
<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>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CircleAlertIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
FileTextIcon,
SearchIcon,
SpinnerIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const messages = defineMessages({
updateVersionHeader: {
id: 'instances.updater-modal.header',
defaultMessage: 'Update version',
},
switchModpackVersionHeader: {
id: 'instances.updater-modal.header-modpack',
defaultMessage: 'Switch modpack 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:
'Updating can break your instance. Review version changelogs and back up first.',
},
updateWarningWeb: {
id: 'instances.updater-modal.warning-web',
defaultMessage: 'Updating can break your world. Review version changelogs and back up first.',
},
downgradeToVersion: {
id: 'instances.updater-modal.downgrade-to',
defaultMessage: 'Downgrade to {version}',
},
updateToVersion: {
id: 'instances.updater-modal.update-to',
defaultMessage: 'Update to {version}',
},
currentBadge: {
id: 'instances.updater-modal.badge.current',
defaultMessage: 'Current',
},
incompatibleBadge: {
id: 'instances.updater-modal.badge.incompatible',
defaultMessage: 'Incompatible',
},
loadingVersions: {
id: 'instances.updater-modal.loading-versions',
defaultMessage: 'Loading versions...',
},
loadingChangelog: {
id: 'instances.updater-modal.loading-changelog',
defaultMessage: 'Loading changelog...',
},
})
const props = withDefaults(
defineProps<{
versions: Labrinth.Versions.v2.Version[]
currentGameVersion: string
currentLoader: string
currentVersionId: string
isApp: boolean
/** Whether this is a modpack update (changes header text) */
isModpack?: boolean
projectIconUrl?: string
projectName?: string
header?: string
/** Whether versions are currently being loaded */
loading?: boolean
/** Whether changelog is being loaded for the selected version */
loadingChangelog?: boolean
}>(),
{
isModpack: false,
projectIconUrl: undefined,
projectName: undefined,
header: undefined,
loading: false,
loadingChangelog: false,
},
)
const emit = defineEmits<{
update: [version: Labrinth.Versions.v2.Version]
cancel: []
/** Emitted when user selects a version, so parent can fetch full version data with changelog */
versionSelect: [version: Labrinth.Versions.v2.Version]
versionHover: [version: Labrinth.Versions.v2.Version]
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const searchQuery = ref('')
const hideIncompatibleState = ref(true)
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
// Store the initial version ID to select when versions become available
const pendingInitialVersionId = ref<string | undefined>(undefined)
watch(
() => props.versions,
(newVersions) => {
// If we have a selected version, check if it was updated with new data (e.g., changelog)
if (selectedVersion.value) {
const updatedVersion = newVersions.find((v) => v.id === selectedVersion.value?.id)
if (updatedVersion && updatedVersion !== selectedVersion.value) {
selectedVersion.value = updatedVersion
}
}
// Handle initial selection when versions first arrive
if (newVersions.length > 0 && !selectedVersion.value && pendingInitialVersionId.value) {
const version =
newVersions.find((v) => v.id === pendingInitialVersionId.value) ?? newVersions[0]
selectedVersion.value = version
if (version) {
emit('versionSelect', version)
}
pendingInitialVersionId.value = undefined
}
},
{ deep: true },
)
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]
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
versions = versions.filter(
(v) => v.name.toLowerCase().includes(query) || v.version_number.toLowerCase().includes(query),
)
}
if (hideIncompatibleState.value) {
versions = versions.filter(isVersionCompatible)
}
return versions
})
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
return version.id === props.currentVersionId || !isVersionCompatible(version)
}
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 ''
}
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 getVersionTypeBadgeClasses(version: Labrinth.Versions.v2.Version): string {
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 new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
const loader = capitalizeString(version.loaders[0] || '')
const gameVersion = version.game_versions[0] || ''
return `${loader} ${gameVersion}`
}
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
function handleVersionMouseEnter(version: Labrinth.Versions.v2.Version) {
prefetchTimeout = useTimeoutFn(
() => emit('versionHover', version),
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
)
prefetchTimeout.start()
}
function handleVersionMouseLeave() {
if (prefetchTimeout) prefetchTimeout.stop()
}
function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (prefetchTimeout) prefetchTimeout.stop()
selectedVersion.value = version
// Emit event so parent can fetch full version data with changelog
emit('versionSelect', version)
}
function handleUpdate() {
if (selectedVersion.value) {
emit('update', selectedVersion.value)
hide()
}
}
function handleCancel() {
emit('cancel')
hide()
}
function show(initialVersionId?: string) {
searchQuery.value = ''
hideIncompatibleState.value = true
if (props.versions.length > 0) {
if (initialVersionId) {
selectedVersion.value =
props.versions.find((v) => v.id === initialVersionId) ?? props.versions[0]
} else {
selectedVersion.value = props.versions[0]
}
pendingInitialVersionId.value = undefined
if (selectedVersion.value) {
emit('versionSelect', selectedVersion.value)
}
} else {
selectedVersion.value = null
pendingInitialVersionId.value = initialVersionId
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div class="flex flex-col gap-3">
<span class="text-primary">
{{ formatMessage(messages.warningBody, { type: backup.isServer ? 'server' : 'instance' }) }}
</span>
<span v-if="backup.isServer" class="text-brand-orange font-semibold">
{{ formatMessage(messages.backupTakesAWhile) }}
</span>
<div v-if="backup.available">
<!-- Button / Loading state -->
<ButtonStyled v-if="!backup.backupComplete.value && !backup.backupFailed.value">
<button
v-tooltip="
backup.externalBackupInProgress.value
? formatMessage(messages.backupInProgress)
: undefined
"
class="!shadow-none"
:disabled="backup.isBackingUp.value || backup.externalBackupInProgress.value"
@click="backup.startBackup()"
>
<SpinnerIcon v-if="backup.isBackingUp.value" class="size-5 animate-spin" />
<PlusIcon v-else class="size-5" />
{{ formatMessage(backup.isBackingUp.value ? messages.backingUp : messages.createBackup) }}
</button>
</ButtonStyled>
<!-- Success -->
<div
v-else-if="backup.backupComplete.value"
class="flex items-center gap-1.5 text-sm font-medium text-green"
>
<CheckCircleIcon class="size-5" />
{{ formatMessage(messages.backupComplete) }}
</div>
<!-- Failed -->
<div v-else-if="backup.backupFailed.value" class="text-sm text-red">
{{ formatMessage(messages.backupFailed) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useInlineBackup } from '../../composables/use-inline-backup'
const props = defineProps<{
backupName: string
}>()
const emit = defineEmits<{
(e: 'update:buttonsDisabled', value: boolean): void
}>()
const { formatMessage } = useVIntl()
const backup = useInlineBackup(() => props.backupName)
watch(
() => backup.isBackingUp.value,
(backing) => {
emit('update:buttonsDisabled', backing)
},
)
defineExpose({
cancelBackup: backup.cancelBackup,
isBackingUp: backup.isBackingUp,
})
const messages = defineMessages({
warningBody: {
id: 'content.inline-backup.warning-body',
defaultMessage:
'We recommend creating a backup before proceeding so you can restore your {type, select, server {world} other {instance}} if anything breaks.',
},
createBackup: {
id: 'content.inline-backup.create-backup',
defaultMessage: 'Create backup',
},
backingUp: {
id: 'content.inline-backup.backing-up',
defaultMessage: 'Creating backup...',
},
backupComplete: {
id: 'content.inline-backup.backup-complete',
defaultMessage: 'Backup created successfully',
},
backupFailed: {
id: 'content.inline-backup.backup-failed',
defaultMessage: 'Backup creation failed. You can still proceed.',
},
backupTakesAWhile: {
id: 'content.inline-backup.backup-takes-a-while',
defaultMessage:
'Creating a backup may take several minutes depending on the size of your server.',
},
backupInProgress: {
id: 'content.inline-backup.backup-in-progress',
defaultMessage:
"A backup is in progress, it's recommended to wait for it to finish before performing this action.",
},
})
</script>

View File

@@ -4,8 +4,6 @@ import {
FilterIcon,
GlassesIcon,
PaintbrushIcon,
PowerIcon,
PowerOffIcon,
SearchIcon,
SpinnerIcon,
} from '@modrinth/assets'
@@ -13,17 +11,19 @@ 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 Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { isClientOnlyEnvironment } from '../../composables/content-filtering'
import type { ContentCardTableItem, ContentItem } from '../../types'
import ContentCardTable from '../ContentCardTable.vue'
import type { ContentCardTableItem, ContentItem } from '../types'
import ContentSelectionBar from '../ContentSelectionBar.vue'
const { formatMessage } = useVIntl()
@@ -31,16 +31,20 @@ interface Props {
modpackName?: string
modpackIconUrl?: string
enableToggle?: boolean
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
}
const props = withDefaults(defineProps<Props>(), {
modpackName: undefined,
modpackIconUrl: undefined,
enableToggle: false,
getOverflowOptions: undefined,
})
const emit = defineEmits<{
'update:enabled': [item: ContentItem, value: boolean]
'bulk:enable': [items: ContentItem[]]
'bulk:disable': [items: ContentItem[]]
}>()
const messages = defineMessages({
@@ -80,18 +84,6 @@ const messages = defineMessages({
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 {
@@ -104,6 +96,7 @@ export interface ModpackContentModalState {
const modal = ref<InstanceType<typeof NewModal>>()
const scrollContainer = ref<HTMLElement | null>(null)
const items = ref<ContentItem[]>([])
const disabledIds = ref(new Set<string>())
const loading = ref(false)
const searchQuery = ref('')
const selectedFilters = ref<string[]>([])
@@ -225,6 +218,9 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
isClientOnly: isClientOnlyEnvironment(item.environment),
disabled: disabledIds.value.has(item.file_name),
overflowOptions: props.getOverflowOptions?.(item),
})),
)
@@ -249,16 +245,12 @@ function handleEnabledChange(fileName: string, value: boolean) {
}
function bulkEnable() {
for (const item of selectedItems.value) {
emit('update:enabled', item, true)
}
emit('bulk:enable', [...selectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
for (const item of selectedItems.value) {
emit('update:enabled', item, false)
}
emit('bulk:disable', [...selectedItems.value])
selectedIds.value = []
}
@@ -267,8 +259,8 @@ function show(contentItems: ContentItem[]) {
searchQuery.value = ''
selectedFilters.value = []
selectedIds.value = []
disabledIds.value = new Set()
loading.value = false
modal.value?.show()
}
function showLoading() {
@@ -306,7 +298,26 @@ async function restore(state: ModpackContentModalState) {
}
}
defineExpose({ show, showLoading, hide, getState, restore })
function updateItem(fileName: string, updates: Partial<ContentItem> & { disabled?: boolean }) {
if (updates.disabled !== undefined) {
const newSet = new Set(disabledIds.value)
if (updates.disabled) {
newSet.add(fileName)
} else {
newSet.delete(fileName)
}
disabledIds.value = newSet
}
const { disabled: _, ...itemUpdates } = updates
if (Object.keys(itemUpdates).length > 0) {
const index = items.value.findIndex((i) => i.file_name === fileName)
if (index !== -1) {
items.value[index] = { ...items.value[index], ...itemUpdates }
}
}
}
defineExpose({ show, showLoading, hide, getState, restore, updateItem })
</script>
<template>
@@ -341,6 +352,7 @@ defineExpose({ show, showLoading, hide, getState, restore })
<FilterIcon class="size-5 text-secondary shrink-0" />
<div class="flex flex-wrap items-center gap-1.5">
<button
:aria-pressed="selectedFilters.length === 0"
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.length === 0
@@ -354,6 +366,7 @@ defineExpose({ show, showLoading, hide, getState, restore })
<button
v-for="option in filterOptions"
:key="option.id"
:aria-pressed="selectedFilters.includes(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)
@@ -415,6 +428,7 @@ defineExpose({ show, showLoading, hide, getState, restore })
v-if="props.enableToggle"
:model-value="allSelected"
:indeterminate="someSelected"
:aria-label="formatMessage(commonMessages.selectAllLabel)"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
@@ -444,7 +458,11 @@ defineExpose({ show, showLoading, hide, getState, restore })
hide-delete
hide-header
flat
@update:enabled="(id, val) => handleEnabledChange(id, val)"
v-on="
props.enableToggle
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
: {}
"
/>
</div>
</div>
@@ -469,37 +487,13 @@ defineExpose({ show, showLoading, hide, getState, restore })
</div>
</div>
<FloatingActionBar
<ContentSelectionBar
v-if="props.enableToggle"
:shown="selectedItems.length > 0"
:selected-items="selectedItems"
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>
@clear="selectedIds = []"
@enable="bulkEnable"
@disable="bulkDisable"
/>
</NewModal>
</template>

View File

@@ -0,0 +1,70 @@
import { onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
export type BulkOperationType = 'enable' | 'disable' | 'delete' | 'update'
export function useBulkOperation() {
const isBulkOperating = ref(false)
const bulkProgress = ref(0)
const bulkTotal = ref(0)
const bulkOperation = ref<BulkOperationType | null>(null)
async function runBulk<T>(
operation: BulkOperationType,
items: T[],
fn: (item: T) => Promise<void>,
options?: { delayMs?: number; onComplete?: () => void },
) {
const delayMs = options?.delayMs ?? 250
isBulkOperating.value = true
bulkOperation.value = operation
bulkTotal.value = items.length
bulkProgress.value = 0
try {
for (const item of items) {
await fn(item)
bulkProgress.value++
if (delayMs > 0 && bulkProgress.value < items.length) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
}
} finally {
options?.onComplete?.()
isBulkOperating.value = false
bulkOperation.value = null
bulkProgress.value = 0
bulkTotal.value = 0
}
}
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isBulkOperating.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(isBulkOperating, (operating) => {
if (operating) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(() => {
if (isBulkOperating.value) {
return window.confirm('A bulk operation is in progress. Are you sure you want to leave?')
}
return true
})
}
return { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk }
}

View File

@@ -0,0 +1,21 @@
import { ref } from 'vue'
export function useChangingItems() {
const changingItems = ref(new Set<string>())
function markChanging(id: string) {
changingItems.value = new Set([...changingItems.value, id])
}
function unmarkChanging(id: string) {
const next = new Set(changingItems.value)
next.delete(id)
changingItems.value = next
}
function isChanging(id: string): boolean {
return changingItems.value.has(id)
}
return { changingItems, markChanging, unmarkChanging, isChanging }
}

View File

@@ -0,0 +1,98 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { ContentItem } from '../types'
const CLIENT_ONLY_ENVIRONMENTS = new Set([
'client_only',
'client_only_server_optional',
'singleplayer_only',
])
export function isClientOnlyEnvironment(env?: string | null): boolean {
return !!env && CLIENT_ONLY_ENVIRONMENTS.has(env)
}
export interface ContentFilterOption {
id: string
label: string
}
export interface ContentFilterConfig {
showTypeFilters?: boolean
showUpdateFilter?: boolean
showClientOnlyFilter?: boolean
isPackLocked?: Ref<boolean>
formatProjectType?: (type: string) => string
}
export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFilterConfig) {
const selectedFilters = ref<string[]>([])
const filterOptions = computed<ContentFilterOption[]>(() => {
const options: ContentFilterOption[] = []
if (config?.showTypeFilters) {
const frequency = items.value.reduce((map: Record<string, number>, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
return map
}, {})
const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a])
for (const type of types) {
const label = config.formatProjectType ? config.formatProjectType(type) + 's' : type + 's'
options.push({ id: type, label })
}
}
if (
config?.showUpdateFilter &&
!config?.isPackLocked?.value &&
items.value.some((m) => m.has_update)
) {
options.push({ id: 'updates', label: 'Updates' })
}
if (
config?.showClientOnlyFilter &&
items.value.some((m) => isClientOnlyEnvironment(m.environment))
) {
options.push({ id: 'client-only', label: 'Client-only' })
}
if (items.value.some((m) => !m.enabled)) {
options.push({ id: 'disabled', label: 'Disabled' })
}
return options
})
watch(filterOptions, () => {
selectedFilters.value = selectedFilters.value.filter((f) =>
filterOptions.value.some((opt) => opt.id === f),
)
})
function toggleFilter(filterId: string) {
const index = selectedFilters.value.indexOf(filterId)
if (index === -1) {
selectedFilters.value.push(filterId)
} else {
selectedFilters.value.splice(index, 1)
}
}
function applyFilters(source: ContentItem[]): ContentItem[] {
if (selectedFilters.value.length === 0) return source
return source.filter((item) => {
for (const filter of selectedFilters.value) {
if (filter === 'updates' && item.has_update) return true
if (filter === 'disabled' && !item.enabled) return true
if (filter === 'client-only' && isClientOnlyEnvironment(item.environment)) return true
if (item.project_type === filter) return true
}
return false
})
}
return { selectedFilters, filterOptions, toggleFilter, applyFilters }
}

View File

@@ -0,0 +1,25 @@
import Fuse from 'fuse.js'
import type { Ref } from 'vue'
import { ref, watchSyncEffect } from 'vue'
export function useContentSearch<T>(
items: Ref<T[]>,
keys: string[],
options?: { threshold?: number; distance?: number },
) {
const searchQuery = ref('')
const fuse = new Fuse<T>([], {
keys,
threshold: options?.threshold ?? 0.4,
distance: options?.distance ?? 100,
})
watchSyncEffect(() => fuse.setCollection(items.value))
function search(source: T[]): T[] {
const query = searchQuery.value.trim()
if (!query) return source
return fuse.search(query).map(({ item }) => item)
}
return { searchQuery, search }
}

View File

@@ -0,0 +1,34 @@
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { ContentItem } from '../types'
export function useContentSelection(
items: Ref<ContentItem[]>,
getItemId: (item: ContentItem) => string,
) {
const selectedIds = ref<string[]>([])
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(getItemId(item))),
)
watch(items, (newItems) => {
if (selectedIds.value.length === 0) return
const validIds = new Set(newItems.map(getItemId))
const pruned = selectedIds.value.filter((id) => validIds.has(id))
if (pruned.length !== selectedIds.value.length) {
selectedIds.value = pruned
}
})
function clearSelection() {
selectedIds.value = []
}
function removeFromSelection(id: string) {
selectedIds.value = selectedIds.value.filter((i) => i !== id)
}
return { selectedIds, selectedItems, clearSelection, removeFromSelection }
}

View File

@@ -0,0 +1,6 @@
export * from './bulk-operations'
export * from './changing-items'
export * from './content-filtering'
export * from './content-search'
export * from './content-selection'
export * from './use-inline-backup'

View File

@@ -0,0 +1,235 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import {
injectAppBackup,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers/'
export function useInlineBackup(backupName: string | (() => string)) {
const serverCtx = injectModrinthServerContext(null)
const appBackup = injectAppBackup(null)
if (!serverCtx) {
if (appBackup) {
const isBackingUp = ref(false)
const backupFailed = ref(false)
const backupComplete = ref(false)
return {
available: true as const,
isServer: false as const,
isBackingUp,
isCancelling: ref(false),
backupFailed,
backupComplete,
backupCancelled: ref(false),
externalBackupInProgress: computed(() => false),
startBackup: async () => {
isBackingUp.value = true
backupFailed.value = false
backupComplete.value = false
try {
await appBackup.createBackup()
backupComplete.value = true
} catch {
backupFailed.value = true
} finally {
isBackingUp.value = false
}
},
cancelBackup: async () => {},
}
}
return {
available: false as const,
isServer: false as const,
isBackingUp: ref(false),
isCancelling: ref(false),
backupFailed: ref(false),
backupComplete: ref(false),
backupCancelled: ref(false),
externalBackupInProgress: ref(false),
startBackup: async () => {},
cancelBackup: async () => {},
}
}
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { serverId, worldId, backupsState, markBackupCancelled } = serverCtx
const isBackingUp = ref(false)
const backupFailed = ref(false)
const backupComplete = ref(false)
const backupCancelled = ref(false)
const isCancelling = ref(false)
const createdBackupId = ref<string | null>(null)
const externalBackupInProgress = computed(() => {
for (const [id, entry] of backupsState.entries()) {
if (id !== createdBackupId.value && entry.create?.state === 'ongoing') return true
}
return false
})
// Watch backupsState for websocket progress events from Kyros
watch(
() => {
if (!createdBackupId.value) return null
return backupsState.get(createdBackupId.value)
},
(entry) => {
if (!entry?.create) return
if (entry.create.state === 'done') {
isBackingUp.value = false
backupComplete.value = true
} else if (entry.create.state === 'cancelled') {
isBackingUp.value = false
isCancelling.value = false
backupCancelled.value = true
} else if (entry.create.state === 'failed') {
isBackingUp.value = false
backupFailed.value = true
}
},
{ deep: true },
)
// Fallback: poll the REST API in case websocket events don't arrive
let pollTimer: ReturnType<typeof setInterval> | null = null
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function pollBackupStatus(backupId: string) {
if (!isBackingUp.value) {
stopPolling()
return
}
try {
const backup = await client.archon.backups_v1.get(serverId, worldId.value!, backupId)
if (!backup.ongoing) {
stopPolling()
if (backup.interrupted) {
isBackingUp.value = false
backupFailed.value = true
} else {
isBackingUp.value = false
backupComplete.value = true
}
}
} catch {
stopPolling()
isBackingUp.value = false
backupFailed.value = true
}
}
async function startBackup() {
if (!worldId.value) return
const name = typeof backupName === 'function' ? backupName() : backupName
isBackingUp.value = true
backupFailed.value = false
backupComplete.value = false
backupCancelled.value = false
isCancelling.value = false
createdBackupId.value = null
try {
const { id } = await client.archon.backups_v1.create(serverId, worldId.value, { name })
createdBackupId.value = id
stopPolling()
pollTimer = setInterval(() => pollBackupStatus(id), 3000)
} catch (error) {
isBackingUp.value = false
backupFailed.value = true
const message = error instanceof Error ? error.message : String(error)
const isRateLimit = message.includes('429')
addNotification({
type: 'error',
title: 'Error creating backup',
text: isRateLimit ? "You're creating backups too fast." : message,
})
}
}
async function cancelBackup() {
if (!worldId.value || !createdBackupId.value || !isBackingUp.value) return
isCancelling.value = true
stopPolling()
markBackupCancelled(createdBackupId.value)
try {
await client.archon.backups_v1.delete(serverId, worldId.value, createdBackupId.value)
isBackingUp.value = false
isCancelling.value = false
backupCancelled.value = true
addNotification({
type: 'info',
title: 'Backup cancelled',
text: 'The backup has been cancelled. You can create a new one or proceed without a backup.',
})
} catch {
isCancelling.value = false
}
}
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isBackingUp.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(isBackingUp, (operating) => {
if (operating) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
stopPolling()
})
onBeforeRouteLeave(() => {
if (isBackingUp.value) {
return window.confirm('A backup is being created. Are you sure you want to leave?')
}
return true
})
}
return {
available: true as const,
isServer: true as const,
isBackingUp,
isCancelling,
backupFailed,
backupComplete,
backupCancelled,
externalBackupInProgress,
startBackup,
cancelBackup,
}
}

View File

@@ -0,0 +1,20 @@
export { default as ContentCardItem } from './components/ContentCardItem.vue'
export { default as ContentCard } from './components/ContentCardItem.vue'
export { default as ContentCardTable } from './components/ContentCardTable.vue'
export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
export { default as ConfirmLeaveModal } from './components/modals/ConfirmLeaveModal.vue'
export { default as ConfirmModpackUpdateModal } from './components/modals/ConfirmModpackUpdateModal.vue'
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
export { default as ConfirmUnlinkModal } from './components/modals/ConfirmUnlinkModal.vue'
export type { ContentInstallInstance } from './components/modals/ContentInstallModal.vue'
export { default as ContentInstallModal } from './components/modals/ContentInstallModal.vue'
export { default as ContentUpdaterModal } from './components/modals/ContentUpdaterModal.vue'
export type { ModpackContentModalState } from './components/modals/ModpackContentModal.vue'
export { default as ModpackContentModal } from './components/modals/ModpackContentModal.vue'
export { default as ContentCardLayout } from './layout.vue'
export { default as ContentPageLayout } from './layout.vue'
export * from './providers'
export * from './types'

View File

@@ -0,0 +1,793 @@
<script setup lang="ts">
import {
ArrowUpDownIcon,
CodeIcon,
CompassIcon,
DownloadIcon,
DropdownIcon,
FileIcon,
FilterIcon,
FolderOpenIcon,
LinkIcon,
RefreshCwIcon,
SearchIcon,
ShareIcon,
SpinnerIcon,
TextCursorInputIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { formatBytes, formatProjectType } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import ContentCardTable from './components/ContentCardTable.vue'
import ContentModpackCard from './components/ContentModpackCard.vue'
import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import {
isClientOnlyEnvironment,
useBulkOperation,
useChangingItems,
useContentFilters,
useContentSearch,
useContentSelection,
} from './composables'
import { injectContentManager } from './providers/content-manager'
import type { ContentCardTableItem, ContentItem } from './types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
loadingContent: {
id: 'content.page-layout.loading',
defaultMessage: 'Loading content...',
},
failedToLoad: {
id: 'content.page-layout.failed-to-load',
defaultMessage: 'Failed to load content',
},
additionalContent: {
id: 'content.page-layout.additional-content',
defaultMessage: 'Additional content',
},
searchPlaceholder: {
id: 'content.page-layout.search-placeholder',
defaultMessage: 'Search {count} {contentType}...',
},
browseContent: {
id: 'content.page-layout.browse-content',
defaultMessage: 'Browse content',
},
uploadFiles: {
id: 'content.page-layout.upload-files',
defaultMessage: 'Upload files',
},
sortAlphabetical: {
id: 'content.page-layout.sort.alphabetical',
defaultMessage: 'Alphabetical',
},
sortDateAdded: {
id: 'content.page-layout.sort.date-added',
defaultMessage: 'Date added',
},
updateAll: {
id: 'content.page-layout.update-all',
defaultMessage: 'Update all',
},
noContentFound: {
id: 'content.page-layout.no-content-found',
defaultMessage: 'No content found.',
},
noExtraContentInstalled: {
id: 'content.page-layout.empty.no-extra-content-installed',
defaultMessage: 'No extra content installed',
},
noContentInstalled: {
id: 'content.page-layout.empty.no-content-installed',
defaultMessage: 'No content installed',
},
emptyModpackHint: {
id: 'content.page-layout.empty.modpack-hint',
defaultMessage: 'Add additional content on top of this modpack',
},
emptyHint: {
id: 'content.page-layout.empty.hint',
defaultMessage: 'Browse or upload {contentType} to get started',
},
shareProjectNames: {
id: 'content.page-layout.share.project-names',
defaultMessage: 'Project names',
},
shareFileNames: {
id: 'content.page-layout.share.file-names',
defaultMessage: 'File names',
},
shareProjectLinks: {
id: 'content.page-layout.share.project-links',
defaultMessage: 'Project links',
},
shareMarkdownLinks: {
id: 'content.page-layout.share.markdown-links',
defaultMessage: 'Markdown links',
},
share: {
id: 'content.page-layout.share.label',
defaultMessage: 'Share',
},
uploadingFiles: {
id: 'content.page-layout.uploading-files',
defaultMessage: 'Uploading files ({completed}/{total})',
},
sortByLabel: {
id: 'content.page-layout.sort.label',
defaultMessage: 'Sort by {mode}',
},
busyDescription: {
id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.',
},
})
const ctx = injectContentManager()
const uploadOverallProgress = computed(() => {
const state = ctx.uploadState?.value
if (!state || !state.isUploading || state.totalFiles === 0) return 0
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
})
type SortMode = 'alphabetical' | 'date-added'
const sortMode = ref<SortMode>('alphabetical')
const sortLabels: Record<SortMode, () => string> = {
alphabetical: () => formatMessage(messages.sortAlphabetical),
'date-added': () => formatMessage(messages.sortDateAdded),
}
function cycleSortMode() {
const modes: SortMode[] = ['alphabetical', 'date-added']
const idx = modes.indexOf(sortMode.value)
sortMode.value = modes[(idx + 1) % modes.length]
}
const sortedItems = computed(() => {
const items = [...ctx.items.value]
if (sortMode.value === 'date-added') {
return items.sort((a, b) => {
const dateA = a.date_added ?? ''
const dateB = b.date_added ?? ''
return dateB.localeCompare(dateA)
})
}
return items.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())
})
})
const { searchQuery, search } = useContentSearch(sortedItems, [
'project.title',
'owner.name',
'file_name',
])
const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useContentFilters(
ctx.items,
{
showTypeFilters: true,
showUpdateFilter: ctx.hasUpdateSupport,
showClientOnlyFilter: ctx.showClientOnlyFilter ?? false,
isPackLocked: ctx.isPackLocked,
formatProjectType,
},
)
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
ctx.items,
ctx.getItemId,
)
const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation()
// Sync bulk operation state back to the content manager so providers can suppress refreshes
if (ctx.isBulkOperating) {
watch(isBulkOperating, (val) => {
ctx.isBulkOperating!.value = val
})
}
const { isChanging, markChanging, unmarkChanging } = useChangingItems()
const bulkWaiting = ref(false)
const refreshing = ref(false)
async function handleRefresh() {
if (refreshing.value) return
refreshing.value = true
try {
await ctx.refresh()
} finally {
refreshing.value = false
}
}
const filteredItems = computed(() => applyFilters(search(sortedItems.value)))
const tableItems = computed<ContentCardTableItem[]>(() =>
filteredItems.value.map((item) => {
const base = ctx.mapToTableItem(item)
return {
...base,
disabled: isChanging(base.id) || ctx.isBusy.value || item.installing === true,
hasUpdate: !ctx.isPackLocked.value && item.has_update,
isClientOnly: isClientOnlyEnvironment(item.environment),
overflowOptions: ctx.getOverflowOptions?.(item),
}
}),
)
const hasOutdatedProjects = computed(() => ctx.items.value.some((p) => p.has_update))
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
function handleDeleteById(id: string) {
const item = ctx.items.value.find((i) => ctx.getItemId(i) === id)
if (item) {
pendingDeletionItems.value = [item]
confirmDeletionModal.value?.show()
}
}
function showBulkDeleteModal() {
pendingDeletionItems.value = [...selectedItems.value]
confirmDeletionModal.value?.show()
}
async function confirmDelete() {
const itemsToDelete = [...pendingDeletionItems.value]
pendingDeletionItems.value = []
if (itemsToDelete.length === 0) return
if (ctx.bulkDeleteItems && itemsToDelete.length > 1) {
isBulkOperating.value = true
bulkOperation.value = 'delete'
bulkWaiting.value = true
try {
await ctx.bulkDeleteItems(itemsToDelete)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkWaiting.value = false
}
return
}
if (itemsToDelete.length === 1) {
const item = itemsToDelete[0]
const id = ctx.getItemId(item)
markChanging(id)
await ctx.deleteItem(item)
removeFromSelection(id)
unmarkChanging(id)
return
}
await runBulk(
'delete',
itemsToDelete,
async (item) => {
await ctx.deleteItem(item)
removeFromSelection(ctx.getItemId(item))
},
{ onComplete: clearSelection },
)
}
async function handleToggleEnabledById(id: string, _value: boolean) {
const item = ctx.items.value.find((i) => ctx.getItemId(i) === id)
if (!item) return
markChanging(id)
try {
await ctx.toggleEnabled(item)
} finally {
unmarkChanging(id)
}
}
async function bulkEnable() {
const items = selectedItems.value.filter((item) => !item.enabled)
if (items.length === 0) return
if (ctx.bulkEnableItems) {
isBulkOperating.value = true
bulkOperation.value = 'enable'
bulkWaiting.value = true
try {
await ctx.bulkEnableItems(items)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkWaiting.value = false
}
return
}
await runBulk('enable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
}
async function bulkDisable() {
const items = selectedItems.value.filter((item) => item.enabled)
if (items.length === 0) return
if (ctx.bulkDisableItems) {
isBulkOperating.value = true
bulkOperation.value = 'disable'
bulkWaiting.value = true
try {
await ctx.bulkDisableItems(items)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkWaiting.value = false
}
return
}
await runBulk('disable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
}
function handleUpdateById(id: string) {
ctx.updateItem?.(id)
}
// Bulk updating
const confirmBulkUpdateModal = ref<InstanceType<typeof ConfirmBulkUpdateModal>>()
const pendingBulkUpdateItems = ref<ContentItem[]>([])
const hasBulkUpdateSupport = computed(() => !!(ctx.bulkUpdateItem || ctx.bulkUpdateItems))
function promptUpdateAll() {
if (!hasBulkUpdateSupport.value) return
const items = ctx.items.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
confirmBulkUpdateModal.value?.show()
}
function promptUpdateSelected() {
if (!hasBulkUpdateSupport.value) return
const items = selectedItems.value.filter((item) => item.has_update)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
confirmBulkUpdateModal.value?.show()
}
async function confirmBulkUpdate() {
const items = pendingBulkUpdateItems.value
if (items.length === 0 || !hasBulkUpdateSupport.value) return
if (ctx.bulkUpdateItems) {
isBulkOperating.value = true
bulkOperation.value = 'update'
bulkWaiting.value = true
try {
await ctx.bulkUpdateItems(items)
} finally {
clearSelection()
isBulkOperating.value = false
bulkOperation.value = null
bulkWaiting.value = false
}
} else if (ctx.bulkUpdateItem) {
await runBulk('update', items, ctx.bulkUpdateItem, { onComplete: clearSelection })
}
pendingBulkUpdateItems.value = []
}
const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</script>
<template>
<div class="flex flex-col gap-4 pb-6">
<div
v-if="ctx.loading.value"
role="status"
aria-live="polite"
class="flex min-h-[50vh] w-full flex-col items-center justify-center gap-2 text-center text-secondary"
>
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.loadingContent) }}
</div>
<div
v-else-if="ctx.error.value"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="universal-card flex flex-col items-center gap-4 p-6">
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
<p class="text-secondary">{{ ctx.error.value.message }}</p>
<ButtonStyled color="brand">
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
</ButtonStyled>
</div>
</div>
<template v-else>
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
<template #header>{{ ctx.busyMessage.value }}</template>
{{ formatMessage(messages.busyDescription) }}
</Admonition>
<ContentModpackCard
v-if="ctx.modpack.value"
:project="ctx.modpack.value.project"
:project-link="ctx.modpack.value.projectLink"
:version="ctx.modpack.value.version"
:version-link="ctx.modpack.value.versionLink"
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="ctx.modpack.value.disabledText"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"
v-on="{
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
}"
@dismiss-content-hint="ctx.dismissContentHint?.()"
/>
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
aria-live="polite"
>
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" show-actions-underneath>
<template #icon>
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
{{
formatMessage(messages.uploadingFiles, {
completed: ctx.uploadState?.value?.completedFiles ?? 0,
total: ctx.uploadState?.value?.totalFiles ?? 0,
})
}}
</template>
<span class="text-secondary">
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
Math.round(uploadOverallProgress * 100)
}}%)
</span>
<template #actions>
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
</template>
</Admonition>
</Transition>
<template v-if="ctx.items.value.length > 0">
<div class="flex flex-col gap-4">
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.additionalContent) }}
</span>
<div class="flex flex-wrap items-center gap-2">
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
type="text"
autocomplete="off"
:spellcheck="false"
input-class="!h-10"
wrapper-class="flex-1 min-w-0"
clearable
:placeholder="
formatMessage(messages.searchPlaceholder, {
count: ctx.items.value.length,
contentType: `${ctx.contentTypeLabel.value}${ctx.items.value.length === 1 ? '' : 's'}`,
})
"
/>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 !border-button-bg !border-[1px]"
@click="ctx.uploadFiles"
>
<FolderOpenIcon class="size-5" />
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.length === 0
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="selectedFilters.length === 0"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.includes(option.id)
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="selectedFilters.includes(option.id)"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
<div class="ml-4 mx-0.5 h-5 w-px bg-surface-5" />
<ButtonStyled type="transparent" hover-color-fill="none">
<button
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
>
<ArrowUpDownIcon />
{{ sortLabels[sortMode]() }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="hasBulkUpdateSupport && !ctx.isPackLocked.value && hasOutdatedProjects"
color="green"
type="transparent"
color-fill="text"
hover-color-fill="background"
>
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
<DownloadIcon />
{{ formatMessage(messages.updateAll) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent" hover-color-fill="none">
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</button>
</ButtonStyled>
</div>
</div>
<ContentCardTable
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="true"
@update:enabled="handleToggleEnabledById"
@delete="handleDeleteById"
@update="handleUpdateById"
>
<template #empty>
<span>{{ formatMessage(messages.noContentFound) }}</span>
</template>
</ContentCardTable>
</div>
</template>
<EmptyState v-else type="empty-inbox">
<template #heading>
{{
formatMessage(
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
)
}}
</template>
<template #description>
{{
ctx.modpack.value
? formatMessage(messages.emptyModpackHint)
: formatMessage(messages.emptyHint, {
contentType: `${ctx.contentTypeLabel.value}s`,
})
}}
</template>
<template #actions>
<ButtonStyled type="outlined">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 !border-button-bg !border-[1px]"
@click="ctx.uploadFiles"
>
<FolderOpenIcon class="size-5" />
{{ formatMessage(messages.uploadFiles) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
v-tooltip="
ctx.busyMessage?.value ??
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
"
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
class="!h-10 flex items-center gap-2"
@click="ctx.browse"
>
<CompassIcon class="size-5" />
<span>{{ formatMessage(messages.browseContent) }}</span>
</button>
</ButtonStyled>
</template>
</EmptyState>
</template>
<ContentSelectionBar
:selected-items="selectedItems"
:content-type-label="ctx.contentTypeLabel.value"
:is-busy="ctx.isBusy.value"
:is-bulk-operating="isBulkOperating"
:bulk-operation="bulkOperation"
:bulk-progress="bulkProgress"
:bulk-total="bulkTotal"
:bulk-waiting="bulkWaiting"
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
@clear="clearSelection"
@enable="bulkEnable"
@disable="bulkDisable"
>
<template #actions>
<ButtonStyled
v-if="
hasBulkUpdateSupport &&
!ctx.isPackLocked.value &&
selectedItems.some((m) => m.has_update)
"
type="transparent"
color="green"
color-fill="text"
hover-color-fill="background"
>
<button :disabled="ctx.isBusy.value" @click="promptUpdateSelected">
<DownloadIcon />
{{ formatMessage(commonMessages.updateButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="ctx.shareItems" type="transparent">
<OverflowMenu
:options="[
{
id: 'share-names',
action: () => ctx.shareItems!(selectedItems, 'names'),
},
{
id: 'share-file-names',
action: () => ctx.shareItems!(selectedItems, 'file-names'),
},
{
id: 'share-urls',
action: () => ctx.shareItems!(selectedItems, 'urls'),
},
{
id: 'share-markdown',
action: () => ctx.shareItems!(selectedItems, 'markdown'),
},
]"
>
<ShareIcon />
{{ formatMessage(messages.share) }}
<DropdownIcon />
<template #share-names>
<TextCursorInputIcon />
{{ formatMessage(messages.shareProjectNames) }}
</template>
<template #share-file-names>
<FileIcon />
{{ formatMessage(messages.shareFileNames) }}
</template>
<template #share-urls>
<LinkIcon />
{{ formatMessage(messages.shareProjectLinks) }}
</template>
<template #share-markdown>
<CodeIcon />
{{ formatMessage(messages.shareMarkdownLinks) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template #actions-end>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled
type="transparent"
color="red"
color-fill="text"
hover-color-fill="background"
>
<button :disabled="ctx.isBusy.value" @click="showBulkDeleteModal">
<TrashIcon />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</template>
</ContentSelectionBar>
<ConfirmDeletionModal
ref="confirmDeletionModal"
:count="pendingDeletionItems.length"
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
@delete="confirmDelete"
/>
<ConfirmBulkUpdateModal
v-if="hasBulkUpdateSupport"
ref="confirmBulkUpdateModal"
:count="pendingBulkUpdateItems.length"
:server="ctx.deletionContext === 'server'"
@update="confirmBulkUpdate"
/>
<ConfirmUnlinkModal
v-if="ctx.unlinkModpack"
ref="confirmUnlinkModal"
:server="ctx.deletionContext === 'server'"
@unlink="ctx.unlinkModpack!()"
/>
<slot name="modals" />
</div>
</template>

View File

@@ -0,0 +1,111 @@
import type { ComputedRef, Ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import { createContext } from '#ui/providers/create-context'
import type {
ContentCardTableItem,
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '../types'
export interface ContentModpackData {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
version?: ContentModpackCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
categories: ContentModpackCardCategory[]
hasUpdate: boolean
disabled?: boolean
disabledText?: string
}
export interface UploadState {
isUploading: boolean
currentFileName: string | null
currentFileProgress: number
uploadedBytes: number
totalBytes: number
completedFiles: number
totalFiles: number
}
export interface ContentManagerContext {
// Data
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
loading: Ref<boolean>
error: Ref<Error | null>
// Modpack
modpack: Ref<ContentModpackData | null> | ComputedRef<ContentModpackData | null>
isPackLocked: Ref<boolean> | ComputedRef<boolean>
// Guards
isBusy: Ref<boolean> | ComputedRef<boolean>
busyMessage?: Ref<string | null> | ComputedRef<string | null>
disableAddContent?: Ref<boolean> | ComputedRef<boolean>
disableAddContentTooltip?: string
// Identity & labelling
getItemId: (item: ContentItem) => string
contentTypeLabel: Ref<string> | ComputedRef<string>
// Core actions
toggleEnabled: (item: ContentItem) => Promise<void>
deleteItem: (item: ContentItem) => Promise<void>
refresh: () => Promise<void>
browse: () => void
uploadFiles: () => void
// Bulk actions (optional — when provided, used instead of one-by-one loops)
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
// Update support (optional per-platform)
hasUpdateSupport: boolean
updateItem?: (id: string) => void
bulkUpdateItem?: (item: ContentItem) => Promise<void>
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
// Modpack actions (optional)
updateModpack?: () => void
viewModpackContent?: () => void
unlinkModpack?: () => void
openSettings?: () => void
// Per-item overflow menu (optional)
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
// Share support (optional — when undefined, share button becomes hidden entirely)
shareItems?: (items: ContentItem[], format: 'names' | 'file-names' | 'urls' | 'markdown') => void
// Upload progress (optional)
uploadState?: Ref<UploadState> | ComputedRef<UploadState>
// Show client-only environment filter pill
showClientOnlyFilter?: boolean
// Bulk operation guard — set by layout, checked by providers to suppress refreshes
isBulkOperating?: Ref<boolean>
// Deletion context (controls modal variant)
deletionContext?: 'instance' | 'server'
// One-time content hint (optional — shows tooltip on modpack content button)
showContentHint?: Ref<boolean>
dismissContentHint?: () => void
// Table item mapping (link generation differs per platform)
mapToTableItem: (item: ContentItem) => ContentCardTableItem
}
export const [injectContentManager, provideContentManager] = createContext<ContentManagerContext>(
'ContentPageLayout',
'contentManagerContext',
)

View File

@@ -0,0 +1 @@
export * from './content-manager'

View File

@@ -1,7 +1,7 @@
import type { Labrinth } from '@modrinth/api-client'
import type { RouteLocationRaw } from 'vue-router'
import type { Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
export type ContentCardProject = Pick<
Labrinth.Projects.v2.Project,
@@ -18,7 +18,7 @@ export interface ContentOwner {
name: string
avatar_url?: string
type: 'user' | 'organization'
link?: string | RouteLocationRaw
link?: string | RouteLocationRaw | (() => void)
}
export interface ContentCardTableItem {
@@ -31,6 +31,7 @@ export interface ContentCardTableItem {
enabled?: boolean
disabled?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
overflowOptions?: OverflowMenuOption[]
}
@@ -50,6 +51,8 @@ export interface ContentItem extends Omit<
has_update: boolean
update_version_id: string | null
date_added?: string
environment?: string
installing?: boolean
}
export type ContentModpackCardProject = Pick<

View File

@@ -0,0 +1,222 @@
<template>
<NewModal ref="modal" :header="header" :closable="true" no-padding>
<div class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition :type="hasUnknownContent ? 'warning' : 'info'" :header="admonitionHeader">
{{ description }}
</Admonition>
<Admonition
v-if="hasUnknownContent"
type="warning"
:header="formatMessage(messages.unknownContentHeader)"
>
{{ formatMessage(messages.unknownContentBody) }}
</Admonition>
<div v-if="diffs.length" class="flex gap-2">
<div v-if="removedCount" class="flex gap-1 items-center">
<MinusIcon />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<div v-if="addedCount" class="flex gap-1 items-center">
<PlusIcon />
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
</div>
<div v-if="updatedCount" class="flex gap-1 items-center">
<RefreshCwIcon />
{{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div
v-for="(diff, index) in sortedDiffs"
:key="diff.projectName || diff.fileName || index"
class="grid items-center min-h-10 h-10 gap-2"
:class="diff.projectName ? 'grid-cols-[auto_auto_1fr]' : 'grid-cols-[auto_auto_1fr]'"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" class="text-red" />
<RefreshCwIcon v-else />
<div
:class="index === sortedDiffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
</div>
<span class="text-sm shrink-0 whitespace-nowrap">{{
diff.type === 'removed' && props.removedLabel
? props.removedLabel
: formatMessage(diffTypeMessages[diff.type])
}}</span>
<span
v-if="diff.projectName"
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ diff.projectName }}
</span>
<span
v-else-if="diff.fileName"
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ decodeURIComponent(diff.fileName) }}
</span>
</div>
</div>
<div
v-if="showBackupCreator"
class="p-4 border-t border-solid border-surface-5 border-b-0 border-l-0 border-r-0"
>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before version change"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
</div>
<template #actions>
<div class="flex justify-between gap-2">
<div>
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
<button @click="emit('report')">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex gap-2">
<ButtonStyled>
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="buttonsDisabled" @click="handleConfirm">
<component :is="confirmIcon" v-if="confirmIcon" />
{{ confirmLabel || formatMessage(commonMessages.confirmButton) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { MinusIcon, PlusIcon, RefreshCwIcon, ReportIcon, XIcon } from '@modrinth/assets'
import { type Component, computed, ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from '../../content-tab/components/modals/InlineBackupCreator.vue'
import type { ContentDiffItem } from '../types'
const props = defineProps<{
header: string
description?: string
admonitionHeader?: string
diffs: ContentDiffItem[]
hasUnknownContent?: boolean
confirmLabel?: string
confirmIcon?: Component
showReportButton?: boolean
showBackupCreator?: boolean
removedLabel?: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
report: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
const removedCount = computed(() => props.diffs.filter((d) => d.type === 'removed').length)
const addedCount = computed(() => props.diffs.filter((d) => d.type === 'added').length)
const updatedCount = computed(() => props.diffs.filter((d) => d.type === 'updated').length)
const sortedDiffs = computed(() =>
[...props.diffs].sort((a, b) => {
const typeOrder = { added: 0, updated: 1, removed: 2 }
return typeOrder[a.type] - typeOrder[b.type]
}),
)
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
function handleConfirm() {
hide()
emit('confirm')
}
function handleCancel() {
hide()
emit('cancel')
}
const messages = defineMessages({
removedCount: {
id: 'content.diff-modal.removed-count',
defaultMessage: '{count} removed',
},
addedCount: {
id: 'content.diff-modal.added-count',
defaultMessage: '{count} added',
},
updatedCount: {
id: 'content.diff-modal.updated-count',
defaultMessage: '{count} updated',
},
unknownContentHeader: {
id: 'content.diff-modal.unknown-content-header',
defaultMessage: 'Unknown content',
},
unknownContentBody: {
id: 'content.diff-modal.unknown-content-body',
defaultMessage:
'Some content on your server could not be analyzed and may be affected by this change.',
},
})
const diffTypeMessages = defineMessages({
added: {
id: 'content.diff-modal.diff-type.added',
defaultMessage: 'Added (dependency)',
},
removed: {
id: 'content.diff-modal.diff-type.removed',
defaultMessage: 'Removed',
},
updated: {
id: 'content.diff-modal.diff-type.updated',
defaultMessage: 'Updated',
},
})
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1 @@
export { useInstallationForm } from './use-installation-form'

View File

@@ -0,0 +1,278 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { formatLoaderLabel } from '#ui/utils/loaders'
import type { ContentUpdaterModal } from '../../content-tab'
import type ContentDiffModal from '../components/ContentDiffModal.vue'
import type { InstallationSettingsContext } from '../providers/installation-settings'
import type { ContentDiffPreview } from '../types'
export function useInstallationForm(
ctx: InstallationSettingsContext,
updaterModalRef: Ref<InstanceType<typeof ContentUpdaterModal> | null | undefined>,
contentDiffModalRef?: Ref<InstanceType<typeof ContentDiffModal> | null | undefined>,
) {
const isEditing = ref(false)
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
const selectedGameVersion = ctx.editingGameVersionRef ?? ref(ctx.currentGameVersion.value)
const selectedLoaderVersion = ref(0)
const showSnapshots = ref(false)
const isSaving = ref(false)
const isVerifying = ref(false)
const pendingPreview = ref<ContentDiffPreview | null>(null)
let abortController: AbortController | null = null
const gameVersionOptions = computed(() =>
ctx.resolveGameVersions(selectedPlatform.value, showSnapshots.value),
)
const loaderVersionEntries = computed(() =>
ctx.resolveLoaderVersions(selectedPlatform.value, selectedGameVersion.value),
)
const loaderVersionOptions = computed(() =>
loaderVersionEntries.value.map((v, index) => ({ value: index, label: v.id })),
)
const loaderVersionDisplayValue = computed(() => {
const idx = selectedLoaderVersion.value
return idx >= 0 && loaderVersionEntries.value[idx] ? loaderVersionEntries.value[idx].id : ''
})
const hasSnapshots = computed(() => ctx.resolveHasSnapshots(selectedPlatform.value))
const formattedLoaderName = computed(() => formatLoaderLabel(selectedPlatform.value))
const isValid = computed(() => {
if (!selectedGameVersion.value) return false
if (selectedPlatform.value !== 'vanilla') {
return selectedLoaderVersion.value >= 0 && loaderVersionEntries.value.length > 0
}
return true
})
const hasChanges = computed(() => {
if (selectedPlatform.value !== ctx.currentPlatform.value) return true
if (selectedGameVersion.value !== ctx.currentGameVersion.value) return true
if (
selectedPlatform.value !== 'vanilla' &&
loaderVersionEntries.value[selectedLoaderVersion.value]?.id !== ctx.currentLoaderVersion.value
) {
return true
}
return false
})
watch(selectedPlatform, () => {
selectedLoaderVersion.value = 0
})
watch(selectedGameVersion, () => {
selectedLoaderVersion.value = 0
})
async function save() {
isSaving.value = true
try {
const isModded = ctx.currentPlatform.value !== 'vanilla'
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
if (ctx.previewSave && isModded && gameVersionChanged) {
isVerifying.value = true
abortController = new AbortController()
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
let preview: ContentDiffPreview | null
try {
preview = await ctx.previewSave(
selectedPlatform.value,
selectedGameVersion.value,
loaderVersionId,
abortController.signal,
)
} finally {
isVerifying.value = false
abortController = null
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
pendingPreview.value = preview
await nextTick()
contentDiffModalRef?.value?.show()
return
}
}
await performSave()
} catch {
isSaving.value = false
}
}
async function performSave() {
try {
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
await ctx.save(selectedPlatform.value, selectedGameVersion.value, loaderVersionId)
if (ctx.afterSave) await ctx.afterSave()
isEditing.value = false
} finally {
isSaving.value = false
}
}
async function confirmSave() {
pendingPreview.value = null
try {
await performSave()
} catch {
// Error handled in performSave
}
}
function cancelPreview() {
pendingPreview.value = null
isSaving.value = false
}
function cancelEditing() {
abortController?.abort()
abortController = null
isVerifying.value = false
isSaving.value = false
pendingPreview.value = null
selectedPlatform.value = ctx.currentPlatform.value
selectedGameVersion.value = ctx.currentGameVersion.value
const currentId = ctx.currentLoaderVersion.value
const entries = ctx.resolveLoaderVersions(
ctx.currentPlatform.value,
ctx.currentGameVersion.value,
)
selectedLoaderVersion.value = Math.max(
entries.findIndex((e) => e.id === currentId),
0,
)
isEditing.value = false
}
// Modpack updater state
const updatingModpack = ref(false)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
async function handleChangeModpackVersion() {
updatingModpack.value = true
loadingChangelog.value = false
const cached = ctx.getCachedModpackVersions()
if (cached) {
updatingProjectVersions.value = [...cached].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
loadingVersions.value = false
} else {
updatingProjectVersions.value = []
loadingVersions.value = true
}
await nextTick()
updaterModalRef.value?.show(ctx.updaterModalProps.value.currentVersionId || undefined)
if (!cached) {
try {
const versions = await ctx.fetchModpackVersions()
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
} catch {
// Error handled by context
} finally {
loadingVersions.value = false
}
}
}
function spliceVersion(full: Labrinth.Versions.v2.Version) {
const i = updatingProjectVersions.value.findIndex((v) => v.id === full.id)
if (i !== -1) {
const arr = [...updatingProjectVersions.value]
arr[i] = full
updatingProjectVersions.value = arr
}
}
async function handleUpdaterVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
loadingChangelog.value = true
try {
const full = await ctx.getVersionChangelog(version.id)
if (full) spliceVersion(full)
} finally {
loadingChangelog.value = false
}
}
async function handleUpdaterVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
try {
const full = await ctx.getVersionChangelog(version.id)
if (full) spliceVersion(full)
} catch {
// silent
}
}
function resetUpdateState() {
updatingModpack.value = false
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
}
async function handleUpdaterConfirm(version: Labrinth.Versions.v2.Version) {
try {
await ctx.onModpackVersionConfirm(version)
} finally {
resetUpdateState()
}
}
return {
isEditing,
selectedPlatform,
selectedGameVersion,
selectedLoaderVersion,
showSnapshots,
isSaving,
isVerifying,
gameVersionOptions,
loaderVersionOptions,
loaderVersionDisplayValue,
hasSnapshots,
formattedLoaderName,
isValid,
hasChanges,
save,
pendingPreview,
confirmSave,
cancelPreview,
cancelEditing,
updatingModpack,
updatingProjectVersions,
loadingVersions,
loadingChangelog,
handleChangeModpackVersion,
handleUpdaterVersionSelect,
handleUpdaterVersionHover,
handleUpdaterConfirm,
resetUpdateState,
}
}

View File

@@ -0,0 +1,5 @@
export { default as ContentDiffModal } from './components/ContentDiffModal.vue'
export { useInstallationForm } from './composables'
export { default as InstallationSettingsLayout } from './layout.vue'
export * from './providers'
export * from './types'

View File

@@ -0,0 +1,710 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ArrowLeftRightIcon,
CircleAlertIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
HammerIcon,
PencilIcon,
SaveIcon,
SpinnerIcon,
UnlinkIcon,
XIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.vue'
import Combobox from '#ui/components/base/Combobox.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import ConfirmLeaveModal from '../content-tab/components/modals/ConfirmLeaveModal.vue'
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
import ConfirmRepairModal from '../content-tab/components/modals/ConfirmRepairModal.vue'
import ConfirmUnlinkModal from '../content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../content-tab/components/modals/ContentUpdaterModal.vue'
import ContentDiffModal from './components/ContentDiffModal.vue'
import { useInstallationForm } from './composables'
import { injectInstallationSettings } from './providers/installation-settings'
const { formatMessage } = useVIntl()
const ctx = injectInstallationSettings()
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const repairModal = ref<InstanceType<typeof ConfirmRepairModal>>()
const reinstallModal = ref<InstanceType<typeof ConfirmReinstallModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const contentDiffModal = ref<InstanceType<typeof ContentDiffModal>>()
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
const pendingUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isUpdateDowngrade = ref(false)
const form = useInstallationForm(ctx, contentUpdaterModal, contentDiffModal)
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (form.isSaving.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(
() => form.isSaving.value,
(saving) => {
if (saving) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
},
)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (form.isSaving.value) {
return (await confirmLeaveModal.value?.prompt()) ?? false
}
return true
})
}
const disabledPlatforms = computed(() => {
if (!ctx.lockPlatform || ctx.currentPlatform.value === 'vanilla') return []
return ctx.availablePlatforms.filter((p) => p !== ctx.currentPlatform.value)
})
const showModpackVersionActions = ctx.showModpackVersionActions ?? true
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version) {
pendingUpdateVersion.value = version
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
const currentVersion = form.updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isUpdateDowngrade.value = currentVersion
? new Date(version.date_published) < new Date(currentVersion.date_published)
: false
modpackUpdateModal.value?.show()
}
function handleModpackUpdateConfirm() {
if (pendingUpdateVersion.value) {
form.cancelEditing()
form.handleUpdaterConfirm(pendingUpdateVersion.value)
pendingUpdateVersion.value = null
}
}
function handleModpackUpdateCancel() {
pendingUpdateVersion.value = null
}
function handleRepair() {
form.cancelEditing()
ctx.repair()
}
function handleReinstall() {
form.cancelEditing()
ctx.reinstallModpack()
}
function handleUnlink() {
form.cancelEditing()
ctx.unlinkModpack()
}
defineExpose({
cancelEditing: () => form.cancelEditing(),
})
const messages = defineMessages({
linkedInstanceTitle: {
id: 'installation-settings.linked-instance.title',
defaultMessage: 'Linked {projectType, select, server {server project} other {modpack}}',
},
reinstallModpackTitle: {
id: 'installation-settings.reinstall-modpack.title',
defaultMessage: 'Re-install modpack',
},
reinstallModpackDescription: {
id: 'installation-settings.reinstall-modpack.description',
defaultMessage:
"Re-installing the modpack resets the {type, select, server {server's} other {instance's}} content to its original state, removing any mods or content you have added.",
},
editInstallationTitle: {
id: 'installation-settings.edit-installation.title',
defaultMessage: 'Edit installation',
},
unlinkDescription: {
id: 'installation-settings.unlink.description',
defaultMessage:
"Unlinking permanently disconnects this {type, select, server {server} other {instance}} from the {projectType, select, server {server} other {modpack}} project, allowing you to change the loader and Minecraft version, but you won't receive future updates.",
},
repairInstanceTitle: {
id: 'installation-settings.repair.instance-title',
defaultMessage: 'Repair instance',
},
repairInstanceDescription: {
id: 'installation-settings.repair.instance-description',
defaultMessage:
'Reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors.',
},
repairServerTitle: {
id: 'installation-settings.repair.server-title',
defaultMessage: 'Repair server',
},
repairServerDescription: {
id: 'installation-settings.repair.server-description',
defaultMessage:
'Reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your server is not starting correctly.',
},
editWarningInstance: {
id: 'installation-settings.edit.warning-instance',
defaultMessage:
"We don't recommend editing your installation settings after installing content. If you want to edit them, be cautious as it may cause issues.",
},
editWarningServer: {
id: 'installation-settings.edit.warning-server',
defaultMessage:
"We don't recommend editing your installation settings after installing content. If you want to edit them reset your server.",
},
loaderVersionLabel: {
id: 'installation-settings.loader-version',
defaultMessage: '{loader} version',
},
searchGameVersionPlaceholder: {
id: 'installation-settings.search-game-version',
defaultMessage: 'Search game version...',
},
savingLabel: {
id: 'installation-settings.saving',
defaultMessage: 'Saving...',
},
verifyingLabel: {
id: 'installation-settings.verifying',
defaultMessage: 'Verifying...',
},
selectPlatformAriaLabel: {
id: 'installation-settings.aria.select-platform',
defaultMessage: 'Select platform',
},
selectGameVersionAriaLabel: {
id: 'installation-settings.aria.select-game-version',
defaultMessage: 'Select game version',
},
selectLoaderVersionAriaLabel: {
id: 'installation-settings.aria.select-loader-version',
defaultMessage: 'Select {loader} version',
},
reinstallingModpackButton: {
id: 'installation-settings.reinstalling-modpack',
defaultMessage: 'Reinstalling modpack',
},
unlinkButton: {
id: 'installation-settings.unlink',
defaultMessage: 'Unlink',
},
platformLockTooltip: {
id: 'installation-settings.platform-lock-tooltip',
defaultMessage: 'You will need to reset your server to switch loader.',
},
confirmVersionChangeHeader: {
id: 'installation-settings.confirm-version-change-header',
defaultMessage: 'Review content changes',
},
confirmVersionChange: {
id: 'installation-settings.confirm-version-change',
defaultMessage: 'Confirm',
},
confirmVersionChangeDescription: {
id: 'installation-settings.confirm-version-change-description',
defaultMessage: 'Changing to {gameVersion} will modify the following content on your server.',
},
removedIncompatible: {
id: 'installation-settings.removed-incompatible',
defaultMessage: 'Removed (incompatible)',
},
})
</script>
<template>
<div class="flex flex-col gap-6">
<!-- Loading state -->
<div v-if="ctx.loading.value" class="flex items-center justify-center py-12">
<SpinnerIcon class="size-8 animate-spin text-secondary" />
</div>
<template v-else>
<!-- Installation Info (linked state) -->
<div v-if="ctx.isLinked.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installationInfoTitle) }}
</span>
<div class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
<div
v-for="row in ctx.installationInfo.value"
:key="row.label"
class="flex items-center justify-between"
>
<span class="text-primary">{{ row.label }}</span>
<span v-if="row.value" class="font-semibold text-contrast">{{ row.value }}</span>
<span
v-else
class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"
></span>
</div>
</div>
</div>
<!-- LINKED -->
<template v-if="ctx.isLinked.value">
<!-- Installed Modpack -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installedModpackTitle) }}
</span>
<div
v-if="ctx.modpack.value"
class="flex items-center gap-2.5 rounded-[20px] bg-surface-2 p-3"
>
<AutoLink :to="ctx.modpack.value.link" class="shrink-0">
<div
class="size-14 shrink-0 overflow-hidden rounded-2xl border border-solid border-surface-5"
>
<Avatar
v-if="ctx.modpack.value.iconUrl"
:src="ctx.modpack.value.iconUrl"
:alt="ctx.modpack.value.title"
size="100%"
no-shadow
/>
</div>
</AutoLink>
<div class="flex flex-col gap-1">
<AutoLink
:to="ctx.modpack.value.link"
class="font-semibold text-contrast hover:underline"
>
{{ ctx.modpack.value.title }}
</AutoLink>
<div class="flex items-center gap-2 text-sm text-secondary">
<AutoLink
v-if="ctx.modpack.value.owner"
:to="
ctx.modpack.value.owner.type === 'organization'
? `/organization/${ctx.modpack.value.owner.id}`
: `/user/${ctx.modpack.value.owner.id}`
"
class="flex items-center gap-1.5 hover:underline"
>
<Avatar
:src="ctx.modpack.value.owner.iconUrl"
:alt="ctx.modpack.value.owner.name"
size="1.25rem"
:circle="ctx.modpack.value.owner.type === 'user'"
no-shadow
/>
<span class="font-medium">{{ ctx.modpack.value.owner.name }}</span>
</AutoLink>
<template v-if="ctx.modpack.value.owner && ctx.modpack.value.versionNumber">
&middot;
</template>
<span v-if="ctx.modpack.value.versionNumber" class="font-medium">
{{ ctx.modpack.value.versionNumber }}
</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled v-if="showModpackVersionActions">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.handleChangeModpackVersion()"
>
<ArrowLeftRightIcon class="size-5" />
{{ formatMessage(commonMessages.changeVersionButton) }}
</button>
</ButtonStyled>
</div>
</div>
<!-- Unlink -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(messages.linkedInstanceTitle, {
projectType: showModpackVersionActions ? 'modpack' : 'server',
})
}}
</span>
<span class="text-primary">
{{
formatMessage(messages.unlinkDescription, {
type: ctx.isServer ? 'server' : 'instance',
projectType: showModpackVersionActions ? 'modpack' : 'server',
})
}}
</span>
<div>
<ButtonStyled color="orange">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="unlinkModal?.show()"
>
<UnlinkIcon class="size-5" />
{{
formatMessage(
showModpackVersionActions
? commonMessages.unlinkModpackButton
: messages.unlinkButton,
)
}}
</button>
</ButtonStyled>
</div>
</div>
<!-- Reinstall -->
<div v-if="showModpackVersionActions" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.reinstallModpackTitle) }}
</span>
<span class="text-primary">
{{
formatMessage(messages.reinstallModpackDescription, {
type: ctx.isServer ? 'server' : 'instance',
})
}}
</span>
<div>
<ButtonStyled color="red">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="reinstallModal?.show()"
>
<SpinnerIcon v-if="ctx.reinstalling?.value" class="animate-spin" />
<DownloadIcon v-else class="size-5" />
{{
ctx.reinstalling?.value
? formatMessage(messages.reinstallingModpackButton)
: formatMessage(commonMessages.reinstallModpackButton)
}}
</button>
</ButtonStyled>
</div>
</div>
<!-- Repair -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(
ctx.isServer ? messages.repairServerTitle : messages.repairInstanceTitle,
)
}}
</span>
<span class="text-primary">
{{
formatMessage(
ctx.isServer
? messages.repairServerDescription
: messages.repairInstanceDescription,
)
}}
</span>
<div>
<ButtonStyled>
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
{{
ctx.repairing?.value
? formatMessage(commonMessages.repairingButton)
: formatMessage(commonMessages.repairButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</template>
<!-- NOT LINKED -->
<template v-else>
<!-- Edit form -->
<div v-if="form.isEditing.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.editInstallationTitle) }}
</span>
<div class="flex flex-col gap-3 rounded-[20px] border border-solid border-surface-5 p-4">
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(commonMessages.platformLabel) }}
</span>
<Chips
v-model="form.selectedPlatform.value"
:items="ctx.availablePlatforms"
:disabled-items="disabledPlatforms"
:disabled-tooltip="formatMessage(messages.platformLockTooltip)"
:aria-label="formatMessage(messages.selectPlatformAriaLabel)"
/>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(commonMessages.gameVersionLabel) }}
</span>
<Combobox
v-model="form.selectedGameVersion.value"
:options="form.gameVersionOptions.value"
searchable
sync-with-selection
:placeholder="formatMessage(commonMessages.selectVersionPlaceholder)"
:search-placeholder="formatMessage(messages.searchGameVersionPlaceholder)"
:display-value="
form.selectedGameVersion.value ||
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
>
<template v-if="form.hasSnapshots.value" #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="form.showSnapshots.value = !form.showSnapshots.value"
>
<EyeOffIcon v-if="form.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" />
{{
form.showSnapshots.value
? formatMessage(commonMessages.hideSnapshotsButton)
: formatMessage(commonMessages.showAllVersionsButton)
}}
</button>
</template>
</Combobox>
</div>
<div
v-if="form.selectedPlatform.value !== 'vanilla' && !ctx.hideLoaderVersion"
class="flex flex-col gap-2.5"
>
<span class="font-semibold text-contrast">
{{
formatMessage(messages.loaderVersionLabel, {
loader: form.formattedLoaderName.value,
})
}}
</span>
<Combobox
v-model="form.selectedLoaderVersion.value"
searchable
sync-with-selection
:placeholder="
form.loaderVersionDisplayValue.value ||
formatMessage(commonMessages.selectVersionPlaceholder)
"
:search-placeholder="formatMessage(commonMessages.searchVersionPlaceholder)"
:options="form.loaderVersionOptions.value"
:display-value="
form.loaderVersionDisplayValue.value ||
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="
formatMessage(messages.selectLoaderVersionAriaLabel, {
loader: form.formattedLoaderName.value,
})
"
/>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button
class="!shadow-none"
:disabled="!form.isValid.value || !form.hasChanges.value || form.isSaving.value"
@click="form.save()"
>
<SpinnerIcon v-if="form.isSaving.value" class="animate-spin" />
<SaveIcon v-else />
{{
form.isVerifying.value
? formatMessage(messages.verifyingLabel)
: form.isSaving.value
? formatMessage(messages.savingLabel)
: formatMessage(commonMessages.saveButton)
}}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
class="!border !border-surface-5 !shadow-none"
@click="form.cancelEditing()"
>
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<!-- Non-editing: installation info + warning + edit button -->
<div v-if="!form.isEditing.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installationInfoTitle) }}
</span>
<div class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
<div
v-for="row in ctx.installationInfo.value"
:key="row.label"
class="flex items-center justify-between"
>
<span class="text-primary">{{ row.label }}</span>
<span class="font-semibold text-contrast">{{ row.value }}</span>
</div>
</div>
<div class="flex items-start gap-2">
<CircleAlertIcon class="mt-0.5 size-5 shrink-0 text-orange" />
<span class="text-primary">
{{
formatMessage(
ctx.isServer ? messages.editWarningServer : messages.editWarningInstance,
)
}}
</span>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="orange">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.isEditing.value = true"
>
<PencilIcon class="size-5" />
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<slot name="unlinked-extra-buttons" />
</div>
</div>
<!-- Repair section -->
<div v-if="ctx.currentPlatform.value !== 'vanilla'" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(
ctx.isServer ? messages.repairServerTitle : messages.repairInstanceTitle,
)
}}
</span>
<span class="text-primary">
{{
formatMessage(
ctx.isServer
? messages.repairServerDescription
: messages.repairInstanceDescription,
)
}}
</span>
<div>
<ButtonStyled>
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
{{
ctx.repairing?.value
? formatMessage(commonMessages.repairingButton)
: formatMessage(commonMessages.repairButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</template>
<slot name="extra" />
</template>
</div>
<!-- Modals -->
<Teleport to="body">
<ContentUpdaterModal
v-if="form.updatingModpack.value"
ref="contentUpdaterModal"
:versions="form.updatingProjectVersions.value"
:current-game-version="ctx.updaterModalProps.value.currentGameVersion"
:current-loader="ctx.updaterModalProps.value.currentLoader"
:current-version-id="ctx.updaterModalProps.value.currentVersionId"
:is-app="ctx.isApp"
:is-modpack="true"
:project-icon-url="ctx.updaterModalProps.value.projectIconUrl"
:project-name="ctx.updaterModalProps.value.projectName"
:loading="form.loadingVersions.value"
:loading-changelog="form.loadingChangelog.value"
@update="handleModpackUpdateRequest"
@cancel="form.resetUpdateState()"
@version-select="form.handleUpdaterVersionSelect"
@version-hover="form.handleUpdaterVersionHover"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateModal"
:downgrade="isUpdateDowngrade"
:server="ctx.isServer"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ConfirmRepairModal ref="repairModal" :server="ctx.isServer" @repair="handleRepair" />
<ConfirmReinstallModal
ref="reinstallModal"
:server="ctx.isServer"
@reinstall="handleReinstall"
/>
<ConfirmUnlinkModal ref="unlinkModal" :server="ctx.isServer" @unlink="handleUnlink" />
<ContentDiffModal
v-if="form.pendingPreview.value"
ref="contentDiffModal"
:header="formatMessage(messages.confirmVersionChangeHeader)"
:description="
formatMessage(messages.confirmVersionChangeDescription, {
gameVersion: form.pendingPreview.value.newGameVersion,
})
"
:admonition-header="formatMessage(messages.confirmVersionChangeHeader)"
:diffs="form.pendingPreview.value.diffs"
:has-unknown-content="form.pendingPreview.value.hasUnknownContent"
:confirm-label="formatMessage(messages.confirmVersionChange)"
:confirm-icon="SaveIcon"
:removed-label="formatMessage(messages.removedIncompatible)"
:show-backup-creator="ctx.isServer"
@confirm="form.confirmSave()"
@cancel="form.cancelPreview()"
/>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<slot name="extra-modals" />
</Teleport>
</template>

View File

@@ -0,0 +1 @@
export * from './installation-settings'

View File

@@ -0,0 +1,84 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { createContext } from '#ui/providers/create-context'
import type {
ContentDiffPreview,
GameVersionOption,
InstallationInfoRow,
InstallationModpackData,
LoaderVersionEntry,
} from '../types'
export interface InstallationSettingsContext {
loading: Ref<boolean> | ComputedRef<boolean>
installationInfo: ComputedRef<InstallationInfoRow[]>
isLinked: ComputedRef<boolean>
isBusy: Ref<boolean> | ComputedRef<boolean>
modpack: Ref<InstallationModpackData | null> | ComputedRef<InstallationModpackData | null>
currentPlatform: ComputedRef<string>
currentGameVersion: ComputedRef<string>
currentLoaderVersion: ComputedRef<string>
availablePlatforms: string[]
resolveGameVersions: (loader: string, showSnapshots: boolean) => GameVersionOption[]
resolveLoaderVersions: (loader: string, gameVersion: string) => LoaderVersionEntry[]
resolveHasSnapshots: (loader: string) => boolean
save: (platform: string, gameVersion: string, loaderVersionId: string | null) => Promise<void>
repair: () => Promise<void>
reinstallModpack: () => Promise<void>
unlinkModpack: () => Promise<void>
getCachedModpackVersions: () => Labrinth.Versions.v2.Version[] | null
fetchModpackVersions: () => Promise<Labrinth.Versions.v2.Version[]>
getVersionChangelog: (versionId: string) => Promise<Labrinth.Versions.v2.Version | null>
onModpackVersionConfirm: (version: Labrinth.Versions.v2.Version) => Promise<void>
updaterModalProps: ComputedRef<{
isApp: boolean
currentVersionId: string
projectIconUrl?: string
projectName: string
currentGameVersion: string
currentLoader: string
}>
isServer: boolean
isApp: boolean
/** When false, hides change-version and reinstall buttons in linked state (default: true) */
showModpackVersionActions?: boolean
repairing?: Ref<boolean>
reinstalling?: Ref<boolean>
afterSave?: () => Promise<void>
lockPlatform?: boolean
hideLoaderVersion?: boolean
previewSave?: (
platform: string,
gameVersion: string,
loaderVersionId: string | null,
signal?: AbortSignal,
) => Promise<ContentDiffPreview | null>
/**
* Optional refs for the editing form state. When provided, the composable
* uses these instead of creating its own. This lets the wrapper observe
* editing state for reactive query dependencies (e.g. paper/purpur builds).
*/
editingPlatformRef?: Ref<string>
editingGameVersionRef?: Ref<string>
}
export const [injectInstallationSettings, provideInstallationSettings] =
createContext<InstallationSettingsContext>(
'InstallationSettingsLayout',
'installationSettingsContext',
)

View File

@@ -0,0 +1,46 @@
import type { RouteLocationRaw } from 'vue-router'
export interface InstallationInfoRow {
label: string
value: string | null
}
export interface InstallationModpackOwner {
id: string
name: string
iconUrl?: string
type: 'user' | 'organization'
}
export interface InstallationModpackData {
iconUrl?: string
title: string
link: string | RouteLocationRaw
versionNumber?: string
owner?: InstallationModpackOwner
}
export interface GameVersionOption {
value: string
label: string
}
export interface LoaderVersionEntry {
id: string
stable?: boolean
}
export interface ContentDiffItem {
type: 'added' | 'removed' | 'updated'
projectName?: string
fileName?: string
currentVersionName?: string
newVersionName?: string
}
export interface ContentDiffPreview {
diffs: ContentDiffItem[]
newGameVersion: string
newLoaderVersion: string
hasUnknownContent: boolean
}

View File

@@ -0,0 +1,260 @@
<template>
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-6 max-w-[500px]">
<div class="flex flex-col gap-2 w-full">
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth</h2>
<p class="m-0 text-base text-secondary">
Your server is ready. Here's what you need to do to start playing!
</p>
</div>
<div class="flex flex-col gap-4">
<span class="text-base font-medium text-secondary"> Setup your server (~2mins) </span>
<div class="rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-5">
<div class="flex flex-col">
<div v-for="(step, i) in steps" :key="i" class="flex gap-3">
<div class="flex w-10 shrink-0 flex-col items-center">
<div
class="flex size-10 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-4"
>
<component :is="step.icon" class="size-6" />
</div>
<div
v-if="i < steps.length - 1"
class="my-2 flex-1 w-0.5 rounded-full bg-surface-5"
/>
</div>
<div :class="['flex flex-col gap-1 pt-2', i < steps.length - 1 ? 'pb-[44px]' : '']">
<span class="text-base font-semibold text-contrast">
{{ i + 1 }}. {{ step.title }}
</span>
<span class="text-base text-secondary">
{{ step.description }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="w-full">
<ButtonStyled v-if="uploading" size="large">
<button class="ml-auto" disabled>
<SpinnerIcon class="animate-spin" />
Uploading ({{ uploadPercent }}%)
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button class="ml-auto" @click="openModal">Setup server <RightArrowIcon /></button>
</ButtonStyled>
</div>
<CreationFlowModal
ref="modalRef"
type="server-onboarding"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
@hide="() => {}"
@browse-modpacks="onBrowseModpacks"
@create="onCreate"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { GlobeIcon, PackageIcon, RightArrowIcon, SpinnerIcon, UsersIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { CreationFlowContextValue } from '#ui/components'
import { CreationFlowModal } from '#ui/components'
import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
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 }))
}
const { serverId, worldId, server } = injectModrinthServerContext()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const modalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const uploading = ref(false)
const uploadedBytes = ref(0)
const totalBytes = ref(0)
const uploadPercent = computed(() =>
totalBytes.value > 0 ? Math.round((uploadedBytes.value / totalBytes.value) * 100) : 0,
)
const openModal = () => modalRef.value?.show()
onBeforeUnmount(() => modalRef.value?.hide())
function onBrowseModpacks() {
router.push({
path: '/discover/modpacks',
query: { sid: serverId, from: 'onboarding', wid: worldId.value },
})
}
onMounted(async () => {
if (route.query.resumeModal === 'setup-type') {
router.replace({ query: {} })
openModal()
return
}
if (route.query.resumeModal === 'modpack') {
const mpPid = route.query.mp_pid as string | undefined
const mpVid = route.query.mp_vid as string | undefined
const mpName = route.query.mp_name as string | undefined
router.replace({ query: {} })
openModal()
await nextTick()
const ctx = modalRef.value?.ctx
if (ctx && mpPid && mpVid) {
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: mpPid,
versionId: mpVid,
name: mpName ?? '',
}
ctx.modal.value?.setStage('final-config')
} else {
ctx?.setSetupType('modpack')
}
}
})
async function finalizeSetup() {
modalRef.value?.hide()
server.value.flows = { intro: false }
client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
})
await router.push(`/hosting/manage/${serverId}/content`)
}
/** Map UI loader names to API Modloader values */
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
if (loader === 'neoforge') return 'neo_forge'
return loader as Archon.Content.v1.Modloader
}
const onCreate = async (config: CreationFlowContextValue) => {
// Handle mrpack file upload
if (config.setupType.value === 'modpack' && config.modpackFile.value) {
modalRef.value?.hide()
uploading.value = true
uploadedBytes.value = 0
totalBytes.value = config.modpackFile.value.size
try {
const handle = client.kyros.content_v1.uploadModpackFile(
worldId.value!,
config.modpackFile.value,
config.buildProperties(),
{
softOverride: true,
onProgress: ({ loaded, total }) => {
uploadedBytes.value = loaded
totalBytes.value = total
},
},
)
await handle.promise
server.value.status = 'installing'
await finalizeSetup()
} catch {
addNotification({
title: 'Modpack upload failed',
text: 'An unexpected error occurred while uploading. Please try again later.',
type: 'error',
})
config.loading.value = false
uploading.value = false
}
return
}
let request: Archon.Content.v1.InstallWorldContent
const properties = config.buildProperties()
if (config.setupType.value === 'modpack' && config.modpackSelection.value) {
request = {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties,
}
} else {
const loader = config.selectedLoader.value
request = {
content_variant: 'bare',
loader: loader ? toApiLoader(loader) : 'vanilla',
version: config.selectedLoaderVersion.value ?? '',
game_version: config.selectedGameVersion.value ?? undefined,
soft_override: false,
properties,
}
}
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
server.value.status = 'installing'
await finalizeSetup()
} catch {
addNotification({
title: 'Installation failed',
text: 'An unexpected error occurred while installing. Please try again later.',
type: 'error',
})
config.loading.value = false
}
}
const steps = [
{
icon: PackageIcon,
title: 'Choose what to play',
description:
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
},
{
icon: GlobeIcon,
title: 'Configure your world',
description: 'Set up your world just like singleplayer. Choose your gamemode and world seed.',
},
{
icon: UsersIcon,
title: 'Invite your friends',
description:
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
},
]
</script>

View File

@@ -58,68 +58,12 @@
Loading backups...
</template>
<template v-else>
<div class="mx-auto flex flex-col justify-center p-6 text-center">
<div data-svg-wrapper>
<svg
viewBox="0 0 250 200"
fill="none"
class="h-[200px] w-[250px]"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M63 134H154C154.515 134 155.017 133.944 155.5 133.839C155.983 133.944 156.485 134 157 134H209C212.866 134 216 130.866 216 127C216 123.134 212.866 120 209 120H203C199.134 120 196 116.866 196 113C196 109.134 199.134 106 203 106H222C225.866 106 229 102.866 229 99C229 95.134 225.866 92 222 92H200C203.866 92 207 88.866 207 85C207 81.134 203.866 78 200 78H136C139.866 78 143 74.866 143 71C143 67.134 139.866 64 136 64H79C75.134 64 72 67.134 72 71C72 74.866 75.134 78 79 78H39C35.134 78 32 81.134 32 85C32 88.866 35.134 92 39 92H64C67.866 92 71 95.134 71 99C71 102.866 67.866 106 64 106H24C20.134 106 17 109.134 17 113C17 116.866 20.134 120 24 120H63C59.134 120 56 123.134 56 127C56 130.866 59.134 134 63 134ZM226 134C229.866 134 233 130.866 233 127C233 123.134 229.866 120 226 120C222.134 120 219 123.134 219 127C219 130.866 222.134 134 226 134Z"
fill="var(--surface-2, #1D1F23)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M113.119 112.307C113.04 112.86 113 113.425 113 114C113 120.627 118.373 126 125 126C131.627 126 137 120.627 137 114C137 113.425 136.96 112.86 136.881 112.307H166V139C166 140.657 164.657 142 163 142H87C85.3431 142 84 140.657 84 139V112.307H113.119Z"
fill="var(--surface-1, #16181C)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M138 112C138 119.18 132.18 125 125 125C117.82 125 112 119.18 112 112C112 111.767 112.006 111.536 112.018 111.307H84L93.5604 83.0389C93.9726 81.8202 95.1159 81 96.4023 81H153.598C154.884 81 156.027 81.8202 156.44 83.0389L166 111.307H137.982C137.994 111.536 138 111.767 138 112Z"
fill="var(--surface-1, #16181C)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M136.098 112.955C136.098 118.502 131.129 124 125 124C118.871 124 113.902 118.502 113.902 112.955C113.902 112.775 113.908 111.596 113.918 111.419H93L101.161 91.5755C101.513 90.6338 102.489 90 103.587 90H146.413C147.511 90 148.487 90.6338 148.839 91.5755L157 111.419H136.082C136.092 111.596 136.098 112.775 136.098 112.955Z"
fill="var(--surface-2, #1D1F23)"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.25 111.512V138C85.25 138.966 86.0335 139.75 87 139.75H163C163.966 139.75 164.75 138.966 164.75 138V111.512L155.255 83.4393C155.015 82.7285 154.348 82.25 153.598 82.25H96.4023C95.6519 82.25 94.985 82.7285 94.7446 83.4393L85.25 111.512Z"
stroke="var(--surface-4, #34363C)"
stroke-width="2.5"
/>
<path
d="M98 111C101.937 111 106.185 111 110.745 111C112.621 111 112.621 112.319 112.621 113C112.621 119.627 118.117 125 124.897 125C131.677 125 137.173 119.627 137.173 113C137.173 112.319 137.173 111 139.05 111H164M90.5737 111H93H90.5737Z"
stroke="var(--surface-4, #34363C)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M150.1 58.3027L139 70.7559M124.1 54V70.7559V54ZM98 58.3027L109.1 70.7559L98 58.3027Z"
stroke="var(--surface-3, #27292E)"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<div class="flex flex-col gap-4 -mt-4">
<div class="flex flex-col gap-1.5">
<span class="text-lg text-contrast md:text-2xl">No backups yet</span>
<span class="max-w-[256px] text-sm md:text-base leading-6 text-secondary">
Create your first backup
</span>
</div>
<EmptyState
type="empty-inbox"
heading="No backups yet"
description="Create your first backup"
>
<template #actions>
<ButtonStyled color="brand">
<button
v-tooltip="backupCreationDisabled"
@@ -131,8 +75,8 @@
Create backup
</button>
</ButtonStyled>
</div>
</div>
</template>
</EmptyState>
</template>
</div>
@@ -204,22 +148,26 @@ import type { Component } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import ButtonStyled from '../../../components/base/ButtonStyled.vue'
import BackupCreateModal from '../../../components/servers/backups/BackupCreateModal.vue'
import BackupDeleteModal from '../../../components/servers/backups/BackupDeleteModal.vue'
import BackupItem from '../../../components/servers/backups/BackupItem.vue'
import BackupRenameModal from '../../../components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '../../../components/servers/backups/BackupRestoreModal.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import BackupCreateModal from '#ui/components/servers/backups/BackupCreateModal.vue'
import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.vue'
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { server, backupsState, markBackupCancelled } = injectModrinthServerContext()
const { server, worldId, backupsState, markBackupCancelled, busyReasons } =
injectModrinthServerContext()
const props = defineProps<{
isServerRunning: boolean
@@ -238,20 +186,23 @@ const {
refetch,
} = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v0.list(serverId),
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
})
const deleteMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.delete(serverId, backupId),
mutationFn: (backupId: string) =>
client.archon.backups_v1.delete(serverId, worldId.value!, backupId),
onSuccess: (_data, backupId) => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
},
})
const retryMutation = useMutation({
mutationFn: (backupId: string) => client.archon.backups_v0.retry(serverId, backupId),
mutationFn: (backupId: string) =>
client.archon.backups_v1.retry(serverId, worldId.value!, backupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
})
@@ -336,43 +287,27 @@ const backupRestoreDisabled = computed(() => {
if (props.isServerRunning) {
return 'Cannot restore backup while server is running'
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
return 'Cannot restore backup while a backup is being created'
}
if (entry.restore?.state === 'ongoing') {
return 'Cannot restore backup while another restore is in progress'
}
if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason)
}
return undefined
})
const backupCreationDisabled = computed(() => {
if (
server.value.used_backup_quota !== undefined &&
server.value.backup_quota !== undefined &&
server.value.used_backup_quota >= server.value.backup_quota
) {
return `All ${server.value.backup_quota} of your backup slots are in use`
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
return 'A backup is already in progress'
}
if (entry.restore?.state === 'ongoing') {
return 'Cannot create backup while a restore is in progress'
const quota = server.value.backup_quota
if (quota !== undefined) {
const usedCount = backupsData.value?.length ?? server.value.used_backup_quota ?? 0
if (usedCount >= quota) {
return `All ${quota} of your backup slots are in use`
}
}
if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason)
}
// also check API data for ongoing backups (before ws fires)
if (backupsData.value?.some((backup) => backup.ongoing)) {
return 'A backup is already in progress'
}
if (server.value.status === 'installing') {
return 'Cannot create backup while server is installing'
}
return undefined
})

View File

@@ -0,0 +1,830 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import ConfirmLeaveModal from '../../../shared/content-tab/components/modals/ConfirmLeaveModal.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
import type {
ContentModpackData,
UploadState,
} from '../../../shared/content-tab/providers/content-manager'
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
} from '../../../shared/content-tab/types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
failedToRemoveContent: {
id: 'hosting.content.failed-to-remove',
defaultMessage: 'Failed to remove content',
},
failedToToggle: {
id: 'hosting.content.failed-to-toggle',
defaultMessage: 'Failed to toggle {name}',
},
failedToUpload: {
id: 'hosting.content.failed-to-upload',
defaultMessage: 'Failed to upload file',
},
failedToUnlink: {
id: 'hosting.content.failed-to-unlink',
defaultMessage: 'Failed to unlink modpack',
},
failedToLoadModpackContent: {
id: 'hosting.content.failed-to-load-modpack-content',
defaultMessage: 'Failed to load modpack content',
},
failedToLoadVersions: {
id: 'hosting.content.failed-to-load-versions',
defaultMessage: 'Failed to load versions',
},
failedToUpdate: {
id: 'hosting.content.failed-to-update',
defaultMessage: 'Failed to update',
},
})
const props = withDefaults(
defineProps<{
showClientOnlyFilter?: boolean
}>(),
{
showClientOnlyFilter: false,
},
)
const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const serverId = route.params.id as string
const type = computed(() => {
const loader = server.value?.loader?.toLowerCase()
if (loader === 'paper' || loader === 'purpur') return 'plugin'
if (loader === 'vanilla') return 'datapack'
return 'mod'
})
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
const contentQuery = useQuery({
queryKey,
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
})
const modpackProjectId = computed(() => contentQuery.data.value?.modpack?.spec.project_id ?? null)
const modpackVersionsQuery = useQuery({
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpackProjectId.value]),
queryFn: () =>
client.labrinth.versions_v2.getProjectVersions(modpackProjectId.value!, {
include_changelog: false,
}),
enabled: computed(() => !!modpackProjectId.value),
})
const projectQuery = useQuery({
queryKey: computed(() => ['labrinth', 'project', modpackProjectId.value]),
queryFn: () => client.labrinth.projects_v2.get(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value),
})
const modpack = computed<ContentModpackData | null>(() => {
const mp = contentQuery.data.value?.modpack
if (!mp) return null
const project = projectQuery.data.value
return {
project: {
id: mp.spec.project_id,
slug: project?.slug ?? mp.spec.project_id,
title: mp.title ?? mp.spec.project_id,
icon_url: mp.icon_url ?? undefined,
description: mp.description ?? '',
downloads: mp.downloads ?? 0,
followers: mp.followers ?? 0,
} as ContentModpackCardProject,
projectLink: `/project/${project?.slug ?? mp.spec.project_id}`,
version: {
id: mp.spec.version_id,
version_number: mp.version_number ?? '',
date_published: mp.date_published ?? '',
} as ContentModpackCardVersion,
versionLink: `/project/${project?.slug ?? mp.spec.project_id}/version/${mp.spec.version_id}`,
owner: mp.owner
? {
id: mp.owner.id,
name: mp.owner.name,
avatar_url: mp.owner.icon_url ?? undefined,
type: mp.owner.type,
link:
mp.owner.type === 'organization'
? `/organization/${mp.owner.id}`
: `/user/${mp.owner.id}`,
}
: undefined,
categories: (project?.display_categories ?? []).map((name) => ({
name,
icon: name,
project_type: 'modpack',
header: 'categories',
})) as ContentModpackCardCategory[],
hasUpdate: !!mp.has_update,
}
})
function friendlyAddonName(addon: Archon.Content.v1.Addon): string {
if (addon.name) return addon.name
let cleanName = addon.filename
const lastDotIndex = cleanName.lastIndexOf('.')
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
return cleanName
}
const modpackAddons = ref<Archon.Content.v1.Addon[]>([])
const addonLookup = computed(() => {
const map = new Map<string, Archon.Content.v1.Addon>()
for (const addon of contentQuery.data.value?.addons ?? []) {
map.set(addon.filename, addon)
}
for (const addon of modpackAddons.value) {
map.set(addon.filename, addon)
}
return map
})
const contentItems = computed<ContentItem[]>(() => {
return (contentQuery.data.value?.addons ?? []).map(addonToContentItem)
})
const deleteMutation = useMutation({
mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) =>
client.archon.content_v1.deleteAddon(serverId, worldId.value!, {
filename: addon.filename,
kind: addon.kind,
}),
onMutate: async ({ addon }) => {
await queryClient.cancelQueries({ queryKey: queryKey.value })
const previousData = queryClient.getQueryData<Archon.Content.v1.Addons>(queryKey.value)
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
if (!oldData) return oldData
return {
...oldData,
addons: (oldData.addons ?? []).filter((a) => a.filename !== addon.filename),
}
})
return { previousData }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
},
onError: (err, _vars, context) => {
if (context?.previousData) {
queryClient.setQueryData(queryKey.value, context.previousData)
}
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRemoveContent),
})
},
})
const toggleMutation = useMutation({
mutationFn: async ({ addon }: { addon: Archon.Content.v1.Addon }) => {
const request: Archon.Content.v1.RemoveAddonRequest = {
filename: addon.filename,
kind: addon.kind,
}
if (addon.disabled) {
await client.archon.content_v1.enableAddon(serverId, worldId.value!, request)
} else {
await client.archon.content_v1.disableAddon(serverId, worldId.value!, request)
}
return { filename: addon.filename, newDisabled: !addon.disabled }
},
onSuccess: ({ filename, newDisabled }) => {
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
if (!oldData) return oldData
return {
...oldData,
addons: (oldData.addons ?? []).map((a) =>
a.filename === filename ? { ...a, disabled: newDisabled } : a,
),
}
})
queryClient.invalidateQueries({ queryKey: queryKey.value })
},
onError: (_err, { addon }) => {
addNotification({
type: 'error',
text: formatMessage(messages.failedToToggle, { name: friendlyAddonName(addon) }),
})
},
})
async function handleToggleEnabled(item: ContentItem) {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await toggleMutation.mutateAsync({ addon })
}
async function handleDeleteItem(item: ContentItem) {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
await deleteMutation.mutateAsync({ addon })
}
function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAddonRequest[] {
return items.flatMap((item) => {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return []
return [{ filename: addon.filename, kind: addon.kind }]
})
}
async function handleBulkDelete(items: ContentItem[]) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
await client.archon.content_v1.deleteAddons(serverId, worldId.value!, requests)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
async function handleBulkEnable(items: ContentItem[]) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
async function handleBulkDisable(items: ContentItem[]) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
const uploadState = ref<UploadState>({
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
})
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
let activeUploadCancel: (() => void) | null = null
const isUploading = computed(() => uploadState.value.isUploading)
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (isUploading.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(isUploading, (uploading) => {
if (uploading) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (isUploading.value) {
const shouldLeave = (await confirmLeaveModal.value?.prompt()) ?? false
if (shouldLeave) {
activeUploadCancel?.()
}
return shouldLeave
}
return true
})
}
const updatingProject = ref<ContentItem | null>(null)
const updatingModpack = ref(false)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const currentGameVersion = computed(() => contentQuery.data.value?.game_version ?? '')
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
function handleBrowseContent() {
router.push({
path: `/discover/${type.value}s`,
query: { sid: serverId, wid: worldId.value },
})
}
function handleUploadFiles() {
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
input.accept = type.value === 'datapack' ? '.zip' : '.jar'
input.onchange = async () => {
if (!input.files) return
const files = Array.from(input.files)
const wid = worldId.value
if (!wid) return
uploadState.value = {
isUploading: true,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: files.reduce((sum, f) => sum + f.size, 0),
completedFiles: 0,
totalFiles: files.length,
}
const handle = client.kyros.content_v1.uploadAddonFile(wid, files, {
onProgress: (p) => {
uploadState.value.currentFileProgress = p.progress
uploadState.value.uploadedBytes = p.loaded
uploadState.value.totalBytes = p.total
},
})
activeUploadCancel = () => handle.cancel()
try {
await handle.promise
uploadState.value.completedFiles = files.length
await contentQuery.refetch()
} catch (err) {
if (err instanceof Error && err.message === 'Upload cancelled') return
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUpload),
})
} finally {
activeUploadCancel = null
uploadState.value = {
isUploading: false,
currentFileName: null,
currentFileProgress: 0,
uploadedBytes: 0,
totalBytes: 0,
completedFiles: 0,
totalFiles: 0,
}
}
}
input.click()
}
function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
return {
project: {
id: addon.project_id ?? addon.filename,
slug: addon.project_id ?? addon.filename,
title: friendlyAddonName(addon),
icon_url: addon.icon_url ?? undefined,
},
version: {
id: addon.version?.id ?? addon.filename,
version_number: addon.version?.name ?? formatMessage(commonMessages.unknownLabel),
file_name: addon.filename,
},
owner: addon.owner
? {
id: addon.owner.id,
name: addon.owner.name,
type: addon.owner.type,
avatar_url: addon.owner.icon_url ?? undefined,
link: `/${addon.owner.type}/${addon.owner.id}`,
}
: undefined,
enabled: !addon.disabled,
file_name: addon.filename,
project_type: addon.kind,
has_update: !!addon.has_update,
update_version_id: addon.has_update,
environment: addon.version?.environment ?? undefined,
}
}
async function handleViewModpackContent() {
modpackContentModal.value?.showLoading()
try {
const data = await client.archon.content_v1.getAddons(serverId, worldId.value!, {
from_modpack: true,
})
modpackAddons.value = data.addons ?? []
const items = (data.addons ?? []).map(addonToContentItem)
modpackContentModal.value?.show(items)
} catch (err) {
modpackContentModal.value?.hide()
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadModpackContent),
})
}
}
async function handleModpackContentToggle(item: ContentItem) {
const addon = addonLookup.value.get(item.file_name)
if (!addon) return
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
try {
await toggleMutation.mutateAsync({ addon })
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
)
modpackContentModal.value?.updateItem(item.file_name, {
enabled: !item.enabled,
disabled: false,
})
} catch {
modpackContentModal.value?.updateItem(item.file_name, { disabled: false })
}
}
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
const requests = itemsToAddonRequests(items)
if (requests.length === 0) return
// Optimistic update
for (const item of items) {
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === item.file_name ? { ...a, disabled: !enable } : a,
)
modpackContentModal.value?.updateItem(item.file_name, { enabled: enable })
}
try {
if (enable) {
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
} else {
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
}
await queryClient.invalidateQueries({ queryKey: queryKey.value })
} catch {
// Revert
for (const item of items) {
modpackAddons.value = modpackAddons.value.map((a) =>
a.filename === item.file_name ? { ...a, disabled: enable } : a,
)
modpackContentModal.value?.updateItem(item.file_name, { enabled: !enable })
}
}
}
function handleModpackUnlink() {
modpackUnlinkModal.value?.show()
}
async function handleModpackUnlinkConfirm() {
try {
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
await contentQuery.refetch()
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
})
}
}
async function handleBulkUpdate(items: ContentItem[]) {
const addons = items
.filter((item) => item.has_update)
.map((item) => ({
filename: item.file_name,
version_id: item.update_version_id ?? undefined,
}))
if (addons.length === 0) return
await client.archon.content_v1.updateAddons(serverId, worldId.value!, addons)
await queryClient.invalidateQueries({ queryKey: queryKey.value })
}
async function handleUpdateItem(fileNameKey: string) {
const item = contentItems.value.find((i) => i.file_name === fileNameKey)
if (!item?.has_update || !item.project?.id || !item.version?.id) return
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
loadingVersions.value = true
loadingChangelog.value = false
await nextTick()
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
include_changelog: false,
})
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
})
} finally {
loadingVersions.value = false
}
}
async function handleModpackUpdate() {
const mp = contentQuery.data.value?.modpack
if (!mp?.spec.project_id) return
updatingModpack.value = true
updatingProject.value = null
loadingChangelog.value = false
const cached = modpackVersionsQuery.data.value
if (cached) {
const sorted = [...cached].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = sorted
loadingVersions.value = false
} else {
updatingProjectVersions.value = []
loadingVersions.value = true
}
await nextTick()
contentUpdaterModal.value?.show(mp.spec.version_id ?? undefined)
if (!cached) {
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(mp.spec.project_id, {
include_changelog: false,
})
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
})
} finally {
loadingVersions.value = false
}
}
}
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
loadingChangelog.value = true
try {
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
if (index !== -1) {
const newVersions = [...updatingProjectVersions.value]
newVersions[index] = fullVersion
updatingProjectVersions.value = newVersions
}
} catch {
// Silently fail on changelog fetch
} finally {
loadingChangelog.value = false
}
}
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
try {
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
if (index !== -1) {
const newVersions = [...updatingProjectVersions.value]
newVersions[index] = fullVersion
updatingProjectVersions.value = newVersions
}
} catch {
// Silently fail on hover prefetch
}
}
function resetUpdateState() {
updatingModpack.value = false
updatingProject.value = null
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
}
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
if (updatingModpack.value) {
const currentVersionId = contentQuery.data.value?.modpack?.spec.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
pendingModpackUpdateVersion.value = selectedVersion
modpackUpdateModal.value?.show()
return
}
performUpdate(selectedVersion)
}
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
try {
if (updatingModpack.value) {
const mp = contentQuery.data.value?.modpack
if (!mp) return
await client.archon.content_v1.installContent(serverId, worldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: mp.spec.project_id,
version_id: selectedVersion.id,
},
soft_override: true,
})
} else if (updatingProject.value) {
const addon = addonLookup.value.get(updatingProject.value.file_name)
if (addon) {
await client.archon.content_v1.updateAddon(serverId, worldId.value!, {
filename: addon.filename,
version_id: selectedVersion.id,
})
}
}
await contentQuery.refetch()
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUpdate),
})
} finally {
resetUpdateState()
}
}
function handleModpackUpdateConfirm() {
if (pendingModpackUpdateVersion.value) {
performUpdate(pendingModpackUpdateVersion.value)
pendingModpackUpdateVersion.value = null
}
}
function handleModpackUpdateCancel() {
pendingModpackUpdateVersion.value = null
}
provideContentManager({
items: contentItems,
loading: computed(() => contentQuery.isLoading.value),
error: computed(() => contentQuery.error.value ?? null),
modpack,
isPackLocked: ref(false),
isBusy: computed(() => busyReasons.value.length > 0),
busyMessage: computed(() => {
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
const nonBannerReasons = bannerCoversInstalling
? busyReasons.value.filter(
(r) =>
r.reason.id !== 'servers.busy.installing' &&
r.reason.id !== 'servers.busy.syncing-content',
)
: busyReasons.value
return nonBannerReasons.length > 0 ? formatMessage(nonBannerReasons[0].reason) : null
}),
getItemId: (item) => item.file_name,
contentTypeLabel: type,
toggleEnabled: handleToggleEnabled,
deleteItem: handleDeleteItem,
bulkDeleteItems: handleBulkDelete,
bulkEnableItems: handleBulkEnable,
bulkDisableItems: handleBulkDisable,
refresh: async () => {
await contentQuery.refetch()
},
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
uploadState,
showClientOnlyFilter: props.showClientOnlyFilter,
deletionContext: 'server',
hasUpdateSupport: true,
updateItem: handleUpdateItem,
bulkUpdateItems: handleBulkUpdate,
updateModpack: handleModpackUpdate,
viewModpackContent: handleViewModpackContent,
unlinkModpack: handleModpackUnlink,
openSettings: () => router.push(`/hosting/manage/${serverId}/options/loader`),
mapToTableItem: (item) => {
const projectType = item.project_type ?? type.value
return {
id: item.file_name,
project: item.project,
projectLink: item.project?.id ? `/${projectType}/${item.project.id}` : undefined,
version: item.version,
versionLink:
item.project?.id && item.version?.id
? `/${projectType}/${item.project.id}/version/${item.version.id}`
: undefined,
owner: item.owner
? { ...item.owner, link: `/${item.owner.type}/${item.owner.id}` }
: undefined,
enabled: item.enabled,
}
},
})
</script>
<template>
<ContentPageLayout>
<template #modals>
<ConfirmUnlinkModal ref="modpackUnlinkModal" server @unlink="handleModpackUnlinkConfirm" />
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="modpack?.project.title"
:modpack-icon-url="modpack?.project.icon_url"
enable-toggle
@update:enabled="handleModpackContentToggle"
@bulk:enable="handleModpackBulkToggle($event, true)"
@bulk:disable="handleModpackBulkToggle($event, false)"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="currentGameVersion"
:current-loader="currentLoader"
:current-version-id="
updatingModpack
? (contentQuery.data.value?.modpack?.spec.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="false"
:is-modpack="updatingModpack"
:project-icon-url="
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
<ConfirmModpackUpdateModal
ref="modpackUpdateModal"
:downgrade="isModpackUpdateDowngrade"
server
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ConfirmLeaveModal ref="confirmLeaveModal" />
</template>

View File

@@ -21,7 +21,11 @@
</div>
<div v-else key="content" class="contents">
<div class="relative -mt-2 flex w-full flex-col">
<Admonition v-if="serverBusy" type="warning" class="mb-5">
<template #header>{{ busyTooltip }}</template>
File operations are disabled while the operation is in progress.
</Admonition>
<div class="relative flex w-full flex-col">
<div class="relative isolate flex w-full flex-col gap-2">
<FileNavbar
:breadcrumbs="breadcrumbSegments"
@@ -32,6 +36,8 @@
:search-query="searchQuery"
:show-refresh-button="showRefreshButton"
:base-id="baseId"
:disabled="serverBusy"
:disabled-tooltip="busyTooltip"
@navigate="navigateToSegment"
@navigate-home="() => navigateToSegment(-1)"
@prefetch-home="handlePrefetchHome"
@@ -48,8 +54,8 @@
/>
<div v-if="!isEditing" class="contents">
<div ref="labelBarSentinel" class="h-0 w-full" aria-hidden="true" />
<FileUploadDragAndDrop
ref="fileUploadRef"
class="relative flex flex-col shadow-md"
@files-dropped="handleDroppedFiles"
>
@@ -185,6 +191,8 @@
<FileVirtualList
:items="filteredItems"
:selected-items="selectedItems"
:write-disabled="serverBusy"
:write-disabled-tooltip="busyTooltip"
@extract="handleExtractItem"
@delete="showDeleteModal"
@rename="showRenameModal"
@@ -239,13 +247,13 @@
<span class="text-sm font-medium text-contrast"> {{ selectedItems.size }} selected </span>
<div class="ml-auto flex items-center gap-2">
<ButtonStyled>
<button @click="showBulkMoveModal">
<button v-tooltip="busyTooltip" :disabled="serverBusy" @click="showBulkMoveModal">
<RightArrowIcon class="h-4 w-4" />
Move
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="showBulkDeleteModal">
<button v-tooltip="busyTooltip" :disabled="serverBusy" @click="showBulkDeleteModal">
<TrashIcon class="h-4 w-4" />
Delete
</button>
@@ -273,9 +281,10 @@ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/vue-que
import { computed, inject, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ButtonStyled from '../../../components/base/ButtonStyled.vue'
import FloatingActionBar from '../../../components/base/FloatingActionBar.vue'
import ProgressBar from '../../../components/base/ProgressBar.vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import ProgressBar from '#ui/components/base/ProgressBar.vue'
import {
FileEditor,
FileLabelBar,
@@ -284,7 +293,7 @@ import {
FileUploadDragAndDrop,
FileUploadDropdown,
FileVirtualList,
} from '../../../components/servers/files'
} from '#ui/components/servers/files'
import {
FileCreateItemModal,
FileDeleteItemModal,
@@ -292,16 +301,15 @@ import {
FileRenameItemModal,
FileUploadConflictModal,
FileUploadZipUrlModal,
} from '../../../components/servers/files/modals'
} from '#ui/components/servers/files/modals'
import { useVIntl } from '#ui/composables/i18n'
import { useStickyObserver } from '#ui/composables/sticky-observer'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
import {
getFileExtension,
isEditableFile as isEditableFileCheck,
} from '../../../utils/file-extensions'
} from '#ui/providers'
import { getFileExtension, isEditableFile as isEditableFileCheck } from '#ui/utils/file-extensions'
defineProps<{
showDebugInfo?: boolean
@@ -312,7 +320,13 @@ const notifications = injectNotificationManager()
const { addNotification } = notifications
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId, fsOps, fsQueuedOps } = serverContext
const { serverId, fsOps, fsQueuedOps, busyReasons } = serverContext
const { formatMessage } = useVIntl()
const serverBusy = computed(() => busyReasons.value.length > 0)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
const queryClient = useQueryClient()
interface BaseOperation {
@@ -410,9 +424,9 @@ const uploadDropdownRef = ref<InstanceType<typeof FileUploadDropdown>>()
const VAceEditor = ref()
const labelBarSentinel = ref<HTMLDivElement>()
const isLabelBarStuck = ref(false)
let labelBarObserver: IntersectionObserver | null = null
const fileUploadRef = ref<InstanceType<typeof FileUploadDragAndDrop>>()
const fileUploadEl = computed(() => fileUploadRef.value?.$el as HTMLElement | null)
const { isStuck: isLabelBarStuck } = useStickyObserver(fileUploadEl)
const viewFilter = ref('all')
@@ -840,6 +854,7 @@ function extractItem(path: string) {
}
async function handleExtractItem(item: { name: string; type: string; path: string }) {
if (serverBusy.value) return
try {
const dry = await client.kyros.files_v0.extractFile(item.path, true, true)
if (dry) {
@@ -892,6 +907,7 @@ function handleDirectMove(moveData: {
path: string
destination: string
}) {
if (serverBusy.value) return
const dest = `${moveData.destination}/${moveData.name}`.replace('//', '/')
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf('/'))
@@ -921,25 +937,30 @@ function handleDeleteItem() {
}
function showCreateModal(type: 'file' | 'directory') {
if (serverBusy.value) return
newItemType.value = type
createItemModal.value?.show()
}
function showUnzipFromUrlModal(cf: boolean) {
if (serverBusy.value) return
uploadZipUrlModal.value?.show(cf)
}
function showRenameModal(item: Kyros.Files.v0.DirectoryItem) {
if (serverBusy.value) return
selectedItem.value = item
renameItemModal.value?.show(item)
}
function showMoveModal(item: Kyros.Files.v0.DirectoryItem) {
if (serverBusy.value) return
selectedItem.value = item
moveItemModal.value?.show()
}
function showDeleteModal(item: Kyros.Files.v0.DirectoryItem) {
if (serverBusy.value) return
selectedItem.value = item
deleteItemModal.value?.show()
}
@@ -953,6 +974,7 @@ async function showBulkMoveModal() {
}
async function showBulkDeleteModal() {
if (serverBusy.value) return
if (selectedItems.value.size === 0) return
const itemsToDelete = Array.from(selectedItems.value)
@@ -1107,7 +1129,7 @@ onMounted(async () => {
import('ace-builds/src-noconflict/mode-properties'),
import('ace-builds/src-noconflict/mode-ini'),
import('ace-builds/src-noconflict/mode-text'),
import('../../../utils/ace-theme.ts'),
import('#ui/utils/ace-theme.ts'),
])
VAceEditor.value = Ace
}
@@ -1121,7 +1143,6 @@ onMounted(async () => {
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
labelBarObserver?.disconnect()
})
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
@@ -1168,29 +1189,6 @@ watch(
},
)
watch(
labelBarSentinel,
(newSentinel) => {
// Disconnect any existing observer
if (labelBarObserver) {
labelBarObserver.disconnect()
labelBarObserver = null
}
// Create new observer when sentinel becomes available
if (newSentinel) {
labelBarObserver = new IntersectionObserver(
([entry]) => {
isLabelBarStuck.value = !entry.isIntersecting
},
{ threshold: 0 },
)
labelBarObserver.observe(newSentinel)
}
},
{ flush: 'post' },
)
watch(
() => route.query,
(newQuery, oldQuery) => {
@@ -1244,7 +1242,7 @@ function navigateToSegment(index: number) {
}
function handleDroppedFiles(files: File[]) {
if (isEditing.value) return
if (isEditing.value || serverBusy.value) return
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
@@ -1252,6 +1250,7 @@ function handleDroppedFiles(files: File[]) {
}
function initiateFileUpload() {
if (serverBusy.value) return
const input = document.createElement('input')
input.type = 'file'
input.multiple = true

View File

@@ -177,12 +177,12 @@ import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '../../../components/billing/ServersUpgradeModalWrapper.vue'
import MedalServerListing from '../../../components/servers/marketing/MedalServerListing.vue'
import ServerListing from '../../../components/servers/ServerListing.vue'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue'
defineProps<{
stripePublishableKey?: string
@@ -234,15 +234,17 @@ const {
pollingState.value.count++
if (response.servers.length !== pollingState.value.initialServers.length) {
pollingState.value.enabled = false
isPollingForNewServers.value = false
router.replace({ query: {} })
} else if (pollingState.value.count >= 5) {
pollingState.value.enabled = false
isPollingForNewServers.value = false
}
}
return response
},
refetchInterval: () => (pollingState.value.enabled ? 5000 : false),
refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
})
watch([fetchError, serverResponse], ([error, response]) => {
@@ -280,13 +282,19 @@ const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
: []
})
onMounted(() => {
if (route.query.redirect_status === 'succeeded') {
// Start polling only after initial data is available so the baseline is correct
watch(serverResponse, (response) => {
if (
route.query.redirect_status === 'succeeded' &&
response &&
!pollingState.value.enabled &&
pollingState.value.count === 0
) {
isPollingForNewServers.value = true
pollingState.value = {
enabled: true,
count: 0,
initialServers: [...(serverResponse.value?.servers ?? [])],
initialServers: [...response.servers],
}
}
})

View File

@@ -1,3 +1,5 @@
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
export { default as ServersManageContentPage } from './hosting/manage/content.vue'
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'

View File

@@ -56,6 +56,9 @@
"button.cancel": {
"defaultMessage": "Cancel"
},
"button.change-version": {
"defaultMessage": "Change version"
},
"button.clear": {
"defaultMessage": "Clear"
},
@@ -89,6 +92,9 @@
"button.follow": {
"defaultMessage": "Follow"
},
"button.hide-snapshots": {
"defaultMessage": "Hide snapshots"
},
"button.max": {
"defaultMessage": "Max"
},
@@ -107,18 +113,30 @@
"button.refresh": {
"defaultMessage": "Refresh"
},
"button.reinstall-modpack": {
"defaultMessage": "Re-install modpack"
},
"button.remove": {
"defaultMessage": "Remove"
},
"button.remove-image": {
"defaultMessage": "Remove image"
},
"button.repair": {
"defaultMessage": "Repair"
},
"button.repairing": {
"defaultMessage": "Repairing..."
},
"button.report": {
"defaultMessage": "Report"
},
"button.reset": {
"defaultMessage": "Reset"
},
"button.reset-server": {
"defaultMessage": "Reset server"
},
"button.retry": {
"defaultMessage": "Retry"
},
@@ -131,6 +149,9 @@
"button.saving": {
"defaultMessage": "Saving"
},
"button.show-all-versions": {
"defaultMessage": "Show all versions"
},
"button.sign-in": {
"defaultMessage": "Sign in"
},
@@ -143,9 +164,15 @@
"button.stop": {
"defaultMessage": "Stop"
},
"button.switch-version": {
"defaultMessage": "Switch version"
},
"button.unfollow": {
"defaultMessage": "Unfollow"
},
"button.unlink-modpack": {
"defaultMessage": "Unlink modpack"
},
"button.update": {
"defaultMessage": "Update"
},
@@ -170,6 +197,213 @@
"collections.label.private": {
"defaultMessage": "Private"
},
"content.confirm-bulk-update.admonition-body": {
"defaultMessage": "Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one."
},
"content.confirm-bulk-update.admonition-header": {
"defaultMessage": "Update warning"
},
"content.confirm-bulk-update.header": {
"defaultMessage": "Update projects"
},
"content.confirm-bulk-update.update-button": {
"defaultMessage": "Update {count, plural, one {# project} other {# projects}}"
},
"content.confirm-deletion.admonition-body": {
"defaultMessage": "Deleting a mod can permanently affect your world and may cause missing content or unexpected issues when it loads again."
},
"content.confirm-deletion.admonition-header": {
"defaultMessage": "Deletion warning"
},
"content.confirm-deletion.delete-button": {
"defaultMessage": "Delete {count} {itemType}{count, plural, one {} other {s}}"
},
"content.confirm-deletion.header": {
"defaultMessage": "Delete {itemType}{count, plural, one {} other {s}}"
},
"content.confirm-modpack-update.admonition-body": {
"defaultMessage": "Any mods or content you added on top of the modpack will be deleted."
},
"content.confirm-modpack-update.admonition-header": {
"defaultMessage": "{action, select, downgrade {Downgrade} other {Update}} warning"
},
"content.confirm-modpack-update.confirm-button": {
"defaultMessage": "{action, select, downgrade {Downgrade} other {Update}} modpack"
},
"content.confirm-modpack-update.header": {
"defaultMessage": "{action, select, downgrade {Downgrade} other {Update}} modpack"
},
"content.confirm-unlink.admonition-body": {
"defaultMessage": "Mods and content will be merged with what you added on top of the modpack, and it will stop receiving updates."
},
"content.confirm-unlink.admonition-header": {
"defaultMessage": "Unlinking modpack"
},
"content.confirm-unlink.header": {
"defaultMessage": "Unlink modpack"
},
"content.confirm-unlink.unlink-button": {
"defaultMessage": "Unlink"
},
"content.diff-modal.added-count": {
"defaultMessage": "{count} added"
},
"content.diff-modal.diff-type.added": {
"defaultMessage": "Added (dependency)"
},
"content.diff-modal.diff-type.removed": {
"defaultMessage": "Removed"
},
"content.diff-modal.diff-type.updated": {
"defaultMessage": "Updated"
},
"content.diff-modal.removed-count": {
"defaultMessage": "{count} removed"
},
"content.diff-modal.unknown-content-body": {
"defaultMessage": "Some content on your server could not be analyzed and may be affected by this change."
},
"content.diff-modal.unknown-content-header": {
"defaultMessage": "Unknown content"
},
"content.diff-modal.updated-count": {
"defaultMessage": "{count} updated"
},
"content.inline-backup.backing-up": {
"defaultMessage": "Creating backup..."
},
"content.inline-backup.backup-complete": {
"defaultMessage": "Backup created successfully"
},
"content.inline-backup.backup-failed": {
"defaultMessage": "Backup creation failed. You can still proceed."
},
"content.inline-backup.backup-in-progress": {
"defaultMessage": "A backup is in progress, it's recommended to wait for it to finish before performing this action."
},
"content.inline-backup.backup-takes-a-while": {
"defaultMessage": "Creating a backup may take several minutes depending on the size of your server."
},
"content.inline-backup.create-backup": {
"defaultMessage": "Create backup"
},
"content.inline-backup.warning-body": {
"defaultMessage": "We recommend creating a backup before proceeding so you can restore your {type, select, server {world} other {instance}} if anything breaks."
},
"content.modpack-card.content-hint-description": {
"defaultMessage": "Your modpack's content can now be found here!"
},
"content.modpack-card.content-hint-title": {
"defaultMessage": "Modpack content moved"
},
"content.modpack-card.dismiss-hint": {
"defaultMessage": "Don't show again"
},
"content.modpack-card.updating": {
"defaultMessage": "Updating..."
},
"content.page-layout.additional-content": {
"defaultMessage": "Additional content"
},
"content.page-layout.browse-content": {
"defaultMessage": "Browse content"
},
"content.page-layout.busy-description": {
"defaultMessage": "Please wait for the operation to complete before editing content."
},
"content.page-layout.empty.hint": {
"defaultMessage": "Browse or upload {contentType} to get started"
},
"content.page-layout.empty.modpack-hint": {
"defaultMessage": "Add additional content on top of this modpack"
},
"content.page-layout.empty.no-content-installed": {
"defaultMessage": "No content installed"
},
"content.page-layout.empty.no-extra-content-installed": {
"defaultMessage": "No extra content installed"
},
"content.page-layout.failed-to-load": {
"defaultMessage": "Failed to load content"
},
"content.page-layout.loading": {
"defaultMessage": "Loading content..."
},
"content.page-layout.no-content-found": {
"defaultMessage": "No content found."
},
"content.page-layout.search-placeholder": {
"defaultMessage": "Search {count} {contentType}..."
},
"content.page-layout.share.file-names": {
"defaultMessage": "File names"
},
"content.page-layout.share.label": {
"defaultMessage": "Share"
},
"content.page-layout.share.markdown-links": {
"defaultMessage": "Markdown links"
},
"content.page-layout.share.project-links": {
"defaultMessage": "Project links"
},
"content.page-layout.share.project-names": {
"defaultMessage": "Project names"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alphabetical"
},
"content.page-layout.sort.date-added": {
"defaultMessage": "Date added"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sort by {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Update all"
},
"content.page-layout.upload-files": {
"defaultMessage": "Upload files"
},
"content.page-layout.uploading-files": {
"defaultMessage": "Uploading files ({completed}/{total})"
},
"content.selection-bar.bulk.deleting": {
"defaultMessage": "Deleting {progress}/{total} {contentType}..."
},
"content.selection-bar.bulk.deleting-waiting": {
"defaultMessage": "Deleting {contentType}..."
},
"content.selection-bar.bulk.disabling": {
"defaultMessage": "Disabling {progress}/{total} {contentType}..."
},
"content.selection-bar.bulk.disabling-waiting": {
"defaultMessage": "Disabling {contentType}..."
},
"content.selection-bar.bulk.enabling": {
"defaultMessage": "Enabling {progress}/{total} {contentType}..."
},
"content.selection-bar.bulk.enabling-waiting": {
"defaultMessage": "Enabling {contentType}..."
},
"content.selection-bar.bulk.updating": {
"defaultMessage": "Updating {progress}/{total} {contentType}..."
},
"content.selection-bar.bulk.updating-waiting": {
"defaultMessage": "Updating {contentType}..."
},
"content.selection-bar.disable": {
"defaultMessage": "Disable"
},
"content.selection-bar.enable": {
"defaultMessage": "Enable"
},
"content.selection-bar.selected-count": {
"defaultMessage": "{count} {contentType} selected"
},
"content.selection-bar.selected-count-simple": {
"defaultMessage": "{count, number} selected"
},
"form.label.address-line": {
"defaultMessage": "Address line"
},
@@ -272,6 +506,27 @@
"header.category.resolutions": {
"defaultMessage": "Resolution"
},
"hosting.content.failed-to-load-modpack-content": {
"defaultMessage": "Failed to load modpack content"
},
"hosting.content.failed-to-load-versions": {
"defaultMessage": "Failed to load versions"
},
"hosting.content.failed-to-remove": {
"defaultMessage": "Failed to remove content"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Failed to toggle {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Failed to unlink modpack"
},
"hosting.content.failed-to-update": {
"defaultMessage": "Failed to update"
},
"hosting.content.failed-to-upload": {
"defaultMessage": "Failed to upload file"
},
"hosting.specs.burst": {
"defaultMessage": "Bursts up to {cpus} CPUs"
},
@@ -299,9 +554,15 @@
"icon-select.select": {
"defaultMessage": "Select icon"
},
"input.search-version.placeholder": {
"defaultMessage": "Search version..."
},
"input.search.placeholder": {
"defaultMessage": "Search..."
},
"input.select-version.placeholder": {
"defaultMessage": "Select version"
},
"input.view.gallery": {
"defaultMessage": "Gallery view"
},
@@ -311,6 +572,102 @@
"input.view.list": {
"defaultMessage": "Rows view"
},
"installation-settings.aria.select-game-version": {
"defaultMessage": "Select game version"
},
"installation-settings.aria.select-loader-version": {
"defaultMessage": "Select {loader} version"
},
"installation-settings.aria.select-platform": {
"defaultMessage": "Select platform"
},
"installation-settings.confirm-version-change": {
"defaultMessage": "Confirm"
},
"installation-settings.confirm-version-change-description": {
"defaultMessage": "Changing to {gameVersion} will modify the following content on your server."
},
"installation-settings.confirm-version-change-header": {
"defaultMessage": "Review content changes"
},
"installation-settings.edit-installation.title": {
"defaultMessage": "Edit installation"
},
"installation-settings.edit.warning-instance": {
"defaultMessage": "We don't recommend editing your installation settings after installing content. If you want to edit them, be cautious as it may cause issues."
},
"installation-settings.edit.warning-server": {
"defaultMessage": "We don't recommend editing your installation settings after installing content. If you want to edit them reset your server."
},
"installation-settings.linked-instance.title": {
"defaultMessage": "Linked {projectType, select, server {server project} other {modpack}}"
},
"installation-settings.loader-version": {
"defaultMessage": "{loader} version"
},
"installation-settings.platform-lock-tooltip": {
"defaultMessage": "You will need to reset your server to switch loader."
},
"installation-settings.reinstall-modpack.description": {
"defaultMessage": "Re-installing the modpack resets the {type, select, server {server's} other {instance's}} content to its original state, removing any mods or content you have added."
},
"installation-settings.reinstall-modpack.title": {
"defaultMessage": "Re-install modpack"
},
"installation-settings.reinstalling-modpack": {
"defaultMessage": "Reinstalling modpack"
},
"installation-settings.removed-incompatible": {
"defaultMessage": "Removed (incompatible)"
},
"installation-settings.repair.instance-description": {
"defaultMessage": "Reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors."
},
"installation-settings.repair.instance-title": {
"defaultMessage": "Repair instance"
},
"installation-settings.repair.server-description": {
"defaultMessage": "Reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your server is not starting correctly."
},
"installation-settings.repair.server-title": {
"defaultMessage": "Repair server"
},
"installation-settings.saving": {
"defaultMessage": "Saving..."
},
"installation-settings.search-game-version": {
"defaultMessage": "Search game version..."
},
"installation-settings.unlink": {
"defaultMessage": "Unlink"
},
"installation-settings.unlink.description": {
"defaultMessage": "Unlinking permanently disconnects this {type, select, server {server} other {instance}} from the {projectType, select, server {server} other {modpack}} project, allowing you to change the loader and Minecraft version, but you won't receive future updates."
},
"installation-settings.verifying": {
"defaultMessage": "Verifying..."
},
"instance.confirm-reinstall.admonition-body": {
"defaultMessage": "Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation."
},
"instance.confirm-reinstall.admonition-header": {
"defaultMessage": "Reinstallation warning"
},
"instance.confirm-reinstall.header": {
"defaultMessage": "Reinstall modpack"
},
"instance.confirm-reinstall.reinstall-button": {
"defaultMessage": "Reinstall modpack"
},
"instance.confirm-repair.body": {
"defaultMessage": "Repairing reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your {type, select, server {server is not starting correctly} other {game is not launching due to launcher-related errors}}."
},
"instance.confirm-repair.header": {
"defaultMessage": "Repair {type, select, server {server} other {instance}}"
},
"instance.confirm-repair.repair-button": {
"defaultMessage": "Repair"
},
"instance.worlds.game_mode.adventure": {
"defaultMessage": "Adventure mode"
},
@@ -326,8 +683,77 @@
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Unknown game mode"
},
"instances.modpack-card.unlink": {
"defaultMessage": "Unlink modpack"
"instances.confirm-leave-modal.body": {
"defaultMessage": "Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost."
},
"instances.confirm-leave-modal.leave": {
"defaultMessage": "Leave page"
},
"instances.confirm-leave-modal.stay": {
"defaultMessage": "Stay on page"
},
"instances.confirm-leave-modal.title": {
"defaultMessage": "Leave page?"
},
"instances.confirm-leave-modal.upload-in-progress": {
"defaultMessage": "Upload in progress"
},
"instances.content-install.compatible-count": {
"defaultMessage": "{count} compatible {count, plural, one {instance} other {instances}}"
},
"instances.content-install.existing-tab": {
"defaultMessage": "Existing instance"
},
"instances.content-install.game-version-label": {
"defaultMessage": "Game version"
},
"instances.content-install.game-version-placeholder": {
"defaultMessage": "Select game version"
},
"instances.content-install.header": {
"defaultMessage": "Install project"
},
"instances.content-install.hide-snapshots": {
"defaultMessage": "Hide snapshots"
},
"instances.content-install.install-button": {
"defaultMessage": "Install"
},
"instances.content-install.installed-badge": {
"defaultMessage": "Installed"
},
"instances.content-install.installing-label": {
"defaultMessage": "Installing..."
},
"instances.content-install.instance-type": {
"defaultMessage": "Instance type"
},
"instances.content-install.loader-label": {
"defaultMessage": "Loader"
},
"instances.content-install.name-label": {
"defaultMessage": "Name"
},
"instances.content-install.name-placeholder": {
"defaultMessage": "Enter instance name"
},
"instances.content-install.new-tab": {
"defaultMessage": "New instance"
},
"instances.content-install.no-instances": {
"defaultMessage": "No compatible instances found"
},
"instances.content-install.remove-icon": {
"defaultMessage": "Remove icon"
},
"instances.content-install.search-placeholder": {
"defaultMessage": "Search instance"
},
"instances.content-install.select-icon": {
"defaultMessage": "Select icon"
},
"instances.content-install.show-all-versions": {
"defaultMessage": "Show all versions"
},
"instances.modpack-content-modal.back-button": {
"defaultMessage": "Back"
@@ -335,18 +761,12 @@
"instances.modpack-content-modal.copy-link": {
"defaultMessage": "Copy link"
},
"instances.modpack-content-modal.disable": {
"defaultMessage": "Disable"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "This modpack does not include any additional content."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "No content found"
},
"instances.modpack-content-modal.enable": {
"defaultMessage": "Enable"
},
"instances.modpack-content-modal.filter-all": {
"defaultMessage": "All"
},
@@ -362,9 +782,6 @@
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Search {count, number} {count, plural, one {project} other {projects}}"
},
"instances.modpack-content-modal.selected-count": {
"defaultMessage": "{count, number} selected"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Current"
},
@@ -372,14 +789,23 @@
"defaultMessage": "Incompatible"
},
"instances.updater-modal.downgrade-to": {
"defaultMessage": "Downgrade to v{version}"
"defaultMessage": "Downgrade to {version}"
},
"instances.updater-modal.header": {
"defaultMessage": "Update version"
},
"instances.updater-modal.header-modpack": {
"defaultMessage": "Switch modpack version"
},
"instances.updater-modal.hide-incompatible": {
"defaultMessage": "Hide incompatible"
},
"instances.updater-modal.loading-changelog": {
"defaultMessage": "Loading changelog..."
},
"instances.updater-modal.loading-versions": {
"defaultMessage": "Loading versions..."
},
"instances.updater-modal.no-changelog": {
"defaultMessage": "No changelog provided for this version."
},
@@ -396,13 +822,13 @@
"defaultMessage": "Show incompatible"
},
"instances.updater-modal.update-to": {
"defaultMessage": "Update to v{version}"
"defaultMessage": "Update to {version}"
},
"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."
"instances.updater-modal.warning-app": {
"defaultMessage": "Updating can break your instance. Review version changelogs and back up first."
},
"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."
"instances.updater-modal.warning-web": {
"defaultMessage": "Updating can break your world. Review version changelogs and back up first."
},
"label.actions": {
"defaultMessage": "Actions"
@@ -416,6 +842,9 @@
"label.changes-saved": {
"defaultMessage": "Changes saved"
},
"label.client-only-warning": {
"defaultMessage": "This is a client-side mod and may cause issues. We've kept it enabled because some authors mislabel environments, and the loader should resolve the conflict."
},
"label.collections": {
"defaultMessage": "Collections"
},
@@ -455,12 +884,24 @@
"label.followed-projects": {
"defaultMessage": "Followed projects"
},
"label.game-version": {
"defaultMessage": "Game version"
},
"label.installation-info": {
"defaultMessage": "Installation info"
},
"label.installed-modpack": {
"defaultMessage": "Installed modpack"
},
"label.loading": {
"defaultMessage": "Loading..."
},
"label.moderation": {
"defaultMessage": "Moderation"
},
"label.modpack": {
"defaultMessage": "Modpack"
},
"label.no": {
"defaultMessage": "No"
},
@@ -476,6 +917,9 @@
"label.password": {
"defaultMessage": "Password"
},
"label.platform": {
"defaultMessage": "Platform"
},
"label.played": {
"defaultMessage": "Played {ago}"
},
@@ -500,6 +944,12 @@
"label.search": {
"defaultMessage": "Search"
},
"label.select-all": {
"defaultMessage": "Select all"
},
"label.selection-actions": {
"defaultMessage": "Selection actions"
},
"label.server": {
"defaultMessage": "Server"
},
@@ -521,6 +971,9 @@
"label.title": {
"defaultMessage": "Title"
},
"label.unknown": {
"defaultMessage": "Unknown"
},
"label.unlisted": {
"defaultMessage": "Unlisted"
},
@@ -1466,6 +1919,24 @@
"servers.region.western-europe": {
"defaultMessage": "Western Europe"
},
"servers.setup.rate-limit.text": {
"defaultMessage": "You are being rate limited. Please try again later."
},
"servers.setup.rate-limit.title": {
"defaultMessage": "Cannot reinstall server"
},
"servers.setup.reinstall-failed.text": {
"defaultMessage": "An unexpected error occurred while reinstalling. Please try again later."
},
"servers.setup.reinstall-failed.title": {
"defaultMessage": "Reinstall Failed"
},
"servers.setup.upload-warning": {
"defaultMessage": "Please don't close this page while uploading."
},
"servers.setup.uploading-modpack.header": {
"defaultMessage": "Uploading modpack"
},
"settings.account.title": {
"defaultMessage": "Account and security"
},

View File

@@ -1,6 +1,6 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
import { createContext } from './index'
import { createContext } from './create-context'
export const [injectModrinthClient, provideModrinthClient] = createContext<AbstractModrinthClient>(
'root',

View File

@@ -0,0 +1,10 @@
import { createContext } from './create-context'
export interface AppBackupContext {
createBackup: () => Promise<void>
}
export const [injectAppBackup, provideAppBackup] = createContext<AppBackupContext>(
'AppBackupContext',
'appBackupContext',
)

View File

@@ -0,0 +1,7 @@
export {
type ContentManagerContext,
type ContentModpackData,
injectContentManager,
provideContentManager,
type UploadState,
} from '../layouts/shared/content-tab/providers/content-manager'

View File

@@ -0,0 +1,79 @@
/**
* MIT License
*
* Copyright (c) 2023 UnoVue
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* @source https://github.com/unovue/reka-ui/blob/53b4734734f8ebef9a344b1e62db291177c59bfe/packages/core/src/shared/createContext.ts
*/
import type { InjectionKey } from 'vue'
import { inject, provide } from 'vue'
/**
* @param providerComponentName - The name(s) of the component(s) providing the context.
*
* There are situations where context can come from multiple components. In such cases, you might need to give an array of component names to provide your context, instead of just a single string.
*
* @param contextName The description for injection key symbol.
*/
export function createContext<ContextValue>(
providerComponentName: string | string[],
contextName?: string,
) {
const symbolDescription =
typeof providerComponentName === 'string' && !contextName
? `${providerComponentName}Context`
: contextName
const injectionKey: InjectionKey<ContextValue | null> = Symbol(symbolDescription)
/**
* @param fallback The context value to return if the injection fails.
*
* @throws When context injection failed and no fallback is specified.
* This happens when the component injecting the context is not a child of the root component providing the context.
*/
const injectContext = <T extends ContextValue | null | undefined = ContextValue>(
fallback?: T,
): T extends null ? ContextValue | null : ContextValue => {
const context = inject(injectionKey, fallback)
if (context) return context
if (context === null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return context as any
throw new Error(
`Injection \`${injectionKey.toString()}\` not found. Component must be used within ${
Array.isArray(providerComponentName)
? `one of the following components: ${providerComponentName.join(', ')}`
: `\`${providerComponentName}\``
}`,
)
}
const provideContext = (contextValue: ContextValue) => {
provide(injectionKey, contextValue)
return contextValue
}
return [injectContext, provideContext] as const
}

View File

@@ -0,0 +1,19 @@
import { createContext } from '.'
export interface PickedFile {
/** Browser File object */
file: File
/** Native file system path (available on Tauri, undefined on web) */
path?: string
/** URL suitable for display (blob URL on web, convertFileSrc URL on Tauri) */
previewUrl: string
}
export interface FilePickerProvider {
/** Pick an image file (for icons) */
pickImage: () => Promise<PickedFile | null>
/** Pick a .mrpack modpack file */
pickModpackFile: () => Promise<PickedFile | null>
}
export const [injectFilePicker, provideFilePicker] = createContext<FilePickerProvider>('FilePicker')

View File

@@ -1,88 +1,15 @@
/**
* MIT License
*
* Copyright (c) 2023 UnoVue
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* @source https://github.com/unovue/reka-ui/blob/53b4734734f8ebef9a344b1e62db291177c59bfe/packages/core/src/shared/createContext.ts
*/
import type { InjectionKey } from 'vue'
import { inject, provide } from 'vue'
/**
* @param providerComponentName - The name(s) of the component(s) providing the context.
*
* There are situations where context can come from multiple components. In such cases, you might need to give an array of component names to provide your context, instead of just a single string.
*
* @param contextName The description for injection key symbol.
*/
export function createContext<ContextValue>(
providerComponentName: string | string[],
contextName?: string,
) {
const symbolDescription =
typeof providerComponentName === 'string' && !contextName
? `${providerComponentName}Context`
: contextName
const injectionKey: InjectionKey<ContextValue | null> = Symbol(symbolDescription)
/**
* @param fallback The context value to return if the injection fails.
*
* @throws When context injection failed and no fallback is specified.
* This happens when the component injecting the context is not a child of the root component providing the context.
*/
const injectContext = <T extends ContextValue | null | undefined = ContextValue>(
fallback?: T,
): T extends null ? ContextValue | null : ContextValue => {
const context = inject(injectionKey, fallback)
if (context) return context
if (context === null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return context as any
throw new Error(
`Injection \`${injectionKey.toString()}\` not found. Component must be used within ${
Array.isArray(providerComponentName)
? `one of the following components: ${providerComponentName.join(', ')}`
: `\`${providerComponentName}\``
}`,
)
}
const provideContext = (contextValue: ContextValue) => {
provide(injectionKey, contextValue)
return contextValue
}
return [injectContext, provideContext] as const
}
export * from './api-client'
export * from './app-backup'
export * from './content-manager'
export { createContext } from './create-context'
export * from './file-picker'
export * from './i18n'
export * from './instance-import'
export * from './modal-behavior'
export * from './page-context'
export * from './popup-notifications'
export * from './project-page'
export * from './project-page-new'
export * from './server-context'
export * from './tags'
export * from './web-notifications'

Some files were not shown because too many files have changed in this diff Show More