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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,371 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxesIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import OverflowMenu, {
|
||||
type Option as OverflowMenuOption,
|
||||
} from '#ui/components/base/OverflowMenu.vue'
|
||||
import TagItem from '#ui/components/base/TagItem.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
|
||||
import { useRelativeTime } from '#ui/composables/how-ago'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type {
|
||||
ContentModpackCardCategory,
|
||||
ContentModpackCardProject,
|
||||
ContentModpackCardVersion,
|
||||
ContentOwner,
|
||||
} from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
updating: {
|
||||
id: 'content.modpack-card.updating',
|
||||
defaultMessage: 'Updating...',
|
||||
},
|
||||
contentHintTitle: {
|
||||
id: 'content.modpack-card.content-hint-title',
|
||||
defaultMessage: 'Modpack content moved',
|
||||
},
|
||||
contentHintDescription: {
|
||||
id: 'content.modpack-card.content-hint-description',
|
||||
defaultMessage: "Your modpack's content can now be found here!",
|
||||
},
|
||||
dismissHint: {
|
||||
id: 'content.modpack-card.dismiss-hint',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
project: ContentModpackCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
version?: ContentModpackCardVersion
|
||||
versionLink?: string | RouteLocationRaw
|
||||
owner?: ContentOwner
|
||||
categories?: ContentModpackCardCategory[]
|
||||
disabled?: boolean
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
hasUpdate?: boolean
|
||||
disabledText?: string
|
||||
showContentHint?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
projectLink: undefined,
|
||||
version: undefined,
|
||||
versionLink: undefined,
|
||||
owner: undefined,
|
||||
categories: undefined,
|
||||
disabled: false,
|
||||
overflowOptions: undefined,
|
||||
hasUpdate: false,
|
||||
disabledText: undefined,
|
||||
showContentHint: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: []
|
||||
content: []
|
||||
settings: []
|
||||
'dismiss-content-hint': []
|
||||
}>()
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
|
||||
const hasContentListener = computed(() => typeof instance?.vnode.props?.onContent === 'function')
|
||||
const hasSettingsListener = computed(() => typeof instance?.vnode.props?.onSettings === 'function')
|
||||
|
||||
const formatTimeAgo = useRelativeTime()
|
||||
|
||||
const formatCompact = (n: number | undefined) => {
|
||||
if (n === undefined) return ''
|
||||
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(n)
|
||||
}
|
||||
|
||||
const collapsedOptions = computed(() => {
|
||||
const options: {
|
||||
id: string
|
||||
action: () => void
|
||||
color?: 'standard' | 'red' | 'brand' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
}[] = []
|
||||
if (hasContentListener.value) {
|
||||
options.push({
|
||||
id: 'content',
|
||||
action: () => emit('content'),
|
||||
})
|
||||
}
|
||||
if (hasSettingsListener.value) {
|
||||
options.push({
|
||||
id: 'settings',
|
||||
action: () => emit('settings'),
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const isExpanded = ref(true)
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
isExpanded.value = entry.contentRect.width >= 700
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
if (containerRef.value) observer.observe(containerRef.value)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
|
||||
:class="{ 'opacity-50': disabled }"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-4">
|
||||
<AutoLink :to="projectLink" class="shrink-0">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised />
|
||||
</AutoLink>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<AutoLink
|
||||
:to="projectLink"
|
||||
class="text-xl font-semibold leading-8 text-contrast hover:underline"
|
||||
>
|
||||
{{ project.title }}
|
||||
</AutoLink>
|
||||
<div class="flex flex-nowrap items-center gap-2 overflow-hidden text-secondary">
|
||||
<AutoLink
|
||||
v-if="owner"
|
||||
:to="owner.link"
|
||||
class="flex shrink-0 items-center gap-1.5 hover:underline"
|
||||
>
|
||||
<Avatar
|
||||
:src="owner.avatar_url"
|
||||
:alt="owner.name"
|
||||
size="2rem"
|
||||
:circle="owner.type === 'user'"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="font-medium whitespace-nowrap">{{ owner.name }}</span>
|
||||
</AutoLink>
|
||||
<template v-if="version">
|
||||
<BulletDivider v-if="owner" />
|
||||
<AutoLink
|
||||
:to="versionLink"
|
||||
class="shrink-0 font-medium text-secondary !decoration-secondary whitespace-nowrap"
|
||||
:class="versionLink ? 'hover:underline' : ''"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</AutoLink>
|
||||
</template>
|
||||
<template v-if="version?.date_published">
|
||||
<BulletDivider />
|
||||
<div class="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
||||
<ClockIcon class="size-5" />
|
||||
<span>{{ formatTimeAgo(new Date(version.date_published)) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<template v-if="disabled">
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
<span class="font-semibold">{{
|
||||
disabledText ?? formatMessage(messages.updating)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Expanded actions visible at >= 700px -->
|
||||
<div class="hidden @[700px]:flex items-center gap-2">
|
||||
<ButtonStyled
|
||||
v-if="hasUpdateListener && hasUpdate"
|
||||
type="transparent"
|
||||
color="green"
|
||||
color-fill="text"
|
||||
>
|
||||
<button class="flex items-center gap-2" @click="emit('update')">
|
||||
<DownloadIcon class="!text-green" />
|
||||
<span class="font-semibold">{{ formatMessage(commonMessages.updateButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Tooltip
|
||||
v-if="hasContentListener"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="showContentHint && isExpanded"
|
||||
:auto-hide="false"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
@click="
|
||||
() => {
|
||||
emit('content')
|
||||
emit('dismiss-content-hint')
|
||||
}
|
||||
"
|
||||
>
|
||||
<BoxesIcon />
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="experimental-styles-within grid grid-cols-[min-content] gap-1">
|
||||
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.contentHintTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismissHint)"
|
||||
@click="emit('dismiss-content-hint')"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ formatMessage(messages.contentHintDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
|
||||
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
|
||||
<button
|
||||
class="!border !border-surface-4"
|
||||
@click="
|
||||
() => {
|
||||
emit('settings')
|
||||
emit('dismiss-content-hint')
|
||||
}
|
||||
"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Collapsed actions visible at < 700px -->
|
||||
<div v-if="hasUpdate && hasUpdateListener" class="flex @[700px]:hidden">
|
||||
<ButtonStyled circular type="transparent" color="green" color-fill="text">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.updateButton)"
|
||||
@click="emit('update')"
|
||||
>
|
||||
<DownloadIcon class="size-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="collapsedOptions.length"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="showContentHint && !isExpanded"
|
||||
:auto-hide="false"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ButtonStyled circular type="outlined"
|
||||
><TeleportOverflowMenu
|
||||
:options="collapsedOptions"
|
||||
class="flex @[700px]:hidden"
|
||||
btn-class="!border-surface-4 !border"
|
||||
@open="emit('dismiss-content-hint')"
|
||||
>
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
<template #content>
|
||||
<BoxesIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</template>
|
||||
<template #settings>
|
||||
<SettingsIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu></ButtonStyled
|
||||
>
|
||||
<template #popper>
|
||||
<div class="experimental-styles-within grid grid-cols-[min-content] gap-1">
|
||||
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.contentHintTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismissHint)"
|
||||
@click="emit('dismiss-content-hint')"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ formatMessage(messages.contentHintDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
|
||||
<ButtonStyled
|
||||
v-if="overflowOptions?.length"
|
||||
circular
|
||||
type="transparent"
|
||||
class="hidden @[700px]:flex"
|
||||
>
|
||||
<OverflowMenu :options="overflowOptions">
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="project.description" class="text-secondary">
|
||||
{{ project.description }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div v-if="project.downloads !== undefined" class="flex items-center gap-2 text-secondary">
|
||||
<DownloadIcon class="size-5" />
|
||||
<span class="font-medium">{{ formatCompact(project.downloads) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="project.followers !== undefined" class="flex items-center gap-2 text-secondary">
|
||||
<HeartIcon class="size-5" />
|
||||
<span class="font-medium">{{ formatCompact(project.followers) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="categories?.length" class="flex flex-wrap items-center gap-1">
|
||||
<TagItem v-for="cat in categories" :key="cat.name" :action="cat.action">
|
||||
{{ cat.name }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { PowerIcon, PowerOffIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { BulkOperationType } from '../composables/bulk-operations'
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
selectedCount: {
|
||||
id: 'content.selection-bar.selected-count',
|
||||
defaultMessage: '{count} {contentType} selected',
|
||||
},
|
||||
selectedCountSimple: {
|
||||
id: 'content.selection-bar.selected-count-simple',
|
||||
defaultMessage: '{count, number} selected',
|
||||
},
|
||||
enable: {
|
||||
id: 'content.selection-bar.enable',
|
||||
defaultMessage: 'Enable',
|
||||
},
|
||||
disable: {
|
||||
id: 'content.selection-bar.disable',
|
||||
defaultMessage: 'Disable',
|
||||
},
|
||||
bulkEnabling: {
|
||||
id: 'content.selection-bar.bulk.enabling',
|
||||
defaultMessage: 'Enabling {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkEnablingWaiting: {
|
||||
id: 'content.selection-bar.bulk.enabling-waiting',
|
||||
defaultMessage: 'Enabling {contentType}...',
|
||||
},
|
||||
bulkDisabling: {
|
||||
id: 'content.selection-bar.bulk.disabling',
|
||||
defaultMessage: 'Disabling {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkDisablingWaiting: {
|
||||
id: 'content.selection-bar.bulk.disabling-waiting',
|
||||
defaultMessage: 'Disabling {contentType}...',
|
||||
},
|
||||
bulkUpdating: {
|
||||
id: 'content.selection-bar.bulk.updating',
|
||||
defaultMessage: 'Updating {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkUpdatingWaiting: {
|
||||
id: 'content.selection-bar.bulk.updating-waiting',
|
||||
defaultMessage: 'Updating {contentType}...',
|
||||
},
|
||||
bulkDeleting: {
|
||||
id: 'content.selection-bar.bulk.deleting',
|
||||
defaultMessage: 'Deleting {progress}/{total} {contentType}...',
|
||||
},
|
||||
bulkDeletingWaiting: {
|
||||
id: 'content.selection-bar.bulk.deleting-waiting',
|
||||
defaultMessage: 'Deleting {contentType}...',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
selectedItems: ContentItem[]
|
||||
contentTypeLabel?: string
|
||||
isBusy?: boolean
|
||||
isBulkOperating?: boolean
|
||||
bulkOperation?: BulkOperationType | null
|
||||
bulkProgress?: number
|
||||
bulkTotal?: number
|
||||
bulkWaiting?: boolean
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
contentTypeLabel: undefined,
|
||||
isBusy: false,
|
||||
isBulkOperating: false,
|
||||
bulkOperation: null,
|
||||
bulkProgress: 0,
|
||||
bulkTotal: 0,
|
||||
bulkWaiting: false,
|
||||
ariaLabel: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
enable: []
|
||||
disable: []
|
||||
}>()
|
||||
|
||||
const shown = computed(() => props.selectedItems.length > 0 || props.isBulkOperating)
|
||||
|
||||
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
|
||||
|
||||
const selectedCountText = computed(() => {
|
||||
const count = props.selectedItems.length || props.bulkTotal
|
||||
if (props.contentTypeLabel) {
|
||||
return formatMessage(messages.selectedCount, {
|
||||
count,
|
||||
contentType: `${props.contentTypeLabel}${count === 1 ? '' : 's'}`,
|
||||
})
|
||||
}
|
||||
return formatMessage(messages.selectedCountSimple, { count })
|
||||
})
|
||||
|
||||
const bulkProgressMessage = computed(() => {
|
||||
if (!props.bulkOperation) return ''
|
||||
const contentType = props.contentTypeLabel
|
||||
? `${props.contentTypeLabel}${props.bulkTotal === 1 ? '' : 's'}`
|
||||
: 'items'
|
||||
const messageMap = {
|
||||
enable: props.bulkWaiting ? messages.bulkEnablingWaiting : messages.bulkEnabling,
|
||||
disable: props.bulkWaiting ? messages.bulkDisablingWaiting : messages.bulkDisabling,
|
||||
update: props.bulkWaiting ? messages.bulkUpdatingWaiting : messages.bulkUpdating,
|
||||
delete: props.bulkWaiting ? messages.bulkDeletingWaiting : messages.bulkDeleting,
|
||||
}
|
||||
return formatMessage(messageMap[props.bulkOperation], {
|
||||
progress: props.bulkProgress,
|
||||
total: props.bulkTotal,
|
||||
contentType,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingActionBar :shown="shown" :aria-label="ariaLabel">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ selectedCountText }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="!text-primary"
|
||||
:disabled="isBulkOperating"
|
||||
:class="{ 'opacity-60 pointer-events-none': isBulkOperating }"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ formatMessage(commonMessages.clearButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
|
||||
<slot name="actions" />
|
||||
|
||||
<ButtonStyled v-if="allDisabled" type="transparent">
|
||||
<button :disabled="isBusy" @click="emit('enable')">
|
||||
<PowerIcon />
|
||||
{{ formatMessage(messages.enable) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else type="transparent">
|
||||
<button :disabled="isBusy" @click="emit('disable')">
|
||||
<PowerOffIcon />
|
||||
{{ formatMessage(messages.disable) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<slot name="actions-end" />
|
||||
</div>
|
||||
|
||||
<div v-else class="ml-auto flex items-center" aria-live="polite">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-secondary tabular-nums">
|
||||
{{ bulkProgressMessage }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isBulkOperating" class="absolute bottom-0 left-0 right-0 h-1">
|
||||
<div
|
||||
class="h-full rounded-l-full bg-brand transition-[width] duration-200 ease-in-out"
|
||||
:class="{ 'animate-indeterminate': bulkWaiting }"
|
||||
:style="
|
||||
!bulkWaiting
|
||||
? { width: `${bulkTotal > 0 ? (bulkProgress / bulkTotal) * 100 : 0}%` }
|
||||
: undefined
|
||||
"
|
||||
role="progressbar"
|
||||
:aria-valuenow="bulkWaiting ? undefined : bulkProgress"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="bulkTotal"
|
||||
style="box-shadow: 0px -2px 4px 0px rgba(27, 217, 106, 0.1)"
|
||||
/>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
width: 20%;
|
||||
margin-left: -20%;
|
||||
}
|
||||
100% {
|
||||
width: 60%;
|
||||
margin-left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody, { count }) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before bulk update"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.updateButton, { count }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-bulk-update.header',
|
||||
defaultMessage: 'Update projects',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-bulk-update.admonition-header',
|
||||
defaultMessage: 'Update warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-bulk-update.admonition-body',
|
||||
defaultMessage:
|
||||
"Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one.",
|
||||
},
|
||||
updateButton: {
|
||||
id: 'content.confirm-bulk-update.update-button',
|
||||
defaultMessage: 'Update {count, plural, one {# project} other {# projects}}',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
count: number
|
||||
server?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('update')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { count, itemType })"
|
||||
:fade="variant === 'server' ? 'warning' : 'danger'"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition
|
||||
:type="variant === 'server' ? 'warning' : 'critical'"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before deletion"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :color="variant === 'server' ? 'orange' : 'red'">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.deleteButton, { count, itemType }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-deletion.header',
|
||||
defaultMessage: 'Delete {itemType}{count, plural, one {} other {s}}',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-deletion.admonition-header',
|
||||
defaultMessage: 'Deletion warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-deletion.admonition-body',
|
||||
defaultMessage:
|
||||
'Deleting a mod can permanently affect your world and may cause missing content or unexpected issues when it loads again.',
|
||||
},
|
||||
deleteButton: {
|
||||
id: 'content.confirm-deletion.delete-button',
|
||||
defaultMessage: 'Delete {count} {itemType}{count, plural, one {} other {s}}',
|
||||
},
|
||||
})
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
count: number
|
||||
itemType: string
|
||||
variant?: 'instance' | 'server'
|
||||
}>(),
|
||||
{
|
||||
variant: 'instance',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.leavePageTitle)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" :header="formatMessage(messages.uploadInProgress)">
|
||||
{{ formatMessage(messages.leavePageBody) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.stayOnPageButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="leave">
|
||||
<RightArrowIcon />
|
||||
{{ formatMessage(messages.leavePageButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
leavePageTitle: {
|
||||
id: 'instances.confirm-leave-modal.title',
|
||||
defaultMessage: 'Leave page?',
|
||||
},
|
||||
uploadInProgress: {
|
||||
id: 'instances.confirm-leave-modal.upload-in-progress',
|
||||
defaultMessage: 'Upload in progress',
|
||||
},
|
||||
leavePageBody: {
|
||||
id: 'instances.confirm-leave-modal.body',
|
||||
defaultMessage:
|
||||
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
|
||||
},
|
||||
stayOnPageButton: {
|
||||
id: 'instances.confirm-leave-modal.stay',
|
||||
defaultMessage: 'Stay on page',
|
||||
},
|
||||
leavePageButton: {
|
||||
id: 'instances.confirm-leave-modal.leave',
|
||||
defaultMessage: 'Leave page',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: boolean) => void) | null = null
|
||||
|
||||
function prompt(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function leave() {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(true)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(false)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { action: downgrade ? 'downgrade' : 'update' })"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition
|
||||
type="warning"
|
||||
:header="
|
||||
formatMessage(messages.admonitionHeader, { action: downgrade ? 'downgrade' : 'update' })
|
||||
"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="downgrade ? 'Before modpack downgrade' : 'Before modpack update'"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="handleConfirm">
|
||||
<DownloadIcon />
|
||||
{{
|
||||
formatMessage(messages.confirmButton, { action: downgrade ? 'downgrade' : 'update' })
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
defineProps<{
|
||||
downgrade?: boolean
|
||||
server?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-modpack-update.header',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-modpack-update.admonition-header',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-modpack-update.admonition-body',
|
||||
defaultMessage: 'Any mods or content you added on top of the modpack will be deleted.',
|
||||
},
|
||||
confirmButton: {
|
||||
id: 'content.confirm-modpack-update.confirm-button',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm' | 'cancel'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
modal.value?.hide()
|
||||
emit('confirm')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modal.value?.hide()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
fade="danger"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before reinstall"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.reinstallButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instance.confirm-reinstall.header',
|
||||
defaultMessage: 'Reinstall modpack',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'instance.confirm-reinstall.admonition-header',
|
||||
defaultMessage: 'Reinstallation warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'instance.confirm-reinstall.admonition-body',
|
||||
defaultMessage:
|
||||
'Reinstalling will reset all installed or modified content to what is provided by the modpack, removing any mods or content you have added on top of the original installation.',
|
||||
},
|
||||
reinstallButton: {
|
||||
id: 'instance.confirm-reinstall.reinstall-button',
|
||||
defaultMessage: 'Reinstall modpack',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reinstall'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('reinstall')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { type: server ? 'server' : 'instance' })"
|
||||
max-width="500px"
|
||||
>
|
||||
<span class="text-primary">
|
||||
{{ formatMessage(messages.body, { type: server ? 'server' : 'instance' }) }}
|
||||
</span>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="confirm">
|
||||
<HammerIcon />
|
||||
{{ formatMessage(messages.repairButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HammerIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instance.confirm-repair.header',
|
||||
defaultMessage: 'Repair {type, select, server {server} other {instance}}',
|
||||
},
|
||||
body: {
|
||||
id: 'instance.confirm-repair.body',
|
||||
defaultMessage:
|
||||
'Repairing reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your {type, select, server {server is not starting correctly} other {game is not launching due to launcher-related errors}}.',
|
||||
},
|
||||
repairButton: {
|
||||
id: 'instance.confirm-repair.repair-button',
|
||||
defaultMessage: 'Repair',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'repair'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('repair')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before unlink"
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="confirm">
|
||||
<UnlinkIcon />
|
||||
{{ formatMessage(server ? messages.header : messages.unlinkButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UnlinkIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
defineProps<{
|
||||
server?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-unlink.header',
|
||||
defaultMessage: 'Unlink modpack',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.confirm-unlink.admonition-header',
|
||||
defaultMessage: 'Unlinking modpack',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-unlink.admonition-body',
|
||||
defaultMessage:
|
||||
'Mods and content will be merged with what you added on top of the modpack, and it will stop receiving updates.',
|
||||
},
|
||||
unlinkButton: {
|
||||
id: 'content.confirm-unlink.unlink-button',
|
||||
defaultMessage: 'Unlink',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'unlink'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
|
||||
function show() {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('unlink')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<NewModal ref="modal" no-padding scrollable max-width="560px" width="560px" :on-hide="handleHide">
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.header) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2.5 p-6">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.instanceType) }}
|
||||
</span>
|
||||
<Chips v-model="tab" :items="tabs" :format-label="formatTabLabel" :never-empty="true" />
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
|
||||
<!-- Existing instance tab -->
|
||||
<div
|
||||
v-if="tab === 'existing'"
|
||||
class="flex flex-col gap-3 bg-surface-2 py-4"
|
||||
style="height: 400px; overflow-y: auto"
|
||||
>
|
||||
<div class="flex items-start gap-3 px-6">
|
||||
<StyledInput
|
||||
v-model="searchFilter"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchPlaceholder)"
|
||||
class="flex-1"
|
||||
/>
|
||||
<ButtonStyled type="outlined" circular>
|
||||
<button
|
||||
v-tooltip="`${hideUninstallable ? 'Show' : 'Hide'} unavailable`"
|
||||
class="!border-surface-4 !border"
|
||||
@click="hideUninstallable = !hideUninstallable"
|
||||
>
|
||||
<EyeIcon v-if="hideUninstallable" />
|
||||
<EyeOffIcon v-else />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.noInstances) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="inst in filteredInstances"
|
||||
:key="inst.id"
|
||||
class="flex items-center justify-between px-6 py-1.5"
|
||||
:class="
|
||||
!inst.compatible ? 'opacity-40' : inst.installed ? 'opacity-60' : 'hover:bg-surface-3'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!inst.compatible ? 'This instance is not compatible with this project' : undefined
|
||||
"
|
||||
class="flex min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden border-0 bg-transparent p-0 text-left"
|
||||
@click="emit('navigate', inst)"
|
||||
>
|
||||
<Avatar :src="inst.iconUrl ?? undefined" size="2rem" rounded="md" />
|
||||
<span class="truncate font-semibold text-contrast hover:underline">{{
|
||||
inst.name
|
||||
}}</span>
|
||||
</button>
|
||||
<ButtonStyled v-if="inst.installed" :disabled="true">
|
||||
<button>
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.installedBadge) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="inst.compatible" :disabled="inst.installing">
|
||||
<button @click="emit('install', inst)">
|
||||
{{
|
||||
inst.installing
|
||||
? formatMessage(messages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New instance tab -->
|
||||
<div v-else class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar :src="iconPreviewUrl ?? undefined" size="5rem" rounded="2xl" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-surface-4 !border" @click="selectIcon">
|
||||
<UploadIcon />
|
||||
{{ formatMessage(messages.selectIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
class="!border-surface-4 !border"
|
||||
:disabled="!iconPreviewUrl"
|
||||
@click="removeIcon"
|
||||
>
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.removeIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.nameLabel) }}
|
||||
</span>
|
||||
<StyledInput
|
||||
v-model="instanceName"
|
||||
:placeholder="formatMessage(messages.namePlaceholder)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.loaderLabel) }}
|
||||
</span>
|
||||
<Chips
|
||||
v-model="selectedLoader"
|
||||
:items="compatibleLoaders"
|
||||
:format-label="formatLoaderLabel"
|
||||
:never-empty="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameVersionLabel) }}
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="selectedGameVersion"
|
||||
:options="gameVersionOptions"
|
||||
searchable
|
||||
sync-with-selection
|
||||
:placeholder="formatMessage(messages.gameVersionPlaceholder)"
|
||||
>
|
||||
<template v-if="hasReleaseData" #dropdown-footer>
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
|
||||
@mousedown.prevent
|
||||
@click="showSnapshots = !showSnapshots"
|
||||
>
|
||||
<EyeOffIcon v-if="showSnapshots" class="size-4" />
|
||||
<EyeIcon v-else class="size-4" />
|
||||
{{
|
||||
showSnapshots
|
||||
? formatMessage(messages.hideSnapshots)
|
||||
: formatMessage(messages.showAllVersions)
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div v-if="tab === 'existing'" class="flex items-center justify-between pt-5 pb-1 px-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<BoxIcon class="size-5" />
|
||||
<span>
|
||||
{{ formatMessage(messages.compatibleCount, { count: compatibleCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-surface-4 !border" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-surface-4 !border" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!instanceName" @click="handleCreateAndInstall">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.installButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
CheckIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
SearchIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Chips from '#ui/components/base/Chips.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectFilePicker } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instances.content-install.header',
|
||||
defaultMessage: 'Install project',
|
||||
},
|
||||
instanceType: {
|
||||
id: 'instances.content-install.instance-type',
|
||||
defaultMessage: 'Instance type',
|
||||
},
|
||||
existingTab: {
|
||||
id: 'instances.content-install.existing-tab',
|
||||
defaultMessage: 'Existing instance',
|
||||
},
|
||||
newTab: {
|
||||
id: 'instances.content-install.new-tab',
|
||||
defaultMessage: 'New instance',
|
||||
},
|
||||
searchPlaceholder: {
|
||||
id: 'instances.content-install.search-placeholder',
|
||||
defaultMessage: 'Search instance',
|
||||
},
|
||||
installedBadge: {
|
||||
id: 'instances.content-install.installed-badge',
|
||||
defaultMessage: 'Installed',
|
||||
},
|
||||
installingLabel: {
|
||||
id: 'instances.content-install.installing-label',
|
||||
defaultMessage: 'Installing...',
|
||||
},
|
||||
installButton: {
|
||||
id: 'instances.content-install.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instances.content-install.select-icon',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instances.content-install.remove-icon',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
nameLabel: {
|
||||
id: 'instances.content-install.name-label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
namePlaceholder: {
|
||||
id: 'instances.content-install.name-placeholder',
|
||||
defaultMessage: 'Enter instance name',
|
||||
},
|
||||
loaderLabel: {
|
||||
id: 'instances.content-install.loader-label',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
gameVersionLabel: {
|
||||
id: 'instances.content-install.game-version-label',
|
||||
defaultMessage: 'Game version',
|
||||
},
|
||||
gameVersionPlaceholder: {
|
||||
id: 'instances.content-install.game-version-placeholder',
|
||||
defaultMessage: 'Select game version',
|
||||
},
|
||||
compatibleCount: {
|
||||
id: 'instances.content-install.compatible-count',
|
||||
defaultMessage: '{count} compatible {count, plural, one {instance} other {instances}}',
|
||||
},
|
||||
noInstances: {
|
||||
id: 'instances.content-install.no-instances',
|
||||
defaultMessage: 'No compatible instances found',
|
||||
},
|
||||
showAllVersions: {
|
||||
id: 'instances.content-install.show-all-versions',
|
||||
defaultMessage: 'Show all versions',
|
||||
},
|
||||
hideSnapshots: {
|
||||
id: 'instances.content-install.hide-snapshots',
|
||||
defaultMessage: 'Hide snapshots',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ContentInstallInstance {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string | null
|
||||
installed: boolean
|
||||
compatible: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
instances: ContentInstallInstance[]
|
||||
compatibleLoaders: string[]
|
||||
gameVersions: string[]
|
||||
releaseGameVersions?: Set<string>
|
||||
loading?: boolean
|
||||
defaultTab?: 'existing' | 'new'
|
||||
preferredLoader?: string | null
|
||||
preferredGameVersion?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
install: [instance: ContentInstallInstance]
|
||||
'create-and-install': [
|
||||
data: {
|
||||
name: string
|
||||
iconPath: string | null
|
||||
iconPreviewUrl: string | null
|
||||
loader: string
|
||||
gameVersion: string
|
||||
},
|
||||
]
|
||||
navigate: [instance: ContentInstallInstance]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
type Tab = 'existing' | 'new'
|
||||
const tabs = computed<Tab[]>(() =>
|
||||
props.compatibleLoaders.length > 0 ? ['existing', 'new'] : ['existing'],
|
||||
)
|
||||
const tab = ref<Tab>('existing')
|
||||
|
||||
const tabLabels: Record<Tab, () => string> = {
|
||||
existing: () => formatMessage(messages.existingTab),
|
||||
new: () => formatMessage(messages.newTab),
|
||||
}
|
||||
const formatTabLabel = (item: Tab) => tabLabels[item]()
|
||||
|
||||
const searchFilter = ref('')
|
||||
const hideUninstallable = ref(true)
|
||||
|
||||
const filteredInstances = computed(() => {
|
||||
let list = props.instances
|
||||
if (hideUninstallable.value) list = list.filter((i) => i.compatible && !i.installed)
|
||||
if (searchFilter.value) {
|
||||
const query = searchFilter.value.toLowerCase()
|
||||
list = list.filter((i) => i.name.toLowerCase().includes(query))
|
||||
}
|
||||
const score = (i: ContentInstallInstance) => (!i.compatible ? 2 : i.installed ? 1 : 0)
|
||||
return list.slice().sort((a, b) => {
|
||||
const diff = score(a) - score(b)
|
||||
if (diff !== 0) return diff
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
|
||||
const compatibleCount = computed(() => props.instances.filter((i) => i.compatible).length)
|
||||
|
||||
const instanceName = ref('')
|
||||
const selectedLoader = ref<string | null>(null)
|
||||
const selectedGameVersion = ref<string | null>(null)
|
||||
const iconPath = ref<string | null>(null)
|
||||
const iconPreviewUrl = ref<string | null>(null)
|
||||
const showSnapshots = ref(false)
|
||||
|
||||
const hasReleaseData = computed(
|
||||
() => props.releaseGameVersions && props.releaseGameVersions.size > 0,
|
||||
)
|
||||
|
||||
const gameVersionOptions = computed<ComboboxOption<string>[]>(() => {
|
||||
const versions =
|
||||
showSnapshots.value || !hasReleaseData.value
|
||||
? props.gameVersions
|
||||
: props.gameVersions.filter((v) => props.releaseGameVersions!.has(v))
|
||||
return versions.map((v) => ({ value: v, label: v }))
|
||||
})
|
||||
|
||||
const filePicker = injectFilePicker(null)
|
||||
|
||||
async function selectIcon() {
|
||||
if (!filePicker) return
|
||||
const picked = await filePicker.pickImage()
|
||||
if (picked) {
|
||||
iconPath.value = picked.path ?? null
|
||||
iconPreviewUrl.value = picked.previewUrl
|
||||
}
|
||||
}
|
||||
|
||||
function removeIcon() {
|
||||
iconPath.value = null
|
||||
iconPreviewUrl.value = null
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
tab.value = props.defaultTab ?? 'existing'
|
||||
searchFilter.value = ''
|
||||
hideUninstallable.value = true
|
||||
instanceName.value = `New instance (${props.instances.length + 1})`
|
||||
iconPath.value = null
|
||||
iconPreviewUrl.value = null
|
||||
selectedLoader.value = props.preferredLoader ?? props.compatibleLoaders[0] ?? null
|
||||
|
||||
const preferred = props.preferredGameVersion
|
||||
const isSnapshot = preferred && hasReleaseData.value && !props.releaseGameVersions!.has(preferred)
|
||||
showSnapshots.value = !!isSnapshot
|
||||
|
||||
const defaultVersion = hasReleaseData.value
|
||||
? (props.gameVersions.find((v) => props.releaseGameVersions!.has(v)) ??
|
||||
props.gameVersions[0] ??
|
||||
null)
|
||||
: (props.gameVersions[0] ?? null)
|
||||
selectedGameVersion.value = preferred ?? defaultVersion
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
resetState()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
function show() {
|
||||
resetState()
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function handleCreateAndInstall() {
|
||||
if (!instanceName.value || !selectedLoader.value || !selectedGameVersion.value) return
|
||||
emit('create-and-install', {
|
||||
name: instanceName.value,
|
||||
iconPath: iconPath.value,
|
||||
iconPreviewUrl: iconPreviewUrl.value,
|
||||
loader: selectedLoader.value,
|
||||
gameVersion: selectedGameVersion.value,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,546 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
no-padding
|
||||
>
|
||||
<template #title>
|
||||
<Avatar v-if="projectIconUrl" :src="projectIconUrl" size="3rem" :tint-by="projectName" />
|
||||
<span class="text-lg font-extrabold text-contrast">{{
|
||||
header ??
|
||||
formatMessage(
|
||||
isModpack ? messages.switchModpackVersionHeader : messages.updateVersionHeader,
|
||||
)
|
||||
}}</span>
|
||||
</template>
|
||||
<div
|
||||
class="flex h-[min(550px,calc(95vh-10rem))] border-solid border-transparent border-[1px] border-b-surface-4"
|
||||
>
|
||||
<div class="w-[300px] flex flex-col relative bg-surface-3">
|
||||
<div class="p-4 pb-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.searchVersionPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-16">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center h-full gap-2">
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
<span class="text-sm text-secondary">{{
|
||||
formatMessage(messages.loadingVersions)
|
||||
}}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-1.5" role="listbox">
|
||||
<button
|
||||
v-for="version in filteredVersions"
|
||||
:key="version.id"
|
||||
role="option"
|
||||
:aria-selected="selectedVersion?.id === version.id"
|
||||
class="flex items-center h-10 px-4 py-2.5 rounded-xl border-none cursor-pointer transition-colors"
|
||||
:class="[
|
||||
selectedVersion?.id === version.id
|
||||
? 'bg-brand-highlight'
|
||||
: 'bg-transparent hover:bg-button-bg',
|
||||
]"
|
||||
@mouseenter="handleVersionMouseEnter(version)"
|
||||
@mouseleave="handleVersionMouseLeave"
|
||||
@focus="emit('versionHover', version)"
|
||||
@click="handleVersionSelect(version)"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<VersionChannelIndicator
|
||||
:channel="version.version_type"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-tooltip="version.version_number"
|
||||
class="font-semibold text-contrast truncate"
|
||||
>
|
||||
{{ version.version_number }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="shouldShowBadge(version)"
|
||||
class="rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
|
||||
:class="[
|
||||
getBadgeClasses(version),
|
||||
isVersionCompatible(version) ? 'px-2.5 py-0.5' : 'p-1',
|
||||
]"
|
||||
>
|
||||
<CircleAlertIcon
|
||||
v-if="!isVersionCompatible(version)"
|
||||
v-tooltip="formatMessage(messages.incompatibleBadge)"
|
||||
class="size-4"
|
||||
/>
|
||||
<template v-else>{{ getBadgeLabel(version) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredVersions.length === 0"
|
||||
class="p-4 text-center text-secondary text-sm"
|
||||
>
|
||||
{{ formatMessage(messages.noVersionsFound) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
|
||||
>
|
||||
<div class="pointer-events-auto">
|
||||
<ButtonStyled type="transparent" :circular="true">
|
||||
<button
|
||||
class="flex items-center gap-1.5"
|
||||
:aria-label="
|
||||
hideIncompatibleState
|
||||
? formatMessage(messages.showIncompatible)
|
||||
: formatMessage(messages.hideIncompatible)
|
||||
"
|
||||
@click="hideIncompatibleState = !hideIncompatibleState"
|
||||
>
|
||||
<EyeIcon v-if="hideIncompatibleState" class="h-6 w-6" />
|
||||
<EyeOffIcon v-else class="h-6 w-6" />
|
||||
<span class="font-medium">{{
|
||||
hideIncompatibleState
|
||||
? formatMessage(messages.showIncompatible)
|
||||
: formatMessage(messages.hideIncompatible)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-px bg-divider" />
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 relative bg-surface-1" aria-live="polite">
|
||||
<template v-if="selectedVersion">
|
||||
<div class="bg-bg p-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-xl text-contrast">
|
||||
{{ selectedVersion.version_number }}
|
||||
</span>
|
||||
<span
|
||||
class="px-2.5 py-0.5 rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
|
||||
:class="getVersionTypeBadgeClasses(selectedVersion)"
|
||||
>
|
||||
{{ capitalizeString(selectedVersion.version_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatLongDate(selectedVersion.date_published) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 rounded-xl">
|
||||
<FileTextIcon class="h-6 w-6 text-primary" />
|
||||
<span class="font-medium text-primary">{{
|
||||
formatMessage(commonMessages.changelogLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-divider" />
|
||||
<span class="font-medium text-primary">
|
||||
{{ formatLoaderGameVersion(selectedVersion) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
|
||||
<div class="flex-1 bg-bg p-4 overflow-y-auto">
|
||||
<div
|
||||
v-if="loadingChangelog"
|
||||
class="flex flex-col items-center justify-center h-full gap-2"
|
||||
>
|
||||
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
|
||||
<span class="text-sm text-secondary">{{
|
||||
formatMessage(messages.loadingChangelog)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="selectedVersion.changelog"
|
||||
class="markdown [&_img]:max-w-full [&_img]:h-auto"
|
||||
v-html="renderHighlightedString(selectedVersion.changelog)"
|
||||
/>
|
||||
<div v-else class="text-secondary italic">
|
||||
{{ formatMessage(messages.noChangelog) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-bg to-transparent pointer-events-none"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="flex-1 flex items-center justify-center text-secondary bg-bg">
|
||||
{{ formatMessage(messages.selectVersionPrompt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full flex flex-row items-center gap-4 p-4 border-solid border-x-0 border-b-0 border-t border-surface-4"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2 max-w-[55%] flex-1 text-orange mr-auto">
|
||||
<TriangleAlertIcon class="size-6 shrink-0" />
|
||||
<span>{{
|
||||
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 shrink-0">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-[1px] !border-surface-4" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
:disabled="!selectedVersion || selectedVersion.id === currentVersionId"
|
||||
@click="handleUpdate"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{{
|
||||
formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, {
|
||||
version: selectedVersion?.version_number ?? '...',
|
||||
})
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileTextIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
updateVersionHeader: {
|
||||
id: 'instances.updater-modal.header',
|
||||
defaultMessage: 'Update version',
|
||||
},
|
||||
switchModpackVersionHeader: {
|
||||
id: 'instances.updater-modal.header-modpack',
|
||||
defaultMessage: 'Switch modpack version',
|
||||
},
|
||||
searchVersionPlaceholder: {
|
||||
id: 'instances.updater-modal.search-placeholder',
|
||||
defaultMessage: 'Search version...',
|
||||
},
|
||||
noVersionsFound: {
|
||||
id: 'instances.updater-modal.no-versions',
|
||||
defaultMessage: 'No versions found',
|
||||
},
|
||||
showIncompatible: {
|
||||
id: 'instances.updater-modal.show-incompatible',
|
||||
defaultMessage: 'Show incompatible',
|
||||
},
|
||||
hideIncompatible: {
|
||||
id: 'instances.updater-modal.hide-incompatible',
|
||||
defaultMessage: 'Hide incompatible',
|
||||
},
|
||||
noChangelog: {
|
||||
id: 'instances.updater-modal.no-changelog',
|
||||
defaultMessage: 'No changelog provided for this version.',
|
||||
},
|
||||
selectVersionPrompt: {
|
||||
id: 'instances.updater-modal.select-version',
|
||||
defaultMessage: 'Select a version to view its changelog',
|
||||
},
|
||||
updateWarningApp: {
|
||||
id: 'instances.updater-modal.warning-app',
|
||||
defaultMessage:
|
||||
'Updating can break your instance. Review version changelogs and back up first.',
|
||||
},
|
||||
updateWarningWeb: {
|
||||
id: 'instances.updater-modal.warning-web',
|
||||
defaultMessage: 'Updating can break your world. Review version changelogs and back up first.',
|
||||
},
|
||||
downgradeToVersion: {
|
||||
id: 'instances.updater-modal.downgrade-to',
|
||||
defaultMessage: 'Downgrade to {version}',
|
||||
},
|
||||
updateToVersion: {
|
||||
id: 'instances.updater-modal.update-to',
|
||||
defaultMessage: 'Update to {version}',
|
||||
},
|
||||
currentBadge: {
|
||||
id: 'instances.updater-modal.badge.current',
|
||||
defaultMessage: 'Current',
|
||||
},
|
||||
incompatibleBadge: {
|
||||
id: 'instances.updater-modal.badge.incompatible',
|
||||
defaultMessage: 'Incompatible',
|
||||
},
|
||||
loadingVersions: {
|
||||
id: 'instances.updater-modal.loading-versions',
|
||||
defaultMessage: 'Loading versions...',
|
||||
},
|
||||
loadingChangelog: {
|
||||
id: 'instances.updater-modal.loading-changelog',
|
||||
defaultMessage: 'Loading changelog...',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
versions: Labrinth.Versions.v2.Version[]
|
||||
currentGameVersion: string
|
||||
currentLoader: string
|
||||
currentVersionId: string
|
||||
isApp: boolean
|
||||
/** Whether this is a modpack update (changes header text) */
|
||||
isModpack?: boolean
|
||||
projectIconUrl?: string
|
||||
projectName?: string
|
||||
header?: string
|
||||
/** Whether versions are currently being loaded */
|
||||
loading?: boolean
|
||||
/** Whether changelog is being loaded for the selected version */
|
||||
loadingChangelog?: boolean
|
||||
}>(),
|
||||
{
|
||||
isModpack: false,
|
||||
projectIconUrl: undefined,
|
||||
projectName: undefined,
|
||||
header: undefined,
|
||||
loading: false,
|
||||
loadingChangelog: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [version: Labrinth.Versions.v2.Version]
|
||||
cancel: []
|
||||
/** Emitted when user selects a version, so parent can fetch full version data with changelog */
|
||||
versionSelect: [version: Labrinth.Versions.v2.Version]
|
||||
versionHover: [version: Labrinth.Versions.v2.Version]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const searchQuery = ref('')
|
||||
const hideIncompatibleState = ref(true)
|
||||
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
// Store the initial version ID to select when versions become available
|
||||
const pendingInitialVersionId = ref<string | undefined>(undefined)
|
||||
|
||||
watch(
|
||||
() => props.versions,
|
||||
(newVersions) => {
|
||||
// If we have a selected version, check if it was updated with new data (e.g., changelog)
|
||||
if (selectedVersion.value) {
|
||||
const updatedVersion = newVersions.find((v) => v.id === selectedVersion.value?.id)
|
||||
if (updatedVersion && updatedVersion !== selectedVersion.value) {
|
||||
selectedVersion.value = updatedVersion
|
||||
}
|
||||
}
|
||||
|
||||
// Handle initial selection when versions first arrive
|
||||
if (newVersions.length > 0 && !selectedVersion.value && pendingInitialVersionId.value) {
|
||||
const version =
|
||||
newVersions.find((v) => v.id === pendingInitialVersionId.value) ?? newVersions[0]
|
||||
selectedVersion.value = version
|
||||
if (version) {
|
||||
emit('versionSelect', version)
|
||||
}
|
||||
pendingInitialVersionId.value = undefined
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
|
||||
const hasGameVersion = version.game_versions.includes(props.currentGameVersion)
|
||||
const hasLoader = version.loaders.some(
|
||||
(loader) => loader.toLowerCase() === props.currentLoader.toLowerCase(),
|
||||
)
|
||||
return hasGameVersion && hasLoader
|
||||
}
|
||||
|
||||
const currentVersion = computed(() => props.versions.find((v) => v.id === props.currentVersionId))
|
||||
|
||||
const isDowngrade = computed(() => {
|
||||
if (!selectedVersion.value || !currentVersion.value) return false
|
||||
return (
|
||||
new Date(selectedVersion.value.date_published) < new Date(currentVersion.value.date_published)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredVersions = computed(() => {
|
||||
let versions = [...props.versions]
|
||||
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
versions = versions.filter(
|
||||
(v) => v.name.toLowerCase().includes(query) || v.version_number.toLowerCase().includes(query),
|
||||
)
|
||||
}
|
||||
|
||||
if (hideIncompatibleState.value) {
|
||||
versions = versions.filter(isVersionCompatible)
|
||||
}
|
||||
|
||||
return versions
|
||||
})
|
||||
|
||||
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
|
||||
return version.id === props.currentVersionId || !isVersionCompatible(version)
|
||||
}
|
||||
|
||||
function getBadgeLabel(version: Labrinth.Versions.v2.Version): string {
|
||||
if (version.id === props.currentVersionId) return formatMessage(messages.currentBadge)
|
||||
if (!isVersionCompatible(version)) return formatMessage(messages.incompatibleBadge)
|
||||
return ''
|
||||
}
|
||||
|
||||
function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
|
||||
// Current badge
|
||||
if (version.id === props.currentVersionId) {
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
|
||||
// Incompatible badge (takes precedence over version type)
|
||||
if (!isVersionCompatible(version)) {
|
||||
return 'bg-highlight-orange border-brand-orange text-brand-orange'
|
||||
}
|
||||
|
||||
// Version type badges
|
||||
switch (version.version_type) {
|
||||
case 'release':
|
||||
return 'bg-highlight-green border-brand text-brand'
|
||||
case 'beta':
|
||||
return 'bg-highlight-blue border-brand-blue text-brand-blue'
|
||||
case 'alpha':
|
||||
return 'bg-highlight-purple border-brand-purple text-brand-purple'
|
||||
default:
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionTypeBadgeClasses(version: Labrinth.Versions.v2.Version): string {
|
||||
switch (version.version_type) {
|
||||
case 'release':
|
||||
return 'bg-highlight-green border-brand text-brand'
|
||||
case 'beta':
|
||||
return 'bg-highlight-blue border-brand-blue text-brand-blue'
|
||||
case 'alpha':
|
||||
return 'bg-highlight-purple border-brand-purple text-brand-purple'
|
||||
default:
|
||||
return 'bg-surface-4 border-surface-5 text-primary'
|
||||
}
|
||||
}
|
||||
|
||||
function formatLongDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
|
||||
const loader = capitalizeString(version.loaders[0] || '')
|
||||
const gameVersion = version.game_versions[0] || ''
|
||||
return `${loader} ${gameVersion}`
|
||||
}
|
||||
|
||||
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
|
||||
const HOVER_DURATION_TO_PREFETCH_MS = 500
|
||||
function handleVersionMouseEnter(version: Labrinth.Versions.v2.Version) {
|
||||
prefetchTimeout = useTimeoutFn(
|
||||
() => emit('versionHover', version),
|
||||
HOVER_DURATION_TO_PREFETCH_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
prefetchTimeout.start()
|
||||
}
|
||||
|
||||
function handleVersionMouseLeave() {
|
||||
if (prefetchTimeout) prefetchTimeout.stop()
|
||||
}
|
||||
|
||||
function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
if (prefetchTimeout) prefetchTimeout.stop()
|
||||
selectedVersion.value = version
|
||||
// Emit event so parent can fetch full version data with changelog
|
||||
emit('versionSelect', version)
|
||||
}
|
||||
|
||||
function handleUpdate() {
|
||||
if (selectedVersion.value) {
|
||||
emit('update', selectedVersion.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(initialVersionId?: string) {
|
||||
searchQuery.value = ''
|
||||
hideIncompatibleState.value = true
|
||||
|
||||
if (props.versions.length > 0) {
|
||||
if (initialVersionId) {
|
||||
selectedVersion.value =
|
||||
props.versions.find((v) => v.id === initialVersionId) ?? props.versions[0]
|
||||
} else {
|
||||
selectedVersion.value = props.versions[0]
|
||||
}
|
||||
pendingInitialVersionId.value = undefined
|
||||
if (selectedVersion.value) {
|
||||
emit('versionSelect', selectedVersion.value)
|
||||
}
|
||||
} else {
|
||||
selectedVersion.value = null
|
||||
pendingInitialVersionId.value = initialVersionId
|
||||
}
|
||||
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span class="text-primary">
|
||||
{{ formatMessage(messages.warningBody, { type: backup.isServer ? 'server' : 'instance' }) }}
|
||||
</span>
|
||||
<span v-if="backup.isServer" class="text-brand-orange font-semibold">
|
||||
{{ formatMessage(messages.backupTakesAWhile) }}
|
||||
</span>
|
||||
|
||||
<div v-if="backup.available">
|
||||
<!-- Button / Loading state -->
|
||||
<ButtonStyled v-if="!backup.backupComplete.value && !backup.backupFailed.value">
|
||||
<button
|
||||
v-tooltip="
|
||||
backup.externalBackupInProgress.value
|
||||
? formatMessage(messages.backupInProgress)
|
||||
: undefined
|
||||
"
|
||||
class="!shadow-none"
|
||||
:disabled="backup.isBackingUp.value || backup.externalBackupInProgress.value"
|
||||
@click="backup.startBackup()"
|
||||
>
|
||||
<SpinnerIcon v-if="backup.isBackingUp.value" class="size-5 animate-spin" />
|
||||
<PlusIcon v-else class="size-5" />
|
||||
{{ formatMessage(backup.isBackingUp.value ? messages.backingUp : messages.createBackup) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<!-- Success -->
|
||||
<div
|
||||
v-else-if="backup.backupComplete.value"
|
||||
class="flex items-center gap-1.5 text-sm font-medium text-green"
|
||||
>
|
||||
<CheckCircleIcon class="size-5" />
|
||||
{{ formatMessage(messages.backupComplete) }}
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div v-else-if="backup.backupFailed.value" class="text-sm text-red">
|
||||
{{ formatMessage(messages.backupFailed) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import { useInlineBackup } from '../../composables/use-inline-backup'
|
||||
|
||||
const props = defineProps<{
|
||||
backupName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:buttonsDisabled', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const backup = useInlineBackup(() => props.backupName)
|
||||
|
||||
watch(
|
||||
() => backup.isBackingUp.value,
|
||||
(backing) => {
|
||||
emit('update:buttonsDisabled', backing)
|
||||
},
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
cancelBackup: backup.cancelBackup,
|
||||
isBackingUp: backup.isBackingUp,
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
warningBody: {
|
||||
id: 'content.inline-backup.warning-body',
|
||||
defaultMessage:
|
||||
'We recommend creating a backup before proceeding so you can restore your {type, select, server {world} other {instance}} if anything breaks.',
|
||||
},
|
||||
createBackup: {
|
||||
id: 'content.inline-backup.create-backup',
|
||||
defaultMessage: 'Create backup',
|
||||
},
|
||||
backingUp: {
|
||||
id: 'content.inline-backup.backing-up',
|
||||
defaultMessage: 'Creating backup...',
|
||||
},
|
||||
backupComplete: {
|
||||
id: 'content.inline-backup.backup-complete',
|
||||
defaultMessage: 'Backup created successfully',
|
||||
},
|
||||
backupFailed: {
|
||||
id: 'content.inline-backup.backup-failed',
|
||||
defaultMessage: 'Backup creation failed. You can still proceed.',
|
||||
},
|
||||
backupTakesAWhile: {
|
||||
id: 'content.inline-backup.backup-takes-a-while',
|
||||
defaultMessage:
|
||||
'Creating a backup may take several minutes depending on the size of your server.',
|
||||
},
|
||||
backupInProgress: {
|
||||
id: 'content.inline-backup.backup-in-progress',
|
||||
defaultMessage:
|
||||
"A backup is in progress, it's recommended to wait for it to finish before performing this action.",
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user