313 lines
11 KiB
Vue
313 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui'
|
||
import { computed } from 'vue'
|
||
import { playbackControl } from '../utils/playbackControl'
|
||
import type { ChannelState, Intent, ScreenMode, ScreenStep, TimerState } from '../types/screen'
|
||
import { modeLabels } from '../utils/presentation'
|
||
import ScreenStepPreview from './ScreenStepPreview.vue'
|
||
|
||
const props = defineProps<{
|
||
gameName: string
|
||
cover: string | null
|
||
background: string | null
|
||
steps: ScreenStep[]
|
||
currentIndex: number | null
|
||
draft: ScreenStep[]
|
||
isEditing: boolean
|
||
isBusy: boolean
|
||
canPlayMain: boolean
|
||
mainDesired: ChannelState['desired']
|
||
mainStatus: ChannelState['status']
|
||
timerStatus: TimerState['status']
|
||
canEdit: boolean
|
||
notice: string
|
||
}>()
|
||
const emit = defineEmits<{
|
||
add: []
|
||
remove: [index: number]
|
||
move: [index: number, offset: number]
|
||
clear: []
|
||
updateStep: [index: number, patch: Partial<ScreenStep>]
|
||
command: [intent: Intent]
|
||
}>()
|
||
const mainControl = computed(() =>
|
||
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
|
||
)
|
||
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||
value: Number(mode) as ScreenMode,
|
||
label,
|
||
}))
|
||
function timerLabel(index: number) {
|
||
if (props.currentIndex !== index) return 'Старт'
|
||
if (props.timerStatus === 'running') return 'Пауза'
|
||
return props.timerStatus === 'paused' ? 'Продолжить' : 'Старт'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<n-space vertical data-testid="screen-plan">
|
||
<n-alert v-if="notice" type="warning" role="status" class="plan-notice">{{ notice }}</n-alert>
|
||
<p v-if="!canEdit">Для редактирования войдите на сервер игры.</p>
|
||
<template v-if="isEditing">
|
||
<div v-for="(step, index) in draft" :key="index" class="plan-step">
|
||
<div class="plan-edit-header">
|
||
<div class="plan-step-preview">
|
||
<ScreenStepPreview
|
||
:step="step"
|
||
:game-name="gameName"
|
||
:cover="cover"
|
||
:background="background"
|
||
/>
|
||
</div>
|
||
<div class="plan-edit-controls">
|
||
<n-select
|
||
:value="step.mode"
|
||
:options="modeOptions"
|
||
:disabled="isBusy"
|
||
:input-props="{ 'aria-label': `Режим шага ${index + 1}` }"
|
||
@update:value="emit('updateStep', index, { mode: $event })"
|
||
/>
|
||
<n-button
|
||
:disabled="isBusy || index === 0"
|
||
:aria-label="`Поднять шаг ${index + 1}`"
|
||
@click="emit('move', index, -1)"
|
||
>↑</n-button
|
||
>
|
||
<n-button
|
||
:disabled="isBusy || index === draft.length - 1"
|
||
:aria-label="`Опустить шаг ${index + 1}`"
|
||
@click="emit('move', index, 1)"
|
||
>↓</n-button
|
||
>
|
||
<n-button :disabled="isBusy" type="error" ghost @click="emit('remove', index)"
|
||
>Удалить</n-button
|
||
>
|
||
</div>
|
||
</div>
|
||
<n-input
|
||
v-if="step.mode === 8"
|
||
:value="step.text"
|
||
type="textarea"
|
||
:disabled="isBusy"
|
||
placeholder="Текст экрана"
|
||
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
||
@update:value="emit('updateStep', index, { text: $event })"
|
||
/>
|
||
<template v-if="step.mode === 7">
|
||
<label :for="`plan-label-${index}`">Надпись таймера</label>
|
||
<n-input
|
||
:value="step.timerLabel"
|
||
:disabled="isBusy"
|
||
:input-props="{ id: `plan-label-${index}` }"
|
||
@update:value="emit('updateStep', index, { timerLabel: $event })"
|
||
/>
|
||
<label :for="`plan-seconds-${index}`">Длительность в секундах</label>
|
||
<n-input-number
|
||
:value="step.timerDurationSeconds"
|
||
:min="1"
|
||
:max="86400"
|
||
:disabled="isBusy"
|
||
:input-props="{ id: `plan-seconds-${index}` }"
|
||
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
|
||
/>
|
||
</template>
|
||
</div>
|
||
<n-space>
|
||
<n-button :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
||
<n-button :disabled="isBusy || !draft.length" @click="emit('clear')">Удалить план</n-button>
|
||
</n-space>
|
||
</template>
|
||
<template v-else>
|
||
<p v-if="!steps.length">План экранов пуст.</p>
|
||
<div
|
||
v-for="(step, index) in steps"
|
||
:key="index"
|
||
class="plan-step"
|
||
:class="{ 'plan-step-current': currentIndex === index }"
|
||
:data-step-index="index"
|
||
:aria-current="currentIndex === index ? 'step' : undefined"
|
||
>
|
||
<div class="plan-step-header">
|
||
<div class="plan-step-preview">
|
||
<ScreenStepPreview
|
||
:step="step"
|
||
:game-name="gameName"
|
||
:cover="cover"
|
||
:background="background"
|
||
/>
|
||
</div>
|
||
<div class="plan-step-content">
|
||
<div class="plan-step-title">
|
||
<span
|
||
>{{ modeLabels[step.mode]
|
||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||
><template v-if="step.mode === 7"
|
||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||
></span
|
||
>
|
||
<n-button
|
||
data-testid="show-step"
|
||
size="small"
|
||
:disabled="isBusy || currentIndex === index"
|
||
@click="emit('command', { action: 'step', index })"
|
||
>{{ currentIndex === index ? 'Показан' : 'Показать' }}</n-button
|
||
>
|
||
</div>
|
||
<div class="plan-step-actions">
|
||
<n-space v-if="[2, 4, 7].includes(step.mode)" align="center" :wrap="false">
|
||
<template v-if="step.mode === 7">
|
||
<n-button
|
||
square
|
||
:aria-label="timerLabel(index)"
|
||
:title="timerLabel(index)"
|
||
:disabled="isBusy || currentIndex !== index"
|
||
@click="
|
||
emit('command', {
|
||
action:
|
||
timerStatus === 'running'
|
||
? 'timer-pause'
|
||
: timerStatus === 'paused'
|
||
? 'timer-resume'
|
||
: 'timer-start',
|
||
})
|
||
"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||
<path v-if="currentIndex === index && timerStatus === 'running'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
||
<path v-else d="M7 4v16l13-8z" />
|
||
</svg>
|
||
</n-button>
|
||
<n-button square aria-label="Сбросить" title="Сбросить" :disabled="isBusy || currentIndex !== index" @click="emit('command', { action: 'timer-reset' })"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<path d="M3 4v6h6M3 10a9 9 0 1 1 0 6" />
|
||
</svg>
|
||
</n-button
|
||
>
|
||
</template>
|
||
<template v-if="step.mode === 2 || step.mode === 4">
|
||
<n-button
|
||
square
|
||
:aria-label="currentIndex === index ? mainControl.label : 'Воспроизвести'"
|
||
:title="currentIndex === index ? mainControl.label : 'Воспроизвести'"
|
||
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||
@click="emit('command', { action: mainControl.action, channel: 'main' })"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||
<path v-if="currentIndex === index && mainControl.action === 'pause'" d="M6 4h4v16H6zM14 4h4v16h-4z" />
|
||
<path v-else d="M7 4v16l13-8z" />
|
||
</svg>
|
||
</n-button
|
||
>
|
||
<n-button
|
||
square
|
||
aria-label="Стоп"
|
||
title="Стоп"
|
||
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||
@click="emit('command', { action: 'stop', channel: 'main' })"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||
<path d="M5 5h14v14H5z" />
|
||
</svg>
|
||
</n-button
|
||
>
|
||
<n-button
|
||
square
|
||
aria-label="Сначала"
|
||
title="Сначала"
|
||
:disabled="isBusy || currentIndex !== index || !canPlayMain"
|
||
@click="emit('command', { action: 'replay', channel: 'main' })"
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||
<path d="M20 11a8 8 0 1 0-2 6M20 4v7h-7" />
|
||
</svg>
|
||
</n-button
|
||
>
|
||
<slot name="main-volume" :index="index" :active="currentIndex === index" />
|
||
</template>
|
||
</n-space>
|
||
</div>
|
||
<slot
|
||
v-if="step.mode === 2 || step.mode === 4"
|
||
name="main-controls"
|
||
:index="index"
|
||
:active="currentIndex === index"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</n-space>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.plan-notice {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.plan-step {
|
||
border: 1px solid var(--brand-background);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
overflow-wrap: anywhere;
|
||
padding: 12px;
|
||
}
|
||
|
||
.plan-step-header {
|
||
align-items: stretch;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.plan-step-content {
|
||
display: flex;
|
||
flex: 1 1 380px;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
justify-content: space-between;
|
||
min-width: 0;
|
||
}
|
||
|
||
.plan-step-actions {
|
||
align-items: center;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
|
||
.plan-step-title {
|
||
align-items: start;
|
||
display: grid;
|
||
gap: 12px;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
}
|
||
|
||
.plan-step-preview {
|
||
flex: 0 0 160px;
|
||
}
|
||
|
||
.plan-edit-header {
|
||
align-items: center;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.plan-edit-controls {
|
||
align-items: center;
|
||
display: grid;
|
||
flex: 1 1 320px;
|
||
gap: 8px;
|
||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||
min-width: 0;
|
||
}
|
||
|
||
.plan-step-current {
|
||
border-color: var(--brand-text);
|
||
}
|
||
</style>
|