Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) a03ab1354a fix: prepr 2026-08-24 13:12:26 +01:00
Calum H. (IMB11) 0748b8732e fix: qa 2026-08-24 13:11:11 +01:00
Calum H. (IMB11) b0c77bb395 fix: qa 2026-08-24 12:46:43 +01:00
Calum H. (IMB11) cba59f9848 fix: qa 2026-08-24 12:46:11 +01:00
Calum H. (IMB11) 08901e725f fix: qa 2026-08-24 12:37:53 +01:00
Calum H. (IMB11) 0fbd431169 feat: start on content tab env focused design 2026-08-24 12:28:17 +01:00
45 changed files with 1054 additions and 526 deletions
+6 -10
View File
@@ -907,17 +907,18 @@ function getCardActions(
const isQueued = queuedServerInstallProjectIds.value.has(projectResult.project_id)
const isQueuedRoot = queuedServerInstallRootProjectIds.value.has(projectResult.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const showAsInstalling = isInstalling || (isInstallingSelection && isQueuedRoot)
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = showAsInstalled
? commonMessages.installedLabel
: isQueued
? isInstalling || isInstallingSelection
? showAsInstalling
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
: commonMessages.selectedLabel
: isInstalling || isInstallingSelection
: showAsInstalling
? validatingInstall
? commonMessages.validatingLabel
: messages.installingToServer
@@ -926,16 +927,11 @@ function getCardActions(
{
key: 'install',
label: formatMessage(installLabel),
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || showAsInstalled
? CheckIcon
: PlusIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
icon: showAsInstalling ? SpinnerIcon : isQueued || showAsInstalled ? CheckIcon : PlusIcon,
iconClass: showAsInstalling ? 'animate-spin' : undefined,
disabled:
showAsInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
color: isQueued && !showAsInstalling ? 'green' : 'brand',
type: 'outlined',
onClick: async () => {
if (isQueuedRoot) {
@@ -361,17 +361,18 @@ function getCardActions(
serverData.value.upstream?.project_id === result.project_id
const isInstalling = installingProjectIds.value.has(result.project_id)
const isInstallingSelection = isInstallingQueuedServerInstalls.value
const showAsInstalling = isInstalling || (isInstallingSelection && isQueuedRoot)
const validatingInstall =
isInstalling && currentProjectType !== 'modpack' && !isInstallingSelection
const installLabel = isInstalled
? formatMessage(commonMessages.installedLabel)
: isQueued
? isInstalling || isInstallingSelection
? showAsInstalling
? validatingInstall
? formatMessage(commonMessages.validatingLabel)
: formatMessage(commonMessages.installingLabel)
: formatMessage(commonMessages.selectedLabel)
: isInstalling || isInstallingSelection
: showAsInstalling
? validatingInstall
? formatMessage(commonMessages.validatingLabel)
: formatMessage(commonMessages.installingLabel)
@@ -381,16 +382,11 @@ function getCardActions(
{
key: 'install',
label: installLabel,
icon:
isInstalling || isInstallingSelection
? SpinnerIcon
: isQueued || isInstalled
? CheckIcon
: DownloadIcon,
iconClass: isInstalling || isInstallingSelection ? 'animate-spin' : undefined,
icon: showAsInstalling ? SpinnerIcon : isQueued || isInstalled ? CheckIcon : DownloadIcon,
iconClass: showAsInstalling ? 'animate-spin' : undefined,
disabled:
!!isInstalled || isInstalling || isInstallingSelection || (isQueued && !isQueuedRoot),
color: isQueued && !isInstalling && !isInstallingSelection ? 'green' : 'brand',
color: isQueued && !showAsInstalling ? 'green' : 'brand',
type: 'outlined',
onClick: () => serverInstall(projectResult),
},
@@ -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
*
+18 -10
View File
@@ -3,12 +3,12 @@
:id="id"
type="button"
role="switch"
:aria-checked="modelValue"
:aria-checked="indeterminate ? 'mixed' : modelValue"
:disabled="disabled"
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
:class="[
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
modelValue ? 'bg-brand' : 'bg-button-bg',
indeterminate || modelValue ? 'bg-brand' : 'bg-button-bg',
disabled ? 'opacity-50 cursor-not-allowed' : '',
]"
@click="toggle"
@@ -16,17 +16,24 @@
<span
class="rounded-full transition-all duration-200"
:class="[
small ? 'w-3 h-3' : 'w-4 h-4',
modelValue
indeterminate
? small
? 'translate-x-[20px] bg-black/90'
: 'translate-x-[24px] bg-black/90'
: 'bg-gray',
? 'h-0.5 w-2.5 translate-x-[11px] bg-black/90'
: 'h-0.5 w-3 translate-x-[14px] bg-black/90'
: modelValue
? small
? 'h-3 w-3 translate-x-[20px] bg-black/90'
: 'h-4 w-4 translate-x-[24px] bg-black/90'
: small
? 'h-3 w-3 bg-gray'
: 'h-4 w-4 bg-gray',
disabled
? ''
: small
? 'group-hover:w-[14px] group-hover:h-[14px] group-hover:m-[-1px] group-active:w-[10px] group-active:h-[10px] group-active:m-[1px]'
: 'group-hover:w-[18px] group-hover:h-[18px] group-hover:m-[-1px] group-active:w-[14px] group-active:h-[14px] group-active:m-[1px]',
: indeterminate
? 'group-hover:scale-x-110 group-active:scale-x-75'
: small
? 'group-hover:w-[14px] group-hover:h-[14px] group-hover:m-[-1px] group-active:w-[10px] group-active:h-[10px] group-active:m-[1px]'
: 'group-hover:w-[18px] group-hover:h-[18px] group-hover:m-[-1px] group-active:w-[14px] group-active:h-[14px] group-active:m-[1px]',
]"
/>
</button>
@@ -37,6 +44,7 @@ const props = defineProps<{
id?: string
disabled?: boolean
small?: boolean
indeterminate?: boolean
}>()
const modelValue = defineModel<boolean>()
@@ -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()
@@ -49,6 +54,10 @@ const messages = defineMessages({
id: 'content.card.frozen',
defaultMessage: 'This project is locked to its current version until unfrozen.',
},
useEnabledForControls: {
id: 'content.card.use-enabled-for-controls',
defaultMessage: 'Use the Enabled for controls to enable or disable this content.',
},
})
interface Props {
@@ -77,6 +86,8 @@ interface Props {
hideDelete?: boolean
hideActions?: boolean
inline?: boolean
enabledFor?: ContentEnabledForState
embeddedIcon?: ContentCardEmbeddedIcon
}
const props = withDefaults(defineProps<Props>(), {
@@ -104,12 +115,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: []
@@ -127,7 +141,20 @@ const versionNumberRef = ref<HTMLElement | null>(null)
const fileNameRef = ref<HTMLElement | null>(null)
const isDisabled = computed(() => props.disabled || props.installing)
const isToggleDisabled = computed(() => isDisabled.value || props.toggleDisabled)
const isToggleIndeterminate = computed(
() => props.enabledFor !== undefined && props.enabledFor.server !== props.enabledFor.player,
)
const isToggleDisabled = computed(
() => isDisabled.value || props.toggleDisabled || isToggleIndeterminate.value,
)
const toggleTooltip = computed(() => {
if (isToggleIndeterminate.value) {
return formatMessage(messages.useEnabledForControls)
}
if (!isToggleDisabled.value) return undefined
return props.toggleDisabledTooltip ?? props.disabledTooltip ?? undefined
})
const isEnabledForDisabled = computed(() => !props.enabledFor?.server && !props.enabledFor?.player)
const clientWarningMessage = computed(() => {
switch (props.clientWarning) {
@@ -152,18 +179,24 @@ const installTooltip = computed(() => {
<template>
<div
role="row"
class="flex items-center justify-between"
class="flex items-center"
:class="{
'h-[74px] gap-4 px-3': !inline,
'h-[74px] gap-4 px-3': !inline && !enabledFor,
'h-[72px] gap-2 px-3': !inline && enabledFor,
'gap-3': inline,
'justify-between': !enabledFor,
'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 +210,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 +327,20 @@ 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 ? 'min-w-0 flex-1' : 'flex-1 min-w-0',
!enabledFor && enabled === false && !disabled ? 'grayscale opacity-50' : '',
]"
>
<template v-if="version">
@@ -335,7 +376,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-[168px]' : 'min-w-[160px]'"
>
<slot name="additionalButtonsLeft" />
@@ -399,13 +441,10 @@ const installTooltip = computed(() => {
<Toggle
v-if="enabled !== undefined && !hideToggle"
v-tooltip="
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
? (toggleDisabledTooltip ?? disabledTooltip)
: undefined
"
v-tooltip="toggleTooltip"
:model-value="enabled"
:disabled="isToggleDisabled"
:indeterminate="isToggleIndeterminate"
:aria-label="project.title"
class="my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
@@ -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,17 @@ 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 where this content is enabled. Turn both off to disable it.',
},
})
interface Props {
items: ContentCardTableItem[]
showSelection?: boolean
@@ -28,6 +39,7 @@ interface Props {
hideHeader?: boolean
flat?: boolean
showItemActions?: boolean
showEnabledForColumn?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -40,6 +52,7 @@ const props = withDefaults(defineProps<Props>(), {
hideHeader: false,
flat: false,
showItemActions: false,
showEnabledForColumn: false,
})
const stickyHeaderRef = ref<HTMLElement | null>(null)
@@ -49,6 +62,7 @@ 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]
@@ -65,6 +79,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 +97,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'),
@@ -179,8 +197,9 @@ function handleSort(column: ContentCardTableSortColumn) {
v-if="!hideHeader"
ref="stickyHeaderRef"
role="rowgroup"
class="sticky top-0 z-10 flex h-12 items-center justify-between gap-4 bg-surface-3 px-3"
class="sticky top-0 z-10 flex h-12 items-center bg-surface-3 px-3"
:class="[
hasEnabledForColumn ? 'gap-2' : 'justify-between gap-4',
flat || isStuck ? 'rounded-none' : 'rounded-t-[20px]',
isStuck
? 'transition-[border-radius] duration-100 border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
@@ -191,7 +210,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 +248,27 @@ 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"
class="hidden w-[200px] shrink-0 items-center gap-1.5 font-semibold text-secondary @[800px]:flex"
>
<span>{{ formatMessage(messages.enabledFor) }}</span>
<span
v-tooltip="formatMessage(messages.enabledForDescription)"
class="inline-flex size-4 cursor-help items-center justify-center"
tabindex="0"
>
<InfoIcon class="size-4" />
</span>
</div>
<div
class="hidden @[800px]:flex"
:class="
hasAnyActions ? (hasEnabledForColumn ? 'min-w-0 flex-1' : 'flex-1 min-w-0') : 'flex-1'
"
>
<button
v-if="sortable"
role="columnheader"
@@ -247,7 +290,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-[168px]' : 'min-w-[160px]'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
@@ -288,6 +336,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 +356,7 @@ 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 +407,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 +424,7 @@ 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,112 @@
<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: 'These choices are locked. Select Unlock from the row menu to change them.',
},
requiredHere: {
id: 'content.enabled-for.required-here',
defaultMessage: 'This content needs to be enabled here to work correctly.',
},
notSupportedHere: {
id: 'content.enabled-for.not-supported-here',
defaultMessage: "This content isn't designed to be enabled here.",
},
})
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 getDisabledTooltip(side: ContentSide) {
if (props.disabled) return props.disabledTooltip
if (!disabledSides.value.has(side)) return undefined
return formatMessage(isSelected(side) ? messages.requiredHere : messages.notSupportedHere)
}
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="getDisabledTooltip(side)"
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 && disabledSides.size > 0"
v-tooltip="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>
@@ -8,6 +8,7 @@ import {
PaintbrushIcon,
SearchIcon,
SpinnerIcon,
UnknownIcon,
} from '@modrinth/assets'
import Fuse from 'fuse.js'
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
@@ -30,7 +31,12 @@ 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 +48,7 @@ interface Props {
sourceName?: string
sourceIconUrl?: string
enableToggle?: boolean
enableEnabledFor?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
@@ -54,6 +61,7 @@ const props = withDefaults(defineProps<Props>(), {
sourceName: undefined,
sourceIconUrl: undefined,
enableToggle: false,
enableEnabledFor: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
@@ -63,6 +71,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 +118,18 @@ const messages = defineMessages({
id: 'instances.managed-content-modal.download-file',
defaultMessage: 'Download File',
},
enabledFor: {
id: 'content.enabled-for.label',
defaultMessage: 'Enabled for',
},
enabledForDescription: {
id: 'content.enabled-for.description',
defaultMessage: 'Choose where this content is enabled. Turn both off to disable it.',
},
pleaseWait: {
id: 'content.enabled-for.please-wait',
defaultMessage: 'Please wait',
},
})
export interface ManagedContentModalState {
@@ -262,6 +283,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),
@@ -282,16 +304,24 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
link: item.source.link ?? sourceProjectLink(item.source.project),
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
...(props.enableToggle || props.enableEnabledFor ? { 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 +359,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 +404,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 +513,12 @@ 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
>
@@ -539,13 +582,16 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<div v-else class="@container flex-1 min-h-0 flex flex-col">
<div
class="flex h-12 shrink-0 items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-3 px-3"
class="flex h-12 shrink-0 items-center border-0 border-b border-solid border-surface-4 bg-surface-3 px-3"
:class="props.enableEnabledFor ? 'gap-2' : 'justify-between gap-4'"
>
<div
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 +607,38 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
formatMessage(commonMessages.projectLabel)
}}</span>
</div>
<div
v-if="props.enableEnabledFor"
class="hidden w-[200px] shrink-0 items-center gap-1.5 @[800px]:flex"
>
<span class="font-semibold text-secondary">{{
formatMessage(messages.enabledFor)
}}</span>
<UnknownIcon
v-tooltip="formatMessage(messages.enabledForDescription)"
class="size-4 cursor-help text-secondary"
tabindex="0"
/>
</div>
<div
class="hidden @[800px]:flex"
:class="showTableActions ? 'flex-1 min-w-0' : 'flex-1'"
:class="
showTableActions
? props.enableEnabledFor
? 'min-w-0 flex-1'
: '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-[168px]' : 'min-w-[160px]'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
@@ -580,15 +649,17 @@ 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
v-on="
props.enableToggle
props.enableToggle || props.enableEnabledFor
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
: {}
"
@update:enabled-for="handleEnabledForChange"
>
<template #itemTitleBadges="{ item }">
<span
@@ -180,9 +180,18 @@ function getItemId(item: ContentItem) {
}
type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' | 'date-added-oldest'
const defaultSortMode: SortMode = '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 validSortModes: readonly string[] = [
'alphabetical-asc',
'alphabetical-desc',
'date-added-newest',
'date-added-oldest',
]
if (!validSortModes.includes(sortMode.value)) sortMode.value = defaultSortMode
const sortLabels: Record<SortMode, () => string> = {
'alphabetical-asc': () => formatMessage(messages.sortAlphabeticalAscending),
@@ -191,32 +200,36 @@ const sortLabels: Record<SortMode, () => string> = {
'date-added-oldest': () => formatMessage(messages.sortDateAddedOldest),
}
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'),
},
]
return options
})
const sortedItems = computed(() => {
const items = [...ctx.items.value]
@@ -424,14 +437,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 +526,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 +641,7 @@ async function disableItemsWithoutWarning(items: ContentItem[]) {
await ctx.bulkDisableItems(items)
return
}
if (!ctx.toggleEnabled) return
for (const item of items) {
const id = getItemId(item)
@@ -725,15 +750,15 @@ async function confirmDisable() {
if (itemsToDisable.length === 1) {
const item = itemsToDisable[0]
const id = getItemId(item)
markChanging(id)
if (!item.enabledFor) markChanging(id)
try {
if (ctx.bulkDisableItems) {
await ctx.bulkDisableItems(itemsToDisable)
} else {
await ctx.toggleEnabled(item)
await ctx.toggleEnabled?.(item)
}
} finally {
unmarkChanging(id)
if (!item.enabledFor) unmarkChanging(id)
}
return
}
@@ -744,7 +769,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
@@ -752,11 +777,11 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
await promptDisableItems([item])
return
}
markChanging(id)
if (!item.enabledFor) markChanging(id)
try {
await ctx.toggleEnabled(item)
} finally {
unmarkChanging(id)
if (!item.enabledFor) unmarkChanging(id)
}
}
@@ -782,7 +807,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 +831,14 @@ 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
await ctx.setEnabledFor(item, side, enabled)
}
// Bulk updating
const confirmBulkUpdateModal = ref<InstanceType<typeof ConfirmBulkUpdateModal>>()
const pendingBulkUpdateItems = ref<ContentItem[]>([])
@@ -1219,7 +1253,9 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
v-model:selected-ids="selectedIds"
:items="tableItems"
:show-selection="true"
:show-enabled-for-column="!!ctx.setEnabledFor"
@update:enabled="handleToggleEnabledById"
@update:enabled-for="handleSetEnabledForById"
@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,22 @@ export interface EmbeddedContentMetadata {
icon_url?: string | null
}
export type ContentSide = 'server' | 'player'
export interface ContentEnabledForState {
server: boolean
player: boolean
locked: boolean
disabledSides?: ContentSide[]
warningTooltip?: string | null
}
export interface ContentCardEmbeddedIcon {
queryKey: readonly unknown[]
queryFn: () => Promise<Blob>
fallbackUrl?: string | null
}
export interface ContentCardTableItem {
id: string
project: ContentCardProject
@@ -74,6 +90,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,12 @@ 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,10 +1,11 @@
<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 type { OverflowMenuOption } from '#ui/components/base/buttons'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
@@ -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,19 @@ const messages = defineMessages({
id: 'hosting.content.failed-to-install',
defaultMessage: 'Failed to install content',
},
unknownEnvironment: {
id: 'hosting.content.enabled-for.unknown-environment',
defaultMessage:
"We couldn't tell where this content should be enabled. Review the Server and Player choices.",
},
lockEnvironment: {
id: 'hosting.content.enabled-for.lock-environment',
defaultMessage: 'Lock',
},
unlockEnvironment: {
id: 'hosting.content.enabled-for.unlock-environment',
defaultMessage: 'Unlock',
},
})
const client = injectModrinthClient()
@@ -299,12 +309,56 @@ 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 getEnvironmentLockedSides(addon: Archon.Content.v1.Addon): ContentSide[] {
switch (getAddonEnvironment(addon)) {
case 'client_and_server':
case 'client_only':
case 'singleplayer_only':
case 'server_only':
case 'dedicated_server_only':
return ['server', 'player']
case 'client_only_server_optional':
return ['player']
case 'server_only_client_optional':
return ['server']
default:
return []
}
}
function getLockedSides(addon: Archon.Content.v1.Addon): ContentSide[] {
return addon.side_toggle_unlocked ? [] : getEnvironmentLockedSides(addon)
}
function hasEnvironmentLock(addon: Archon.Content.v1.Addon) {
return getEnvironmentLockedSides(addon).length > 0
}
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 +366,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 +622,142 @@ 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
sides: ContentSide[]
enabled: boolean
}
const setEnabledForMutation = useMutation({
mutationFn: async ({ addon, sides, 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)
} else {
await client.archon.content_v1.disableAddon(serverId, worldId.value!, request)
}
return { filename: addon.filename, newDisabled: !addon.disabled }
await Promise.all(
sides.map((side) =>
side === 'server'
? client.archon.content_v1.setAddonEnabledServer(serverId, worldId.value!, request)
: client.archon.content_v1.setAddonEnabledPlayer(serverId, worldId.value!, request),
),
)
},
onSuccess: ({ filename, newDisabled }) => {
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
onMutate: async ({ addon, sides, 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 {
...candidate,
...(sides.includes('server') ? { disabled_server: !enabled } : {}),
...(sides.includes('player') ? { 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, sides: [side], enabled })
}
async function handleToggleEnabled(item: ContentItem) {
if (contentActionDisabled.value || !item.enabledFor) return
if (item.enabledFor.server !== item.enabledFor.player) return
const addon = getAddonForItem(item)
if (!addon) return
await setEnabledForMutation.mutateAsync({
addon,
sides: ['server', 'player'],
enabled: !item.enabledFor.server,
})
}
async function handleModpackSetEnabledFor(item: ContentItem, side: ContentSide, enabled: boolean) {
try {
await handleSetEnabledFor(item, side, enabled)
} catch {
return
}
}
async function handleModpackToggleEnabled(item: ContentItem) {
try {
await handleToggleEnabled(item)
} catch {
return
}
}
async function handleDeleteItem(item: ContentItem) {
@@ -629,38 +792,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 +944,48 @@ 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
const lockedSides = getLockedSides(addon)
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 +998,23 @@ 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: lockedSides.length > 0,
disabledSides: lockedSides,
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 +1049,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 +1267,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 +1292,18 @@ function getOverflowOptions(item: ContentItem) {
})
}
if (addon && hasEnvironmentLock(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
}
@@ -1205,10 +1319,11 @@ provideContentManager({
disableAddContentTooltip: permissionDeniedMessage.value,
contentTypeLabel: type,
toggleEnabled: handleToggleEnabled,
setEnabledFor: handleSetEnabledFor,
canToggleItem: (item) =>
item.enabledFor !== undefined && item.enabledFor.server === item.enabledFor.player,
deleteItem: handleDeleteItem,
bulkDeleteItems: handleBulkDelete,
bulkEnableItems: handleBulkEnable,
bulkDisableItems: handleBulkDisable,
refresh: async () => {
await contentQuery.refetch()
},
@@ -1245,6 +1360,8 @@ provideContentManager({
: undefined,
external: item.external ?? !hasModrinthProject,
enabled: item.enabled,
enabledFor: item.enabledFor,
embeddedIcon: item.embeddedIcon,
}
},
filterPersistKey: `server:${serverId}:${worldId.value}`,
@@ -1274,13 +1391,13 @@ 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="handleModpackToggleEnabled"
@update:enabled-for="handleModpackSetEnabledFor"
@hide="isModpackContentModalOpen = false"
/>
<ContentUpdaterModal
-10
View File
@@ -1538,12 +1538,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Obsah se nepodařilo smazat"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Obsah se nepodařilo vypnout"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Obsah se nepodařilo zapnout"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Obsah se nepodařilo nahrát"
},
@@ -1559,9 +1553,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Obsah se nepodařilo odebrat"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Nepodařilo se přepnout {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Modpack se nepodařilo odpojit"
},
@@ -4470,4 +4461,3 @@
"defaultMessage": "Typ"
}
}
-10
View File
@@ -1901,12 +1901,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Entfernen von Inhalten fehlgeschlagen"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Deaktivieren von Inhalten fehlgeschlagen"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Aktivieren von Inhalten fehltgeschlagen"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Aktualisieren von Inhalten fehlgeschlagen"
},
@@ -1922,9 +1916,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Entfernen von Inhalten fehlgeschlagen"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Umschalten von {name} fehlgeschlagen"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Entkoppeln von Modpack fehlgeschlagen"
},
@@ -6765,4 +6756,3 @@
"defaultMessage": "Typ"
}
}
-10
View File
@@ -1901,12 +1901,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Inhalte konnten nicht gelöscht werden"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Inhalte konnten nicht deaktiviert werden"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Inhalte konnten nicht aktiviert werden"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Inhalte konnten nicht aktualisiert werden"
},
@@ -1922,9 +1916,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Entfernen von Inhalten fehlgeschlagen"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Umschalten von {name} fehlgeschlagen"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Modpack konnte nicht getrennt werden"
},
@@ -6765,4 +6756,3 @@
"defaultMessage": "Typ"
}
}
+41 -8
View File
@@ -377,6 +377,9 @@
"content.card.uploaded": {
"defaultMessage": "Uploaded"
},
"content.card.use-enabled-for-controls": {
"defaultMessage": "Use the Enabled for controls to enable or disable this content."
},
"content.confirm-bulk-update.admonition-body": {
"defaultMessage": "Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one."
},
@@ -551,6 +554,30 @@
"content.diff-modal.updated-count": {
"defaultMessage": "{count} updated"
},
"content.enabled-for.description": {
"defaultMessage": "Choose where this content is enabled. Turn both off to disable it."
},
"content.enabled-for.label": {
"defaultMessage": "Enabled for"
},
"content.enabled-for.locked": {
"defaultMessage": "These choices are locked. Select Unlock from the row menu to change them."
},
"content.enabled-for.not-supported-here": {
"defaultMessage": "This content isn't designed to be enabled here."
},
"content.enabled-for.player": {
"defaultMessage": "Player"
},
"content.enabled-for.please-wait": {
"defaultMessage": "Please wait"
},
"content.enabled-for.required-here": {
"defaultMessage": "This content needs to be enabled here to work correctly."
},
"content.enabled-for.server": {
"defaultMessage": "Server"
},
"content.filter.disabled": {
"defaultMessage": "Disabled"
},
@@ -1898,15 +1925,18 @@
"header.category.resolutions": {
"defaultMessage": "Resolution"
},
"hosting.content.enabled-for.lock-environment": {
"defaultMessage": "Lock"
},
"hosting.content.enabled-for.unknown-environment": {
"defaultMessage": "We couldn't tell where this content should be enabled. Review the Server and Player choices."
},
"hosting.content.enabled-for.unlock-environment": {
"defaultMessage": "Unlock"
},
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Failed to delete content"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Failed to disable content"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Failed to enable content"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Failed to update content"
},
@@ -1922,8 +1952,11 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Failed to remove content"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Failed to toggle {name}"
"hosting.content.failed-to-set-enabled-for": {
"defaultMessage": "Failed to change where {name} is enabled"
},
"hosting.content.failed-to-set-side-lock": {
"defaultMessage": "Failed to change the environment lock for {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Failed to unlink modpack"
-10
View File
@@ -1901,12 +1901,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Error al eliminar el contenido"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Error al desactivar el contenido"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Error al activar el contenido"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Error al actualizar el contenido"
},
@@ -1922,9 +1916,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Error al eliminar el contenido"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Error al activar/desactivar {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Error al desvincular el modpack"
},
@@ -6765,4 +6756,3 @@
"defaultMessage": "Tipo"
}
}
-9
View File
@@ -1871,12 +1871,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "No se ha podido eliminar el contenido"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "No se ha podido desactivar el contenido"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "No se ha podido habilitar el contenido"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "No se ha podido actualizar el contenido"
},
@@ -1892,9 +1886,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "No se ha podido eliminar el contenido"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "No se ha podido alternar {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "No se ha podido desvincular el modpack"
},
@@ -542,12 +542,6 @@
"header.category.resolutions": {
"defaultMessage": "Resolusyon"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Bigong mapatay ang tumutunggaling kontento"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Bigong mabukas ang kontento"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Bigong ma-unlink ang modpack"
},
@@ -2736,4 +2730,3 @@
"defaultMessage": "Uri"
}
}
-9
View File
@@ -1763,12 +1763,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Impossible de supprimer le contenu"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Impossible de désactiver le contenu"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Impossible d'activer le contenu"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Impossible de mettre à jour le contenu"
},
@@ -1784,9 +1778,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Impossible de retirer le contenu"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Impossible de basculer {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Impossible de dissocier le modpack"
},
-6
View File
@@ -1595,12 +1595,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Nem sikerült törölni a tartalmat"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "A tartalom letiltása nem sikerült"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "A tartalom engedélyezése nem sikerült"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "A tartalom frissítése sikertelen"
},
-9
View File
@@ -1847,12 +1847,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Errore nella rimozione dei contenuti"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Errore nella disabilitazione dei contenuti"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Errore nell'abilitazione dei contenuti"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Errore nell'aggiornamento dei contenuti"
},
@@ -1868,9 +1862,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Errore nella rimozione dei contenuti"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Impossibile cambiare lo stato di \"{name}\""
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Errore nello scollegamento del pacchetto"
},
-9
View File
@@ -1718,12 +1718,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "コンテンツの削除に失敗しました"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "コンテンツの無効化に失敗しました"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "コンテンツの有効化に失敗しました"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "コンテンツの更新に失敗しました"
},
@@ -1739,9 +1733,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "コンテンツの削除に失敗しました"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "{name}の切り替えに失敗しました"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Modパックのリンク解除に失敗しました"
},
-9
View File
@@ -1793,12 +1793,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "콘텐츠 삭제 실패"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "콘텐츠 비활성화 실패"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "콘텐츠 활성화 실패"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "콘텐츠 업데이트 실패"
},
@@ -1814,9 +1808,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "콘텐츠 제거 실패"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "{name} 전환 실패"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "모드팩 연결 해제 실패"
},
-10
View File
@@ -1328,12 +1328,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Gagal memadamkan kandungan"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Gagal menyahdayakan kandungan"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Gagal mendayakan kandungan"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Gagal mengemas kini kandungan"
},
@@ -1349,9 +1343,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Gagal mengalih keluar kandungan"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Gagal menogol {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Gagal menyahpaut pek mod"
},
@@ -4959,4 +4950,3 @@
"defaultMessage": "Jenis"
}
}
-9
View File
@@ -1712,12 +1712,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Het verwijderen van inhoud is mislukt"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Het uitschakelen van inhoud is mislukt"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Het inschakelen van inhoud is mislukt"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Het bijwerken van inhoud is mislukt"
},
@@ -1733,9 +1727,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Het verwijderen van inhoud is mislukt"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Het omschakelen van {name} is mislukt"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Het ontkoppelen van het modpack is mislukt"
},
-9
View File
@@ -1865,12 +1865,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Nie udało się usunąć zawartości"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Nie udało się wyłączyć zawartości"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Nie udało się włączyć zawartości"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Nie udało się zaktualizować zawartości"
},
@@ -1886,9 +1880,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Nie udało się usunąć zawartości"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Nie udało się przełączyć {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Nie udało się rozłączyć od paczki modów"
},
-10
View File
@@ -1829,12 +1829,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Falha ao excluir o conteúdo"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Falha ao desativar o conteúdo"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Falha ao ativar o conteúdo"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Falha ao atualizar o conteúdo"
},
@@ -1850,9 +1844,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Falha ao remover o conteúdo"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Falha ao alterar {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Falha ao desvincular o pacote de mods"
},
@@ -6429,4 +6420,3 @@
"defaultMessage": "Tipo"
}
}
-9
View File
@@ -827,12 +827,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Nu s-a putut șterge conținutul"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Nu s-a putut dezactiva conținutul"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Nu s-a putut activa conținutul"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Nu s-a putut actualiza conținutul"
},
@@ -848,9 +842,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Nu s-a putut înlătura conținutul"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Nu s-a putut comuta {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Nu s-a putut nelinka pachetul de moduri"
},
-9
View File
@@ -1820,12 +1820,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Не удалось удалить контент"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Не удалось отключить контент"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Не удалось включить контент"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Не удалось обновить контент"
},
@@ -1841,9 +1835,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Не удалось удалить контент"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Не удалось переключить {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Не удалось отвязать сборку"
},
-10
View File
@@ -1664,12 +1664,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Greška sa brisanjem sadržaja"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Nije uspelo onemogućavanje sadržaja"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Nije uspelo omogućavanje sadržaja"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Ažuriranje sadržaja nije uspelo"
},
@@ -1685,9 +1679,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Greška sa brisanjem sadržaja"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Greška sa preklapanjem {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Greška sa uklanjanjem veze od modpacka"
},
@@ -5658,4 +5649,3 @@
"defaultMessage": "Tip"
}
}
-9
View File
@@ -1712,12 +1712,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Kunde inte radera innehåll"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Kunde inte inaktivera innehåll"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Kunde inte aktivera innehåll"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Kunde inte uppdatera innehåll"
},
@@ -1733,9 +1727,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Kunde inte radera innehållet"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Kunde inte växla {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Kunde inte avlänka modpaketet"
},
-4
View File
@@ -242,9 +242,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "ไม่สามารถลบเนื้อหาได้"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "ไม่สามารถปิดใช้งานเนื้อหาได้"
},
"icon-select.edit": {
"defaultMessage": "แก้ไขไอคอน"
},
@@ -645,4 +642,3 @@
"defaultMessage": "ภาษา"
}
}
-9
View File
@@ -1841,12 +1841,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "İçeriği silmek başarısız"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "İçeriği kapatmak başarısız"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "İçeriği açmak başarısız"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "İçeriği güncelleme başarısız"
},
@@ -1862,9 +1856,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "İçerik çıkarılamadı"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "{name} durumu değiştirilemedi"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Mod paketinin bağlantısı koparılamadı"
},
-9
View File
@@ -1787,12 +1787,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Не вдалося видалити вміст"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Не вдалося вимкнути вміст"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Не вдалося ввімкнути вміст"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Не вдалося оновити вміст"
},
@@ -1808,9 +1802,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Не вдалося видалити вміст"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Не вдалося перемкнути {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Не вдалося відв’язати збірку"
},
-10
View File
@@ -1346,12 +1346,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "Không thể xóa nội dung"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "Không thể tắt nội dung"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "Không thể bật nội dung"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "Không thể cập nhật nội dung"
},
@@ -1367,9 +1361,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "Không thể xóa nội dung"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "Lỗi khi chuyển đổi {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "Hủy liên kết modpack thất bại"
},
@@ -4974,4 +4965,3 @@
"defaultMessage": "Đội ngũ Modrinth"
}
}
-10
View File
@@ -1901,12 +1901,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "删除内容失败"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "禁用内容失败"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "启用内容失败"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "更新内容失败"
},
@@ -1922,9 +1916,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "移除内容失败"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "无法切换 {name}"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "链接整合包失败"
},
@@ -6765,4 +6756,3 @@
"defaultMessage": "类型"
}
}
-10
View File
@@ -1901,12 +1901,6 @@
"hosting.content.failed-to-bulk-delete": {
"defaultMessage": "無法刪除內容"
},
"hosting.content.failed-to-bulk-disable": {
"defaultMessage": "無法停用內容"
},
"hosting.content.failed-to-bulk-enable": {
"defaultMessage": "無法啟用內容"
},
"hosting.content.failed-to-bulk-update": {
"defaultMessage": "無法更新內容"
},
@@ -1922,9 +1916,6 @@
"hosting.content.failed-to-remove": {
"defaultMessage": "無法移除內容"
},
"hosting.content.failed-to-toggle": {
"defaultMessage": "無法切換「{name}」"
},
"hosting.content.failed-to-unlink": {
"defaultMessage": "無法取消連結模組包"
},
@@ -6765,4 +6756,3 @@
"defaultMessage": "類型"
}
}
+15 -1
View File
@@ -8,7 +8,8 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Toggle uses touch manipulation so double tapping on mobile does not zoom.',
component:
'Toggle uses touch manipulation so double tapping on mobile does not zoom. Set indeterminate to show a line between its on and off states.',
},
},
},
@@ -30,6 +31,13 @@ export const Checked: Story = {
},
}
export const Indeterminate: Story = {
args: {
modelValue: false,
indeterminate: true,
},
}
export const Disabled: Story = {
args: {
modelValue: false,
@@ -63,6 +71,9 @@ export const AllStates: Story = {
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="true" /> On
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :indeterminate="true" /> Indeterminate
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :disabled="true" /> Disabled
</div>
@@ -73,6 +84,9 @@ export const AllStates: Story = {
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="true" :small="true" /> On
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :indeterminate="true" :small="true" /> Indeterminate
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :small="true" :disabled="true" /> Disabled
</div>