feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

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

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

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

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

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

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

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

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

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

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

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

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

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

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

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

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

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

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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