This commit is contained in:
Calum H. (IMB11)
2026-08-24 13:11:11 +01:00
parent b0c77bb395
commit 0748b8732e
9 changed files with 138 additions and 70 deletions
+15 -7
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,14 +16,21 @@
<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
? ''
: 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]',
@@ -37,6 +44,7 @@ const props = defineProps<{
id?: string
disabled?: boolean
small?: boolean
indeterminate?: boolean
}>()
const modelValue = defineModel<boolean>()
@@ -54,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 {
@@ -137,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(() => {
@@ -368,7 +385,7 @@ const installTooltip = computed(() => {
<div
v-if="!hideActions"
class="flex shrink-0 items-center justify-end gap-2 transition-colors duration-200"
:class="enabledFor ? 'w-[112px]' : 'min-w-[160px]'"
:class="enabledFor ? 'w-[168px]' : 'min-w-[160px]'"
>
<slot name="additionalButtonsLeft" />
@@ -431,14 +448,11 @@ const installTooltip = computed(() => {
</div>
<Toggle
v-if="enabled !== undefined && !hideToggle && !enabledFor"
v-tooltip="
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
? (toggleDisabledTooltip ?? disabledTooltip)
: undefined
"
v-if="enabled !== undefined && !hideToggle"
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)"
@@ -24,8 +24,7 @@ const messages = defineMessages({
},
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.',
defaultMessage: 'Choose where this content is enabled. Turn both off to disable it.',
},
})
@@ -300,7 +299,7 @@ function handleSort(column: ContentCardTableSortColumn) {
v-if="hasAnyActions"
role="columnheader"
class="shrink-0 text-right"
:class="hasEnabledForColumn ? 'w-[112px]' : 'min-w-[160px]'"
:class="hasEnabledForColumn ? 'w-[168px]' : 'min-w-[160px]'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
@@ -17,7 +17,15 @@ const messages = defineMessages({
},
locked: {
id: 'content.enabled-for.locked',
defaultMessage: 'Unlock the environment controls from the row menu to change this selection.',
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.",
},
})
@@ -50,6 +58,12 @@ 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))
@@ -61,11 +75,7 @@ function toggle(side: ContentSide) {
<button
v-for="side in sides"
:key="side"
v-tooltip="
isDisabled(side)
? (disabledTooltip ?? modelValue.disabledTooltip ?? formatMessage(messages.locked))
: undefined
"
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="[
@@ -119,8 +119,7 @@ const messages = defineMessages({
},
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.',
defaultMessage: 'Choose where this content is enabled. Turn both off to disable it.',
},
pleaseWait: {
id: 'content.enabled-for.please-wait',
@@ -300,7 +299,7 @@ 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,
@@ -638,7 +637,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<div
v-if="showTableActions"
class="shrink-0 text-right"
:class="props.enableEnabledFor ? 'w-[112px]' : 'min-w-[160px]'"
:class="props.enableEnabledFor ? 'w-[168px]' : 'min-w-[160px]'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
@@ -656,7 +655,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
hide-header
flat
v-on="
props.enableToggle
props.enableToggle || props.enableEnabledFor
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
: {}
"
@@ -754,7 +754,7 @@ 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)
@@ -762,7 +762,7 @@ async function confirmDisable() {
await ctx.toggleEnabled?.(item)
}
} finally {
unmarkChanging(id)
if (!item.enabledFor) unmarkChanging(id)
}
return
}
@@ -781,11 +781,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)
}
}
@@ -844,12 +844,7 @@ async function handleSetEnabledForById(
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)
}
await ctx.setEnabledFor(item, side, enabled)
}
// Bulk updating
@@ -57,7 +57,6 @@ export interface ContentEnabledForState {
player: boolean
locked: boolean
disabledSides?: ContentSide[]
disabledTooltip?: string | null
warningTooltip?: string | null
}
@@ -104,14 +104,10 @@ 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.',
},
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.',
"We couldn't tell where this content should be enabled. Review the Server and Player choices.",
},
lockEnvironment: {
id: 'hosting.content.enabled-for.lock-environment',
@@ -331,21 +327,31 @@ function hasDetectedEnvironment(addon: Archon.Content.v1.Addon) {
return environment !== undefined && environment !== 'unknown'
}
function getLockedSides(addon: Archon.Content.v1.Addon): ContentSide[] {
if (addon.side_toggle_unlocked || !hasDetectedEnvironment(addon)) return []
function getEnvironmentLockedSides(addon: Archon.Content.v1.Addon): ContentSide[] {
switch (getAddonEnvironment(addon)) {
case 'client_and_server':
case 'client_only':
case 'singleplayer_only':
return ['server']
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)
@@ -618,24 +624,26 @@ const deleteMutation = useMutation({
type SetEnabledForVariables = {
addon: Archon.Content.v1.Addon
side: ContentSide
sides: ContentSide[]
enabled: boolean
}
const setEnabledForMutation = useMutation({
mutationFn: async ({ addon, side, enabled }: SetEnabledForVariables) => {
mutationFn: async ({ addon, sides, enabled }: SetEnabledForVariables) => {
const request: Archon.Content.v1.SetAddonEnabledRequest = {
filename: addon.filename,
kind: addon.kind,
enabled,
}
if (side === 'server') {
await client.archon.content_v1.setAddonEnabledServer(serverId, worldId.value!, request)
} else {
await client.archon.content_v1.setAddonEnabledPlayer(serverId, worldId.value!, request)
}
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),
),
)
},
onMutate: async ({ addon, side, enabled }) => {
onMutate: async ({ addon, sides, enabled }) => {
const targetQueryKey = getAddonQueryKey(addon)
await queryClient.cancelQueries({ queryKey: targetQueryKey })
const previousData = queryClient.getQueryData<Archon.Content.v1.Addons>(targetQueryKey)
@@ -645,9 +653,11 @@ const setEnabledForMutation = useMutation({
...oldData,
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 }
return {
...candidate,
...(sides.includes('server') ? { disabled_server: !enabled } : {}),
...(sides.includes('player') ? { disabled_player: !enabled } : {}),
}
}),
}
})
@@ -718,7 +728,19 @@ async function handleSetEnabledFor(item: ContentItem, side: ContentSide, enabled
if (contentActionDisabled.value) return
const addon = getAddonForItem(item)
if (!addon) return
await setEnabledForMutation.mutateAsync({ addon, side, enabled })
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(
@@ -726,13 +748,18 @@ async function handleModpackSetEnabledFor(
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 handleModpackToggleEnabled(item: ContentItem) {
try {
await handleToggleEnabled(item)
} catch {
return
}
}
@@ -946,6 +973,7 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
: undefined
const serverEnabled = !addon.disabled_server
const playerEnabled = !addon.disabled_player
const lockedSides = getLockedSides(addon)
return {
project: {
...(projectMetadata ?? {}),
@@ -976,9 +1004,8 @@ function addonToContentItem(addon: AddonWithUiState): ContentItem {
enabledFor: {
server: serverEnabled,
player: playerEnabled,
locked: hasDetectedEnvironment(addon) && !addon.side_toggle_unlocked,
disabledSides: getLockedSides(addon),
disabledTooltip: formatMessage(messages.sideLocked),
locked: lockedSides.length > 0,
disabledSides: lockedSides,
warningTooltip: getEnabledForWarning(addon),
},
embeddedIcon,
@@ -1265,7 +1292,7 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
})
}
if (addon && hasDetectedEnvironment(addon)) {
if (addon && hasEnvironmentLock(addon)) {
if (options.length > 0) options.push({ type: 'divider' })
options.push({
id: 'toggle-side-lock',
@@ -1291,7 +1318,10 @@ provideContentManager({
disableAddContent: computed(() => !canSetup.value),
disableAddContentTooltip: permissionDeniedMessage.value,
contentTypeLabel: type,
toggleEnabled: handleToggleEnabled,
setEnabledFor: handleSetEnabledFor,
canToggleItem: (item) =>
item.enabledFor !== undefined && item.enabledFor.server === item.enabledFor.player,
deleteItem: handleDeleteItem,
bulkDeleteItems: handleBulkDelete,
refresh: async () => {
@@ -1332,7 +1362,6 @@ provideContentManager({
enabled: item.enabled,
enabledFor: item.enabledFor,
embeddedIcon: item.embeddedIcon,
hideToggle: true,
}
},
filterPersistKey: `server:${serverId}:${worldId.value}`,
@@ -1367,6 +1396,7 @@ provideContentManager({
:get-overflow-options="getOverflowOptions"
:action-disabled="contentActionDisabled"
:action-disabled-tooltip="contentActionBusyMessage ?? undefined"
@update:enabled="handleModpackToggleEnabled"
@update:enabled-for="handleModpackSetEnabledFor"
@hide="isModpackContentModalOpen = false"
/>
+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>