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>