feat: clean up server power action buttons (#5836)

This commit is contained in:
Calum H.
2026-04-17 18:11:52 +01:00
committed by GitHub
parent 5244060588
commit dd51c08a18
4 changed files with 229 additions and 80 deletions

View File

@@ -1,13 +1,26 @@
<template>
<div class="joined-buttons">
<ButtonStyled :color="color">
<button :disabled="disabled" @click="handlePrimaryAction">
<ButtonStyled
:color="color"
:size="size"
:class="{ 'joined-buttons__primary--muted': primaryMuted }"
>
<button :disabled="primaryDisabledResolved" @click="handlePrimaryAction">
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
{{ primaryAction.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="dropdownActions.length > 0" :color="color">
<OverflowMenu class="btn-dropdown-animation" :options="dropdownOptions" :disabled="disabled">
<ButtonStyled
v-if="dropdownActions.length > 0"
:color="color"
:size="size"
class="joined-buttons__dropdown"
>
<OverflowMenu
class="btn-dropdown-animation !w-10"
:options="dropdownOptions"
:disabled="dropdownDisabledResolved"
>
<DropdownIcon />
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
@@ -40,14 +53,25 @@ export interface JoinedButtonAction {
interface Props {
actions: JoinedButtonAction[]
color?: Colors
size?: 'standard' | 'large' | 'small'
disabled?: boolean
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
}
const props = withDefaults(defineProps<Props>(), {
color: 'standard',
size: 'standard',
disabled: false,
primaryDisabled: undefined,
dropdownDisabled: undefined,
primaryMuted: false,
})
const primaryDisabledResolved = computed(() => props.primaryDisabled ?? props.disabled)
const dropdownDisabledResolved = computed(() => props.dropdownDisabled ?? props.disabled)
const primaryAction = computed(() => props.actions[0])
const dropdownActions = computed(() => props.actions.slice(1))
@@ -84,7 +108,7 @@ const dropdownOptions = computed(() =>
)
function handlePrimaryAction() {
if (primaryAction.value && !props.disabled) {
if (primaryAction.value && !primaryDisabledResolved.value) {
primaryAction.value.action()
}
}
@@ -118,4 +142,8 @@ function handlePrimaryAction() {
.btn-dropdown-animation {
padding: 0.5rem !important;
}
.joined-buttons__primary--muted {
opacity: 0.6;
}
</style>

View File

@@ -7,50 +7,46 @@
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled v-if="showStopButton" type="standard" color="red" size="large">
<button
v-tooltip="busyTooltip"
:disabled="!canTakeAction"
@click="initiateAction('Stop')"
>
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>Stop</span>
</div>
<template v-else-if="showRestartButton">
<ButtonStyled type="standard" color="orange" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<div v-if="showRestartDropdown" class="joined-buttons">
<ButtonStyled type="standard" color="orange" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="orange" size="large">
<OverflowMenu
v-tooltip="busyTooltip"
:disabled="!canTakeAction"
:options="[
{
id: 'kill_server',
action: () => initiateAction('Kill'),
},
]"
>
<div class="w-0 text-xl relative top-0.5 right-2.5">
<DropdownIcon />
</div>
<JoinedButtons
color="red"
size="large"
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
Kill server
</template>
</JoinedButtons>
</template>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
Kill server
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-else type="standard" color="brand" size="large">
<template v-else-if="isStopping">
<JoinedButtons
color="red"
size="large"
:actions="stopSplitActions"
:primary-disabled="true"
:dropdown-disabled="!canKill"
:primary-muted="true"
>
<template #kill_server>
<SlashIcon class="h-5 w-5" />
Kill server
</template>
</JoinedButtons>
</template>
<template v-else>
<ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<PlayIcon />
<span>{{ primaryActionText }}</span>
@@ -63,7 +59,6 @@
<script setup lang="ts">
import {
DropdownIcon,
LoaderCircleIcon,
PlayIcon,
SlashIcon,
@@ -72,7 +67,7 @@ import {
} from '@modrinth/assets'
import { computed } from 'vue'
import { ButtonStyled, OverflowMenu } from '#ui/components'
import { ButtonStyled, type JoinedButtonAction, JoinedButtons } from '#ui/components'
import { useServerPowerAction } from './use-server-power-action'
@@ -87,9 +82,11 @@ const props = withDefaults(
const {
isInstalling,
showStopButton,
isStopping,
showRestartButton,
busyTooltip,
canTakeAction,
canKill,
primaryActionText,
initiateAction,
handlePrimaryAction,
@@ -97,31 +94,18 @@ const {
disabled: computed(() => props.disabled),
})
const showRestartDropdown = computed(() => primaryActionText.value === 'Restart')
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
{
id: 'stop',
label: isStopping.value ? 'Stopping' : 'Stop',
icon: StopCircleIcon,
action: () => initiateAction('Stop'),
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: () => initiateAction('Kill'),
},
])
</script>
<style scoped>
.joined-buttons {
display: flex;
align-items: center;
}
.joined-buttons > :deep(.btn) {
border-radius: 0;
}
.joined-buttons > :deep(.btn:first-child) {
border-top-left-radius: var(--radius-md);
border-bottom-left-radius: var(--radius-md);
}
.joined-buttons > :deep(.btn:last-child) {
border-top-right-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
margin-left: -1px;
}
.joined-buttons > :deep(.btn:not(:last-child)) {
border-right: none;
}
</style>

View File

@@ -20,15 +20,24 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const isStopping = computed(() => powerState.value === 'stopping')
const isStarting = computed(() => powerState.value === 'starting')
const isTransitioning = computed(() => isStarting.value || isStopping.value)
const showStopButton = computed(() => isRunning.value || isStarting.value)
const showStopSplit = computed(() => isRunning.value || isStarting.value || isStopping.value)
const showRestartButton = computed(() => isRunning.value || isStarting.value)
const isBlockedByPropsOrBusy = computed(
() => Boolean(options?.disabled?.value) || busyReasons.value.length > 0,
)
const busyTooltip = computed(() => {
if (isStarting.value) return 'Your server is starting'
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
const canTakeAction = computed(
() => !isTransitioning.value && !options?.disabled?.value && busyReasons.value.length === 0,
const canTakeAction = computed(() => !isTransitioning.value && !isBlockedByPropsOrBusy.value)
const canKill = computed(
() =>
!isBlockedByPropsOrBusy.value && (isStopping.value || isRunning.value || isStarting.value),
)
const primaryActionText = computed(() => {
@@ -57,7 +66,11 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
}
function initiateAction(action: PowerAction) {
if (!canTakeAction.value) return
if (action === 'Kill') {
if (!canKill.value) return
} else {
if (!canTakeAction.value) return
}
void sendPowerAction(action)
}
@@ -70,9 +83,11 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
isRunning,
isStopping,
isTransitioning,
showStopButton,
showStopSplit,
showRestartButton,
busyTooltip,
canTakeAction,
canKill,
primaryActionText,
sendPowerAction,
initiateAction,

View File

@@ -0,0 +1,122 @@
import { PlayIcon, SlashIcon, StopCircleIcon, UpdatedIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import JoinedButtons from '../../components/base/JoinedButtons.vue'
const meta = {
title: 'Base/JoinedButtons',
component: JoinedButtons,
argTypes: {
color: {
control: 'select',
options: ['standard', 'brand', 'red', 'orange', 'green', 'blue', 'purple'],
},
size: {
control: 'select',
options: ['small', 'standard', 'large'],
},
disabled: { control: 'boolean' },
primaryDisabled: { control: 'boolean' },
dropdownDisabled: { control: 'boolean' },
primaryMuted: { control: 'boolean' },
},
} satisfies Meta<typeof JoinedButtons>
export default meta
type Story = StoryObj<typeof meta>
export const Start: Story = {
args: {
color: 'brand',
size: 'large',
actions: [
{
id: 'start',
label: 'Start',
icon: PlayIcon,
action: () => console.log('Start'),
},
],
},
}
export const StopWithKill: Story = {
args: {
color: 'red',
size: 'large',
actions: [
{
id: 'stop',
label: 'Stop',
icon: StopCircleIcon,
action: () => console.log('Stop'),
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: () => console.log('Kill'),
},
],
},
}
export const Stopping: Story = {
args: {
color: 'red',
size: 'large',
primaryDisabled: true,
primaryMuted: true,
actions: [
{
id: 'stop',
label: 'Stopping',
icon: StopCircleIcon,
action: () => console.log('Stop'),
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: () => console.log('Kill'),
},
],
},
}
export const Restart: Story = {
args: {
color: 'orange',
size: 'large',
actions: [
{
id: 'restart',
label: 'Restart',
icon: UpdatedIcon,
action: () => console.log('Restart'),
},
],
},
}
export const Disabled: Story = {
args: {
color: 'red',
size: 'large',
disabled: true,
actions: [
{
id: 'stop',
label: 'Stop',
icon: StopCircleIcon,
action: () => console.log('Stop'),
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: () => console.log('Kill'),
},
],
},
}