generated from VLADIMIR/template_frontend
1512 lines
42 KiB
Vue
1512 lines
42 KiB
Vue
<script setup lang="ts">
|
||
import { Flag, Pause, Play, Reset, Settings, Trophy } from '@vicons/carbon'
|
||
import { Icon } from '@vicons/utils'
|
||
import { NButton, NTag } from 'naive-ui'
|
||
import {
|
||
NCard,
|
||
NFlex,
|
||
NInput,
|
||
NInputNumber,
|
||
NModal,
|
||
NQrCode,
|
||
NRate,
|
||
NSelect,
|
||
NSpace,
|
||
NTabPane,
|
||
NTabs,
|
||
NText,
|
||
} from 'naive-ui'
|
||
import { useMessage } from 'naive-ui'
|
||
import { computed, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
import { getAuthClient } from '@/api/auth_client'
|
||
import { getBinaryClient } from '@/api/binary_client'
|
||
import type {
|
||
Application,
|
||
Game,
|
||
GameReview,
|
||
MapItem,
|
||
Question,
|
||
ResultsQuestion,
|
||
ScreenCommand,
|
||
ScreenMode,
|
||
ScreenPlan,
|
||
ScreenPlanStep,
|
||
ScreenState,
|
||
Team,
|
||
TeamAnswers,
|
||
TeamResult,
|
||
Ticket,
|
||
} from '@/api/generated/crabs/evening_detective_server'
|
||
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
|
||
import GameMapPanel from '@/components/GameMapPanel.vue'
|
||
import GameScreensPanel from '@/components/GameScreensPanel.vue'
|
||
import GameTeamsPanel from '@/components/GameTeamsPanel.vue'
|
||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||
import UiBlock from '@/components/UiBlock.vue'
|
||
import { useSimplePolling } from '@/composables/useSimplePolling'
|
||
import { applicationFileType, applicationFileURL } from '@/utils/application'
|
||
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
|
||
|
||
const client = getAuthClient()
|
||
const binaryClient = getBinaryClient()
|
||
const isDownloadingQR = ref(false)
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const gameId = route.params.id
|
||
type GameTab = 'game' | 'teams' | 'screens-map'
|
||
|
||
function isGameTab(value: string): value is GameTab {
|
||
return ['game', 'teams', 'screens-map'].includes(value)
|
||
}
|
||
|
||
const queryTab = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||
const activeTab = ref<GameTab>(isGameTab(queryTab) ? queryTab : 'game')
|
||
|
||
async function setActiveTab(value: string) {
|
||
if (!isGameTab(value)) return
|
||
activeTab.value = value
|
||
await router.replace({ query: { ...route.query, tab: value } })
|
||
}
|
||
|
||
const message = useMessage()
|
||
|
||
const showSettingsModal = ref(false)
|
||
const settingsDraft = ref({ name: '', description: '', format: 'online', location: '' })
|
||
const showGiveApplicationModal = ref(false)
|
||
const showAddTeamModal = ref(false)
|
||
const showDeleteTeamModal = ref(false)
|
||
const showDeleteGameModal = ref(false)
|
||
const newTeamName = ref('')
|
||
const newTeamEmail = ref('')
|
||
const newTeamParticipants = ref(1)
|
||
const deletedGameName = ref('')
|
||
const showQR = ref(false)
|
||
const urlQR = ref('')
|
||
const titleQR = ref('')
|
||
const showIssueTicketsModal = ref(false)
|
||
const ticketCount = ref(1)
|
||
const issuedTickets = ref<Ticket[]>([])
|
||
const isIssuingTickets = ref(false)
|
||
const screenBinding = ref(false)
|
||
|
||
const game = ref<Game>({
|
||
ID: undefined,
|
||
name: undefined,
|
||
description: undefined,
|
||
startAt: undefined,
|
||
status: undefined,
|
||
format: undefined,
|
||
location: undefined,
|
||
scenario: undefined,
|
||
teams: undefined,
|
||
})
|
||
const participationOptions = [
|
||
{ label: 'Ожидает ответа', value: 'pending' },
|
||
{ label: 'Подтверждено', value: 'confirmed' },
|
||
{ label: 'Отменено', value: 'cancelled' },
|
||
]
|
||
const activeTeamCount = computed(
|
||
() => (game.value.teams || []).filter((team) => team.participationStatus !== 'cancelled').length,
|
||
)
|
||
const screens = ref<ScreenState[]>([])
|
||
const screenPlan = ref<ScreenPlan>()
|
||
const screenPlanDraft = ref<ScreenPlanStep[]>([])
|
||
const screenPlanDirty = ref(false)
|
||
const screenPlanSaving = ref(false)
|
||
const screenPlanMode = ref<'use' | 'edit'>('use')
|
||
let screenPlanRequestVersion = 0
|
||
const previewQuestions = ref<ResultsQuestion[]>([])
|
||
const previewTeams = ref<TeamResult[]>([])
|
||
const mapItems = ref<MapItem[]>([])
|
||
const mapSaving = ref(false)
|
||
const screenVolume = ref(100)
|
||
const screenPosition = ref(0)
|
||
|
||
async function getGame(id: number) {
|
||
const res = await client.GetGame({ ID: id })
|
||
game.value = res.game!
|
||
game.value.teams = game.value.teams?.sort((a, b) => {
|
||
let aLen = 0
|
||
if ((a.applications?.length || 0) > 0) {
|
||
aLen = 1
|
||
}
|
||
let bLen = 0
|
||
if ((b.applications?.length || 0) > 0) {
|
||
bLen = 1
|
||
}
|
||
if (aLen > bLen) {
|
||
return -1
|
||
}
|
||
if (aLen < bLen) {
|
||
return 1
|
||
}
|
||
|
||
const aName = a.name || ''
|
||
const bName = b.name || ''
|
||
if (aName > bName) {
|
||
return 1
|
||
}
|
||
if (aName < bName) {
|
||
return -1
|
||
}
|
||
return 0
|
||
})
|
||
}
|
||
getGame(Number(gameId))
|
||
|
||
async function getGameScreens() {
|
||
const res = await client.GetGameScreens({ gameID: Number(gameId) })
|
||
screens.value = res.screens || []
|
||
const previewScreen =
|
||
screens.value.find((screen) => screen.status === 'connected') || screens.value[0]
|
||
if (previewScreen?.mode === 'SCREEN_MODE_RESULTS') {
|
||
const result = await client.GetGameResults({ gameID: Number(gameId) })
|
||
previewQuestions.value = result.questions || []
|
||
previewTeams.value = result.teams || []
|
||
} else {
|
||
previewQuestions.value = []
|
||
previewTeams.value = []
|
||
}
|
||
}
|
||
getGameScreens()
|
||
|
||
function isScreenPlanEditing() {
|
||
return screenPlanMode.value === 'edit'
|
||
}
|
||
|
||
async function getScreenPlan() {
|
||
console.log('getScreenPlan')
|
||
if (isScreenPlanEditing() || screenPlanDirty.value) return
|
||
console.log('no skip getScreenPlan')
|
||
const requestVersion = ++screenPlanRequestVersion
|
||
const res = await client.GetScreenPlan({ gameID: Number(gameId) })
|
||
if (
|
||
requestVersion !== screenPlanRequestVersion ||
|
||
isScreenPlanEditing() ||
|
||
screenPlanDirty.value
|
||
) {
|
||
return
|
||
}
|
||
screenPlan.value = res.plan
|
||
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
|
||
}
|
||
getScreenPlan()
|
||
|
||
function addScreenPlanStep() {
|
||
screenPlanDraft.value.push({
|
||
mode: 'SCREEN_MODE_TEXT',
|
||
text: 'Новый экран',
|
||
timerLabel: 'Перерыв',
|
||
timerDurationSeconds: 600,
|
||
})
|
||
screenPlanDirty.value = true
|
||
screenPlanMode.value = 'edit'
|
||
}
|
||
|
||
function editScreenPlan() {
|
||
if (!screenPlan.value) return
|
||
screenPlanDraft.value = screenPlan.value.steps?.map((step) => ({ ...step })) || []
|
||
screenPlanMode.value = 'edit'
|
||
}
|
||
|
||
function cancelScreenPlanEdit() {
|
||
if (screenPlanDirty.value && !window.confirm('Отменить несохранённые изменения плана?')) return
|
||
screenPlanDraft.value = screenPlan.value?.steps?.map((step) => ({ ...step })) || []
|
||
screenPlanDirty.value = false
|
||
screenPlanMode.value = 'use'
|
||
}
|
||
|
||
function removeScreenPlanStep(index: number) {
|
||
screenPlanDraft.value.splice(index, 1)
|
||
screenPlanDirty.value = true
|
||
}
|
||
|
||
function moveScreenPlanStep(index: number, offset: number) {
|
||
const target = index + offset
|
||
if (target < 0 || target >= screenPlanDraft.value.length) return
|
||
const [step] = screenPlanDraft.value.splice(index, 1)
|
||
screenPlanDraft.value.splice(target, 0, step)
|
||
screenPlanDirty.value = true
|
||
}
|
||
|
||
function markScreenPlanDirty() {
|
||
screenPlanDirty.value = true
|
||
}
|
||
|
||
function clearScreenPlan() {
|
||
screenPlanDraft.value = []
|
||
markScreenPlanDirty()
|
||
}
|
||
|
||
async function saveScreenPlan() {
|
||
if (screenPlanSaving.value) return
|
||
screenPlanRequestVersion += 1
|
||
screenPlanSaving.value = true
|
||
try {
|
||
const currentStep = Math.min(
|
||
screenPlan.value?.currentStep || 0,
|
||
Math.max(0, screenPlanDraft.value.length - 1),
|
||
)
|
||
const res = await client.SaveScreenPlan({
|
||
gameID: Number(gameId),
|
||
steps: screenPlanDraft.value,
|
||
currentStep,
|
||
})
|
||
screenPlan.value = res.plan
|
||
screenPlanDraft.value = res.plan?.steps?.map((step) => ({ ...step })) || []
|
||
screenPlanDirty.value = false
|
||
screenPlanMode.value = 'use'
|
||
message.success(res.plan ? 'План экранов сохранён' : 'План экранов удалён')
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось сохранить план экранов')
|
||
} finally {
|
||
screenPlanSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function setScreenPlanStep(index: number) {
|
||
if (screenPlanDirty.value || !screenPlan.value) return
|
||
try {
|
||
const res = await client.SetScreenPlanStep({ gameID: Number(gameId), currentStep: index })
|
||
screenPlan.value = res.plan
|
||
await getGameScreens()
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось перейти на шаг плана')
|
||
}
|
||
}
|
||
|
||
async function sendScreenPlanCommand(mode: ScreenMode | undefined, command: ScreenCommand) {
|
||
if (
|
||
screenPlanDirty.value ||
|
||
(mode !== 'SCREEN_MODE_TIMER' &&
|
||
mode !== 'SCREEN_MODE_INTRO' &&
|
||
mode !== 'SCREEN_MODE_CONCLUSION')
|
||
) {
|
||
return
|
||
}
|
||
try {
|
||
await client.SendScreenCommand({
|
||
gameID: Number(gameId),
|
||
command,
|
||
positionMs: Math.max(0, Math.round(screenPosition.value * 1000)),
|
||
volume: Math.max(0, Math.min(100, Math.round(screenVolume.value))),
|
||
})
|
||
await getGameScreens()
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось управлять экраном')
|
||
}
|
||
}
|
||
|
||
async function bindScannedScreen(screenID: string, accessToken: string) {
|
||
if (screenBinding.value) return
|
||
screenBinding.value = true
|
||
try {
|
||
await client.BindScreen({ screenID, accessToken, gameID: Number(gameId) })
|
||
message.success('Экран привязан к игре')
|
||
await getGameScreens()
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось привязать экран')
|
||
} finally {
|
||
screenBinding.value = false
|
||
}
|
||
}
|
||
|
||
async function getGameMap() {
|
||
if (mapSaving.value) return
|
||
const res = await client.GetGameMap({ gameID: Number(gameId) })
|
||
mapItems.value = res.items || []
|
||
}
|
||
getGameMap()
|
||
|
||
async function saveMapItem(item: MapItem, hidden = item.hidden) {
|
||
mapSaving.value = true
|
||
try {
|
||
await client.UpdateGameMapItem({
|
||
gameID: Number(gameId),
|
||
kind: item.kind,
|
||
itemID: item.itemID,
|
||
x: Math.round(item.x || 0),
|
||
y: Math.round(item.y || 0),
|
||
hidden,
|
||
})
|
||
} finally {
|
||
mapSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function saveMapItemAndReload(item: MapItem, hidden = item.hidden) {
|
||
await saveMapItem(item, hidden)
|
||
await getGameMap()
|
||
}
|
||
|
||
async function setSoundScreen(screen: ScreenState) {
|
||
await client.SetScreenSound({ screenID: screen.ID, soundEnabled: true })
|
||
await getGameScreens()
|
||
}
|
||
|
||
async function unbindScreen(screen: ScreenState) {
|
||
await client.UnbindScreen({ screenID: screen.ID })
|
||
await getGameScreens()
|
||
}
|
||
|
||
async function toTeamStory(id: number, password: string, newTab: boolean = false) {
|
||
const uri = '/team-story/' + id + '?password=' + password
|
||
if (newTab) {
|
||
window.open(uri, '_blank')
|
||
return
|
||
}
|
||
router.push(uri)
|
||
}
|
||
|
||
async function downloadTeamQR() {
|
||
if (isDownloadingQR.value || !game.value.teams?.length) return
|
||
isDownloadingQR.value = true
|
||
try {
|
||
const { blob, filename } = await binaryClient.downloadTeamQR(game.value.ID!)
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
link.remove()
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось скачать QR команд')
|
||
} finally {
|
||
isDownloadingQR.value = false
|
||
}
|
||
}
|
||
|
||
function toResults() {
|
||
router.push('/games/' + gameId + '/results')
|
||
}
|
||
|
||
function openSettings() {
|
||
settingsDraft.value = {
|
||
name: game.value.name || '',
|
||
description: game.value.description || '',
|
||
format: game.value.format || 'online',
|
||
location: game.value.location || '',
|
||
}
|
||
showSettingsModal.value = true
|
||
}
|
||
|
||
function closeSettings() {
|
||
showSettingsModal.value = false
|
||
}
|
||
|
||
async function updateGame() {
|
||
await client.UpdateGame({
|
||
ID: game.value.ID,
|
||
name: settingsDraft.value.name,
|
||
description: settingsDraft.value.description,
|
||
format: settingsDraft.value.format,
|
||
location: settingsDraft.value.location.trim(),
|
||
startAt: game.value.startAt,
|
||
scenarioID: game.value.scenario?.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
showSettingsModal.value = false
|
||
}
|
||
|
||
const giveApplicationTeam = ref<Team>({
|
||
ID: 0,
|
||
name: '',
|
||
status: '',
|
||
password: '',
|
||
actionsCount: 0,
|
||
applications: [],
|
||
captainEmail: undefined,
|
||
participantsCount: undefined,
|
||
participationStatus: undefined,
|
||
participationSource: undefined,
|
||
participationEmailError: undefined,
|
||
participationEmailSent: undefined,
|
||
})
|
||
|
||
const deletedTeamName = ref('')
|
||
const deletedTeam = ref<Team>({
|
||
ID: 0,
|
||
name: '',
|
||
status: '',
|
||
password: '',
|
||
actionsCount: 0,
|
||
applications: [],
|
||
captainEmail: undefined,
|
||
participantsCount: undefined,
|
||
participationStatus: undefined,
|
||
participationSource: undefined,
|
||
participationEmailError: undefined,
|
||
participationEmailSent: undefined,
|
||
})
|
||
|
||
const giveApplicationApplication = ref<Application>({
|
||
name: '',
|
||
image: '',
|
||
file_type: '',
|
||
})
|
||
|
||
const url = window.location.href
|
||
|
||
function giveApplication(team: Team, application: Application) {
|
||
giveApplicationTeam.value = team
|
||
giveApplicationApplication.value = application
|
||
showGiveApplicationModal.value = true
|
||
}
|
||
|
||
async function addTeam() {
|
||
if (newTeamParticipants.value < 1 || newTeamParticipants.value > 8) {
|
||
message.error('Укажите от 1 до 8 игроков')
|
||
return
|
||
}
|
||
await client.AddTeam({
|
||
gameID: game.value.ID,
|
||
name: newTeamName.value,
|
||
captainEmail: newTeamEmail.value.trim(),
|
||
participantsCount: newTeamParticipants.value,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
showAddTeamModal.value = false
|
||
newTeamEmail.value = ''
|
||
newTeamParticipants.value = 1
|
||
}
|
||
|
||
async function confirmGiveApplication() {
|
||
await client.GiveTeamApplications({
|
||
ID: giveApplicationTeam.value.ID,
|
||
name: giveApplicationApplication.value.name,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
showGiveApplicationModal.value = false
|
||
}
|
||
|
||
async function deleteTeam(team: Team) {
|
||
deletedTeam.value = team
|
||
showDeleteTeamModal.value = true
|
||
}
|
||
|
||
async function confirmDeleteTeam() {
|
||
await client.DeleteTeam({
|
||
ID: deletedTeam.value.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
showDeleteTeamModal.value = false
|
||
}
|
||
|
||
async function deleteGame() {
|
||
await client.DeleteGame({
|
||
ID: game.value.ID,
|
||
})
|
||
message.success('Игра удалена')
|
||
router.push('/games')
|
||
}
|
||
|
||
function getTeamStoryURL(id: number, password: string): string {
|
||
return getURL() + '/team-story/' + id + '?password=' + password
|
||
}
|
||
|
||
function openTeamQR(team: Team) {
|
||
openQR(getTeamStoryURL(team.ID!, team.password!), 'Подсмотреть игру')
|
||
}
|
||
|
||
async function openQR(url: string, text: string) {
|
||
urlQR.value = url
|
||
titleQR.value = text
|
||
showQR.value = true
|
||
}
|
||
|
||
function openIssueTickets() {
|
||
ticketCount.value = 1
|
||
issuedTickets.value = []
|
||
showIssueTicketsModal.value = true
|
||
}
|
||
|
||
async function issueTickets() {
|
||
if (isIssuingTickets.value || !game.value.ID) return
|
||
isIssuingTickets.value = true
|
||
try {
|
||
const res = await client.IssueGameTickets({
|
||
gameID: game.value.ID,
|
||
count: ticketCount.value,
|
||
})
|
||
issuedTickets.value = res.tickets || []
|
||
message.success(`Выпущено билетов: ${issuedTickets.value.length}`)
|
||
} catch (err) {
|
||
message.error(err instanceof Error ? err.message : 'Не удалось выпустить билеты')
|
||
} finally {
|
||
isIssuingTickets.value = false
|
||
}
|
||
}
|
||
|
||
useSimplePolling(() => {
|
||
getGame(Number(gameId))
|
||
getGameScreens()
|
||
getScreenPlan()
|
||
getGameMap()
|
||
}, 2000)
|
||
|
||
async function resetGame() {
|
||
await client.ResetGame({
|
||
ID: game.value.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
}
|
||
|
||
async function startGame() {
|
||
await client.StartGame({
|
||
ID: game.value.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
}
|
||
|
||
async function pauseGame() {
|
||
await client.PauseGame({
|
||
ID: game.value.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
}
|
||
|
||
async function playGame() {
|
||
await client.PlayGame({
|
||
ID: game.value.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
}
|
||
|
||
async function finishGame() {
|
||
await client.FinishGame({
|
||
ID: game.value.ID,
|
||
})
|
||
await getGame(game.value.ID!)
|
||
}
|
||
|
||
// ===== Ответы команд и оценка организатором =====
|
||
const showAnswersModal = ref(false)
|
||
const showReviewsModal = ref(false)
|
||
const gameReviews = ref<GameReview[]>([])
|
||
const reviewsAverage = ref(0)
|
||
const reviewsCount = ref(0)
|
||
const answersQuestions = ref<Question[]>([])
|
||
const answersTeams = ref<TeamAnswers[]>([])
|
||
|
||
// Баллы по ключу "teamID:questionCode" (undefined — балл не выставлен)
|
||
const scores = ref<Record<string, number | undefined>>({})
|
||
|
||
function scoreKey(teamID: number | undefined, questionCode: string | undefined): string {
|
||
return (teamID ?? '') + ':' + (questionCode ?? '')
|
||
}
|
||
|
||
function teamAnswer(team: TeamAnswers, questionCode: string | undefined): string {
|
||
return (team.answers || []).find((item) => item.questionCode == questionCode)?.answer || ''
|
||
}
|
||
|
||
function onScoreUpdate(key: string, value: number | null) {
|
||
scores.value[key] = value === null ? undefined : value
|
||
}
|
||
|
||
async function loadGameAnswers() {
|
||
const res = await client.GetGameAnswers({ gameID: Number(gameId) })
|
||
answersQuestions.value = res.questions || []
|
||
answersTeams.value = res.teams || []
|
||
const next: Record<string, number | undefined> = {}
|
||
for (const team of answersTeams.value) {
|
||
for (const answer of team.answers || []) {
|
||
next[scoreKey(team.team?.ID, answer.questionCode)] = answer.score
|
||
}
|
||
}
|
||
scores.value = next
|
||
}
|
||
|
||
async function openAnswersModal() {
|
||
showAnswersModal.value = true
|
||
await loadGameAnswers()
|
||
}
|
||
|
||
async function openReviewsModal() {
|
||
showReviewsModal.value = true
|
||
const res = await client.GetGameReviewsForModeration({ gameID: Number(gameId) })
|
||
gameReviews.value = res.reviews || []
|
||
reviewsAverage.value = res.averageRating || 0
|
||
reviewsCount.value = res.reviewsCount || 0
|
||
}
|
||
|
||
async function moderateReview(review: GameReview, status: string) {
|
||
await client.ModerateGameReview({ ID: review.ID, status })
|
||
await openReviewsModal()
|
||
}
|
||
|
||
// Сохраняет баллы всех команд за все вопросы; пустой балл сбрасывает его.
|
||
async function saveAllScores() {
|
||
for (const question of answersQuestions.value) {
|
||
for (const team of answersTeams.value) {
|
||
const score = scores.value[scoreKey(team.team?.ID, question.code)]
|
||
await client.SetTeamAnswerScore({
|
||
ID: team.team?.ID,
|
||
questionCode: question.code,
|
||
score: score === undefined || score === null ? undefined : score,
|
||
})
|
||
}
|
||
}
|
||
message.success('Баллы сохранены')
|
||
await loadGameAnswers()
|
||
}
|
||
|
||
function getCurrentUTC(): Date {
|
||
const now = new Date()
|
||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000)
|
||
}
|
||
|
||
function getTimeDiff(startStr: string, endStr: string): string {
|
||
if (startStr == '') {
|
||
return 'Игра ещё не началась'
|
||
}
|
||
const start = parseDateWithoutTimezone(startStr)
|
||
let endTime = getCurrentUTC()
|
||
if (endStr != '') {
|
||
endTime = parseDateWithoutTimezone(endStr)
|
||
}
|
||
|
||
const diffMs = endTime.getTime() - start.getTime()
|
||
|
||
if (diffMs < 0) return 'Игра ещё не началась'
|
||
|
||
const seconds = Math.floor(diffMs / 1000)
|
||
const minutes = Math.floor(seconds / 60)
|
||
const hours = Math.floor(minutes / 60)
|
||
const days = Math.floor(hours / 24)
|
||
const months = Math.floor(days / 30)
|
||
const years = Math.floor(days / 365)
|
||
|
||
if (years > 0) return `${years}г ${days % 365}д`
|
||
if (months > 0) return `${months}мес ${days % 30}д`
|
||
if (days > 0) return `${days}д ${hours % 24}ч`
|
||
if (hours > 0) return `${hours}ч ${minutes % 60}м`
|
||
if (minutes > 0) return `${minutes}м ${seconds % 60}с`
|
||
return `${seconds}с`
|
||
}
|
||
|
||
const timeString = ref('')
|
||
useSimplePolling(() => {
|
||
timeString.value = getTimeDiff(game.value.startedAt || '', game.value.endedAt || '')
|
||
}, 1000)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="center-block-custom">
|
||
<div class="width1200">
|
||
<n-tabs :value="activeTab" type="line" animated @update:value="setActiveTab">
|
||
<n-tab-pane name="game" tab="Игра">
|
||
<div class="game-tab-content">
|
||
<div class="settings-block">
|
||
<n-flex justify="space-between">
|
||
<n-space>
|
||
<n-tag :bordered="false" type="info">
|
||
Участвуют команд: {{ activeTeamCount }}
|
||
</n-tag>
|
||
<n-tag type="info">
|
||
{{ timeString }}
|
||
</n-tag>
|
||
<n-tag v-if="formatGameDateTime(game.startAt)" type="info">
|
||
Начало: {{ formatGameDateTime(game.startAt) }}
|
||
</n-tag>
|
||
</n-space>
|
||
|
||
<n-flex justify="end">
|
||
<n-button quaternary @click="openQR(url, 'Админка')"> QR </n-button>
|
||
<n-button quaternary @click="openIssueTickets()"> Выпустить билеты </n-button>
|
||
<n-button quaternary @click="openAnswersModal()"> Ответы команд </n-button>
|
||
<n-button v-if="game.status == 'finish'" quaternary @click="toResults()">
|
||
<Icon class="settings-icon">
|
||
<Trophy />
|
||
</Icon>
|
||
Итоги
|
||
</n-button>
|
||
<n-button
|
||
v-if="activeTeamCount"
|
||
quaternary
|
||
:loading="isDownloadingQR"
|
||
:disabled="isDownloadingQR"
|
||
@click="downloadTeamQR()"
|
||
>
|
||
QR команд
|
||
</n-button>
|
||
<n-button quaternary @click="openSettings()">
|
||
<Icon class="settings-icon">
|
||
<Settings />
|
||
</Icon>
|
||
Настройки
|
||
</n-button>
|
||
<n-button
|
||
v-if="(game.teams?.length || 0) == 0"
|
||
type="error"
|
||
quaternary
|
||
@click="showDeleteGameModal = true"
|
||
>
|
||
Удалить игру
|
||
</n-button>
|
||
|
||
<n-space>
|
||
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
|
||
<Icon>
|
||
<Reset />
|
||
</Icon>
|
||
</n-button>
|
||
<n-button type="info" v-if="game.status == 'draft'" dashed @click="startGame()">
|
||
<Icon>
|
||
<Play />
|
||
</Icon>
|
||
</n-button>
|
||
<n-button type="info" v-if="game.status == 'pause'" dashed @click="playGame()">
|
||
<Icon>
|
||
<Play />
|
||
</Icon>
|
||
</n-button>
|
||
<n-button type="info" v-if="game.status == 'run'" dashed @click="pauseGame()">
|
||
<Icon>
|
||
<Pause />
|
||
</Icon>
|
||
</n-button>
|
||
<n-button
|
||
type="info"
|
||
v-if="game.status != 'finish'"
|
||
dashed
|
||
@click="finishGame()"
|
||
>
|
||
<Icon>
|
||
<Flag />
|
||
</Icon>
|
||
</n-button>
|
||
</n-space>
|
||
</n-flex>
|
||
</n-flex>
|
||
</div>
|
||
</div>
|
||
</n-tab-pane>
|
||
<n-tab-pane name="teams" tab="Команды">
|
||
<div class="game-tab-content">
|
||
<n-tag :bordered="false" type="info"> Участвуют команд: {{ activeTeamCount }} </n-tag>
|
||
<GameTeamsPanel
|
||
:teams="game.teams || []"
|
||
:participation-options="participationOptions"
|
||
@add="showAddTeamModal = true"
|
||
@open-qr="openTeamQR"
|
||
@preview="(id, password) => toTeamStory(id, password, true)"
|
||
@delete="deleteTeam"
|
||
@give-application="giveApplication"
|
||
/>
|
||
</div>
|
||
</n-tab-pane>
|
||
<n-tab-pane name="screens-map" tab="Экраны и карта">
|
||
<div class="game-tab-content">
|
||
<GameMapPanel
|
||
:map-items="mapItems"
|
||
:teams="game.teams || []"
|
||
:screens="screens"
|
||
:preview-questions="previewQuestions"
|
||
:preview-teams="previewTeams"
|
||
:screen-plan="screenPlan"
|
||
:screen-plan-draft="screenPlanDraft"
|
||
:screen-plan-dirty="screenPlanDirty"
|
||
:screen-plan-saving="screenPlanSaving"
|
||
:screen-plan-mode="screenPlanMode"
|
||
:screen-volume="screenVolume"
|
||
:screen-position="screenPosition"
|
||
@save-item="saveMapItemAndReload"
|
||
@give-application="giveApplication"
|
||
@add-step="addScreenPlanStep"
|
||
@edit="editScreenPlan"
|
||
@cancel="cancelScreenPlanEdit"
|
||
@remove-step="removeScreenPlanStep"
|
||
@move-step="moveScreenPlanStep"
|
||
@dirty="markScreenPlanDirty"
|
||
@clear-plan="clearScreenPlan"
|
||
@save="saveScreenPlan"
|
||
@set-step="setScreenPlanStep"
|
||
@command="sendScreenPlanCommand"
|
||
@update:screen-volume="screenVolume = $event ?? 0"
|
||
@update:screen-position="screenPosition = $event ?? 0"
|
||
/>
|
||
<GameScreensPanel
|
||
:screens="screens"
|
||
:screen-binding="screenBinding"
|
||
@bind="bindScannedScreen"
|
||
@set-sound="setSoundScreen"
|
||
@unbind="unbindScreen"
|
||
/>
|
||
</div>
|
||
</n-tab-pane>
|
||
</n-tabs>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Окно настроек -->
|
||
<n-modal v-model:show="showSettingsModal">
|
||
<n-card
|
||
style="width: 600px"
|
||
title="Настройки игры"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="closeSettings()"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<p>Название</p>
|
||
<n-input
|
||
v-model:value="settingsDraft.name"
|
||
type="text"
|
||
placeholder='Дело №0 "Следствие ведут овечки"'
|
||
/>
|
||
|
||
<p class="settings-header">Формат игры</p>
|
||
<n-select
|
||
v-model:value="settingsDraft.format"
|
||
:options="[
|
||
{ label: 'Online', value: 'online' },
|
||
{ label: 'Offline', value: 'offline' },
|
||
]"
|
||
aria-label="Формат игры"
|
||
/>
|
||
|
||
<p class="settings-header">Описание</p>
|
||
<n-input
|
||
v-model:value="settingsDraft.description"
|
||
type="textarea"
|
||
placeholder="Убийство пастуха..."
|
||
/>
|
||
|
||
<p class="settings-header">Место проведения</p>
|
||
<n-input
|
||
v-model:value="settingsDraft.location"
|
||
type="text"
|
||
placeholder="Оставьте пустым для онлайн-игры"
|
||
/>
|
||
</n-space>
|
||
<template #footer>
|
||
<n-flex justify="end">
|
||
<n-button @click="updateGame()" ghost> Сохранить изменения </n-button>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Окно выдачи улик -->
|
||
<n-modal v-model:show="showGiveApplicationModal">
|
||
<n-card
|
||
style="width: 600px"
|
||
title="Настройки игры"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showGiveApplicationModal = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<p>Команда</p>
|
||
<h3>{{ giveApplicationTeam.name }}</h3>
|
||
|
||
<p>Улика</p>
|
||
<h3>{{ giveApplicationApplication.name }}</h3>
|
||
|
||
<div
|
||
v-if="
|
||
applicationFileType(giveApplicationApplication) == 'image' &&
|
||
giveApplicationApplication.image
|
||
"
|
||
class="give-application-image-block"
|
||
>
|
||
<img
|
||
:src="applicationFileURL(giveApplicationApplication.image)"
|
||
class="give-application-image"
|
||
/>
|
||
</div>
|
||
<audio
|
||
v-else-if="
|
||
applicationFileType(giveApplicationApplication) == 'audio' &&
|
||
giveApplicationApplication.image
|
||
"
|
||
:src="applicationFileURL(giveApplicationApplication.image)"
|
||
controls
|
||
preload="none"
|
||
class="give-application-audio"
|
||
></audio>
|
||
<a
|
||
v-else-if="
|
||
applicationFileType(giveApplicationApplication) == 'pdf' &&
|
||
giveApplicationApplication.image
|
||
"
|
||
:href="applicationFileURL(giveApplicationApplication.image)"
|
||
target="_blank"
|
||
rel="noopener"
|
||
class="give-application-pdf-link"
|
||
>
|
||
PDF
|
||
</a>
|
||
</n-space>
|
||
<template #footer>
|
||
<n-flex justify="end">
|
||
<n-button @click="confirmGiveApplication()" ghost> Выдать </n-button>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Окно создания команды -->
|
||
<n-modal v-model:show="showAddTeamModal">
|
||
<n-card
|
||
style="width: 600px"
|
||
title="Создание команды"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showAddTeamModal = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<n-input v-model:value="newTeamName" type="text" placeholder="Три дебила" />
|
||
<n-input
|
||
v-model:value="newTeamEmail"
|
||
:input-props="{ type: 'email', autocomplete: 'email' }"
|
||
placeholder="Почта капитана (необязательно)"
|
||
aria-label="Почта капитана (необязательно)"
|
||
/>
|
||
<n-input-number
|
||
v-model:value="newTeamParticipants"
|
||
:min="1"
|
||
:max="8"
|
||
:precision="0"
|
||
placeholder="Количество игроков"
|
||
aria-label="Количество игроков"
|
||
/>
|
||
</n-space>
|
||
<template #footer>
|
||
<n-flex justify="end">
|
||
<n-button @click="addTeam()" :disabled="newTeamName.length == 0"> Создать </n-button>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Окно создания команды -->
|
||
<n-modal v-model:show="showDeleteTeamModal">
|
||
<n-card
|
||
style="width: 600px"
|
||
title="Удаление команды"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showDeleteTeamModal = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<p>
|
||
Введите название команды
|
||
<n-text class="name-text" strong>{{ deletedTeam.name }}</n-text> чтобы удалить его
|
||
</p>
|
||
<n-input v-model:value="deletedTeamName" type="text" placeholder="Три дебила" />
|
||
</n-space>
|
||
|
||
<template #footer>
|
||
<n-flex justify="end">
|
||
<n-button @click="confirmDeleteTeam()" :disabled="deletedTeamName != deletedTeam.name">
|
||
Удалить
|
||
</n-button>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Окно удаления игры -->
|
||
<n-modal v-model:show="showDeleteGameModal">
|
||
<n-card
|
||
style="width: 600px"
|
||
title="Удаление игры"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showDeleteGameModal = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<p>
|
||
Введите название игры
|
||
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
|
||
</p>
|
||
<n-input
|
||
v-model:value="deletedGameName"
|
||
type="text"
|
||
placeholder='Дело №0 "Следствие ведут овечки"'
|
||
/>
|
||
</n-space>
|
||
|
||
<template #footer>
|
||
<n-flex justify="end">
|
||
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
|
||
Удалить
|
||
</n-button>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Окно qr -->
|
||
<n-modal v-model:show="showIssueTicketsModal">
|
||
<n-card
|
||
style="width: min(94vw, 700px)"
|
||
title="Выпуск билетов"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showIssueTicketsModal = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<n-input-number v-model:value="ticketCount" :min="1" :max="100" />
|
||
<n-button
|
||
type="primary"
|
||
:loading="isIssuingTickets"
|
||
:disabled="isIssuingTickets"
|
||
@click="issueTickets()"
|
||
>
|
||
Выпустить
|
||
</n-button>
|
||
<div v-for="ticket in issuedTickets" :key="ticket.ID" class="issued-ticket">
|
||
<div>Билет №{{ ticket.ID }}</div>
|
||
<a :href="ticket.URL" target="_blank" rel="noreferrer">Открыть страницу билета</a>
|
||
<n-button size="small" @click="openQR(ticket.URL!, `Билет №${ticket.ID}`)">
|
||
Показать QR
|
||
</n-button>
|
||
</div>
|
||
</n-space>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<n-modal v-model:show="showQR">
|
||
<n-card
|
||
style="width: 600px"
|
||
:title="titleQR"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showQR = false"> x </n-button>
|
||
</template>
|
||
<n-space vertical>
|
||
<div class="qr-block">
|
||
<n-qr-code :value="urlQR" :size="300" :padding="0" />
|
||
</div>
|
||
</n-space>
|
||
|
||
<template #footer></template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<!-- Окно ответов команд и оценки -->
|
||
<n-modal v-model:show="showAnswersModal">
|
||
<n-card
|
||
style="width: min(94vw, 900px)"
|
||
title="Ответы команд"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:content-style="{ maxHeight: '70vh', overflow: 'auto' }"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showAnswersModal = false"> x </n-button>
|
||
</template>
|
||
<div v-if="answersQuestions.length == 0" class="answers-empty">
|
||
Вопросы дела не заданы или команды ещё не отвечали.
|
||
</div>
|
||
<UiBlock v-for="question in answersQuestions" :key="question.code">
|
||
<p class="answers-question-title">{{ question.code }}: {{ question.text }}</p>
|
||
<p class="answers-question-answer">
|
||
Правильный ответ:<br /><span class="correct-answer">{{ question.answer || '—' }}</span>
|
||
</p>
|
||
<div v-for="team in answersTeams" :key="team.team?.ID" class="answers-team-row">
|
||
<div class="answers-team-name">
|
||
{{ team.team?.name }}
|
||
</div>
|
||
<div class="answers-team-answer">
|
||
{{ teamAnswer(team, question.code) || '—' }}
|
||
</div>
|
||
<n-input-number
|
||
:value="scores[scoreKey(team.team?.ID, question.code)]"
|
||
@update:value="onScoreUpdate(scoreKey(team.team?.ID, question.code), $event)"
|
||
placeholder="Балл"
|
||
class="answers-score-input"
|
||
/>
|
||
</div>
|
||
</UiBlock>
|
||
<template #footer>
|
||
<n-flex justify="end">
|
||
<n-button @click="saveAllScores()" :disabled="answersQuestions.length == 0">
|
||
Сохранить баллы
|
||
</n-button>
|
||
</n-flex>
|
||
</template>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<n-modal v-model:show="showReviewsModal">
|
||
<n-card
|
||
style="width: min(94vw, 800px)"
|
||
title="Отзывы игры"
|
||
:bordered="false"
|
||
size="huge"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
:content-style="{ maxHeight: '70vh', overflow: 'auto' }"
|
||
>
|
||
<template #header-extra>
|
||
<n-button @click="showReviewsModal = false"> x </n-button>
|
||
</template>
|
||
<n-flex align="center" class="reviews-summary">
|
||
<n-rate :value="reviewsAverage" readonly allow-half />
|
||
<span>{{
|
||
reviewsCount ? `${reviewsAverage.toFixed(1)} · ${reviewsCount}` : 'Пока нет отзывов'
|
||
}}</span>
|
||
</n-flex>
|
||
<div v-if="gameReviews.length == 0" class="answers-empty">Отзывов пока нет.</div>
|
||
<div v-for="review in gameReviews" :key="review.ID" class="review-row">
|
||
<n-flex justify="space-between" align="center">
|
||
<strong>{{ review.teamName }}</strong>
|
||
<n-tag>{{ review.status }}</n-tag>
|
||
</n-flex>
|
||
<n-rate :value="review.rating" readonly size="small" />
|
||
<div>Сложность: {{ ['', 'лёгкая', 'средняя', 'тяжёлая'][review.difficulty || 0] }}</div>
|
||
<p>{{ review.text }}</p>
|
||
<n-flex v-if="review.status == 'pending'" justify="end">
|
||
<n-button type="success" @click="moderateReview(review, 'approved')">Одобрить</n-button>
|
||
<n-button type="error" ghost @click="moderateReview(review, 'rejected')"
|
||
>Отклонить</n-button
|
||
>
|
||
</n-flex>
|
||
<n-flex v-else-if="review.status == 'approved'" justify="end">
|
||
<n-button type="error" ghost @click="moderateReview(review, 'hidden')">Скрыть</n-button>
|
||
</n-flex>
|
||
</div>
|
||
</n-card>
|
||
</n-modal>
|
||
|
||
<HeaderMenu active="games" />
|
||
</template>
|
||
|
||
<style scoped>
|
||
.reviews-summary {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.game-tab-content {
|
||
padding-top: 0;
|
||
}
|
||
|
||
.review-row {
|
||
border-top: 1px solid #777;
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.review-row p {
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.team-block-hover:hover {
|
||
border: 1px solid #63e2b7;
|
||
color: #63e2b7;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.team-content-block {
|
||
padding: 20px;
|
||
}
|
||
|
||
.team-name-block {
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.settings-icon {
|
||
width: 20px;
|
||
}
|
||
|
||
.link-button {
|
||
margin-top: 16px;
|
||
width: 100%;
|
||
}
|
||
|
||
.place-image-plus {
|
||
align-items: center;
|
||
display: flex;
|
||
font-size: 30px;
|
||
justify-content: center;
|
||
}
|
||
|
||
.name-text {
|
||
background-color: rgb(255 255 255 / 10%);
|
||
border-radius: 3px;
|
||
padding: 3px;
|
||
}
|
||
|
||
.qr-block {
|
||
background-color: white;
|
||
display: inline-block;
|
||
line-height: 0;
|
||
margin: 0 auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
.give-application-image-block {
|
||
background-image: url('@/assets/images/paper_white.jpg');
|
||
background-size: cover;
|
||
box-shadow: 0 3px 15px rgb(98 98 98);
|
||
margin: 8px 0;
|
||
max-width: 320px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.give-application-image {
|
||
width: 100%;
|
||
}
|
||
|
||
.give-application-audio {
|
||
margin: 8px 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.give-application-pdf-link {
|
||
display: inline-block;
|
||
margin: 8px 0;
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.answers-empty {
|
||
color: #777;
|
||
}
|
||
|
||
.answers-question-title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.answers-question-answer {
|
||
color: #aaa;
|
||
margin: 0 0 16px;
|
||
}
|
||
|
||
.correct-answer {
|
||
color: gold;
|
||
display: inline-block;
|
||
font-weight: 500;
|
||
margin-top: 8px;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.answers-team-row {
|
||
align-items: center;
|
||
background-color: rgb(255 255 255 / 5%);
|
||
border-radius: 6px;
|
||
display: flex;
|
||
flex-flow: row wrap;
|
||
gap: 8px;
|
||
margin: 8px 0;
|
||
padding: 8px;
|
||
}
|
||
|
||
.answers-team-name {
|
||
font-weight: 600;
|
||
width: 180px;
|
||
}
|
||
|
||
.answers-team-answer {
|
||
color: #aaa;
|
||
flex: 1 1 200px;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.answers-score-input {
|
||
width: 110px;
|
||
}
|
||
|
||
.screens-title {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.screen-row {
|
||
align-items: center;
|
||
border-top: 1px solid rgb(255 255 255 / 10%);
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: space-between;
|
||
padding: 16px 0;
|
||
}
|
||
|
||
.screen-status {
|
||
color: #aaa;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.screen-empty {
|
||
color: #aaa;
|
||
margin: 16px 0 0;
|
||
}
|
||
|
||
.screen-error {
|
||
color: #f2a2a2;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.map-preview-layout {
|
||
display: grid;
|
||
gap: 16px;
|
||
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
|
||
}
|
||
|
||
.screen-preview-placeholder {
|
||
align-items: center;
|
||
aspect-ratio: 16 / 9;
|
||
background-color: #111;
|
||
color: #aaa;
|
||
display: flex;
|
||
justify-content: center;
|
||
text-align: center;
|
||
}
|
||
|
||
.map-field {
|
||
background-color: #151515;
|
||
border: 1px dashed rgb(255 255 255 / 20%);
|
||
cursor: grab;
|
||
height: 420px;
|
||
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 10px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
|
||
.issued-ticket {
|
||
align-items: center;
|
||
border-top: 1px solid #777;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding-top: 8px;
|
||
}
|
||
</style>
|