-
@@ -662,6 +634,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
persistKey?: string
}
@@ -88,15 +93,22 @@ export function useContentFilters(items: Ref, config?: ContentFil
options.push({ id: 'updates', label: formatMessage(messages.updates) })
}
- if (config?.showWarningsFilter && items.value.some((m) => getClientWarningType(m) !== null)) {
+ if (
+ config?.showWarningsFilter &&
+ items.value.some(
+ (item) => getClientWarningType(item, config.showEnvironmentWarnings) !== null,
+ )
+ ) {
options.push({ id: 'warnings', label: formatMessage(messages.warnings) })
}
- for (const status of availableStatusFilters.value) {
- options.push({
- id: status,
- label: formatMessage(status === 'enabled' ? messages.enabled : messages.disabled),
- })
+ if (config?.showStatusFilters !== false) {
+ for (const status of availableStatusFilters.value) {
+ options.push({
+ id: status,
+ label: formatMessage(status === 'enabled' ? messages.enabled : messages.disabled),
+ })
+ }
}
return options
@@ -154,7 +166,11 @@ export function useContentFilters(items: Ref, config?: ContentFil
if (filter === 'updates' && !item.has_update) return false
if (filter === 'enabled' && !item.enabled) return false
if (filter === 'disabled' && item.enabled) return false
- if (filter === 'warnings' && getClientWarningType(item) === null) return false
+ if (
+ filter === 'warnings' &&
+ getClientWarningType(item, config?.showEnvironmentWarnings) === null
+ )
+ return false
}
return true
diff --git a/packages/ui/src/layouts/shared/content-tab/composables/index.ts b/packages/ui/src/layouts/shared/content-tab/composables/index.ts
index 40b31778d5..6acf59cf0b 100644
--- a/packages/ui/src/layouts/shared/content-tab/composables/index.ts
+++ b/packages/ui/src/layouts/shared/content-tab/composables/index.ts
@@ -3,4 +3,5 @@ export * from './changing-items'
export * from './content-filtering'
export * from './content-search'
export * from './content-selection'
+export * from './use-content-metadata-filters'
export * from './use-inline-backup'
diff --git a/packages/ui/src/layouts/shared/content-tab/composables/use-content-metadata-filters.ts b/packages/ui/src/layouts/shared/content-tab/composables/use-content-metadata-filters.ts
new file mode 100644
index 0000000000..54d2ac2c37
--- /dev/null
+++ b/packages/ui/src/layouts/shared/content-tab/composables/use-content-metadata-filters.ts
@@ -0,0 +1,378 @@
+import { useSessionStorage } from '@vueuse/core'
+import type { Ref } from 'vue'
+import { computed, ref, watch } from 'vue'
+
+import type {
+ DropdownFilterBarCategory,
+ DropdownFilterBarOption,
+} from '#ui/components/base/DropdownFilterBar.vue'
+import { defineMessages, useVIntl } from '#ui/composables/i18n'
+
+import type { ContentItem } from '../types'
+import { getClientWarningType } from './content-filtering'
+
+export type ContentMetadataFilterValue = Record
+
+interface MetadataFilterDefinition {
+ key: string
+ label: string
+ searchable?: boolean
+ direct?: boolean
+ options?: DropdownFilterBarOption[]
+ values: (item: ContentItem) => DropdownFilterBarOption[]
+}
+
+interface ContentMetadataFilterConfig {
+ showSharedContent?: Ref | Readonly[>
+ showEnvironmentWarnings?: boolean
+}
+
+const openSourceLicenseIds = new Set([
+ '0BSD',
+ 'AFL-3.0',
+ 'AGPL-3.0',
+ 'Apache-2.0',
+ 'Artistic-2.0',
+ 'BSD-2-Clause',
+ 'BSD-3-Clause',
+ 'BSL-1.0',
+ 'CDDL-1.0',
+ 'ECL-2.0',
+ 'EPL-1.0',
+ 'EPL-2.0',
+ 'EUPL-1.1',
+ 'EUPL-1.2',
+ 'GPL-2.0',
+ 'GPL-3.0',
+ 'ISC',
+ 'LGPL-2.1',
+ 'LGPL-3.0',
+ 'MIT',
+ 'MPL-2.0',
+ 'NCSA',
+ 'OSL-3.0',
+ 'PostgreSQL',
+ 'Python-2.0',
+ 'Unlicense',
+ 'UPL-1.0',
+ 'Zlib',
+])
+
+type EnvironmentFilterValue = 'client' | 'server' | 'client_and_server' | 'singleplayer'
+
+function getEnvironmentFilterValue(
+ environment?: ContentItem['environment'],
+): EnvironmentFilterValue | undefined {
+ switch (environment) {
+ case 'client_only':
+ return 'client'
+ case 'server_only':
+ case 'dedicated_server_only':
+ return 'server'
+ case 'client_and_server':
+ case 'client_only_server_optional':
+ case 'server_only_client_optional':
+ case 'client_or_server':
+ case 'client_or_server_prefers_both':
+ return 'client_and_server'
+ case 'singleplayer_only':
+ return 'singleplayer'
+ default:
+ return undefined
+ }
+}
+
+const messages = defineMessages({
+ author: {
+ id: 'content.metadata-filter.author',
+ defaultMessage: 'Author',
+ },
+ openSource: {
+ id: 'content.metadata-filter.open-source',
+ defaultMessage: 'Open source',
+ },
+ environment: {
+ id: 'content.metadata-filter.environment',
+ defaultMessage: 'Environment',
+ },
+ clientSideOnly: {
+ id: 'project.settings.environment.client_only.title',
+ defaultMessage: 'Client-side only',
+ },
+ serverSideOnly: {
+ id: 'project.settings.environment.server_only.title',
+ defaultMessage: 'Server-side only',
+ },
+ clientAndServer: {
+ id: 'project.settings.environment.client_and_server.title',
+ defaultMessage: 'Client and server',
+ },
+ singleplayerOnly: {
+ id: 'project.settings.environment.singleplayer.title',
+ defaultMessage: 'Singleplayer only',
+ },
+ state: {
+ id: 'content.metadata-filter.state',
+ defaultMessage: 'State',
+ },
+ updates: {
+ id: 'content.metadata-filter.updates',
+ defaultMessage: 'Updates',
+ },
+ warnings: {
+ id: 'content.metadata-filter.warnings',
+ defaultMessage: 'Warnings',
+ },
+ enabled: {
+ id: 'content.metadata-filter.state.enabled',
+ defaultMessage: 'Enabled',
+ },
+ disabled: {
+ id: 'content.metadata-filter.state.disabled',
+ defaultMessage: 'Disabled',
+ },
+ updateAvailable: {
+ id: 'content.metadata-filter.update.available',
+ defaultMessage: 'Update available',
+ },
+ upToDate: {
+ id: 'content.metadata-filter.update.up-to-date',
+ defaultMessage: 'Up to date',
+ },
+ clientRetained: {
+ id: 'content.metadata-filter.warning.client-retained',
+ defaultMessage: 'Client file retained',
+ },
+ clientDepends: {
+ id: 'content.metadata-filter.warning.client-depends',
+ defaultMessage: 'Client depends on file',
+ },
+ clientOnly: {
+ id: 'content.metadata-filter.warning.client-only',
+ defaultMessage: 'Client-only content',
+ },
+ noWarnings: {
+ id: 'content.metadata-filter.warning.none',
+ defaultMessage: 'No warnings',
+ },
+ external: {
+ id: 'content.metadata-filter.source.external',
+ defaultMessage: 'External',
+ },
+ sharedContent: {
+ id: 'content.metadata-filter.shared-content',
+ defaultMessage: 'Shared content',
+ },
+})
+
+export function useContentMetadataFilters(
+ items: Ref,
+ persistKey?: string,
+ config?: ContentMetadataFilterConfig,
+) {
+ const { formatMessage } = useVIntl()
+ const selectedMetadataFilters = persistKey
+ ? useSessionStorage(`content-metadata-filters:${persistKey}`, {})
+ : ref({})
+
+ function option(value: string, label: string, searchTerms?: string[]): DropdownFilterBarOption {
+ return { value, label, searchTerms }
+ }
+
+ function isOpenSource(item: ContentItem) {
+ const licenseId = item.project?.license?.id.replace(/-(?:only|or-later)$/, '')
+ return !!licenseId && openSourceLicenseIds.has(licenseId)
+ }
+
+ function isExternal(item: ContentItem) {
+ return item.external || !item.project?.license
+ }
+
+ function getEnvironmentFilterLabel(value: EnvironmentFilterValue) {
+ switch (value) {
+ case 'client':
+ return formatMessage(messages.clientSideOnly)
+ case 'server':
+ return formatMessage(messages.serverSideOnly)
+ case 'client_and_server':
+ return formatMessage(messages.clientAndServer)
+ case 'singleplayer':
+ return formatMessage(messages.singleplayerOnly)
+ }
+ }
+
+ const definitions = computed(() => [
+ {
+ key: 'author',
+ label: formatMessage(messages.author),
+ searchable: true,
+ values: (item) =>
+ item.owner
+ ? [option(`${item.owner.type}:${item.owner.id}`, item.owner.name, [item.owner.id])]
+ : [],
+ },
+ {
+ key: 'environment',
+ label: formatMessage(messages.environment),
+ options: [
+ option('client', getEnvironmentFilterLabel('client')),
+ option('server', getEnvironmentFilterLabel('server')),
+ option('client_and_server', getEnvironmentFilterLabel('client_and_server')),
+ option('singleplayer', getEnvironmentFilterLabel('singleplayer')),
+ ],
+ values: (item) => {
+ const value = getEnvironmentFilterValue(item.environment)
+ return value ? [option(value, getEnvironmentFilterLabel(value))] : []
+ },
+ },
+ {
+ key: 'state',
+ label: formatMessage(messages.state),
+ values: (item) =>
+ item.enabled === undefined
+ ? []
+ : [
+ item.enabled
+ ? option('enabled', formatMessage(messages.enabled))
+ : option('disabled', formatMessage(messages.disabled)),
+ ],
+ },
+ {
+ key: 'updates',
+ label: formatMessage(messages.updates),
+ values: (item) => [
+ item.has_update
+ ? option('available', formatMessage(messages.updateAvailable))
+ : option('current', formatMessage(messages.upToDate)),
+ ],
+ },
+ {
+ key: 'warnings',
+ label: formatMessage(messages.warnings),
+ values: (item) => {
+ const warning = getClientWarningType(item, config?.showEnvironmentWarnings)
+ switch (warning) {
+ case 'retained':
+ return [option(warning, formatMessage(messages.clientRetained))]
+ case 'depends':
+ return [option(warning, formatMessage(messages.clientDepends))]
+ case 'environment':
+ return [option(warning, formatMessage(messages.clientOnly))]
+ default:
+ return [option('none', formatMessage(messages.noWarnings))]
+ }
+ },
+ },
+ {
+ key: 'open_source',
+ label: formatMessage(messages.openSource),
+ direct: true,
+ values: (item) =>
+ isOpenSource(item) ? [option('open_source', formatMessage(messages.openSource))] : [],
+ },
+ {
+ key: 'external',
+ label: formatMessage(messages.external),
+ direct: true,
+ values: (item) =>
+ isExternal(item) ? [option('external', formatMessage(messages.external))] : [],
+ },
+ ...(config?.showSharedContent?.value
+ ? [
+ {
+ key: 'shared_content',
+ label: formatMessage(messages.sharedContent),
+ direct: true,
+ values: (item: ContentItem) =>
+ ['server_project', 'shared_instance'].includes(item.source_kind ?? '')
+ ? [option('shared_content', formatMessage(messages.sharedContent))]
+ : [],
+ },
+ ]
+ : []),
+ ])
+
+ const metadataFilterCategories = computed(() =>
+ definitions.value
+ .map((definition) => {
+ let visibleOptions = definition.options
+ if (!visibleOptions) {
+ const options = new Map()
+ const optionMatchCounts = new Map()
+ for (const item of items.value) {
+ const itemValues = new Map(
+ definition.values(item).map((value) => [value.value, value] as const),
+ )
+ for (const value of itemValues.values()) {
+ if (!options.has(value.value)) options.set(value.value, value)
+ optionMatchCounts.set(value.value, (optionMatchCounts.get(value.value) ?? 0) + 1)
+ }
+ }
+
+ visibleOptions = [...options.values()]
+ .filter((option) => optionMatchCounts.get(option.value) !== items.value.length)
+ .sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }))
+ }
+
+ return {
+ key: definition.key,
+ label: definition.label,
+ direct: definition.direct,
+ searchable: definition.searchable,
+ options: visibleOptions,
+ }
+ })
+ .filter((category) => category.options.some((option) => !('type' in option))),
+ )
+
+ watch(
+ metadataFilterCategories,
+ (categories) => {
+ if (items.value.length === 0) return
+ const availableValues = new Map(
+ categories.map((category) => [
+ category.key,
+ new Set(
+ category.options
+ .filter((item): item is DropdownFilterBarOption => !('type' in item))
+ .map((item) => item.value),
+ ),
+ ]),
+ )
+ const nextFilters: ContentMetadataFilterValue = {}
+ for (const [key, values] of Object.entries(selectedMetadataFilters.value)) {
+ const validValues = values.filter((value) => availableValues.get(key)?.has(value))
+ if (validValues.length > 0) nextFilters[key] = validValues
+ }
+ if (JSON.stringify(nextFilters) !== JSON.stringify(selectedMetadataFilters.value)) {
+ selectedMetadataFilters.value = nextFilters
+ }
+ },
+ { immediate: true },
+ )
+
+ function applyMetadataFilters(source: ContentItem[]) {
+ const activeFilters = Object.entries(selectedMetadataFilters.value).filter(
+ ([, values]) => values.length > 0,
+ )
+ if (activeFilters.length === 0) return source
+
+ const definitionsByKey = new Map(
+ definitions.value.map((definition) => [definition.key, definition]),
+ )
+ return source.filter((item) =>
+ activeFilters.every(([key, selectedValues]) => {
+ const definition = definitionsByKey.get(key)
+ if (!definition) return true
+ const itemValues = definition.values(item).map((value) => value.value)
+ return itemValues.some((value) => selectedValues.includes(value))
+ }),
+ )
+ }
+
+ return {
+ selectedMetadataFilters,
+ metadataFilterCategories,
+ applyMetadataFilters,
+ }
+}
diff --git a/packages/ui/src/layouts/shared/content-tab/index.ts b/packages/ui/src/layouts/shared/content-tab/index.ts
index ffb63e9c64..012920a760 100644
--- a/packages/ui/src/layouts/shared/content-tab/index.ts
+++ b/packages/ui/src/layouts/shared/content-tab/index.ts
@@ -1,7 +1,9 @@
export { default as ContentCardItem } from './components/ContentCardItem.vue'
export { default as ContentCard } from './components/ContentCardItem.vue'
export { default as ContentCardTable } from './components/ContentCardTable.vue'
-export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
+export { default as ManagedContentCard } from './components/managed-content-card/index.vue'
+export type { ManagedContentModalState } from './components/managed-content-modal/index.vue'
+export { default as ManagedContentModal } from './components/managed-content-modal/index.vue'
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
export { default as ConfirmDisableModal } from './components/modals/ConfirmDisableModal.vue'
@@ -18,11 +20,10 @@ export type {
} from './components/modals/ContentInstallModal.vue'
export { default as ContentInstallModal } from './components/modals/ContentInstallModal.vue'
export { default as InlineBackupCreator } from './components/modals/InlineBackupCreator.vue'
-export type { ModpackContentModalState } from './components/modals/ModpackContentModal.vue'
-export { default as ModpackContentModal } from './components/modals/ModpackContentModal.vue'
export { default as ContentCardLayout } from './layout.vue'
export { default as ContentPageLayout } from './layout.vue'
export * from './providers'
export * from './types'
+export * from './utils/managed-content'
export * from './utils/update-channels'
export { default as ConfirmLeaveModal } from '#ui/components/modal/ConfirmLeaveModal.vue'
diff --git a/packages/ui/src/layouts/shared/content-tab/layout.vue b/packages/ui/src/layouts/shared/content-tab/layout.vue
index 2ec27bb643..33e2749d40 100644
--- a/packages/ui/src/layouts/shared/content-tab/layout.vue
+++ b/packages/ui/src/layouts/shared/content-tab/layout.vue
@@ -9,27 +9,31 @@ import {
DownloadIcon,
DropdownIcon,
FileIcon,
- FilterIcon,
FolderOpenIcon,
LinkIcon,
+ OrganizationIcon,
RefreshCwIcon,
SearchIcon,
ShareIcon,
TextCursorInputIcon,
TrashIcon,
+ UserIcon,
} from '@modrinth/assets'
-import { computed, nextTick, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import { Button, TeleportOverflowMenu } from '#ui/components/base/buttons'
+import Avatar from '#ui/components/base/Avatar.vue'
+import { Button, type OverflowMenuOption, TeleportOverflowMenu } from '#ui/components/base/buttons'
+import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
+import FilterPills from '#ui/components/base/FilterPills.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, formatContentTypeSentence } from '#ui/utils/common-messages'
import ContentCardTable from './components/ContentCardTable.vue'
-import ContentModpackCard from './components/ContentModpackCard.vue'
import ContentSelectionBar from './components/ContentSelectionBar.vue'
+import ManagedContentCard from './components/managed-content-card/index.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmDisableModal from './components/modals/ConfirmDisableModal.vue'
@@ -37,10 +41,10 @@ import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
getClientWarningType,
- isClientOnlyEnvironment,
useBulkOperation,
useChangingItems,
useContentFilters,
+ useContentMetadataFilters,
useContentSearch,
useContentSelection,
} from './composables'
@@ -89,9 +93,13 @@ const messages = defineMessages({
id: 'content.page-layout.upload-files',
defaultMessage: 'Upload files',
},
- sortAlphabetical: {
- id: 'content.page-layout.sort.alphabetical',
- defaultMessage: 'Alphabetical',
+ sortAlphabeticalAscending: {
+ id: 'content.page-layout.sort.alphabetical-ascending',
+ defaultMessage: 'Name (A-Z)',
+ },
+ sortAlphabeticalDescending: {
+ id: 'content.page-layout.sort.alphabetical-descending',
+ defaultMessage: 'Name (Z-A)',
},
sortDateAddedNewest: {
id: 'content.page-layout.sort.date-added-newest',
@@ -101,6 +109,14 @@ const messages = defineMessages({
id: 'content.page-layout.sort.date-added-oldest',
defaultMessage: 'Oldest first',
},
+ filter: {
+ id: 'content.page-layout.filter.add',
+ defaultMessage: 'Filter',
+ },
+ authorCount: {
+ id: 'content.page-layout.filter.author-count',
+ defaultMessage: '{count, plural, one {# author} other {# authors}}',
+ },
updateAll: {
id: 'content.page-layout.update-all',
defaultMessage: 'Update all',
@@ -166,22 +182,38 @@ type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' |
const sortMode = ref('alphabetical-asc')
const sortLabels: Record string> = {
- 'alphabetical-asc': () => formatMessage(messages.sortAlphabetical),
- 'alphabetical-desc': () => formatMessage(messages.sortAlphabetical),
+ 'alphabetical-asc': () => formatMessage(messages.sortAlphabeticalAscending),
+ 'alphabetical-desc': () => formatMessage(messages.sortAlphabeticalDescending),
'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 sortOptions = computed(() => [
+ {
+ id: 'alphabetical-asc',
+ label: formatMessage(messages.sortAlphabeticalAscending),
+ icon: ArrowDownAZIcon,
+ action: () => (sortMode.value = 'alphabetical-asc'),
+ },
+ {
+ id: 'alphabetical-desc',
+ label: formatMessage(messages.sortAlphabeticalDescending),
+ icon: ArrowUpZAIcon,
+ action: () => (sortMode.value = 'alphabetical-desc'),
+ },
+ {
+ id: 'date-added-newest',
+ label: formatMessage(messages.sortDateAddedNewest),
+ icon: ClockArrowDownIcon,
+ action: () => (sortMode.value = 'date-added-newest'),
+ },
+ {
+ id: 'date-added-oldest',
+ label: formatMessage(messages.sortDateAddedOldest),
+ icon: ClockArrowUpIcon,
+ action: () => (sortMode.value = 'date-added-oldest'),
+ },
+])
const sortedItems = computed(() => {
const items = [...ctx.items.value]
@@ -229,13 +261,125 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
ctx.items,
{
showTypeFilters: true,
- showUpdateFilter: ctx.hasUpdateSupport,
- showWarningsFilter: true,
+ showUpdateFilter: false,
+ showWarningsFilter: false,
+ showStatusFilters: false,
+ showEnvironmentWarnings: ctx.showEnvironmentWarnings,
isPackLocked: ctx.isPackLocked,
persistKey: ctx.filterPersistKey,
},
)
+const { selectedMetadataFilters, metadataFilterCategories, applyMetadataFilters } =
+ useContentMetadataFilters(ctx.items, ctx.filterPersistKey, {
+ showSharedContent: ctx.showSharedContentFilter,
+ showEnvironmentWarnings: ctx.showEnvironmentWarnings,
+ })
+
+const metadataFilterAuthors = computed(() => {
+ const authors = new Map>()
+ for (const item of ctx.items.value) {
+ if (!item.owner) continue
+ authors.set(`${item.owner.type}:${item.owner.id}`, item.owner)
+ }
+ return authors
+})
+
+function getMetadataFilterAuthor(value: string) {
+ return metadataFilterAuthors.value.get(value)
+}
+
+function getMetadataFilterPreviewAuthor(selectedValues: string[]) {
+ const [selectedValue] = selectedValues
+ return selectedValues.length === 1 && selectedValue
+ ? getMetadataFilterAuthor(selectedValue)
+ : undefined
+}
+
+const metadataFilterPreviewAuthorLimit = 3
+const metadataFilterPreviewAuthorSize = 20
+const metadataFilterPreviewAuthorOffset = 14
+
+function getMetadataFilterPreviewAuthorValues(selectedValues: string[]) {
+ return selectedValues.slice(0, metadataFilterPreviewAuthorLimit)
+}
+
+function getMetadataFilterPreviewAuthorOverflow(selectedValues: string[]) {
+ return Math.max(0, selectedValues.length - metadataFilterPreviewAuthorLimit)
+}
+
+function getMetadataFilterPreviewAuthorStackWidth(selectedValues: string[]) {
+ const visibleCount = Math.min(selectedValues.length, metadataFilterPreviewAuthorLimit)
+ if (visibleCount === 0) return 0
+ return (
+ metadataFilterPreviewAuthorSize +
+ (visibleCount - 1 + (selectedValues.length > metadataFilterPreviewAuthorLimit ? 1 : 0)) *
+ metadataFilterPreviewAuthorOffset
+ )
+}
+
+function isMetadataFilterOrganization(value: string) {
+ return (
+ getMetadataFilterAuthor(value)?.type === 'organization' || value.startsWith('organization:')
+ )
+}
+
+const metadataFilterTriggerClass =
+ '!h-[34px] !rounded-xl !border !border-solid !border-surface-5 !bg-transparent !px-3 !text-sm !font-medium !text-primary !shadow-[0_1px_1.5px_rgba(0,0,0,0.15)] transition-all duration-100 active:scale-[0.97] hover:!bg-surface-3 focus-visible:!outline-none focus-visible:!ring-4 focus-visible:!ring-brand-shadow [&>svg]:!size-5'
+const metadataFilterPreviewTriggerClass =
+ '!h-[34px] !rounded-xl !border !border-solid !border-brand !bg-brand-highlight !px-3 !text-sm !font-medium !text-brand !shadow-[0_1px_1.5px_rgba(0,0,0,0.15)] transition-all duration-100 active:scale-[0.97] hover:!bg-brand-highlight focus-visible:!outline-none focus-visible:!ring-4 focus-visible:!ring-brand-shadow [&>svg]:!size-5 [&>svg]:!text-brand'
+
+const filterControlsRef = ref(null)
+const projectTypeFiltersRef = ref(null)
+const metadataFiltersRef = ref(null)
+const metadataFiltersWrapped = ref(false)
+let filterControlsResizeObserver: ResizeObserver | null = null
+
+function updateMetadataFiltersWrapped() {
+ metadataFiltersWrapped.value =
+ !!projectTypeFiltersRef.value &&
+ !!metadataFiltersRef.value &&
+ metadataFiltersRef.value.offsetTop > projectTypeFiltersRef.value.offsetTop
+}
+
+function observeFilterControls() {
+ filterControlsResizeObserver?.disconnect()
+ for (const element of [
+ filterControlsRef.value,
+ projectTypeFiltersRef.value,
+ metadataFiltersRef.value,
+ ]) {
+ if (element) filterControlsResizeObserver?.observe(element)
+ }
+ updateMetadataFiltersWrapped()
+}
+
+onMounted(() => {
+ if (typeof ResizeObserver === 'undefined') return
+ filterControlsResizeObserver = new ResizeObserver(updateMetadataFiltersWrapped)
+ observeFilterControls()
+})
+
+watch([filterControlsRef, projectTypeFiltersRef, metadataFiltersRef], observeFilterControls, {
+ flush: 'post',
+})
+
+onBeforeUnmount(() => {
+ filterControlsResizeObserver?.disconnect()
+})
+
+function updateFilterChips(nextFilters: string[]) {
+ if (nextFilters.length === 0) {
+ selectedFilters.value = []
+ return
+ }
+
+ const changedFilter =
+ nextFilters.find((filter) => !selectedFilters.value.includes(filter)) ??
+ selectedFilters.value.find((filter) => !nextFilters.includes(filter))
+ if (changedFilter) toggleFilter(changedFilter)
+}
+
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
ctx.items,
getItemId,
@@ -270,27 +414,29 @@ async function handleRefresh() {
const filteredItems = computed(() => {
const sorted = sortedItems.value
const searched = search(sorted)
- return applyFilters(searched)
+ return applyMetadataFilters(applyFilters(searched))
})
const tableItems = computed(() => {
const items = filteredItems.value.map((item) => {
const base = ctx.mapToTableItem(item)
const id = getItemId(item)
+ const locked = base.locked ?? item.locked ?? false
+ const clientWarning = getClientWarningType(item, ctx.showEnvironmentWarnings)
return {
...base,
id,
+ locked,
disabled:
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
- toggleDisabled: ctx.isBusy.value,
- toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
+ toggleDisabled: ctx.isBusy.value || base.toggleDisabled,
+ toggleDisabledTooltip: ctx.isBusy.value
+ ? (ctx.busyMessage?.value ?? null)
+ : base.toggleDisabledTooltip,
installing: item.installing === true,
hasUpdate: base.hasUpdate ?? item.has_update,
- isClientOnly:
- isClientOnlyEnvironment(item.environment) ||
- !!item.pack_client_retained ||
- !!item.pack_client_depends,
- clientWarning: getClientWarningType(item),
+ isClientOnly: clientWarning !== null,
+ clientWarning,
hideDelete: base.hideDelete,
hideSwitchVersion: base.hideSwitchVersion ?? !base.versionLink,
overflowOptions: ctx.getOverflowOptions?.(item),
@@ -310,7 +456,7 @@ const tableItems = computed(() => {
})
const hasOutdatedProjects = computed(() => {
- const outdated = ctx.items.value.filter((p) => p.has_update)
+ const outdated = ctx.items.value.filter((p) => p.has_update && !p.locked)
if (outdated.length > 0) {
debug('hasOutdatedProjects: raw items with has_update=true', {
count: outdated.length,
@@ -533,9 +679,10 @@ async function confirmDelete() {
}
async function promptDisableItems(items: ContentItem[]) {
- if (items.length === 0) return
- pendingDisableItems.value = items
- const warning = ctx.getDisableWarning?.(items) ?? null
+ const toggleableItems = items.filter(canToggleItem)
+ if (toggleableItems.length === 0) return
+ pendingDisableItems.value = toggleableItems
+ const warning = ctx.getDisableWarning?.(toggleableItems) ?? null
if (warning) {
pendingDisableWarning.value = warning
confirmDisableModal.value?.show()
@@ -642,12 +789,14 @@ async function bulkDisable() {
}
function handleUpdateById(id: string) {
+ const item = ctx.items.value.find((item) => getItemId(item) === id)
+ if (item?.locked) return
ctx.updateItem?.(id)
}
function handleSwitchVersionById(id: string) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
- if (item) {
+ if (item && !item.locked) {
ctx.switchVersion?.(item)
}
}
@@ -663,7 +812,7 @@ const hasBulkUpdateSupport = computed(
function promptUpdateAll(event?: MouseEvent) {
if (!hasBulkUpdateSupport.value) return
- const items = ctx.items.value.filter((item) => item.has_update)
+ const items = ctx.items.value.filter((item) => item.has_update && !item.locked)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
pendingBulkUpdateAll.value = true
@@ -676,7 +825,7 @@ function promptUpdateAll(event?: MouseEvent) {
function promptUpdateSelected(event?: MouseEvent) {
if (!hasBulkUpdateSupport.value) return
- const items = selectedItems.value.filter((item) => item.has_update)
+ const items = selectedItems.value.filter((item) => item.has_update && !item.locked)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
pendingBulkUpdateAll.value = false
@@ -769,28 +918,22 @@ const confirmUnlinkModal = ref>()
]
-
-
+
{{ formatMessage(messages.additionalContent) }}
@@ -847,85 +990,224 @@ const confirmUnlinkModal = ref>()