* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
928 lines
27 KiB
Vue
928 lines
27 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ArrowDownAZIcon,
|
|
ArrowUpZAIcon,
|
|
ClockArrowDownIcon,
|
|
ClockArrowUpIcon,
|
|
CodeIcon,
|
|
CompassIcon,
|
|
DownloadIcon,
|
|
DropdownIcon,
|
|
FileIcon,
|
|
FilterIcon,
|
|
FolderOpenIcon,
|
|
LinkIcon,
|
|
RefreshCwIcon,
|
|
SearchIcon,
|
|
ShareIcon,
|
|
SpinnerIcon,
|
|
TextCursorInputIcon,
|
|
TrashIcon,
|
|
UploadIcon,
|
|
} from '@modrinth/assets'
|
|
import { formatBytes } from '@modrinth/utils'
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
import Admonition from '#ui/components/base/Admonition.vue'
|
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
|
import EmptyState from '#ui/components/base/EmptyState.vue'
|
|
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
|
|
import ProgressBar from '#ui/components/base/ProgressBar.vue'
|
|
import StyledInput from '#ui/components/base/StyledInput.vue'
|
|
import { useDebugLogger } from '#ui/composables/debug-logger'
|
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
|
import { commonMessages } from '#ui/utils/common-messages'
|
|
|
|
import ContentCardTable from './components/ContentCardTable.vue'
|
|
import ContentModpackCard from './components/ContentModpackCard.vue'
|
|
import ContentSelectionBar from './components/ContentSelectionBar.vue'
|
|
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
|
|
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
|
|
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
|
|
import {
|
|
isClientOnlyEnvironment,
|
|
useBulkOperation,
|
|
useChangingItems,
|
|
useContentFilters,
|
|
useContentSearch,
|
|
useContentSelection,
|
|
} from './composables'
|
|
import { injectContentManager } from './providers/content-manager'
|
|
import type { ContentCardTableItem, ContentItem } from './types'
|
|
|
|
const { formatMessage } = useVIntl()
|
|
const debug = useDebugLogger('ContentPageLayout')
|
|
|
|
const messages = defineMessages({
|
|
loadingContent: {
|
|
id: 'content.page-layout.loading',
|
|
defaultMessage: 'Loading content...',
|
|
},
|
|
failedToLoad: {
|
|
id: 'content.page-layout.failed-to-load',
|
|
defaultMessage: 'Failed to load content',
|
|
},
|
|
additionalContent: {
|
|
id: 'content.page-layout.additional-content',
|
|
defaultMessage: 'Additional content',
|
|
},
|
|
searchPlaceholder: {
|
|
id: 'content.page-layout.search-placeholder',
|
|
defaultMessage: 'Search {count} {contentType}...',
|
|
},
|
|
browseContent: {
|
|
id: 'content.page-layout.browse-content',
|
|
defaultMessage: 'Browse content',
|
|
},
|
|
uploadFiles: {
|
|
id: 'content.page-layout.upload-files',
|
|
defaultMessage: 'Upload files',
|
|
},
|
|
sortAlphabetical: {
|
|
id: 'content.page-layout.sort.alphabetical',
|
|
defaultMessage: 'Alphabetical',
|
|
},
|
|
sortDateAddedNewest: {
|
|
id: 'content.page-layout.sort.date-added-newest',
|
|
defaultMessage: 'Newest first',
|
|
},
|
|
sortDateAddedOldest: {
|
|
id: 'content.page-layout.sort.date-added-oldest',
|
|
defaultMessage: 'Oldest first',
|
|
},
|
|
updateAll: {
|
|
id: 'content.page-layout.update-all',
|
|
defaultMessage: 'Update all',
|
|
},
|
|
noContentFound: {
|
|
id: 'content.page-layout.no-content-found',
|
|
defaultMessage: 'No content found.',
|
|
},
|
|
noExtraContentInstalled: {
|
|
id: 'content.page-layout.empty.no-extra-content-installed',
|
|
defaultMessage: 'No extra content installed',
|
|
},
|
|
noContentInstalled: {
|
|
id: 'content.page-layout.empty.no-content-installed',
|
|
defaultMessage: 'No content installed',
|
|
},
|
|
emptyModpackHint: {
|
|
id: 'content.page-layout.empty.modpack-hint',
|
|
defaultMessage: 'Add additional content on top of this modpack',
|
|
},
|
|
emptyHint: {
|
|
id: 'content.page-layout.empty.hint',
|
|
defaultMessage: 'Browse or upload {contentType} to get started',
|
|
},
|
|
shareProjectNames: {
|
|
id: 'content.page-layout.share.project-names',
|
|
defaultMessage: 'Project names',
|
|
},
|
|
shareFileNames: {
|
|
id: 'content.page-layout.share.file-names',
|
|
defaultMessage: 'File names',
|
|
},
|
|
shareProjectLinks: {
|
|
id: 'content.page-layout.share.project-links',
|
|
defaultMessage: 'Project links',
|
|
},
|
|
shareMarkdownLinks: {
|
|
id: 'content.page-layout.share.markdown-links',
|
|
defaultMessage: 'Markdown links',
|
|
},
|
|
share: {
|
|
id: 'content.page-layout.share.label',
|
|
defaultMessage: 'Share',
|
|
},
|
|
uploadingFiles: {
|
|
id: 'content.page-layout.uploading-files',
|
|
defaultMessage: 'Uploading files ({completed}/{total})',
|
|
},
|
|
sortByLabel: {
|
|
id: 'content.page-layout.sort.label',
|
|
defaultMessage: 'Sort by {mode}',
|
|
},
|
|
busyDescription: {
|
|
id: 'content.page-layout.busy-description',
|
|
defaultMessage: 'Please wait for the operation to complete before editing content.',
|
|
},
|
|
pleaseWait: {
|
|
id: 'content.page-layout.please-wait',
|
|
defaultMessage: 'Please wait',
|
|
},
|
|
})
|
|
|
|
const ctx = injectContentManager()
|
|
|
|
const uploadOverallProgress = computed(() => {
|
|
const state = ctx.uploadState?.value
|
|
if (!state || !state.isUploading || state.totalFiles === 0) return 0
|
|
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
|
|
})
|
|
|
|
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
|
|
const sortMode = ref<SortMode>('alphabetical-asc')
|
|
|
|
const sortLabels: Record<SortMode, () => string> = {
|
|
'alphabetical-asc': () => formatMessage(messages.sortAlphabetical),
|
|
'alphabetical-desc': () => formatMessage(messages.sortAlphabetical),
|
|
'date-added-newest': () => formatMessage(messages.sortDateAddedNewest),
|
|
'date-added-oldest': () => formatMessage(messages.sortDateAddedOldest),
|
|
}
|
|
|
|
function cycleSortMode() {
|
|
const modes: SortMode[] = [
|
|
'alphabetical-asc',
|
|
'alphabetical-desc',
|
|
'date-added-newest',
|
|
'date-added-oldest',
|
|
]
|
|
const idx = modes.indexOf(sortMode.value)
|
|
sortMode.value = modes[(idx + 1) % modes.length]
|
|
}
|
|
|
|
const sortedItems = computed(() => {
|
|
const items = [...ctx.items.value]
|
|
switch (sortMode.value) {
|
|
case 'alphabetical-desc':
|
|
return items.sort((a, b) => {
|
|
const nameA = a.project?.title ?? a.file_name
|
|
const nameB = b.project?.title ?? b.file_name
|
|
return (
|
|
nameB.toLowerCase().localeCompare(nameA.toLowerCase()) ||
|
|
a.file_name.localeCompare(b.file_name)
|
|
)
|
|
})
|
|
case 'date-added-newest':
|
|
return items.sort((a, b) => {
|
|
const dateA = a.date_added ?? ''
|
|
const dateB = b.date_added ?? ''
|
|
return dateB.localeCompare(dateA) || a.file_name.localeCompare(b.file_name)
|
|
})
|
|
case 'date-added-oldest':
|
|
return items.sort((a, b) => {
|
|
const dateA = a.date_added ?? ''
|
|
const dateB = b.date_added ?? ''
|
|
return dateA.localeCompare(dateB) || a.file_name.localeCompare(b.file_name)
|
|
})
|
|
default:
|
|
return items.sort((a, b) => {
|
|
const nameA = a.project?.title ?? a.file_name
|
|
const nameB = b.project?.title ?? b.file_name
|
|
return (
|
|
nameA.toLowerCase().localeCompare(nameB.toLowerCase()) ||
|
|
a.file_name.localeCompare(b.file_name)
|
|
)
|
|
})
|
|
}
|
|
})
|
|
|
|
const { searchQuery, search } = useContentSearch(sortedItems, [
|
|
'project.title',
|
|
'owner.name',
|
|
'file_name',
|
|
])
|
|
|
|
const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useContentFilters(
|
|
ctx.items,
|
|
{
|
|
showTypeFilters: true,
|
|
showUpdateFilter: ctx.hasUpdateSupport,
|
|
showClientOnlyFilter: ctx.showClientOnlyFilter ?? false,
|
|
isPackLocked: ctx.isPackLocked,
|
|
persistKey: ctx.filterPersistKey,
|
|
},
|
|
)
|
|
|
|
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
|
|
ctx.items,
|
|
)
|
|
|
|
const { isBulkOperating, bulkProgress, bulkTotal, bulkOperation, runBulk } = useBulkOperation()
|
|
|
|
// Sync bulk operation state back to the content manager so providers can suppress refreshes
|
|
if (ctx.isBulkOperating) {
|
|
watch(isBulkOperating, (val) => {
|
|
ctx.isBulkOperating!.value = val
|
|
})
|
|
}
|
|
|
|
const { isChanging, markChanging, unmarkChanging } = useChangingItems()
|
|
|
|
const bulkWaiting = ref(false)
|
|
|
|
const refreshing = ref(false)
|
|
async function handleRefresh() {
|
|
if (refreshing.value) return
|
|
refreshing.value = true
|
|
try {
|
|
await ctx.refresh()
|
|
} finally {
|
|
refreshing.value = false
|
|
}
|
|
}
|
|
|
|
const filteredItems = computed(() => {
|
|
const sorted = sortedItems.value
|
|
const searched = search(sorted)
|
|
return applyFilters(searched)
|
|
})
|
|
const tableItems = computed<ContentCardTableItem[]>(() => {
|
|
const items = filteredItems.value.map((item) => {
|
|
const base = ctx.mapToTableItem(item)
|
|
return {
|
|
...base,
|
|
disabled:
|
|
isChanging(base.id) ||
|
|
ctx.isBusy.value ||
|
|
isBulkOperating.value ||
|
|
item.installing === true,
|
|
installing: item.installing === true,
|
|
hasUpdate: !ctx.isPackLocked.value && item.has_update,
|
|
isClientOnly: isClientOnlyEnvironment(item.environment),
|
|
overflowOptions: ctx.getOverflowOptions?.(item),
|
|
}
|
|
})
|
|
|
|
const updatable = items.filter((i) => i.hasUpdate)
|
|
if (updatable.length > 0) {
|
|
debug('tableItems: items with hasUpdate=true', {
|
|
count: updatable.length,
|
|
ids: updatable.map((i) => i.id),
|
|
isPackLocked: ctx.isPackLocked.value,
|
|
})
|
|
}
|
|
|
|
return items
|
|
})
|
|
|
|
const hasOutdatedProjects = computed(() => {
|
|
const outdated = ctx.items.value.filter((p) => p.has_update)
|
|
if (outdated.length > 0) {
|
|
debug('hasOutdatedProjects: raw items with has_update=true', {
|
|
count: outdated.length,
|
|
items: outdated.map((p) => ({
|
|
id: p.id,
|
|
fileName: p.file_name,
|
|
title: p.project?.title,
|
|
has_update: p.has_update,
|
|
update_version_id: p.update_version_id,
|
|
})),
|
|
})
|
|
}
|
|
return outdated.length > 0
|
|
})
|
|
|
|
// Deletion
|
|
const pendingDeletionItems = ref<ContentItem[]>([])
|
|
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
|
|
|
|
function handleDeleteById(id: string, event?: MouseEvent) {
|
|
const item = ctx.items.value.find((i) => i.id === id)
|
|
if (item) {
|
|
pendingDeletionItems.value = [item]
|
|
if (event?.shiftKey) {
|
|
confirmDelete()
|
|
} else {
|
|
confirmDeletionModal.value?.show()
|
|
}
|
|
}
|
|
}
|
|
|
|
function showBulkDeleteModal(event?: MouseEvent) {
|
|
pendingDeletionItems.value = [...selectedItems.value]
|
|
if (event?.shiftKey) {
|
|
confirmDelete()
|
|
} else {
|
|
confirmDeletionModal.value?.show()
|
|
}
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
const itemsToDelete = [...pendingDeletionItems.value]
|
|
pendingDeletionItems.value = []
|
|
if (itemsToDelete.length === 0) return
|
|
|
|
if (ctx.bulkDeleteItems && itemsToDelete.length > 1) {
|
|
isBulkOperating.value = true
|
|
bulkOperation.value = 'delete'
|
|
bulkWaiting.value = true
|
|
try {
|
|
await ctx.bulkDeleteItems(itemsToDelete)
|
|
} finally {
|
|
clearSelection()
|
|
isBulkOperating.value = false
|
|
bulkOperation.value = null
|
|
bulkWaiting.value = false
|
|
}
|
|
return
|
|
}
|
|
|
|
if (itemsToDelete.length === 1) {
|
|
const item = itemsToDelete[0]
|
|
const id = item.id
|
|
markChanging(id)
|
|
await ctx.deleteItem(item)
|
|
removeFromSelection(id)
|
|
unmarkChanging(id)
|
|
return
|
|
}
|
|
|
|
await runBulk(
|
|
'delete',
|
|
itemsToDelete,
|
|
async (item) => {
|
|
await ctx.deleteItem(item)
|
|
removeFromSelection(item.id)
|
|
},
|
|
{ onComplete: clearSelection },
|
|
)
|
|
}
|
|
|
|
async function handleToggleEnabledById(id: string, _value: boolean) {
|
|
const item = ctx.items.value.find((i) => i.id === id)
|
|
if (!item) return
|
|
markChanging(id)
|
|
try {
|
|
await ctx.toggleEnabled(item)
|
|
} finally {
|
|
unmarkChanging(id)
|
|
}
|
|
}
|
|
|
|
async function bulkEnable() {
|
|
const items = selectedItems.value.filter((item) => !item.enabled)
|
|
if (items.length === 0) return
|
|
if (ctx.bulkEnableItems) {
|
|
isBulkOperating.value = true
|
|
bulkOperation.value = 'enable'
|
|
bulkWaiting.value = true
|
|
try {
|
|
await ctx.bulkEnableItems(items)
|
|
} finally {
|
|
clearSelection()
|
|
isBulkOperating.value = false
|
|
bulkOperation.value = null
|
|
bulkWaiting.value = false
|
|
}
|
|
return
|
|
}
|
|
await runBulk('enable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
|
|
}
|
|
|
|
async function bulkDisable() {
|
|
const items = selectedItems.value.filter((item) => item.enabled)
|
|
if (items.length === 0) return
|
|
if (ctx.bulkDisableItems) {
|
|
isBulkOperating.value = true
|
|
bulkOperation.value = 'disable'
|
|
bulkWaiting.value = true
|
|
try {
|
|
await ctx.bulkDisableItems(items)
|
|
} finally {
|
|
clearSelection()
|
|
isBulkOperating.value = false
|
|
bulkOperation.value = null
|
|
bulkWaiting.value = false
|
|
}
|
|
return
|
|
}
|
|
await runBulk('disable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
|
|
}
|
|
|
|
function handleUpdateById(id: string) {
|
|
ctx.updateItem?.(id)
|
|
}
|
|
|
|
function handleSwitchVersionById(id: string) {
|
|
const item = ctx.items.value.find((i) => i.id === id)
|
|
if (item) {
|
|
ctx.switchVersion?.(item)
|
|
}
|
|
}
|
|
|
|
// Bulk updating
|
|
const confirmBulkUpdateModal = ref<InstanceType<typeof ConfirmBulkUpdateModal>>()
|
|
const pendingBulkUpdateItems = ref<ContentItem[]>([])
|
|
|
|
const hasBulkUpdateSupport = computed(() => !!(ctx.bulkUpdateItem || ctx.bulkUpdateItems))
|
|
|
|
function promptUpdateAll(event?: MouseEvent) {
|
|
if (!hasBulkUpdateSupport.value) return
|
|
const items = ctx.items.value.filter((item) => item.has_update)
|
|
if (items.length === 0) return
|
|
pendingBulkUpdateItems.value = items
|
|
if (event?.shiftKey) {
|
|
confirmBulkUpdate()
|
|
} else {
|
|
confirmBulkUpdateModal.value?.show()
|
|
}
|
|
}
|
|
|
|
function promptUpdateSelected(event?: MouseEvent) {
|
|
if (!hasBulkUpdateSupport.value) return
|
|
const items = selectedItems.value.filter((item) => item.has_update)
|
|
if (items.length === 0) return
|
|
pendingBulkUpdateItems.value = items
|
|
if (event?.shiftKey) {
|
|
confirmBulkUpdate()
|
|
} else {
|
|
confirmBulkUpdateModal.value?.show()
|
|
}
|
|
}
|
|
|
|
async function confirmBulkUpdate() {
|
|
const items = pendingBulkUpdateItems.value
|
|
if (items.length === 0 || !hasBulkUpdateSupport.value) return
|
|
|
|
if (ctx.bulkUpdateItems) {
|
|
isBulkOperating.value = true
|
|
bulkOperation.value = 'update'
|
|
bulkWaiting.value = true
|
|
try {
|
|
await ctx.bulkUpdateItems(items)
|
|
} finally {
|
|
clearSelection()
|
|
isBulkOperating.value = false
|
|
bulkOperation.value = null
|
|
bulkWaiting.value = false
|
|
}
|
|
} else if (ctx.bulkUpdateItem) {
|
|
await runBulk('update', items, ctx.bulkUpdateItem, { onComplete: clearSelection })
|
|
}
|
|
pendingBulkUpdateItems.value = []
|
|
}
|
|
|
|
const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col gap-4 pb-6">
|
|
<div
|
|
v-if="ctx.loading.value"
|
|
role="status"
|
|
aria-live="polite"
|
|
class="flex min-h-[50vh] w-full flex-col items-center justify-center gap-2 text-center text-secondary"
|
|
>
|
|
<SpinnerIcon class="animate-spin" />
|
|
{{ formatMessage(messages.loadingContent) }}
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="ctx.error.value"
|
|
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
|
>
|
|
<div class="universal-card flex flex-col items-center gap-4 p-6">
|
|
<h2 class="m-0 text-xl font-bold">{{ formatMessage(messages.failedToLoad) }}</h2>
|
|
<p class="text-secondary">{{ ctx.error.value.message }}</p>
|
|
<ButtonStyled color="brand">
|
|
<button @click="handleRefresh">{{ formatMessage(commonMessages.retryButton) }}</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<Admonition v-if="ctx.isBusy.value && ctx.busyMessage?.value" type="warning">
|
|
<template #header>{{ ctx.busyMessage.value }}</template>
|
|
{{ formatMessage(messages.busyDescription) }}
|
|
</Admonition>
|
|
|
|
<ContentModpackCard
|
|
v-if="ctx.modpack.value"
|
|
:project="ctx.modpack.value.project"
|
|
:project-link="ctx.modpack.value.projectLink"
|
|
:version="ctx.modpack.value.version"
|
|
:version-link="ctx.modpack.value.versionLink"
|
|
:owner="ctx.modpack.value.owner"
|
|
:categories="ctx.modpack.value.categories"
|
|
:has-update="ctx.modpack.value.hasUpdate"
|
|
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
|
|
:disabled-text="
|
|
ctx.modpack.value.disabledText ??
|
|
ctx.busyMessage?.value ??
|
|
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
|
|
"
|
|
:show-content-hint="
|
|
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
|
"
|
|
v-on="{
|
|
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
|
|
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
|
|
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
|
|
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
|
|
}"
|
|
@dismiss-content-hint="ctx.dismissContentHint?.()"
|
|
/>
|
|
|
|
<Transition
|
|
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
|
enter-from-class="opacity-0 max-h-0"
|
|
enter-to-class="opacity-100 max-h-40"
|
|
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
|
leave-from-class="opacity-100 max-h-40"
|
|
leave-to-class="opacity-0 max-h-0"
|
|
aria-live="polite"
|
|
>
|
|
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" show-actions-underneath>
|
|
<template #icon>
|
|
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
|
</template>
|
|
<template #header>
|
|
{{
|
|
formatMessage(messages.uploadingFiles, {
|
|
completed: ctx.uploadState?.value?.completedFiles ?? 0,
|
|
total: ctx.uploadState?.value?.totalFiles ?? 0,
|
|
})
|
|
}}
|
|
</template>
|
|
<span class="text-secondary">
|
|
{{ formatBytes(ctx.uploadState?.value?.uploadedBytes ?? 0) }}
|
|
/ {{ formatBytes(ctx.uploadState?.value?.totalBytes ?? 0) }} ({{
|
|
Math.round(uploadOverallProgress * 100)
|
|
}}%)
|
|
</span>
|
|
<template #actions>
|
|
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
|
</template>
|
|
</Admonition>
|
|
</Transition>
|
|
|
|
<template v-if="ctx.items.value.length > 0">
|
|
<div class="flex flex-col gap-4">
|
|
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
|
|
{{ formatMessage(messages.additionalContent) }}
|
|
</span>
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<StyledInput
|
|
v-model="searchQuery"
|
|
:icon="SearchIcon"
|
|
type="text"
|
|
autocomplete="off"
|
|
:spellcheck="false"
|
|
input-class="!h-10"
|
|
wrapper-class="flex-1 min-w-0"
|
|
clearable
|
|
:placeholder="
|
|
formatMessage(messages.searchPlaceholder, {
|
|
count: tableItems.length,
|
|
contentType: `${ctx.contentTypeLabel.value}${tableItems.length === 1 ? '' : 's'}`,
|
|
})
|
|
"
|
|
/>
|
|
|
|
<div class="flex gap-2">
|
|
<ButtonStyled color="brand">
|
|
<button
|
|
v-tooltip="
|
|
ctx.busyMessage?.value ??
|
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
"
|
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
class="!h-10 flex items-center gap-2"
|
|
@click="ctx.browse"
|
|
>
|
|
<CompassIcon class="size-5" />
|
|
<span>{{ formatMessage(messages.browseContent) }}</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled type="outlined">
|
|
<button
|
|
v-tooltip="
|
|
ctx.busyMessage?.value ??
|
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
"
|
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
class="!h-10 !border-button-bg !border-[1px]"
|
|
@click="ctx.uploadFiles"
|
|
>
|
|
<FolderOpenIcon class="size-5" />
|
|
{{ formatMessage(messages.uploadFiles) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="@container flex flex-wrap items-center justify-between gap-2">
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
<FilterIcon class="size-5 text-secondary" />
|
|
<button
|
|
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
|
:class="
|
|
selectedFilters.length === 0
|
|
? 'border-green bg-brand-highlight text-brand'
|
|
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
|
"
|
|
:aria-pressed="selectedFilters.length === 0"
|
|
@click="selectedFilters = []"
|
|
>
|
|
{{ formatMessage(commonMessages.allProjectType) }}
|
|
</button>
|
|
<button
|
|
v-for="option in filterOptions"
|
|
:key="option.id"
|
|
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
|
|
:class="
|
|
selectedFilters.includes(option.id)
|
|
? 'border-green bg-brand-highlight text-brand'
|
|
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
|
|
"
|
|
:aria-pressed="selectedFilters.includes(option.id)"
|
|
@click="toggleFilter(option.id)"
|
|
>
|
|
{{ option.label }}
|
|
</button>
|
|
<div class="hidden @[900px]:block">
|
|
<ButtonStyled type="transparent" hover-color-fill="none">
|
|
<button
|
|
:aria-label="
|
|
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
|
"
|
|
@click="cycleSortMode"
|
|
>
|
|
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
|
v-else-if="sortMode === 'date-added-newest'"
|
|
/><ClockArrowUpIcon
|
|
v-else-if="sortMode === 'date-added-oldest'"
|
|
/><ArrowDownAZIcon v-else />
|
|
{{ sortLabels[sortMode]() }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<div class="@[900px]:hidden">
|
|
<ButtonStyled type="transparent" hover-color-fill="none">
|
|
<button
|
|
:aria-label="
|
|
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
|
"
|
|
@click="cycleSortMode"
|
|
>
|
|
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
|
|
v-else-if="sortMode === 'date-added-newest'"
|
|
/><ClockArrowUpIcon
|
|
v-else-if="sortMode === 'date-added-oldest'"
|
|
/><ArrowDownAZIcon v-else />
|
|
{{ sortLabels[sortMode]() }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
|
|
<ButtonStyled
|
|
v-if="hasBulkUpdateSupport && !ctx.isPackLocked.value && hasOutdatedProjects"
|
|
color="green"
|
|
type="transparent"
|
|
color-fill="text"
|
|
hover-color-fill="background"
|
|
>
|
|
<button :disabled="isBulkOperating || ctx.isBusy.value" @click="promptUpdateAll">
|
|
<DownloadIcon />
|
|
{{ formatMessage(messages.updateAll) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
|
|
<ButtonStyled type="transparent" hover-color-fill="none">
|
|
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
|
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
|
{{ formatMessage(commonMessages.refreshButton) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
|
|
<ContentCardTable
|
|
v-model:selected-ids="selectedIds"
|
|
:items="tableItems"
|
|
:show-selection="true"
|
|
@update:enabled="handleToggleEnabledById"
|
|
@delete="handleDeleteById"
|
|
@update="handleUpdateById"
|
|
@switch-version="handleSwitchVersionById"
|
|
>
|
|
<template #empty>
|
|
<span>{{ formatMessage(messages.noContentFound) }}</span>
|
|
</template>
|
|
</ContentCardTable>
|
|
</div>
|
|
</template>
|
|
|
|
<EmptyState v-else type="empty-inbox">
|
|
<template #heading>
|
|
{{
|
|
formatMessage(
|
|
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
|
|
)
|
|
}}
|
|
</template>
|
|
<template #description>
|
|
{{
|
|
ctx.modpack.value
|
|
? formatMessage(messages.emptyModpackHint)
|
|
: formatMessage(messages.emptyHint, {
|
|
contentType: `${ctx.contentTypeLabel.value}s`,
|
|
})
|
|
}}
|
|
</template>
|
|
<template #actions>
|
|
<ButtonStyled type="outlined">
|
|
<button
|
|
v-tooltip="
|
|
ctx.busyMessage?.value ??
|
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
"
|
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
class="!h-10 !border-button-bg !border-[1px]"
|
|
@click="ctx.uploadFiles"
|
|
>
|
|
<FolderOpenIcon class="size-5" />
|
|
{{ formatMessage(messages.uploadFiles) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand">
|
|
<button
|
|
v-tooltip="
|
|
ctx.busyMessage?.value ??
|
|
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
|
"
|
|
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
|
class="!h-10 flex items-center gap-2"
|
|
@click="ctx.browse"
|
|
>
|
|
<CompassIcon class="size-5" />
|
|
<span>{{ formatMessage(messages.browseContent) }}</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
</template>
|
|
</EmptyState>
|
|
</template>
|
|
|
|
<ContentSelectionBar
|
|
:selected-items="selectedItems"
|
|
:content-type-label="ctx.contentTypeLabel.value"
|
|
:is-busy="ctx.isBusy.value"
|
|
:is-bulk-operating="isBulkOperating"
|
|
:bulk-operation="bulkOperation"
|
|
:bulk-progress="bulkProgress"
|
|
:bulk-total="bulkTotal"
|
|
:bulk-waiting="bulkWaiting"
|
|
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
|
|
@clear="clearSelection"
|
|
@enable="bulkEnable"
|
|
@disable="bulkDisable"
|
|
>
|
|
<template #actions>
|
|
<ButtonStyled
|
|
v-if="
|
|
hasBulkUpdateSupport &&
|
|
!ctx.isPackLocked.value &&
|
|
selectedItems.some((m) => m.has_update)
|
|
"
|
|
type="transparent"
|
|
color="green"
|
|
color-fill="text"
|
|
hover-color-fill="background"
|
|
>
|
|
<button
|
|
v-tooltip="formatMessage(commonMessages.updateButton)"
|
|
:disabled="ctx.isBusy.value"
|
|
@click="promptUpdateSelected"
|
|
>
|
|
<DownloadIcon />
|
|
<span class="bar-label">{{ formatMessage(commonMessages.updateButton) }}</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
|
|
<ButtonStyled v-if="ctx.shareItems" type="transparent">
|
|
<OverflowMenu
|
|
:options="[
|
|
{
|
|
id: 'share-names',
|
|
action: () => ctx.shareItems!(selectedItems, 'names'),
|
|
},
|
|
{
|
|
id: 'share-file-names',
|
|
action: () => ctx.shareItems!(selectedItems, 'file-names'),
|
|
},
|
|
{
|
|
id: 'share-urls',
|
|
action: () => ctx.shareItems!(selectedItems, 'urls'),
|
|
},
|
|
{
|
|
id: 'share-markdown',
|
|
action: () => ctx.shareItems!(selectedItems, 'markdown'),
|
|
},
|
|
]"
|
|
>
|
|
<ShareIcon />
|
|
<span class="bar-label">{{ formatMessage(messages.share) }}</span>
|
|
<DropdownIcon />
|
|
<template #share-names>
|
|
<TextCursorInputIcon />
|
|
{{ formatMessage(messages.shareProjectNames) }}
|
|
</template>
|
|
<template #share-file-names>
|
|
<FileIcon />
|
|
{{ formatMessage(messages.shareFileNames) }}
|
|
</template>
|
|
<template #share-urls>
|
|
<LinkIcon />
|
|
{{ formatMessage(messages.shareProjectLinks) }}
|
|
</template>
|
|
<template #share-markdown>
|
|
<CodeIcon />
|
|
{{ formatMessage(messages.shareMarkdownLinks) }}
|
|
</template>
|
|
</OverflowMenu>
|
|
</ButtonStyled>
|
|
</template>
|
|
|
|
<template #actions-end>
|
|
<div class="mx-1 h-6 w-px bg-surface-5" />
|
|
|
|
<ButtonStyled
|
|
type="transparent"
|
|
color="red"
|
|
color-fill="text"
|
|
hover-color-fill="background"
|
|
>
|
|
<button
|
|
v-tooltip="formatMessage(commonMessages.deleteLabel)"
|
|
:disabled="ctx.isBusy.value"
|
|
@click="showBulkDeleteModal"
|
|
>
|
|
<TrashIcon />
|
|
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
|
</button>
|
|
</ButtonStyled>
|
|
</template>
|
|
</ContentSelectionBar>
|
|
|
|
<ConfirmDeletionModal
|
|
ref="confirmDeletionModal"
|
|
:count="pendingDeletionItems.length"
|
|
:item-type="ctx.contentTypeLabel.value"
|
|
:variant="ctx.deletionContext ?? 'instance'"
|
|
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
|
|
@delete="confirmDelete"
|
|
/>
|
|
<ConfirmBulkUpdateModal
|
|
v-if="hasBulkUpdateSupport"
|
|
ref="confirmBulkUpdateModal"
|
|
:count="pendingBulkUpdateItems.length"
|
|
:server="ctx.deletionContext === 'server'"
|
|
@update="confirmBulkUpdate"
|
|
/>
|
|
<ConfirmUnlinkModal
|
|
v-if="ctx.unlinkModpack"
|
|
ref="confirmUnlinkModal"
|
|
:server="ctx.deletionContext === 'server'"
|
|
:backup-tip="ctx.modpack.value?.project.title"
|
|
@unlink="ctx.unlinkModpack!()"
|
|
/>
|
|
|
|
<slot name="modals" />
|
|
</div>
|
|
</template>
|