generated from VLADIMIR/template_frontend
660 lines
20 KiB
Vue
660 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import { NButton, NFlex, NInput, NInputNumber, NSelect, NSpace, NText } from 'naive-ui'
|
||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||
|
||
import type {
|
||
Application,
|
||
MapItem,
|
||
ResultsQuestion,
|
||
ScreenCommand,
|
||
ScreenMode,
|
||
ScreenPlan,
|
||
ScreenPlanStep,
|
||
ScreenState,
|
||
Team,
|
||
TeamResult,
|
||
} from '@/api/generated/crabs/evening_detective_server'
|
||
import ScreenPreview from '@/components/ScreenPreview.vue'
|
||
import UiBlock from '@/components/UiBlock.vue'
|
||
|
||
const props = defineProps<{
|
||
mapItems: MapItem[]
|
||
teams: Team[]
|
||
screens: ScreenState[]
|
||
previewQuestions: ResultsQuestion[]
|
||
previewTeams: TeamResult[]
|
||
screenPlan?: ScreenPlan
|
||
screenPlanDraft: ScreenPlanStep[]
|
||
screenPlanDirty: boolean
|
||
screenPlanSaving: boolean
|
||
screenPlanMode: 'use' | 'edit'
|
||
screenVolume: number
|
||
screenPosition: number
|
||
}>()
|
||
|
||
const GRID_SIZE = 20
|
||
|
||
const emit = defineEmits<{
|
||
saveItem: [item: MapItem, hidden?: boolean]
|
||
dragging: [value: boolean]
|
||
giveApplication: [team: Team, application: Application]
|
||
addStep: []
|
||
edit: []
|
||
cancel: []
|
||
removeStep: [index: number]
|
||
moveStep: [index: number, offset: number]
|
||
dirty: []
|
||
clearPlan: []
|
||
save: []
|
||
setStep: [index: number]
|
||
command: [mode: ScreenMode | undefined, command: ScreenCommand]
|
||
'update:screenVolume': [value: number | null]
|
||
'update:screenPosition': [value: number | null]
|
||
}>()
|
||
|
||
const mapScale = ref(1)
|
||
const mapOffset = ref({ x: 0, y: 0 })
|
||
const mapField = ref<HTMLElement>()
|
||
const previewScreen = computed(
|
||
() => props.screens.find((screen) => screen.status === 'connected') || props.screens[0],
|
||
)
|
||
const teamsNeedingApplications = computed(
|
||
() =>
|
||
new Map(props.teams.filter((team) => team.applications?.length).map((team) => [team.ID, team])),
|
||
)
|
||
const screenPlanModeOptions = [
|
||
{ label: 'Обложка', value: 'SCREEN_MODE_COVER' },
|
||
{ label: 'Введение', value: 'SCREEN_MODE_INTRO' },
|
||
{ label: 'Успехов', value: 'SCREEN_MODE_INTRO_DONE' },
|
||
{ label: 'Закрывашка', value: 'SCREEN_MODE_CONCLUSION' },
|
||
{ label: 'Спасибо за игру', value: 'SCREEN_MODE_CONCLUSION_DONE' },
|
||
{ label: 'Результаты', value: 'SCREEN_MODE_RESULTS' },
|
||
{ label: 'Таймер', value: 'SCREEN_MODE_TIMER' },
|
||
{ label: 'Текст', value: 'SCREEN_MODE_TEXT' },
|
||
]
|
||
let dragState:
|
||
{ item: MapItem; offsetX: number; offsetY: number } | undefined
|
||
let panState: { startX: number; startY: number; offsetX: number; offsetY: number } | undefined
|
||
|
||
function getMapCoordinate(event: PointerEvent) {
|
||
const field = mapField.value
|
||
if (!field) return
|
||
const rect = field.getBoundingClientRect()
|
||
return {
|
||
x: (event.clientX - rect.left - field.clientLeft - mapOffset.value.x) / mapScale.value,
|
||
y: (event.clientY - rect.top - field.clientTop - mapOffset.value.y) / mapScale.value,
|
||
}
|
||
}
|
||
|
||
function startMapDrag(item: MapItem, event: PointerEvent) {
|
||
const point = getMapCoordinate(event)
|
||
if (!point) return
|
||
dragState = {
|
||
item,
|
||
offsetX: point.x - (item.x || 0),
|
||
offsetY: point.y - (item.y || 0),
|
||
}
|
||
emit('dragging', true)
|
||
window.addEventListener('pointermove', moveMapDrag)
|
||
window.addEventListener('pointerup', endMapDrag, { once: true })
|
||
window.addEventListener('pointercancel', endMapDrag, { once: true })
|
||
}
|
||
|
||
function moveMapDrag(event: PointerEvent) {
|
||
if (!dragState) return
|
||
const point = getMapCoordinate(event)
|
||
if (!point) return
|
||
dragState.item.x = Math.round((point.x - dragState.offsetX) / GRID_SIZE) * GRID_SIZE
|
||
dragState.item.y = Math.round((point.y - dragState.offsetY) / GRID_SIZE) * GRID_SIZE
|
||
}
|
||
|
||
function endMapDrag() {
|
||
if (!dragState) return
|
||
const item = dragState.item
|
||
dragState = undefined
|
||
window.removeEventListener('pointermove', moveMapDrag)
|
||
window.removeEventListener('pointerup', endMapDrag)
|
||
window.removeEventListener('pointercancel', endMapDrag)
|
||
emit('dragging', false)
|
||
emit('saveItem', item)
|
||
}
|
||
|
||
function toggleMapItem(item: MapItem) {
|
||
item.hidden = !item.hidden
|
||
emit('saveItem', item, item.hidden)
|
||
}
|
||
|
||
function changeMapScale(delta: number) {
|
||
mapScale.value = Math.min(2, Math.max(0.5, Number((mapScale.value + delta).toFixed(2))))
|
||
}
|
||
|
||
function resetMapView() {
|
||
mapScale.value = 1
|
||
mapOffset.value = { x: 0, y: 0 }
|
||
}
|
||
|
||
function startMapPan(event: PointerEvent) {
|
||
if (event.button !== 0) return
|
||
panState = {
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
offsetX: mapOffset.value.x,
|
||
offsetY: mapOffset.value.y,
|
||
}
|
||
window.addEventListener('pointermove', moveMapPan)
|
||
window.addEventListener('pointerup', endMapPan, { once: true })
|
||
}
|
||
|
||
function moveMapPan(event: PointerEvent) {
|
||
if (!panState) return
|
||
mapOffset.value = {
|
||
x: panState.offsetX + event.clientX - panState.startX,
|
||
y: panState.offsetY + event.clientY - panState.startY,
|
||
}
|
||
}
|
||
|
||
function endMapPan() {
|
||
panState = undefined
|
||
window.removeEventListener('pointermove', moveMapPan)
|
||
}
|
||
|
||
function zoomMap(event: WheelEvent) {
|
||
event.preventDefault()
|
||
changeMapScale(event.deltaY < 0 ? 0.1 : -0.1)
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
if (dragState) emit('dragging', false)
|
||
window.removeEventListener('pointermove', moveMapDrag)
|
||
window.removeEventListener('pointerup', endMapDrag)
|
||
window.removeEventListener('pointercancel', endMapDrag)
|
||
window.removeEventListener('pointermove', moveMapPan)
|
||
window.removeEventListener('pointerup', endMapPan)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="map-preview-layout">
|
||
<UiBlock>
|
||
<n-flex align="center" justify="space-between" class="screen-header">
|
||
<p class="screens-title">Карта помещения</p>
|
||
<n-space>
|
||
<n-button size="small" @click="changeMapScale(-0.1)">−</n-button>
|
||
<n-text>{{ Math.round(mapScale * 100) }}%</n-text>
|
||
<n-button size="small" @click="changeMapScale(0.1)">+</n-button>
|
||
<n-button size="small" @click="resetMapView">Сбросить</n-button>
|
||
</n-space>
|
||
</n-flex>
|
||
<div ref="mapField" class="map-field" @pointerdown="startMapPan" @wheel="zoomMap">
|
||
<div
|
||
class="map-canvas"
|
||
:style="{ transform: `translate(${mapOffset.x}px, ${mapOffset.y}px) scale(${mapScale})` }"
|
||
>
|
||
<div
|
||
v-for="item in mapItems"
|
||
v-show="!item.hidden"
|
||
:key="`${item.kind}-${item.itemID}`"
|
||
class="map-item"
|
||
:class="[
|
||
item.kind === 'screen'
|
||
? 'map-screen'
|
||
: item.kind === 'organizer_desk'
|
||
? 'map-organizer-desk'
|
||
: 'map-team',
|
||
{
|
||
'map-team-needs-applications':
|
||
item.kind === 'team' && teamsNeedingApplications.has(item.itemID),
|
||
},
|
||
]"
|
||
:style="{ left: `${item.x || 0}px`, top: `${item.y || 0}px` }"
|
||
@pointerdown.stop="item.kind !== 'team' && startMapDrag(item, $event)"
|
||
>
|
||
<div
|
||
v-if="item.kind === 'team'"
|
||
class="map-team-handle"
|
||
@pointerdown.stop="startMapDrag(item, $event)"
|
||
/>
|
||
<span>{{ item.label }}</span>
|
||
<n-space
|
||
v-if="item.kind === 'team' && teamsNeedingApplications.has(item.itemID)"
|
||
class="map-team-applications"
|
||
vertical
|
||
>
|
||
<n-button
|
||
v-for="application in teamsNeedingApplications.get(item.itemID)?.applications"
|
||
:key="application.name"
|
||
size="small"
|
||
@click="
|
||
emit('giveApplication', teamsNeedingApplications.get(item.itemID)!, application)
|
||
"
|
||
>
|
||
Выдать: {{ application.name }}
|
||
</n-button>
|
||
</n-space>
|
||
<small v-if="item.kind === 'screen'"
|
||
>{{ item.status }}{{ item.soundEnabled ? ' · звук' : '' }}</small
|
||
>
|
||
<button
|
||
class="map-item-hide"
|
||
type="button"
|
||
@pointerdown.stop
|
||
@click.stop="toggleMapItem(item)"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<n-space v-if="mapItems.some((item) => item.hidden)" class="map-hidden-items">
|
||
<n-button
|
||
v-for="item in mapItems.filter((item) => item.hidden)"
|
||
:key="`hidden-${item.kind}-${item.itemID}`"
|
||
size="small"
|
||
@click="toggleMapItem(item)"
|
||
>
|
||
Добавить {{ item.label }}
|
||
</n-button>
|
||
</n-space>
|
||
</UiBlock>
|
||
<UiBlock tag="aside" class="screen-preview-panel">
|
||
<p class="screens-title">Превью экрана</p>
|
||
<ScreenPreview
|
||
v-if="previewScreen?.status === 'connected'"
|
||
:state="previewScreen"
|
||
:questions="previewQuestions"
|
||
:teams="previewTeams"
|
||
/>
|
||
<div v-else class="screen-preview-placeholder">Экран не подключён</div>
|
||
<div class="screen-plan">
|
||
<n-flex align="center" justify="space-between" class="screen-header">
|
||
<p class="screens-title">План экранов</p>
|
||
<n-space>
|
||
<n-button
|
||
v-if="!screenPlan && screenPlanMode === 'use'"
|
||
size="small"
|
||
@click="emit('addStep')"
|
||
>
|
||
Создать план
|
||
</n-button>
|
||
<n-button
|
||
v-if="screenPlan && screenPlanMode === 'use'"
|
||
size="small"
|
||
@click="emit('edit')"
|
||
>
|
||
Редактировать
|
||
</n-button>
|
||
<template v-if="screenPlanMode === 'edit'">
|
||
<n-button size="small" :disabled="screenPlanSaving" @click="emit('cancel')">
|
||
Отменить
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
type="primary"
|
||
:loading="screenPlanSaving"
|
||
:disabled="screenPlanSaving"
|
||
@click="emit('save')"
|
||
>
|
||
Сохранить план
|
||
</n-button>
|
||
</template>
|
||
</n-space>
|
||
</n-flex>
|
||
<p v-if="!screenPlan && screenPlanMode === 'use'" class="screen-empty">
|
||
План экранов ещё не создан.
|
||
</p>
|
||
<template v-else-if="screenPlanMode === 'edit'">
|
||
<n-space
|
||
v-for="(step, index) in screenPlanDraft"
|
||
:key="index"
|
||
class="screen-plan-step"
|
||
align="center"
|
||
justify="space-between"
|
||
wrap
|
||
>
|
||
<n-space align="center" wrap>
|
||
<n-text>{{ index + 1 }}</n-text>
|
||
<n-select
|
||
v-model:value="step.mode"
|
||
:options="screenPlanModeOptions"
|
||
:disabled="screenPlanSaving"
|
||
size="small"
|
||
style="width: 180px"
|
||
@update:value="emit('dirty')"
|
||
/>
|
||
<n-input
|
||
v-if="step.mode === 'SCREEN_MODE_TEXT'"
|
||
v-model:value="step.text"
|
||
:disabled="screenPlanSaving"
|
||
size="small"
|
||
placeholder="Текст экрана"
|
||
style="min-width: 240px"
|
||
@update:value="emit('dirty')"
|
||
/>
|
||
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||
<n-input
|
||
v-model:value="step.timerLabel"
|
||
:disabled="screenPlanSaving"
|
||
size="small"
|
||
placeholder="Надпись таймера"
|
||
@update:value="emit('dirty')"
|
||
/>
|
||
<n-input-number
|
||
v-model:value="step.timerDurationSeconds"
|
||
:min="1"
|
||
:max="86400"
|
||
:disabled="screenPlanSaving"
|
||
size="small"
|
||
placeholder="Секунды"
|
||
@update:value="emit('dirty')"
|
||
/>
|
||
</template>
|
||
</n-space>
|
||
<n-space class="screen-plan-actions">
|
||
<n-button
|
||
size="small"
|
||
:disabled="screenPlanSaving || index === 0"
|
||
@click="emit('moveStep', index, -1)"
|
||
>
|
||
↑
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
:disabled="screenPlanSaving || index === screenPlanDraft.length - 1"
|
||
@click="emit('moveStep', index, 1)"
|
||
>
|
||
↓
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
type="error"
|
||
ghost
|
||
:disabled="screenPlanSaving"
|
||
@click="emit('removeStep', index)"
|
||
>
|
||
Удалить
|
||
</n-button>
|
||
</n-space>
|
||
</n-space>
|
||
<n-space>
|
||
<n-button size="small" :disabled="screenPlanSaving" @click="emit('addStep')">
|
||
Добавить шаг
|
||
</n-button>
|
||
<n-button
|
||
v-if="screenPlanDraft.length"
|
||
size="small"
|
||
:disabled="screenPlanSaving"
|
||
@click="emit('clearPlan')"
|
||
>
|
||
Удалить план
|
||
</n-button>
|
||
</n-space>
|
||
</template>
|
||
<template v-else-if="screenPlan">
|
||
<n-space
|
||
v-for="(step, index) in screenPlan.steps || []"
|
||
:key="index"
|
||
class="screen-plan-step"
|
||
align="center"
|
||
justify="space-between"
|
||
wrap
|
||
>
|
||
<n-space align="center">
|
||
<n-text>{{ index + 1 }}</n-text>
|
||
<n-text>
|
||
{{ screenPlanModeOptions.find((option) => option.value === step.mode)?.label }}
|
||
<template v-if="step.mode === 'SCREEN_MODE_TEXT'">: {{ step.text }}</template>
|
||
<template v-else-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||
: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)
|
||
</template>
|
||
</n-text>
|
||
</n-space>
|
||
<n-space class="screen-plan-actions">
|
||
<n-button
|
||
size="small"
|
||
:type="screenPlan.currentStep === index ? 'primary' : 'default'"
|
||
:disabled="screenPlan.currentStep === index"
|
||
@click="emit('setStep', index)"
|
||
>
|
||
Показать
|
||
</n-button>
|
||
<template v-if="screenPlan.currentStep === index">
|
||
<template v-if="step.mode === 'SCREEN_MODE_TIMER'">
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_START')"
|
||
>
|
||
Старт
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_PAUSE')"
|
||
>
|
||
Пауза
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESUME')"
|
||
>
|
||
Продолжить
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_TIMER_RESET')"
|
||
>
|
||
Сбросить
|
||
</n-button>
|
||
</template>
|
||
<template
|
||
v-else-if="
|
||
step.mode === 'SCREEN_MODE_INTRO' || step.mode === 'SCREEN_MODE_CONCLUSION'
|
||
"
|
||
>
|
||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_PLAY')">
|
||
Запуск
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_PAUSE')"
|
||
>
|
||
Пауза
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_RESUME')"
|
||
>
|
||
Продолжить
|
||
</n-button>
|
||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_STOP')">
|
||
Стоп
|
||
</n-button>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_REPLAY')"
|
||
>
|
||
Повтор
|
||
</n-button>
|
||
<n-input-number
|
||
:value="screenVolume"
|
||
:min="0"
|
||
:max="100"
|
||
:step="5"
|
||
size="small"
|
||
@update:value="emit('update:screenVolume', $event)"
|
||
/>
|
||
<n-button
|
||
size="small"
|
||
@click="emit('command', step.mode, 'SCREEN_COMMAND_VOLUME')"
|
||
>
|
||
Громкость
|
||
</n-button>
|
||
<n-input-number
|
||
:value="screenPosition"
|
||
:min="0"
|
||
:step="5"
|
||
size="small"
|
||
@update:value="emit('update:screenPosition', $event)"
|
||
/>
|
||
<n-button size="small" @click="emit('command', step.mode, 'SCREEN_COMMAND_SEEK')">
|
||
Перемотать
|
||
</n-button>
|
||
</template>
|
||
</template>
|
||
</n-space>
|
||
</n-space>
|
||
</template>
|
||
</div>
|
||
</UiBlock>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.map-preview-layout {
|
||
display: grid;
|
||
gap: 8px;
|
||
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||
}
|
||
|
||
.screens-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
}
|
||
|
||
.screen-header {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.screen-preview-placeholder {
|
||
align-items: center;
|
||
aspect-ratio: 16 / 9;
|
||
background-color: #111;
|
||
color: #aaa;
|
||
display: flex;
|
||
justify-content: center;
|
||
text-align: center;
|
||
}
|
||
|
||
.screen-preview-panel > .screens-title {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.screen-plan {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.screen-plan-step {
|
||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.map-field {
|
||
aspect-ratio: 1;
|
||
background-color: #151515;
|
||
border: 1px dashed rgb(255 255 255 / 20%);
|
||
cursor: grab;
|
||
height: auto;
|
||
min-height: 200px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
resize: vertical;
|
||
touch-action: none;
|
||
}
|
||
|
||
.map-field:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.map-canvas {
|
||
background-image:
|
||
linear-gradient(rgb(255 255 255 / 8%) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgb(255 255 255 / 8%) 1px, transparent 1px);
|
||
background-size: 20px 20px;
|
||
height: 1000px;
|
||
position: absolute;
|
||
transform-origin: 0 0;
|
||
width: 2000px;
|
||
}
|
||
|
||
.map-item {
|
||
border-radius: 6px;
|
||
cursor: grab;
|
||
min-width: 90px;
|
||
padding: 8px 24px 8px 8px;
|
||
position: absolute;
|
||
user-select: none;
|
||
}
|
||
|
||
.map-item:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.map-screen {
|
||
background-color: #355b75;
|
||
}
|
||
|
||
.map-organizer-desk {
|
||
background-color: #48633a;
|
||
}
|
||
|
||
.map-team {
|
||
background-color: #222;
|
||
}
|
||
|
||
.map-team,
|
||
.map-team:active {
|
||
cursor: default;
|
||
}
|
||
|
||
.map-team-handle {
|
||
background-color: #48633a;
|
||
border-radius: 6px 6px 0 0;
|
||
cursor: grab;
|
||
margin: -8px -24px 8px -8px;
|
||
padding: 8px 24px 8px 8px;
|
||
}
|
||
|
||
.map-team-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.map-team-applications {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.map-team-needs-applications {
|
||
outline: 2px solid gold;
|
||
}
|
||
|
||
.map-item small {
|
||
display: block;
|
||
opacity: 0.75;
|
||
}
|
||
|
||
.map-item-hide {
|
||
background: transparent;
|
||
border: 0;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
position: absolute;
|
||
right: 5px;
|
||
top: 5px;
|
||
}
|
||
|
||
.map-team .map-item-hide {
|
||
line-height: 16px;
|
||
padding: 0 2px;
|
||
top: 0;
|
||
}
|
||
|
||
.map-hidden-items {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
@media (width < 1024px) {
|
||
.map-preview-layout {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|