mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
feat: start on content tab env focused design
This commit is contained in:
@@ -105,6 +105,57 @@ export class ArchonContentV1Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/set-enabled-server */
|
||||
public async setAddonEnabledServer(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
request: Archon.Content.v1.SetAddonEnabledRequest,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(
|
||||
`/servers/${serverId}/worlds/${worldId}/addons/set-enabled-server`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/set-enabled-player */
|
||||
public async setAddonEnabledPlayer(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
request: Archon.Content.v1.SetAddonEnabledRequest,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(
|
||||
`/servers/${serverId}/worlds/${worldId}/addons/set-enabled-player`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/set-locked-side-toggle */
|
||||
public async setAddonSideToggleLocked(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
request: Archon.Content.v1.SetAddonSideToggleLockedRequest,
|
||||
): Promise<void> {
|
||||
await this.client.request<void>(
|
||||
`/servers/${serverId}/worlds/${worldId}/addons/set-locked-side-toggle`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/addons/delete-many */
|
||||
public async deleteAddons(
|
||||
serverId: string,
|
||||
|
||||
@@ -301,6 +301,25 @@ export namespace Archon {
|
||||
environment?: Labrinth.Projects.v3.Environment | null
|
||||
}
|
||||
|
||||
export type AddonManifestEnvironment =
|
||||
| 'client_and_server'
|
||||
| 'client_only'
|
||||
| 'dedicated_server_only'
|
||||
|
||||
export type AddonManifestWarnings = {
|
||||
multiple_mod_entries: number | null
|
||||
malformed: boolean
|
||||
}
|
||||
|
||||
export type AddonManifest = {
|
||||
platform: Modloader
|
||||
name: string | null
|
||||
version: string | null
|
||||
environment: AddonManifestEnvironment | null
|
||||
icon_embedded: boolean
|
||||
warnings: AddonManifestWarnings
|
||||
}
|
||||
|
||||
export type AddonStatus =
|
||||
| 'pending'
|
||||
| 'installed'
|
||||
@@ -316,6 +335,10 @@ export namespace Archon {
|
||||
filesize: number
|
||||
btime?: string
|
||||
disabled: boolean
|
||||
disabled_server: boolean
|
||||
disabled_player: boolean
|
||||
side_toggle_unlocked: boolean
|
||||
manifest: AddonManifest | null
|
||||
kind: AddonKind
|
||||
from_modpack: boolean
|
||||
status: AddonStatus
|
||||
@@ -354,6 +377,14 @@ export namespace Archon {
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type SetAddonEnabledRequest = RemoveAddonRequest & {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type SetAddonSideToggleLockedRequest = RemoveAddonRequest & {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export type UpdateAddonRequest = {
|
||||
filename: string
|
||||
version_id?: string | null
|
||||
@@ -1063,6 +1094,7 @@ export namespace Archon {
|
||||
project_id: string | null
|
||||
pack_client_retained: boolean
|
||||
pack_client_depends: boolean
|
||||
manifest?: Archon.Content.v1.AddonManifest | null
|
||||
status: Archon.Content.v1.AddonStatus
|
||||
filesize: number | null
|
||||
name: string | null
|
||||
|
||||
@@ -7,6 +7,24 @@ export class KyrosContentV1Module extends AbstractModule {
|
||||
return 'kyros_content_v1'
|
||||
}
|
||||
|
||||
/** GET /v1/worlds/:world_id/content/embedded-icon */
|
||||
public async getEmbeddedAddonIcon(
|
||||
worldId: string,
|
||||
parentDirectory: 'mods' | 'plugins',
|
||||
filename: string,
|
||||
): Promise<Blob> {
|
||||
return this.client.request<Blob>(`/worlds/${worldId}/content/embedded-icon`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'GET',
|
||||
params: {
|
||||
parent_directory: parentDirectory,
|
||||
filename,
|
||||
},
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload addon files to a world via multipart form data
|
||||
*
|
||||
|
||||
@@ -233,17 +233,48 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
|
||||
}
|
||||
|
||||
const content = worldContentUpdateToAddons(event)
|
||||
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListKey(serverId), {
|
||||
queryClient.setQueryData<Archon.Content.v1.Addons>(contentListKey(serverId), (current) => ({
|
||||
...content,
|
||||
addons: content.addons?.filter((addon) => !addon.from_modpack) ?? null,
|
||||
})
|
||||
queryClient.setQueryData<Archon.Content.v1.Addons>(modpackContentListKey(serverId), {
|
||||
...content,
|
||||
addons: content.addons?.filter((addon) => addon.from_modpack) ?? null,
|
||||
})
|
||||
addons: mergeWorldContentSideState(
|
||||
current?.addons ?? [],
|
||||
content.addons?.filter((addon) => !addon.from_modpack) ?? [],
|
||||
),
|
||||
}))
|
||||
queryClient.setQueryData<Archon.Content.v1.Addons>(
|
||||
modpackContentListKey(serverId),
|
||||
(current) => ({
|
||||
...content,
|
||||
addons: mergeWorldContentSideState(
|
||||
current?.addons ?? [],
|
||||
content.addons?.filter((addon) => addon.from_modpack) ?? [],
|
||||
),
|
||||
}),
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: contentListKey(serverId) })
|
||||
void queryClient.invalidateQueries({ queryKey: modpackContentListKey(serverId) })
|
||||
void queryClient.invalidateQueries({ queryKey: serverV1DetailKey(serverId) })
|
||||
}
|
||||
|
||||
function mergeWorldContentSideState(
|
||||
currentAddons: Archon.Content.v1.Addon[],
|
||||
incomingAddons: Archon.Content.v1.Addon[],
|
||||
) {
|
||||
const currentByFilename = new Map(
|
||||
currentAddons.map((addon) => [normalizeAddonFilename(addon.filename), addon] as const),
|
||||
)
|
||||
return incomingAddons.map((incoming) => {
|
||||
const current = currentByFilename.get(normalizeAddonFilename(incoming.filename))
|
||||
return current
|
||||
? {
|
||||
...incoming,
|
||||
disabled_server: current.disabled_server,
|
||||
disabled_player: current.disabled_player,
|
||||
side_toggle_unlocked: current.side_toggle_unlocked,
|
||||
}
|
||||
: incoming
|
||||
})
|
||||
}
|
||||
|
||||
function worldContentUpdateToAddons(
|
||||
event: Archon.Sync.v1.WorldContentUpdateEvent,
|
||||
): Archon.Content.v1.Addons {
|
||||
@@ -297,7 +328,11 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
|
||||
filename: item.filename,
|
||||
filesize: item.filesize ?? 0,
|
||||
btime: item.btime,
|
||||
disabled: item.filename.endsWith('.disabled'),
|
||||
disabled: false,
|
||||
disabled_server: false,
|
||||
disabled_player: false,
|
||||
side_toggle_unlocked: false,
|
||||
manifest: item.manifest ?? null,
|
||||
kind: parentDirectoryToAddonKind(item.parent_directory),
|
||||
from_modpack: item.from_modpack,
|
||||
status: item.status,
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ScrollViewportOptions {
|
||||
}
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
itemHeight: number | Ref<number>
|
||||
bufferSize?: number
|
||||
initialItemCount?: number
|
||||
enabled?: Ref<boolean>
|
||||
@@ -140,6 +140,9 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
onNearEnd,
|
||||
nearEndThreshold = 0.2,
|
||||
} = options
|
||||
const resolvedItemHeight = computed(() =>
|
||||
typeof itemHeight === 'number' ? itemHeight : itemHeight.value,
|
||||
)
|
||||
|
||||
const {
|
||||
listContainer,
|
||||
@@ -152,7 +155,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
onScroll: checkNearEnd,
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
const totalHeight = computed(() => items.value.length * resolvedItemHeight.value)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (enabled && !enabled.value) {
|
||||
@@ -163,8 +166,8 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
return { start: 0, end: Math.min(items.value.length, initialItemCount) }
|
||||
}
|
||||
|
||||
const start = Math.floor(relativeScrollTop.value / itemHeight)
|
||||
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
|
||||
const start = Math.floor(relativeScrollTop.value / resolvedItemHeight.value)
|
||||
const visibleCount = Math.ceil(viewportHeight.value / resolvedItemHeight.value)
|
||||
const rangeSize = visibleCount + bufferSize * 2
|
||||
|
||||
const rangeStart = Math.min(
|
||||
@@ -180,7 +183,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
})
|
||||
|
||||
const visibleTop = computed(() =>
|
||||
enabled && !enabled.value ? 0 : visibleRange.value.start * itemHeight,
|
||||
enabled && !enabled.value ? 0 : visibleRange.value.start * resolvedItemHeight.value,
|
||||
)
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
|
||||
@@ -28,11 +28,16 @@ import { truncatedTooltip } from '#ui/utils/truncate'
|
||||
|
||||
import type {
|
||||
ClientWarningType,
|
||||
ContentCardEmbeddedIcon,
|
||||
ContentCardProject,
|
||||
ContentCardVersion,
|
||||
ContentEnabledForState,
|
||||
ContentOwner,
|
||||
ContentSide,
|
||||
ContentSource,
|
||||
} from '../types'
|
||||
import ContentCardItemIcon from './ContentCardItemIcon.vue'
|
||||
import ContentEnabledFor from './ContentEnabledFor.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -77,6 +82,8 @@ interface Props {
|
||||
hideDelete?: boolean
|
||||
hideActions?: boolean
|
||||
inline?: boolean
|
||||
enabledFor?: ContentEnabledForState
|
||||
embeddedIcon?: ContentCardEmbeddedIcon
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -104,12 +111,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hideDelete: false,
|
||||
hideActions: false,
|
||||
inline: false,
|
||||
enabledFor: undefined,
|
||||
embeddedIcon: undefined,
|
||||
})
|
||||
|
||||
const selected = defineModel<boolean>('selected')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean]
|
||||
'update:enabled-for': [side: ContentSide, value: boolean]
|
||||
select: [value: boolean, event?: MouseEvent]
|
||||
delete: [event: MouseEvent]
|
||||
update: []
|
||||
@@ -128,6 +138,7 @@ const fileNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.installing)
|
||||
const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
|
||||
const isEnabledForDisabled = computed(() => !props.enabledFor?.server && !props.enabledFor?.player)
|
||||
|
||||
const clientWarningMessage = computed(() => {
|
||||
switch (props.clientWarning) {
|
||||
@@ -154,16 +165,21 @@ const installTooltip = computed(() => {
|
||||
role="row"
|
||||
class="flex items-center justify-between"
|
||||
:class="{
|
||||
'h-[74px] gap-4 px-3': !inline,
|
||||
'h-[74px] gap-4 px-3': !inline && !enabledFor,
|
||||
'h-[72px] gap-4 px-3': !inline && enabledFor,
|
||||
'gap-3': inline,
|
||||
'opacity-50 grayscale': disabled && !installing,
|
||||
'opacity-50': installing,
|
||||
'opacity-50': installing || (enabledFor && isEnabledForDisabled && !disabled),
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
hideActions
|
||||
? 'flex-1'
|
||||
: enabledFor
|
||||
? 'flex-1 @[800px]:w-[340px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -177,15 +193,14 @@ const installTooltip = computed(() => {
|
||||
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-3 transition-[filter,opacity] duration-200"
|
||||
:class="enabled === false && !disabled ? 'grayscale opacity-50' : ''"
|
||||
:class="!enabledFor && enabled === false && !disabled ? 'grayscale opacity-50' : ''"
|
||||
>
|
||||
<div v-tooltip="installTooltip" class="relative flex shrink-0 items-center">
|
||||
<Avatar
|
||||
<ContentCardItemIcon
|
||||
:src="project.icon_url"
|
||||
:alt="project.title"
|
||||
size="3rem"
|
||||
no-shadow
|
||||
class="rounded-2xl border border-surface-5"
|
||||
:tint-by="project.id"
|
||||
:embedded-icon="embeddedIcon"
|
||||
/>
|
||||
<div
|
||||
v-if="installing"
|
||||
@@ -295,11 +310,27 @@ const installTooltip = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="enabledFor"
|
||||
class="hidden w-[200px] shrink-0 @[800px]:block"
|
||||
>
|
||||
<ContentEnabledFor
|
||||
:model-value="enabledFor"
|
||||
:disabled="isDisabled"
|
||||
:disabled-tooltip="isDisabled ? disabledTooltip : undefined"
|
||||
@update:model-value="(side, value) => emit('update:enabled-for', side, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden flex-col gap-0.5 transition-[filter,opacity] duration-200 @[800px]:flex"
|
||||
:class="[
|
||||
hideActions ? 'flex-1' : 'flex-1 min-w-0',
|
||||
enabled === false && !disabled ? 'grayscale opacity-50' : '',
|
||||
hideActions
|
||||
? 'flex-1'
|
||||
: enabledFor
|
||||
? 'w-[250px] min-w-0 shrink-0'
|
||||
: 'flex-1 min-w-0',
|
||||
!enabledFor && enabled === false && !disabled ? 'grayscale opacity-50' : '',
|
||||
]"
|
||||
>
|
||||
<template v-if="version">
|
||||
@@ -335,7 +366,8 @@ const installTooltip = computed(() => {
|
||||
|
||||
<div
|
||||
v-if="!hideActions"
|
||||
class="flex min-w-[160px] shrink-0 items-center justify-end gap-2 transition-colors duration-200"
|
||||
class="flex shrink-0 items-center justify-end gap-2 transition-colors duration-200"
|
||||
:class="enabledFor ? 'w-[112px]' : 'min-w-[160px]'"
|
||||
>
|
||||
<slot name="additionalButtonsLeft" />
|
||||
|
||||
@@ -398,7 +430,7 @@ const installTooltip = computed(() => {
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
v-if="enabled !== undefined && !hideToggle"
|
||||
v-if="enabled !== undefined && !hideToggle && !enabledFor"
|
||||
v-tooltip="
|
||||
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
|
||||
? (toggleDisabledTooltip ?? disabledTooltip)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
|
||||
import type { ContentCardEmbeddedIcon } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
src?: string | null
|
||||
fallbackUrl?: string | null
|
||||
embeddedIcon?: ContentCardEmbeddedIcon
|
||||
alt: string
|
||||
tintBy?: string
|
||||
}>()
|
||||
|
||||
const queryKey = computed(
|
||||
() => props.embeddedIcon?.queryKey ?? (['content', 'embedded-icon', 'disabled'] as const),
|
||||
)
|
||||
|
||||
const embeddedIconQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => {
|
||||
if (!props.embeddedIcon) throw new Error('Missing embedded icon request')
|
||||
return props.embeddedIcon.queryFn()
|
||||
},
|
||||
enabled: computed(
|
||||
() => typeof window !== 'undefined' && !props.src && props.embeddedIcon !== undefined,
|
||||
),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const embeddedIconUrl = ref<string>()
|
||||
let pendingIconUrl: string | undefined
|
||||
let validationId = 0
|
||||
|
||||
watch(
|
||||
() => embeddedIconQuery.data.value,
|
||||
(blob) => {
|
||||
validationId += 1
|
||||
const currentValidationId = validationId
|
||||
if (embeddedIconUrl.value) URL.revokeObjectURL(embeddedIconUrl.value)
|
||||
if (pendingIconUrl) URL.revokeObjectURL(pendingIconUrl)
|
||||
embeddedIconUrl.value = undefined
|
||||
pendingIconUrl = undefined
|
||||
if (!blob) return
|
||||
|
||||
const candidateUrl = URL.createObjectURL(blob)
|
||||
pendingIconUrl = candidateUrl
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
if (currentValidationId !== validationId) return
|
||||
pendingIconUrl = undefined
|
||||
embeddedIconUrl.value = candidateUrl
|
||||
}
|
||||
image.onerror = () => {
|
||||
if (currentValidationId !== validationId) return
|
||||
pendingIconUrl = undefined
|
||||
URL.revokeObjectURL(candidateUrl)
|
||||
}
|
||||
image.src = candidateUrl
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
validationId += 1
|
||||
if (embeddedIconUrl.value) URL.revokeObjectURL(embeddedIconUrl.value)
|
||||
if (pendingIconUrl) URL.revokeObjectURL(pendingIconUrl)
|
||||
})
|
||||
|
||||
const resolvedSrc = computed(
|
||||
() =>
|
||||
props.src ??
|
||||
embeddedIconUrl.value ??
|
||||
props.embeddedIcon?.fallbackUrl ??
|
||||
props.fallbackUrl ??
|
||||
undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Avatar
|
||||
:src="resolvedSrc"
|
||||
:alt="alt"
|
||||
:tint-by="tintBy"
|
||||
size="3rem"
|
||||
no-shadow
|
||||
class="rounded-2xl border border-surface-5"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { ChevronDownIcon, ChevronUpIcon, InfoIcon } 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 { defineMessages, 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'
|
||||
@@ -17,6 +17,22 @@ import ContentCardItem from './ContentCardItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
enabledFor: {
|
||||
id: 'content.enabled-for.label',
|
||||
defaultMessage: 'Enabled for',
|
||||
},
|
||||
enabledForDescription: {
|
||||
id: 'content.enabled-for.description',
|
||||
defaultMessage:
|
||||
'Choose whether this content runs on the server, is sent to players, or is disabled in both places.',
|
||||
},
|
||||
sortEnabledFor: {
|
||||
id: 'content.enabled-for.sort',
|
||||
defaultMessage: 'Sort by where content is enabled',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
items: ContentCardTableItem[]
|
||||
showSelection?: boolean
|
||||
@@ -28,6 +44,8 @@ interface Props {
|
||||
hideHeader?: boolean
|
||||
flat?: boolean
|
||||
showItemActions?: boolean
|
||||
showEnabledForColumn?: boolean
|
||||
enabledForSortDirection?: ContentCardTableSortDirection
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -40,6 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hideHeader: false,
|
||||
flat: false,
|
||||
showItemActions: false,
|
||||
showEnabledForColumn: false,
|
||||
enabledForSortDirection: undefined,
|
||||
})
|
||||
|
||||
const stickyHeaderRef = ref<HTMLElement | null>(null)
|
||||
@@ -49,10 +69,12 @@ const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [id: string, value: boolean]
|
||||
'update:enabled-for': [id: string, side: 'server' | 'player', value: boolean]
|
||||
delete: [id: string, event: MouseEvent]
|
||||
update: [id: string]
|
||||
switchVersion: [id: string]
|
||||
sort: [column: ContentCardTableSortColumn, direction: ContentCardTableSortDirection]
|
||||
'sort-enabled-for': [direction: ContentCardTableSortDirection]
|
||||
}>()
|
||||
|
||||
// Check if any actions are available
|
||||
@@ -65,6 +87,9 @@ const hasSwitchVersionListener = computed(
|
||||
const hasEnabledListener = computed(
|
||||
() => typeof instance?.vnode.props?.['onUpdate:enabled'] === 'function',
|
||||
)
|
||||
const hasEnabledForColumn = computed(
|
||||
() => props.showEnabledForColumn || props.items.some((item) => item.enabledFor !== undefined),
|
||||
)
|
||||
|
||||
const hasAnyActions = computed(() => {
|
||||
// Check if there are listeners for actions
|
||||
@@ -80,17 +105,18 @@ const hasAnyActions = computed(() => {
|
||||
(item) =>
|
||||
(item.overflowOptions && item.overflowOptions.length > 0) ||
|
||||
item.hasUpdate ||
|
||||
item.enabled !== undefined,
|
||||
(item.enabled !== undefined && !item.hideToggle),
|
||||
)
|
||||
|
||||
return hasListeners || hasItemActions || props.showItemActions
|
||||
})
|
||||
|
||||
// Virtualization
|
||||
const itemHeight = computed(() => (hasEnabledForColumn.value ? 72 : 74))
|
||||
const { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
|
||||
toRef(props, 'items'),
|
||||
{
|
||||
itemHeight: 74,
|
||||
itemHeight,
|
||||
bufferSize: 5,
|
||||
initialItemCount: 20,
|
||||
enabled: toRef(props, 'virtualized'),
|
||||
@@ -167,6 +193,12 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
|
||||
emit('sort', column, newDirection)
|
||||
}
|
||||
|
||||
function handleEnabledForSort() {
|
||||
const newDirection: ContentCardTableSortDirection =
|
||||
props.enabledForSortDirection === 'desc' ? 'asc' : 'desc'
|
||||
emit('sort-enabled-for', newDirection)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -191,7 +223,11 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
role="row"
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hasAnyActions ? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none' : 'flex-1'
|
||||
hasAnyActions
|
||||
? hasEnabledForColumn
|
||||
? 'flex-1 @[800px]:w-[340px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -225,7 +261,53 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="hidden @[800px]:flex" :class="hasAnyActions ? 'flex-1 min-w-0' : 'flex-1'">
|
||||
<div
|
||||
v-if="hasEnabledForColumn"
|
||||
role="columnheader"
|
||||
:aria-sort="
|
||||
enabledForSortDirection
|
||||
? enabledForSortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
"
|
||||
class="hidden w-[200px] shrink-0 items-center gap-1.5 font-semibold text-secondary @[800px]:flex"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer border-0 bg-transparent p-0 font-semibold text-secondary"
|
||||
@click="handleEnabledForSort"
|
||||
>
|
||||
{{ formatMessage(messages.enabledFor) }}
|
||||
</button>
|
||||
<span
|
||||
v-tooltip="formatMessage(messages.enabledForDescription)"
|
||||
class="inline-flex size-4 cursor-help items-center justify-center"
|
||||
tabindex="0"
|
||||
>
|
||||
<InfoIcon class="size-4" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center border-0 bg-transparent p-0 text-secondary"
|
||||
:aria-label="formatMessage(messages.sortEnabledFor)"
|
||||
@click="handleEnabledForSort"
|
||||
>
|
||||
<ChevronUpIcon v-if="enabledForSortDirection === 'asc'" class="size-4" />
|
||||
<ChevronDownIcon v-else class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden @[800px]:flex"
|
||||
:class="
|
||||
hasAnyActions
|
||||
? hasEnabledForColumn
|
||||
? 'w-[250px] min-w-0 shrink-0'
|
||||
: 'flex-1 min-w-0'
|
||||
: 'flex-1'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-if="sortable"
|
||||
role="columnheader"
|
||||
@@ -247,7 +329,12 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasAnyActions" role="columnheader" class="min-w-[160px] shrink-0 text-right">
|
||||
<div
|
||||
v-if="hasAnyActions"
|
||||
role="columnheader"
|
||||
class="shrink-0 text-right"
|
||||
:class="hasEnabledForColumn ? 'w-[112px]' : 'min-w-[160px]'"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
@@ -288,6 +375,8 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:hide-toggle="item.hideToggle"
|
||||
:enabled-for="item.enabledFor"
|
||||
:embedded-icon="item.embeddedIcon"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete || item.hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
@@ -306,6 +395,9 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
toggleItemSelection(item.id, val ?? false, visibleRange.start + idx, event)
|
||||
"
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@update:enabled-for="
|
||||
(side, val) => emit('update:enabled-for', item.id, side, val)
|
||||
"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@update="emit('update', item.id)"
|
||||
v-on="
|
||||
@@ -356,6 +448,8 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:hide-toggle="item.hideToggle"
|
||||
:enabled-for="item.enabledFor"
|
||||
:embedded-icon="item.embeddedIcon"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete || item.hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
@@ -371,6 +465,9 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
]"
|
||||
@select="(val, event) => toggleItemSelection(item.id, val ?? false, index, event)"
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@update:enabled-for="
|
||||
(side, val) => emit('update:enabled-for', item.id, side, val)
|
||||
"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@update="emit('update', item.id)"
|
||||
@switch-version="emit('switchVersion', item.id)"
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { LockIcon, TriangleAlertIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
import type { ContentEnabledForState, ContentSide } from '../types'
|
||||
|
||||
const messages = defineMessages({
|
||||
server: {
|
||||
id: 'content.enabled-for.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
player: {
|
||||
id: 'content.enabled-for.player',
|
||||
defaultMessage: 'Player',
|
||||
},
|
||||
locked: {
|
||||
id: 'content.enabled-for.locked',
|
||||
defaultMessage: 'Unlock the environment controls from the row menu to change this selection.',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: ContentEnabledForState
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string | null
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
disabledTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:model-value': [side: ContentSide, value: boolean]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const sides: ContentSide[] = ['server', 'player']
|
||||
const disabledSides = computed(() => new Set(props.modelValue.disabledSides ?? []))
|
||||
|
||||
function isSelected(side: ContentSide) {
|
||||
return props.modelValue[side]
|
||||
}
|
||||
|
||||
function isDisabled(side: ContentSide) {
|
||||
return props.disabled || disabledSides.value.has(side)
|
||||
}
|
||||
|
||||
function toggle(side: ContentSide) {
|
||||
if (isDisabled(side)) return
|
||||
emit('update:model-value', side, !isSelected(side))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-[200px] shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
v-for="side in sides"
|
||||
:key="side"
|
||||
v-tooltip="
|
||||
isDisabled(side)
|
||||
? (disabledTooltip ?? modelValue.disabledTooltip ?? formatMessage(messages.locked))
|
||||
: undefined
|
||||
"
|
||||
type="button"
|
||||
class="flex h-8 items-center rounded-xl border border-solid px-3 text-sm font-medium transition-[background-color,border-color,color,opacity,transform] duration-100 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:class="[
|
||||
isSelected(side)
|
||||
? 'border-brand bg-brand-highlight text-brand'
|
||||
: 'border-surface-5 bg-transparent text-primary hover:bg-surface-3',
|
||||
isDisabled(side) ? 'cursor-not-allowed opacity-50' : 'cursor-pointer active:scale-[0.97]',
|
||||
]"
|
||||
:aria-pressed="isSelected(side)"
|
||||
:aria-disabled="isDisabled(side)"
|
||||
@click="toggle(side)"
|
||||
>
|
||||
{{ formatMessage(messages[side]) }}
|
||||
</button>
|
||||
|
||||
<span
|
||||
v-if="modelValue.locked"
|
||||
v-tooltip="modelValue.disabledTooltip ?? formatMessage(messages.locked)"
|
||||
class="inline-flex size-5 shrink-0 cursor-help items-center justify-center text-secondary"
|
||||
tabindex="0"
|
||||
>
|
||||
<LockIcon class="size-5" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="modelValue.warningTooltip"
|
||||
v-tooltip="modelValue.warningTooltip"
|
||||
class="inline-flex size-5 shrink-0 cursor-help items-center justify-center"
|
||||
tabindex="0"
|
||||
>
|
||||
<TriangleAlertIcon class="size-5 text-orange" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+71
-11
@@ -30,7 +30,7 @@ import {
|
||||
} from '#ui/utils/common-messages'
|
||||
|
||||
import { getClientWarningType } from '../../composables/content-filtering'
|
||||
import type { ContentCardProject, ContentCardTableItem, ContentItem } from '../../types'
|
||||
import type { ContentCardProject, ContentCardTableItem, ContentItem, ContentSide } from '../../types'
|
||||
import ContentCardTable from '../ContentCardTable.vue'
|
||||
import ContentSelectionBar from '../ContentSelectionBar.vue'
|
||||
|
||||
@@ -42,6 +42,7 @@ interface Props {
|
||||
sourceName?: string
|
||||
sourceIconUrl?: string
|
||||
enableToggle?: boolean
|
||||
enableEnabledFor?: boolean
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string | null
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
@@ -54,6 +55,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
sourceName: undefined,
|
||||
sourceIconUrl: undefined,
|
||||
enableToggle: false,
|
||||
enableEnabledFor: false,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
getOverflowOptions: undefined,
|
||||
@@ -63,6 +65,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [item: ContentItem, value: boolean]
|
||||
'update:enabled-for': [item: ContentItem, side: ContentSide, value: boolean]
|
||||
'bulk:enable': [items: ContentItem[]]
|
||||
'bulk:disable': [items: ContentItem[]]
|
||||
hide: []
|
||||
@@ -109,6 +112,14 @@ const messages = defineMessages({
|
||||
id: 'instances.managed-content-modal.download-file',
|
||||
defaultMessage: 'Download File',
|
||||
},
|
||||
enabledFor: {
|
||||
id: 'content.enabled-for.label',
|
||||
defaultMessage: 'Enabled for',
|
||||
},
|
||||
pleaseWait: {
|
||||
id: 'content.enabled-for.please-wait',
|
||||
defaultMessage: 'Please wait',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ManagedContentModalState {
|
||||
@@ -262,6 +273,7 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
icon_url: item.embedded_metadata?.icon_url ?? null,
|
||||
},
|
||||
projectLink: !item.external && item.project?.id ? `/project/${item.project.id}` : undefined,
|
||||
embeddedIcon: item.embeddedIcon,
|
||||
version: item.version ?? {
|
||||
id: item.id,
|
||||
version_number: contentVersionLabel(item),
|
||||
@@ -283,15 +295,24 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
}
|
||||
: undefined,
|
||||
...(props.enableToggle ? { enabled: item.enabled } : {}),
|
||||
...(props.enableEnabledFor ? { enabledFor: item.enabledFor } : {}),
|
||||
locked: item.locked,
|
||||
installing: item.installing === true,
|
||||
toggleDisabled: props.actionDisabled,
|
||||
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
|
||||
isClientOnly: getClientWarningType(item, props.showEnvironmentWarnings) !== null,
|
||||
clientWarning: getClientWarningType(item, props.showEnvironmentWarnings),
|
||||
isClientOnly:
|
||||
!props.enableEnabledFor &&
|
||||
getClientWarningType(item, props.showEnvironmentWarnings) !== null,
|
||||
clientWarning: props.enableEnabledFor
|
||||
? null
|
||||
: getClientWarningType(item, props.showEnvironmentWarnings),
|
||||
disabled:
|
||||
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
|
||||
disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
|
||||
disabledTooltip: props.actionDisabled
|
||||
? props.actionDisabledTooltip
|
||||
: disabledIds.value.has(item.file_name)
|
||||
? formatMessage(messages.pleaseWait)
|
||||
: undefined,
|
||||
overflowOptions: [
|
||||
...(props.switchVersion && !item.locked
|
||||
? [
|
||||
@@ -329,7 +350,9 @@ const externalUrls = computed(() => {
|
||||
return urls
|
||||
})
|
||||
const hasExternalSlicerUrls = computed(() => Object.keys(externalSlicerUrls.value).length > 0)
|
||||
const showTableActions = computed(() => props.enableToggle || hasExternalSlicerUrls.value)
|
||||
const showTableActions = computed(
|
||||
() => props.enableToggle || props.enableEnabledFor || hasExternalSlicerUrls.value,
|
||||
)
|
||||
|
||||
function getTypeIcon(type: string) {
|
||||
switch (type) {
|
||||
@@ -372,6 +395,13 @@ function handleEnabledChange(id: string, value: boolean) {
|
||||
emit('update:enabled', item, value)
|
||||
}
|
||||
|
||||
function handleEnabledForChange(id: string, side: ContentSide, value: boolean) {
|
||||
if (props.actionDisabled || !props.enableEnabledFor) return
|
||||
const item = items.value.find((item) => item.id === id)
|
||||
if (!item) return
|
||||
emit('update:enabled-for', item, side, value)
|
||||
}
|
||||
|
||||
function bulkEnable() {
|
||||
if (props.actionDisabled) return
|
||||
emit('bulk:enable', [...toggleableSelectedItems.value])
|
||||
@@ -474,8 +504,16 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
:max-width="
|
||||
props.enableEnabledFor
|
||||
? 'min(1080px, calc(95vw - 4rem))'
|
||||
: 'min(928px, calc(95vw - 10rem))'
|
||||
"
|
||||
:width="
|
||||
props.enableEnabledFor
|
||||
? 'min(1080px, calc(95vw - 4rem))'
|
||||
: 'min(928px, calc(95vw - 10rem))'
|
||||
"
|
||||
:on-hide="handleHide"
|
||||
no-padding
|
||||
>
|
||||
@@ -545,7 +583,9 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
showTableActions
|
||||
? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
? props.enableEnabledFor
|
||||
? 'flex-1 @[800px]:w-[340px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1'
|
||||
"
|
||||
>
|
||||
@@ -561,15 +601,33 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
formatMessage(commonMessages.projectLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="props.enableEnabledFor"
|
||||
class="hidden w-[200px] shrink-0 @[800px]:flex"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(messages.enabledFor)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden @[800px]:flex"
|
||||
:class="showTableActions ? 'flex-1 min-w-0' : 'flex-1'"
|
||||
:class="
|
||||
showTableActions
|
||||
? props.enableEnabledFor
|
||||
? 'w-[250px] min-w-0 shrink-0'
|
||||
: 'flex-1 min-w-0'
|
||||
: 'flex-1'
|
||||
"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.versionLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="showTableActions" class="min-w-[160px] shrink-0 text-right">
|
||||
<div
|
||||
v-if="showTableActions"
|
||||
class="shrink-0 text-right"
|
||||
:class="props.enableEnabledFor ? 'w-[112px]' : 'min-w-[160px]'"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
@@ -580,7 +638,8 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
v-model:selected-ids="selectedIds"
|
||||
:items="tableItems"
|
||||
:show-selection="props.enableToggle"
|
||||
:show-item-actions="hasExternalSlicerUrls"
|
||||
:show-enabled-for-column="props.enableEnabledFor"
|
||||
:show-item-actions="props.enableEnabledFor || hasExternalSlicerUrls"
|
||||
hide-delete
|
||||
hide-header
|
||||
flat
|
||||
@@ -589,6 +648,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
|
||||
: {}
|
||||
"
|
||||
@update:enabled-for="handleEnabledForChange"
|
||||
>
|
||||
<template #itemTitleBadges="{ item }">
|
||||
<span
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
OrganizationIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShareIcon,
|
||||
TextCursorInputIcon,
|
||||
TrashIcon,
|
||||
@@ -110,6 +111,14 @@ const messages = defineMessages({
|
||||
id: 'content.page-layout.sort.date-added-oldest',
|
||||
defaultMessage: 'Oldest first',
|
||||
},
|
||||
sortEnabledForDescending: {
|
||||
id: 'content.page-layout.sort.enabled-for-descending',
|
||||
defaultMessage: 'Enabled for (most first)',
|
||||
},
|
||||
sortEnabledForAscending: {
|
||||
id: 'content.page-layout.sort.enabled-for-ascending',
|
||||
defaultMessage: 'Enabled for (least first)',
|
||||
},
|
||||
filter: {
|
||||
id: 'content.page-layout.filter.add',
|
||||
defaultMessage: 'Filter',
|
||||
@@ -179,44 +188,76 @@ function getItemId(item: ContentItem) {
|
||||
return ctx.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
|
||||
type SortMode =
|
||||
| 'alphabetical-asc'
|
||||
| 'alphabetical-desc'
|
||||
| 'date-added-newest'
|
||||
| 'date-added-oldest'
|
||||
| 'enabled-for-desc'
|
||||
| 'enabled-for-asc'
|
||||
const defaultSortMode: SortMode = ctx.setEnabledFor ? 'enabled-for-desc' : 'alphabetical-asc'
|
||||
const sortMode = ctx.filterPersistKey
|
||||
? useSessionStorage<SortMode>(`content-sort:${ctx.filterPersistKey}`, 'alphabetical-asc')
|
||||
: ref<SortMode>('alphabetical-asc')
|
||||
? useSessionStorage<SortMode>(`content-sort:${ctx.filterPersistKey}`, defaultSortMode)
|
||||
: ref<SortMode>(defaultSortMode)
|
||||
|
||||
const sortLabels: Record<SortMode, () => string> = {
|
||||
'alphabetical-asc': () => formatMessage(messages.sortAlphabeticalAscending),
|
||||
'alphabetical-desc': () => formatMessage(messages.sortAlphabeticalDescending),
|
||||
'date-added-newest': () => formatMessage(messages.sortDateAddedNewest),
|
||||
'date-added-oldest': () => formatMessage(messages.sortDateAddedOldest),
|
||||
'enabled-for-desc': () => formatMessage(messages.sortEnabledForDescending),
|
||||
'enabled-for-asc': () => formatMessage(messages.sortEnabledForAscending),
|
||||
}
|
||||
|
||||
const sortOptions = computed<OverflowMenuOption[]>(() => [
|
||||
{
|
||||
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 sortOptions = computed<OverflowMenuOption[]>(() => {
|
||||
const options: OverflowMenuOption[] = [
|
||||
{
|
||||
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'),
|
||||
},
|
||||
]
|
||||
|
||||
if (ctx.setEnabledFor) {
|
||||
options.push(
|
||||
{
|
||||
id: 'enabled-for-desc',
|
||||
label: formatMessage(messages.sortEnabledForDescending),
|
||||
action: () => (sortMode.value = 'enabled-for-desc'),
|
||||
},
|
||||
{
|
||||
id: 'enabled-for-asc',
|
||||
label: formatMessage(messages.sortEnabledForAscending),
|
||||
action: () => (sortMode.value = 'enabled-for-asc'),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
function getEnabledForRank(item: ContentItem) {
|
||||
return Number(item.enabledFor?.server) * 2 + Number(item.enabledFor?.player)
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const items = [...ctx.items.value]
|
||||
@@ -242,6 +283,16 @@ const sortedItems = computed(() => {
|
||||
const dateB = b.date_added ?? ''
|
||||
return dateA.localeCompare(dateB) || a.file_name.localeCompare(b.file_name)
|
||||
})
|
||||
case 'enabled-for-desc':
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
getEnabledForRank(b) - getEnabledForRank(a) || a.file_name.localeCompare(b.file_name),
|
||||
)
|
||||
case 'enabled-for-asc':
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
getEnabledForRank(a) - getEnabledForRank(b) || a.file_name.localeCompare(b.file_name),
|
||||
)
|
||||
default:
|
||||
return items.sort((a, b) => {
|
||||
const nameA = a.project?.title ?? a.file_name
|
||||
@@ -424,14 +475,22 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
const base = ctx.mapToTableItem(item)
|
||||
const id = getItemId(item)
|
||||
const locked = base.locked ?? item.locked ?? false
|
||||
const clientWarning = getClientWarningType(item, ctx.showEnvironmentWarnings)
|
||||
const clientWarning = base.enabledFor
|
||||
? null
|
||||
: 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,
|
||||
disabledTooltip: ctx.isBusy.value
|
||||
? (ctx.busyMessage?.value ?? null)
|
||||
: isChanging(id)
|
||||
? formatMessage(messages.pleaseWait)
|
||||
: item.installing
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: null,
|
||||
toggleDisabled: ctx.isBusy.value || base.toggleDisabled,
|
||||
toggleDisabledTooltip: ctx.isBusy.value
|
||||
? (ctx.busyMessage?.value ?? null)
|
||||
@@ -505,7 +564,10 @@ function canDeleteItem(item: ContentItem) {
|
||||
}
|
||||
|
||||
function canToggleItem(item: ContentItem) {
|
||||
return ctx.canToggleItem?.(item) ?? true
|
||||
return (
|
||||
ctx.canToggleItem?.(item) ??
|
||||
!!(ctx.toggleEnabled || ctx.bulkEnableItems || ctx.bulkDisableItems)
|
||||
)
|
||||
}
|
||||
|
||||
const deletableSelectedItems = computed(() => selectedItems.value.filter(canDeleteItem))
|
||||
@@ -617,6 +679,7 @@ async function disableItemsWithoutWarning(items: ContentItem[]) {
|
||||
await ctx.bulkDisableItems(items)
|
||||
return
|
||||
}
|
||||
if (!ctx.toggleEnabled) return
|
||||
|
||||
for (const item of items) {
|
||||
const id = getItemId(item)
|
||||
@@ -730,7 +793,7 @@ async function confirmDisable() {
|
||||
if (ctx.bulkDisableItems) {
|
||||
await ctx.bulkDisableItems(itemsToDisable)
|
||||
} else {
|
||||
await ctx.toggleEnabled(item)
|
||||
await ctx.toggleEnabled?.(item)
|
||||
}
|
||||
} finally {
|
||||
unmarkChanging(id)
|
||||
@@ -744,7 +807,7 @@ async function confirmDisable() {
|
||||
}
|
||||
|
||||
async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
if (ctx.isBusy.value) return
|
||||
if (ctx.isBusy.value || !ctx.toggleEnabled) return
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (!item) return
|
||||
if (!canToggleItem(item)) return
|
||||
@@ -782,7 +845,8 @@ async function bulkEnable() {
|
||||
}
|
||||
return
|
||||
}
|
||||
await runBulk('enable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
|
||||
if (!ctx.toggleEnabled) return
|
||||
await runBulk('enable', items, (item) => ctx.toggleEnabled!(item), { onComplete: clearSelection })
|
||||
}
|
||||
|
||||
async function bulkDisable() {
|
||||
@@ -805,6 +869,27 @@ function handleSwitchVersionById(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetEnabledForById(
|
||||
id: string,
|
||||
side: 'server' | 'player',
|
||||
enabled: boolean,
|
||||
) {
|
||||
if (ctx.isBusy.value || !ctx.setEnabledFor) return
|
||||
const item = ctx.items.value.find((candidate) => getItemId(candidate) === id)
|
||||
if (!item) return
|
||||
|
||||
markChanging(id)
|
||||
try {
|
||||
await ctx.setEnabledFor(item, side, enabled)
|
||||
} finally {
|
||||
unmarkChanging(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnabledForSort(direction: 'asc' | 'desc') {
|
||||
sortMode.value = direction === 'asc' ? 'enabled-for-asc' : 'enabled-for-desc'
|
||||
}
|
||||
|
||||
// Bulk updating
|
||||
const confirmBulkUpdateModal = ref<InstanceType<typeof ConfirmBulkUpdateModal>>()
|
||||
const pendingBulkUpdateItems = ref<ContentItem[]>([])
|
||||
@@ -1006,6 +1091,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" />
|
||||
<ClockArrowDownIcon v-else-if="sortMode === 'date-added-newest'" />
|
||||
<ClockArrowUpIcon v-else-if="sortMode === 'date-added-oldest'" />
|
||||
<ServerIcon v-else-if="sortMode.startsWith('enabled-for')" />
|
||||
<ArrowDownAZIcon v-else />
|
||||
{{ sortLabels[sortMode]() }}
|
||||
<DropdownIcon />
|
||||
@@ -1219,7 +1305,17 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
v-model:selected-ids="selectedIds"
|
||||
:items="tableItems"
|
||||
:show-selection="true"
|
||||
:show-enabled-for-column="!!ctx.setEnabledFor"
|
||||
:enabled-for-sort-direction="
|
||||
sortMode === 'enabled-for-asc'
|
||||
? 'asc'
|
||||
: sortMode === 'enabled-for-desc'
|
||||
? 'desc'
|
||||
: undefined
|
||||
"
|
||||
@update:enabled="handleToggleEnabledById"
|
||||
@update:enabled-for="handleSetEnabledForById"
|
||||
@sort-enabled-for="handleEnabledForSort"
|
||||
@delete="handleDeleteById"
|
||||
@update="handleUpdateById"
|
||||
@switch-version="handleSwitchVersionById"
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ContentActionWarning,
|
||||
ContentCardTableItem,
|
||||
ContentItem,
|
||||
ContentSide,
|
||||
ManagedContentCardData,
|
||||
} from '../types'
|
||||
|
||||
@@ -46,7 +47,8 @@ export interface ContentManagerContext {
|
||||
contentTypeLabel: Ref<string> | ComputedRef<string>
|
||||
|
||||
// Core actions
|
||||
toggleEnabled: (item: ContentItem) => Promise<void>
|
||||
toggleEnabled?: (item: ContentItem) => Promise<void>
|
||||
setEnabledFor?: (item: ContentItem, side: ContentSide, enabled: boolean) => Promise<void>
|
||||
deleteItem: (item: ContentItem) => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
browse: () => void
|
||||
|
||||
@@ -50,6 +50,23 @@ export interface EmbeddedContentMetadata {
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
export type ContentSide = 'server' | 'player'
|
||||
|
||||
export interface ContentEnabledForState {
|
||||
server: boolean
|
||||
player: boolean
|
||||
locked: boolean
|
||||
disabledSides?: ContentSide[]
|
||||
disabledTooltip?: string | null
|
||||
warningTooltip?: string | null
|
||||
}
|
||||
|
||||
export interface ContentCardEmbeddedIcon {
|
||||
queryKey: readonly unknown[]
|
||||
queryFn: () => Promise<Blob>
|
||||
fallbackUrl?: string | null
|
||||
}
|
||||
|
||||
export interface ContentCardTableItem {
|
||||
id: string
|
||||
project: ContentCardProject
|
||||
@@ -74,6 +91,8 @@ export interface ContentCardTableItem {
|
||||
hideDelete?: boolean
|
||||
hideSwitchVersion?: boolean
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
enabledFor?: ContentEnabledForState
|
||||
embeddedIcon?: ContentCardEmbeddedIcon
|
||||
}
|
||||
|
||||
export type ContentCardTableSortColumn = 'project' | 'version'
|
||||
|
||||
@@ -500,6 +500,26 @@ async function uploadLocalModpackWithSoftOverride() {
|
||||
return true
|
||||
}
|
||||
|
||||
async function disableAddonsEverywhere(addons: Archon.Content.v1.Addon[]) {
|
||||
await Promise.all(
|
||||
addons.flatMap((addon) => {
|
||||
const request: Archon.Content.v1.SetAddonEnabledRequest = {
|
||||
kind: addon.kind,
|
||||
filename: addon.filename,
|
||||
enabled: false,
|
||||
}
|
||||
return [
|
||||
...(!addon.disabled_server
|
||||
? [client.archon.content_v1.setAddonEnabledServer(serverId, worldId.value!, request)]
|
||||
: []),
|
||||
...(!addon.disabled_player
|
||||
? [client.archon.content_v1.setAddonEnabledPlayer(serverId, worldId.value!, request)]
|
||||
: []),
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: serverSettings.closeModal,
|
||||
afterSave: async () => {
|
||||
@@ -899,12 +919,12 @@ provideInstallationSettings({
|
||||
if (setupActionDisabled.value) return
|
||||
debug('disableAllContent: fetching all addons')
|
||||
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
|
||||
const items = (addons.addons ?? [])
|
||||
.filter((a) => !a.disabled)
|
||||
.map((a) => ({ kind: a.kind, filename: a.filename }))
|
||||
if (items.length > 0) {
|
||||
debug('disableAllContent: disabling', items.length, 'addons')
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
|
||||
const activeAddons = (addons.addons ?? []).filter(
|
||||
(addon) => !addon.disabled_server || !addon.disabled_player,
|
||||
)
|
||||
if (activeAddons.length > 0) {
|
||||
debug('disableAllContent: disabling', activeAddons.length, 'addons')
|
||||
await disableAddonsEverywhere(activeAddons)
|
||||
}
|
||||
debug('disableAllContent: done')
|
||||
},
|
||||
@@ -913,7 +933,9 @@ provideInstallationSettings({
|
||||
if (setupActionDisabled.value) return
|
||||
debug('disableIncompatibleContent: fetching addons')
|
||||
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
|
||||
const activeAddons = (addons.addons ?? []).filter((a) => !a.disabled)
|
||||
const activeAddons = (addons.addons ?? []).filter(
|
||||
(addon) => !addon.disabled_server || !addon.disabled_player,
|
||||
)
|
||||
|
||||
const modrinthAddons = activeAddons.filter((a) => a.version?.id)
|
||||
const customAddons = activeAddons.filter((a) => !a.version?.id)
|
||||
@@ -936,7 +958,14 @@ provideInstallationSettings({
|
||||
|
||||
if (incompatibleItems.length > 0) {
|
||||
debug('disableIncompatibleContent: disabling', incompatibleItems.length, 'addons')
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, incompatibleItems)
|
||||
const incompatibleKeys = new Set(
|
||||
incompatibleItems.map((item) => `${item.kind}:${item.filename}`),
|
||||
)
|
||||
await disableAddonsEverywhere(
|
||||
activeAddons.filter((addon) =>
|
||||
incompatibleKeys.has(`${addon.kind}:${addon.filename}`),
|
||||
),
|
||||
)
|
||||
}
|
||||
debug('disableIncompatibleContent: done')
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { type Archon, type Labrinth, ModrinthApiError } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { ClipboardCopyIcon, LockIcon, LockOpenIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import type { OverflowMenuOption } from '#ui/components/base/buttons'
|
||||
import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
@@ -34,7 +35,7 @@ import ContentUpdaterModal from '../../../shared/content-tab/components/modals/c
|
||||
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
|
||||
import type { ManagedContentData } from '../../../shared/content-tab/providers/content-manager'
|
||||
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
|
||||
import type { ContentItem } from '../../../shared/content-tab/types'
|
||||
import type { ContentItem, ContentSide } from '../../../shared/content-tab/types'
|
||||
import { summarizeManagedContent } from '../../../shared/content-tab/utils/managed-content'
|
||||
|
||||
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
|
||||
@@ -63,9 +64,13 @@ const messages = defineMessages({
|
||||
id: 'hosting.content.failed-to-remove',
|
||||
defaultMessage: 'Failed to remove content',
|
||||
},
|
||||
failedToToggle: {
|
||||
id: 'hosting.content.failed-to-toggle',
|
||||
defaultMessage: 'Failed to toggle {name}',
|
||||
failedToSetEnabledFor: {
|
||||
id: 'hosting.content.failed-to-set-enabled-for',
|
||||
defaultMessage: 'Failed to change where {name} is enabled',
|
||||
},
|
||||
failedToSetSideLock: {
|
||||
id: 'hosting.content.failed-to-set-side-lock',
|
||||
defaultMessage: 'Failed to change the environment lock for {name}',
|
||||
},
|
||||
failedToUpload: {
|
||||
id: 'hosting.content.failed-to-upload',
|
||||
@@ -91,14 +96,6 @@ const messages = defineMessages({
|
||||
id: 'hosting.content.failed-to-bulk-delete',
|
||||
defaultMessage: 'Failed to delete content',
|
||||
},
|
||||
failedToBulkEnable: {
|
||||
id: 'hosting.content.failed-to-bulk-enable',
|
||||
defaultMessage: 'Failed to enable content',
|
||||
},
|
||||
failedToBulkDisable: {
|
||||
id: 'hosting.content.failed-to-bulk-disable',
|
||||
defaultMessage: 'Failed to disable content',
|
||||
},
|
||||
failedToBulkUpdate: {
|
||||
id: 'hosting.content.failed-to-bulk-update',
|
||||
defaultMessage: 'Failed to update content',
|
||||
@@ -107,6 +104,24 @@ const messages = defineMessages({
|
||||
id: 'hosting.content.failed-to-install',
|
||||
defaultMessage: 'Failed to install content',
|
||||
},
|
||||
sideLocked: {
|
||||
id: 'hosting.content.enabled-for.side-locked',
|
||||
defaultMessage:
|
||||
'This option conflicts with the detected environment. Unlock the environment controls from the row menu to change it.',
|
||||
},
|
||||
unknownEnvironment: {
|
||||
id: 'hosting.content.enabled-for.unknown-environment',
|
||||
defaultMessage:
|
||||
'We could not determine where this content should run. Check that the selected environments are correct.',
|
||||
},
|
||||
lockEnvironment: {
|
||||
id: 'hosting.content.enabled-for.lock-environment',
|
||||
defaultMessage: 'Lock detected environment',
|
||||
},
|
||||
unlockEnvironment: {
|
||||
id: 'hosting.content.enabled-for.unlock-environment',
|
||||
defaultMessage: 'Unlock environment controls',
|
||||
},
|
||||
})
|
||||
|
||||
const client = injectModrinthClient()
|
||||
@@ -299,12 +314,46 @@ const managedContent = computed<ManagedContentData | null>(() => {
|
||||
|
||||
function friendlyAddonName(addon: Archon.Content.v1.Addon): string {
|
||||
if (addon.name) return addon.name
|
||||
if (addon.manifest?.name) return addon.manifest.name
|
||||
let cleanName = addon.filename
|
||||
const lastDotIndex = cleanName.lastIndexOf('.')
|
||||
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
|
||||
return cleanName
|
||||
}
|
||||
|
||||
function getAddonEnvironment(
|
||||
addon: Archon.Content.v1.Addon,
|
||||
): Labrinth.Projects.v3.Environment | undefined {
|
||||
return addon.version?.environment ?? addon.manifest?.environment ?? undefined
|
||||
}
|
||||
|
||||
function hasDetectedEnvironment(addon: Archon.Content.v1.Addon) {
|
||||
const environment = getAddonEnvironment(addon)
|
||||
return environment !== undefined && environment !== 'unknown'
|
||||
}
|
||||
|
||||
function getLockedSides(addon: Archon.Content.v1.Addon): ContentSide[] {
|
||||
if (addon.side_toggle_unlocked || !hasDetectedEnvironment(addon)) return []
|
||||
|
||||
switch (getAddonEnvironment(addon)) {
|
||||
case 'client_only':
|
||||
case 'singleplayer_only':
|
||||
return ['server']
|
||||
case 'server_only':
|
||||
case 'dedicated_server_only':
|
||||
return ['player']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getEnabledForWarning(addon: Archon.Content.v1.Addon) {
|
||||
if (addon.pack_client_retained) return formatMessage(commonMessages.clientRetainedWarning)
|
||||
if (addon.pack_client_depends) return formatMessage(commonMessages.clientDependsWarning)
|
||||
if (!hasDetectedEnvironment(addon)) return formatMessage(messages.unknownEnvironment)
|
||||
return null
|
||||
}
|
||||
|
||||
const modpackAddons = ref<Archon.Content.v1.Addon[]>([])
|
||||
|
||||
const addonLookup = computed(() => {
|
||||
@@ -312,12 +361,23 @@ const addonLookup = computed(() => {
|
||||
for (const addon of contentQuery.data.value?.addons ?? []) {
|
||||
map.set(addon.filename, addon)
|
||||
}
|
||||
for (const addon of modpackAddons.value) {
|
||||
map.set(addon.filename, addon)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const modpackAddonLookup = computed(
|
||||
() => new Map(modpackAddons.value.map((addon) => [addon.filename, addon])),
|
||||
)
|
||||
|
||||
function getAddonForItem(item: ContentItem) {
|
||||
return item.source_kind === 'modrinth_modpack'
|
||||
? modpackAddonLookup.value.get(item.file_name)
|
||||
: addonLookup.value.get(item.file_name)
|
||||
}
|
||||
|
||||
function getAddonQueryKey(addon: Archon.Content.v1.Addon) {
|
||||
return addon.from_modpack ? modpackContentQueryKey.value : queryKey.value
|
||||
}
|
||||
|
||||
const projectMetadataBatchSize = 800
|
||||
const contentProjectIds = computed(() =>
|
||||
[...(contentQuery.data.value?.addons ?? []), ...modpackAddons.value]
|
||||
@@ -557,44 +617,124 @@ const deleteMutation = useMutation({
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ addon }: { addon: Archon.Content.v1.Addon }) => {
|
||||
const request: Archon.Content.v1.RemoveAddonRequest = {
|
||||
type SetEnabledForVariables = {
|
||||
addon: Archon.Content.v1.Addon
|
||||
side: ContentSide
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const setEnabledForMutation = useMutation({
|
||||
mutationFn: async ({ addon, side, enabled }: SetEnabledForVariables) => {
|
||||
const request: Archon.Content.v1.SetAddonEnabledRequest = {
|
||||
filename: addon.filename,
|
||||
kind: addon.kind,
|
||||
enabled,
|
||||
}
|
||||
if (addon.disabled) {
|
||||
await client.archon.content_v1.enableAddon(serverId, worldId.value!, request)
|
||||
if (side === 'server') {
|
||||
await client.archon.content_v1.setAddonEnabledServer(serverId, worldId.value!, request)
|
||||
} else {
|
||||
await client.archon.content_v1.disableAddon(serverId, worldId.value!, request)
|
||||
await client.archon.content_v1.setAddonEnabledPlayer(serverId, worldId.value!, request)
|
||||
}
|
||||
return { filename: addon.filename, newDisabled: !addon.disabled }
|
||||
},
|
||||
onSuccess: ({ filename, newDisabled }) => {
|
||||
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
onMutate: async ({ addon, side, enabled }) => {
|
||||
const targetQueryKey = getAddonQueryKey(addon)
|
||||
await queryClient.cancelQueries({ queryKey: targetQueryKey })
|
||||
const previousData = queryClient.getQueryData<Archon.Content.v1.Addons>(targetQueryKey)
|
||||
queryClient.setQueryData(targetQueryKey, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((a) =>
|
||||
a.filename === filename ? { ...a, disabled: newDisabled } : a,
|
||||
),
|
||||
addons: (oldData.addons ?? []).map((candidate) => {
|
||||
if (candidate.filename !== addon.filename || candidate.kind !== addon.kind) return candidate
|
||||
return side === 'server'
|
||||
? { ...candidate, disabled_server: !enabled }
|
||||
: { ...candidate, disabled_player: !enabled }
|
||||
}),
|
||||
}
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
return { previousData, targetQueryKey }
|
||||
},
|
||||
onError: (_err, { addon }) => {
|
||||
onError: (error, { addon }, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(context.targetQueryKey, context.previousData)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToToggle, { name: friendlyAddonName(addon) }),
|
||||
title: formatMessage(messages.failedToSetEnabledFor, {
|
||||
name: friendlyAddonName(addon),
|
||||
}),
|
||||
text: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
void queryClient.invalidateQueries({ queryKey: modpackContentQueryKey.value })
|
||||
},
|
||||
})
|
||||
|
||||
async function handleToggleEnabled(item: ContentItem) {
|
||||
const setSideLockMutation = useMutation({
|
||||
mutationFn: async ({ addon, locked }: { addon: Archon.Content.v1.Addon; locked: boolean }) => {
|
||||
await client.archon.content_v1.setAddonSideToggleLocked(serverId, worldId.value!, {
|
||||
filename: addon.filename,
|
||||
kind: addon.kind,
|
||||
locked,
|
||||
})
|
||||
},
|
||||
onMutate: async ({ addon, locked }) => {
|
||||
const targetQueryKey = getAddonQueryKey(addon)
|
||||
await queryClient.cancelQueries({ queryKey: targetQueryKey })
|
||||
const previousData = queryClient.getQueryData<Archon.Content.v1.Addons>(targetQueryKey)
|
||||
queryClient.setQueryData(targetQueryKey, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((candidate) =>
|
||||
candidate.filename === addon.filename && candidate.kind === addon.kind
|
||||
? { ...candidate, side_toggle_unlocked: !locked }
|
||||
: candidate,
|
||||
),
|
||||
}
|
||||
})
|
||||
return { previousData, targetQueryKey }
|
||||
},
|
||||
onError: (error, { addon }, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(context.targetQueryKey, context.previousData)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToSetSideLock, {
|
||||
name: friendlyAddonName(addon),
|
||||
}),
|
||||
text: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
void queryClient.invalidateQueries({ queryKey: modpackContentQueryKey.value })
|
||||
},
|
||||
})
|
||||
|
||||
async function handleSetEnabledFor(item: ContentItem, side: ContentSide, enabled: boolean) {
|
||||
if (contentActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
const addon = getAddonForItem(item)
|
||||
if (!addon) return
|
||||
await toggleMutation.mutateAsync({ addon })
|
||||
await setEnabledForMutation.mutateAsync({ addon, side, enabled })
|
||||
}
|
||||
|
||||
async function handleModpackSetEnabledFor(
|
||||
item: ContentItem,
|
||||
side: ContentSide,
|
||||
enabled: boolean,
|
||||
) {
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
|
||||
try {
|
||||
await handleSetEnabledFor(item, side, enabled)
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteItem(item: ContentItem) {
|
||||
@@ -629,38 +769,6 @@ async function handleBulkDelete(items: ContentItem[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkEnable(items: ContentItem[]) {
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToBulkEnable),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkDisable(items: ContentItem[]) {
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToBulkDisable),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
const modpackContentModal = ref<InstanceType<typeof ManagedContentModal>>()
|
||||
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
|
||||
@@ -813,17 +921,44 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
|
||||
const projectMetadata = addon.project_id
|
||||
? contentProjectsById.value.get(addon.project_id)
|
||||
: undefined
|
||||
const environment = getAddonEnvironment(addon)
|
||||
const embeddedIcon =
|
||||
!addon.icon_url &&
|
||||
addon.manifest?.icon_embedded &&
|
||||
worldId.value &&
|
||||
(addon.kind === 'mod' || addon.kind === 'plugin')
|
||||
? {
|
||||
queryKey: [
|
||||
'kyros',
|
||||
'content',
|
||||
'embedded-icon',
|
||||
worldId.value,
|
||||
addon.kind,
|
||||
addon.filename,
|
||||
] as const,
|
||||
queryFn: () =>
|
||||
client.kyros.content_v1.getEmbeddedAddonIcon(
|
||||
worldId.value!,
|
||||
addon.kind === 'mod' ? 'mods' : 'plugins',
|
||||
addon.filename,
|
||||
),
|
||||
fallbackUrl: projectMetadata?.icon_url,
|
||||
}
|
||||
: undefined
|
||||
const serverEnabled = !addon.disabled_server
|
||||
const playerEnabled = !addon.disabled_player
|
||||
return {
|
||||
project: {
|
||||
...(projectMetadata ?? {}),
|
||||
id: addon.project_id ?? addon.filename,
|
||||
slug: projectMetadata?.slug ?? addon.project_id ?? addon.filename,
|
||||
title: projectMetadata?.title ?? friendlyAddonName(addon),
|
||||
icon_url: addon.icon_url ?? projectMetadata?.icon_url ?? undefined,
|
||||
icon_url: addon.icon_url ?? (embeddedIcon ? undefined : projectMetadata?.icon_url) ?? undefined,
|
||||
},
|
||||
version: {
|
||||
id: addon.version?.id ?? addon.filename,
|
||||
version_number: addon.version?.name ?? formatMessage(commonMessages.unknownLabel),
|
||||
version_number:
|
||||
addon.version?.name ?? addon.manifest?.version ?? formatMessage(commonMessages.unknownLabel),
|
||||
file_name: addon.filename,
|
||||
},
|
||||
owner: addon.owner
|
||||
@@ -836,13 +971,24 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
|
||||
}
|
||||
: undefined,
|
||||
id: addon.id ?? addon.filename,
|
||||
enabled: !addon.disabled,
|
||||
external: !addon.project_id,
|
||||
source_kind: addon.from_modpack ? 'modrinth_modpack' : undefined,
|
||||
enabled: serverEnabled || playerEnabled,
|
||||
enabledFor: {
|
||||
server: serverEnabled,
|
||||
player: playerEnabled,
|
||||
locked: hasDetectedEnvironment(addon) && !addon.side_toggle_unlocked,
|
||||
disabledSides: getLockedSides(addon),
|
||||
disabledTooltip: formatMessage(messages.sideLocked),
|
||||
warningTooltip: getEnabledForWarning(addon),
|
||||
},
|
||||
embeddedIcon,
|
||||
file_name: addon.filename,
|
||||
date_added: addon.btime,
|
||||
project_type: addon.kind,
|
||||
has_update: !!addon.has_update,
|
||||
update_version_id: addon.has_update,
|
||||
environment: addon.version?.environment ?? undefined,
|
||||
environment,
|
||||
pack_client_retained: addon.pack_client_retained,
|
||||
pack_client_depends: addon.pack_client_depends,
|
||||
installing: addon.installing ?? addon.status === 'pending',
|
||||
@@ -877,86 +1023,6 @@ async function handleViewModpackContent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackContentToggle(item: ContentItem) {
|
||||
if (contentActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
|
||||
try {
|
||||
await toggleMutation.mutateAsync({ addon })
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
|
||||
)
|
||||
queryClient.setQueryData(
|
||||
modpackContentQueryKey.value,
|
||||
(oldData: Archon.Content.v1.Addons | undefined) =>
|
||||
oldData
|
||||
? {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((a) =>
|
||||
a.filename === addon.filename ? { ...a, disabled: !addon.disabled } : a,
|
||||
),
|
||||
}
|
||||
: oldData,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, {
|
||||
enabled: !item.enabled,
|
||||
disabled: false,
|
||||
})
|
||||
} catch {
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: false })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
|
||||
// Optimistic update
|
||||
for (const item of items) {
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === item.file_name ? { ...a, disabled: !enable } : a,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, { enabled: enable })
|
||||
}
|
||||
|
||||
try {
|
||||
if (enable) {
|
||||
await client.archon.content_v1.enableAddons(serverId, worldId.value!, requests)
|
||||
} else {
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, requests)
|
||||
}
|
||||
queryClient.setQueryData(
|
||||
modpackContentQueryKey.value,
|
||||
(oldData: Archon.Content.v1.Addons | undefined) =>
|
||||
oldData
|
||||
? {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((addon) =>
|
||||
items.some((item) => item.file_name === addon.filename)
|
||||
? { ...addon, disabled: !enable }
|
||||
: addon,
|
||||
),
|
||||
}
|
||||
: oldData,
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
} catch (err) {
|
||||
for (const item of items) {
|
||||
modpackAddons.value = modpackAddons.value.map((a) =>
|
||||
a.filename === item.file_name ? { ...a, disabled: enable } : a,
|
||||
)
|
||||
modpackContentModal.value?.updateItem(item.file_name, { enabled: !enable })
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(enable ? messages.failedToBulkEnable : messages.failedToBulkDisable),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleModpackUnlink() {
|
||||
modpackUnlinkModal.value?.show()
|
||||
}
|
||||
@@ -1175,12 +1241,22 @@ function handleModpackUpdateCancel() {
|
||||
pendingModpackUpdateVersion.value = null
|
||||
}
|
||||
|
||||
function getOverflowOptions(item: ContentItem) {
|
||||
const options: { id: string; icon?: typeof ClipboardCopyIcon; action: () => void }[] = []
|
||||
async function handleToggleSideLock(addon: Archon.Content.v1.Addon) {
|
||||
if (contentActionDisabled.value) return
|
||||
await setSideLockMutation.mutateAsync({
|
||||
addon,
|
||||
locked: addon.side_toggle_unlocked,
|
||||
})
|
||||
}
|
||||
|
||||
if (item.project?.slug) {
|
||||
function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
|
||||
const options: OverflowMenuOption[] = []
|
||||
const addon = getAddonForItem(item)
|
||||
|
||||
if (addon?.project_id && item.project?.slug) {
|
||||
options.push({
|
||||
id: formatMessage(commonMessages.copyLinkButton),
|
||||
id: 'copy-link',
|
||||
label: formatMessage(commonMessages.copyLinkButton),
|
||||
icon: ClipboardCopyIcon,
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
@@ -1190,6 +1266,18 @@ function getOverflowOptions(item: ContentItem) {
|
||||
})
|
||||
}
|
||||
|
||||
if (addon && hasDetectedEnvironment(addon)) {
|
||||
if (options.length > 0) options.push({ type: 'divider' })
|
||||
options.push({
|
||||
id: 'toggle-side-lock',
|
||||
label: formatMessage(
|
||||
addon.side_toggle_unlocked ? messages.lockEnvironment : messages.unlockEnvironment,
|
||||
),
|
||||
icon: addon.side_toggle_unlocked ? LockIcon : LockOpenIcon,
|
||||
action: () => handleToggleSideLock(addon),
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
@@ -1204,11 +1292,9 @@ provideContentManager({
|
||||
disableAddContent: computed(() => !canSetup.value),
|
||||
disableAddContentTooltip: permissionDeniedMessage.value,
|
||||
contentTypeLabel: type,
|
||||
toggleEnabled: handleToggleEnabled,
|
||||
setEnabledFor: handleSetEnabledFor,
|
||||
deleteItem: handleDeleteItem,
|
||||
bulkDeleteItems: handleBulkDelete,
|
||||
bulkEnableItems: handleBulkEnable,
|
||||
bulkDisableItems: handleBulkDisable,
|
||||
refresh: async () => {
|
||||
await contentQuery.refetch()
|
||||
},
|
||||
@@ -1245,6 +1331,9 @@ provideContentManager({
|
||||
: undefined,
|
||||
external: item.external ?? !hasModrinthProject,
|
||||
enabled: item.enabled,
|
||||
enabledFor: item.enabledFor,
|
||||
embeddedIcon: item.embeddedIcon,
|
||||
hideToggle: true,
|
||||
}
|
||||
},
|
||||
filterPersistKey: `server:${serverId}:${worldId.value}`,
|
||||
@@ -1274,13 +1363,12 @@ provideContentManager({
|
||||
:source-name="managedContent?.card.manager.name"
|
||||
:source-icon-url="managedContent?.card.manager.iconUrl"
|
||||
:header="formatMessage(messages.modpackContent)"
|
||||
enable-toggle
|
||||
enable-enabled-for
|
||||
show-environment-warnings
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
:action-disabled="contentActionDisabled"
|
||||
:action-disabled-tooltip="contentActionBusyMessage ?? undefined"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||
@update:enabled-for="handleModpackSetEnabledFor"
|
||||
@hide="isModpackContentModalOpen = false"
|
||||
/>
|
||||
<ContentUpdaterModal
|
||||
|
||||
Reference in New Issue
Block a user