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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
packages/ui/src/components/base/BigOptionButton.vue
Normal file
42
packages/ui/src/components/base/BigOptionButton.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
packages/ui/src/components/base/EmptyState.vue
Normal file
60
packages/ui/src/components/base/EmptyState.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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="[
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { formatLoaderLabel, loaderDisplayNames } from '#ui/utils/loaders'
|
||||
|
||||
export const capitalize = (item: string) => item.charAt(0).toUpperCase() + item.slice(1)
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
111
packages/ui/src/components/modal/InstallToPlayModal.vue
Normal file
111
packages/ui/src/components/modal/InstallToPlayModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
122
packages/ui/src/components/servers/InstallingBanner.vue
Normal file
122
packages/ui/src/components/servers/InstallingBanner.vue
Normal 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>
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
254
packages/ui/src/components/servers/ServerSetupModal.vue
Normal file
254
packages/ui/src/components/servers/ServerSetupModal.vue
Normal 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>
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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<{
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
11
packages/ui/src/components/servers/flows/index.ts
Normal file
11
packages/ui/src/components/servers/flows/index.ts
Normal 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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
27
packages/ui/src/composables/modal-stack.ts
Normal file
27
packages/ui/src/composables/modal-stack.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
3
packages/ui/src/layouts/index.ts
Normal file
3
packages/ui/src/layouts/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './shared/content-tab'
|
||||
export * from './shared/installation-settings'
|
||||
export * from './wrapped'
|
||||
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
20
packages/ui/src/layouts/shared/content-tab/index.ts
Normal file
20
packages/ui/src/layouts/shared/content-tab/index.ts
Normal 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'
|
||||
793
packages/ui/src/layouts/shared/content-tab/layout.vue
Normal file
793
packages/ui/src/layouts/shared/content-tab/layout.vue
Normal 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>
|
||||
@@ -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',
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from './content-manager'
|
||||
@@ -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<
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { useInstallationForm } from './use-installation-form'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
710
packages/ui/src/layouts/shared/installation-settings/layout.vue
Normal file
710
packages/ui/src/layouts/shared/installation-settings/layout.vue
Normal 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">
|
||||
·
|
||||
</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>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './installation-settings'
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
830
packages/ui/src/layouts/wrapped/hosting/manage/content.vue
Normal file
830
packages/ui/src/layouts/wrapped/hosting/manage/content.vue
Normal 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>
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
10
packages/ui/src/providers/app-backup.ts
Normal file
10
packages/ui/src/providers/app-backup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createContext } from './create-context'
|
||||
|
||||
export interface AppBackupContext {
|
||||
createBackup: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectAppBackup, provideAppBackup] = createContext<AppBackupContext>(
|
||||
'AppBackupContext',
|
||||
'appBackupContext',
|
||||
)
|
||||
7
packages/ui/src/providers/content-manager.ts
Normal file
7
packages/ui/src/providers/content-manager.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ContentManagerContext,
|
||||
type ContentModpackData,
|
||||
injectContentManager,
|
||||
provideContentManager,
|
||||
type UploadState,
|
||||
} from '../layouts/shared/content-tab/providers/content-manager'
|
||||
79
packages/ui/src/providers/create-context.ts
Normal file
79
packages/ui/src/providers/create-context.ts
Normal 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
|
||||
}
|
||||
19
packages/ui/src/providers/file-picker.ts
Normal file
19
packages/ui/src/providers/file-picker.ts
Normal 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')
|
||||
@@ -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
Reference in New Issue
Block a user