Files
evening_detective_frontend/src/components/ScenariosEditorPage.vue
T
2026-10-01 14:20:30 +07:00

1017 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { Settings } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
import {
NAlert,
NCard,
NFlex,
NInput,
NModal,
NSpace,
NTag,
NText,
NUpload,
NUploadDragger,
} from 'naive-ui'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import { getBinaryClient } from '@/api/binary_client'
import type { Conclusion, Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue'
import QuestionBlock from '@/components/QuestionBlock.vue'
import router from '@/router'
import { useAuthStore } from '@/stores/auth'
import type {
EditableApplication,
EditableIntroduction,
EditablePlace,
EditableQuestion,
} from '@/types/editor'
import { applicationFileURL } from '@/utils/application'
const authStore = useAuthStore()
const client = getAuthClient()
const archiveClient = getBinaryClient()
const route = useRoute()
const scenarioId = route.params.id
const showSettingsModal = ref(false)
const statusScenarioName = ref('')
const deletedScenarioName = ref('')
const isDownloadingArchive = ref(false)
const message = useMessage()
const fileList = ref<UploadFileInfo[]>([])
const scenario = ref<Scenario>({
ID: undefined,
name: undefined,
story: undefined,
author: undefined,
status: undefined,
updatedAt: undefined,
createdAt: undefined,
publishedAt: undefined,
averageRating: undefined,
reviewsCount: undefined,
})
const backgroundFiles = ref<UploadFileInfo[]>([])
const savingBackground = ref(false)
const intro = ref<EditableIntroduction>({ text: '', audio: '' })
const introMode = ref('show-editor')
const conclusion = ref<Conclusion>({ text: '', video: '' })
const conclusionFiles = ref<UploadFileInfo[]>([])
const savingConclusion = ref(false)
const conclusionVideoURL = computed(() => applicationFileURL(conclusion.value.video))
function resetConclusion() {
conclusion.value = { text: '', video: '', ...scenario.value.story?.conclusion }
conclusionFiles.value = []
}
function removeConclusionVideo() {
conclusion.value.video = ''
conclusionFiles.value = []
}
async function updateConclusion(remove = false) {
savingConclusion.value = true
try {
let video = toRawFilename(conclusion.value.video)
if (!remove && conclusionFiles.value.length) {
const file = conclusionFiles.value[0].file
if (!file) return
const uploaded = await uploadFile(
file,
'scenarios_' + scenario.value.ID + '_conclusion_' + file.name,
)
if (!uploaded.filename) return
if (uploaded.fileType !== 'video') {
message.error('Закрывашка должна содержать видео MP4 или WebM')
return
}
video = uploaded.filename
}
const saved = remove ? { text: '', video: '' } : { text: conclusion.value.text, video }
const res = await client.UpdateScenarioConclusion({ ID: scenario.value.ID, conclusion: saved })
if (res.error) {
message.error(res.error!)
return
}
scenario.value.story!.conclusion = saved
resetConclusion()
message.success(remove ? 'Закрывашка удалена' : 'Закрывашка сохранена')
} catch {
message.error('Не удалось сохранить закрывашку')
} finally {
savingConclusion.value = false
}
}
// Точки сценария со служебными полями редактора (oldCode, mode, fileList)
const editorPlaces = computed<EditablePlace[]>(() => {
return (scenario.value.story?.places || []) as EditablePlace[]
})
const scrollContainer = ref<HTMLElement | null>(null)
const placeCards = new Map<EditablePlace, HTMLElement>()
function navigateToPlace(code: string | undefined) {
const place = code
? editorPlaces.value.find((place) => (place.oldCode ?? place.code) === code)
: undefined
const card = place && placeCards.get(place)
const container = scrollContainer.value
if (!card || !container) {
message.error(code ? `Точка «${code}» не найдена` : 'У действия не указан код точки')
return
}
card.focus({ preventScroll: true })
container.scrollTo({
top: container.scrollTop + card.getBoundingClientRect().top - container.getBoundingClientRect().top - 10,
behavior: 'smooth',
})
}
// Вопросы дела со служебными полями редактора (oldCode, mode)
const editorQuestions = computed<EditableQuestion[]>(() => {
return (scenario.value.story?.questions || []) as EditableQuestion[]
})
async function getScenario(id: number) {
const res = await client.GetFullScenario({ ID: id })
if (res.error != '') {
message.error(res.error!)
return
}
scenario.value = res.scenario!
resetConclusion()
scenario.value.story?.places?.forEach((item) => {
const editable = item as EditablePlace
editable.oldCode = editable.code
editable.mode = 'show-editor'
})
scenario.value.story?.questions?.forEach((item) => {
const editable = item as EditableQuestion
editable.oldCode = editable.code
editable.mode = 'show-editor'
})
intro.value = scenario.value.story?.introduction || { text: '', audio: '' }
introMode.value = 'show-editor'
}
getScenario(Number(scenarioId))
async function deleteScenario(id: number) {
const res = await client.DeleteScenario({ ID: id })
if (res.error != '') {
message.error(res.error!)
return
}
router.push('/scenarios')
}
async function publicScenario(id: number) {
const res = await client.PublicScenario({ ID: id })
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
statusScenarioName.value = ''
showSettingsModal.value = false
}
async function addGame(name: string, id: number) {
const now = new Date().toISOString()
const resAddGame = await client.AddGame({
name: ('Тестовая игра: ' + name).trim(),
description: 'Тестовая игра',
location: '',
format: 'online',
startAt: now,
scenarioID: id,
})
if (resAddGame.error != '') {
message.error(resAddGame.error!)
return
}
const resAddTeam = await client.AddTeam({
name: 'Тестовая команда',
captainEmail: '',
gameID: resAddGame.ID!,
})
if (resAddTeam.error != '') {
message.error(resAddTeam.error!)
return
}
router.push('/team-story/' + resAddTeam.ID! + '?password=' + resAddTeam.password!)
}
async function draftScenario(id: number) {
const res = await client.DraftScenario({ ID: id })
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
statusScenarioName.value = ''
showSettingsModal.value = false
}
async function downloadArchive() {
isDownloadingArchive.value = true
try {
const { blob, filename } = await archiveClient.downloadScenarioArchive(scenario.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()
// Отзываем URL после того как браузер начал скачивание
setTimeout(() => URL.revokeObjectURL(url), 1000)
message.success('Архив сценария скачан')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось скачать архив')
} finally {
isDownloadingArchive.value = false
}
}
type UploadFileResult = {
filename: string
fileType: string
}
async function uploadFile(file: File | null, filename: string): Promise<UploadFileResult> {
if (!file) {
return { filename: '', fileType: '' }
}
// Читаем как ArrayBuffer
const arrayBuffer = await file.arrayBuffer()
const bytes = new Uint8Array(arrayBuffer)
const resUploadFile = await client.UploadFile({
filename: filename,
data: uint8ToBase64(bytes),
})
if (resUploadFile.error != '') {
message.error(resUploadFile.error!)
return { filename: '', fileType: '' }
}
// file_type возвращает сервер по содержимому файла (image | pdf | audio | video) —
// редактор сохраняет его на улике.
return {
filename: resUploadFile.filename || '',
fileType: resUploadFile.file_type || '',
}
}
async function updateScenario() {
let resUploadFilename = ''
if (fileList.value && fileList.value.length > 0) {
const file = fileList.value[0].file || null
if (file == null) {
return
}
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.ID + '_' + file.name)
if (uploaded.filename === '') {
return
}
resUploadFilename = uploaded.filename
}
const res = await client.UpdateScenario({
ID: scenario.value.ID,
name: scenario.value.name,
description: scenario.value.description,
image: resUploadFilename,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
showSettingsModal.value = false
}
function uint8ToBase64(uint8Array: Uint8Array) {
// Преобразуем Uint8Array в двоичную строку
const binaryString = fromCharCodeSafe(uint8Array)
// Кодируем в Base64
return btoa(binaryString)
}
function fromCharCodeSafe(arr: Uint8Array) {
let result = ''
for (let i = 0; i < arr.length; i += 50000) {
const chunk = arr.slice(i, i + 50000)
result += String.fromCharCode(...chunk)
}
return result
}
const code = ref('')
const name = ref('')
const text = ref('')
const mode = ref('mini-add')
async function addPlace() {
const res = await client.AddScenarioPlace({
ID: scenario.value.ID,
place: {
code: code.value,
name: name.value,
text: text.value,
image: undefined,
hidden: undefined,
applications: undefined,
doors: undefined,
keys: undefined,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
code.value = ''
name.value = ''
text.value = ''
mode.value = 'add'
}
async function cancelAddPlace() {
code.value = ''
name.value = ''
text.value = ''
mode.value = 'mini-add'
}
async function addNotFoundDoors(scenario: Scenario, place: EditablePlace) {
for (const door of place.doors || []) {
const place = scenario.story?.places?.find((place) => {
return place.code == door.code
})
if (place == undefined) {
const res = await client.AddScenarioPlace({
ID: scenario.ID,
place: {
code: door.code,
name: door.name,
text: undefined,
image: undefined,
hidden: true,
applications: undefined,
doors: undefined,
keys: undefined,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
}
}
}
async function cancelUpdatePlace(place: EditablePlace) {
await getScenario(Number(scenarioId))
place.mode = 'show-editor'
}
async function updatePlace(place: EditablePlace) {
if (place.fileList && place.fileList.length > 0) {
const file = place.fileList[0].file || null
if (file == null) {
return
}
const uploaded = await uploadFile(
file,
'scenarios_' + scenario.value.ID + '_place_' + place.code + '_' + file.name,
)
if (uploaded.filename === '') {
return
}
place.image = uploaded.filename
}
// Загружаем файлы улик и проставляем file_type из ответа сервера.
const applications = (place.applications || []) as EditableApplication[]
for (const application of applications) {
if (!application.fileList || application.fileList.length == 0) {
continue
}
const file = application.fileList[0].file || null
if (file == null) {
continue
}
const uploaded = await uploadFile(
file,
'scenarios_' + scenario.value.ID + '_place_' + place.code + '_app_' + file.name,
)
if (uploaded.filename === '') {
return
}
application.image = uploaded.filename
application.file_type = uploaded.fileType
}
await addNotFoundDoors(scenario.value, place)
const res = await client.UpdateScenarioPlace({
ID: scenario.value.ID,
code: place.oldCode,
place: place,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
place.mode = 'show-editor'
}
async function deletePlace(place: EditablePlace) {
const res = await client.DeleteScenarioPlace({
ID: scenario.value.ID,
code: place.oldCode,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
}
const newQuestionCode = ref('')
const newQuestionText = ref('')
const newQuestionAnswer = ref('')
const questionMode = ref('mini-add')
async function addQuestion() {
const res = await client.AddScenarioQuestion({
ID: scenario.value.ID,
question: {
code: newQuestionCode.value,
text: newQuestionText.value,
answer: newQuestionAnswer.value,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
newQuestionCode.value = ''
newQuestionText.value = ''
newQuestionAnswer.value = ''
questionMode.value = 'add'
}
async function cancelAddQuestion() {
newQuestionCode.value = ''
newQuestionText.value = ''
newQuestionAnswer.value = ''
questionMode.value = 'mini-add'
}
async function updateQuestion(question: EditableQuestion) {
const res = await client.UpdateScenarioQuestion({
ID: scenario.value.ID,
code: question.oldCode,
question: {
code: question.code,
text: question.text,
answer: question.answer,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
question.mode = 'show-editor'
}
async function cancelUpdateQuestion(question: EditableQuestion) {
await getScenario(Number(scenarioId))
question.mode = 'show-editor'
}
async function deleteQuestion(question: EditableQuestion) {
const res = await client.DeleteScenarioQuestion({
ID: scenario.value.ID,
code: question.oldCode,
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
}
// Бэкенд хранит аудио вступления как имя файла, а отдаёт полным URL
// (домен + имя). При сохранении отправляем только имя файла.
function toRawFilename(value: string | undefined): string {
if (!value) {
return ''
}
return value.split('/').pop() || ''
}
async function updateIntro() {
let audio = ''
// NUpload кладёт выбранный файл в intro.fileList (поле объекта вступления)
if (intro.value.fileList && intro.value.fileList.length > 0) {
const file = intro.value.fileList[0].file || null
if (file == null) {
return
}
audio = (await uploadFile(file, 'scenarios_' + scenario.value.ID + '_intro_' + file.name))
.filename
if (audio === '') {
return
}
} else {
audio = toRawFilename(intro.value.audio)
}
const res = await client.UpdateScenarioIntro({
ID: scenario.value.ID,
introduction: {
text: intro.value.text,
audio: audio,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
}
async function updateBackground(remove = false) {
savingBackground.value = true
try {
let background = ''
if (!remove) {
const file = backgroundFiles.value[0]?.file
if (!file) return
const uploaded = await uploadFile(
file,
'scenarios_' + scenario.value.ID + '_background_' + file.name,
)
if (!uploaded.filename) return
if (uploaded.fileType !== 'image') {
message.error('Фон дела должен быть изображением')
return
}
background = uploaded.filename
}
const res = await client.UpdateScenarioBackground({ ID: scenario.value.ID, background })
if (res.error) {
message.error(res.error)
return
}
await getScenario(Number(scenarioId))
backgroundFiles.value = []
} catch {
message.error('Не удалось сохранить фон дела')
} finally {
savingBackground.value = false
}
}
async function cancelUpdateIntro() {
await getScenario(Number(scenarioId))
}
function getRandomComplimentForNastya() {
const compliments = [
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
{ text: 'У Насти невероятный вкус!', nameForm: 'Насти' },
{ text: 'Настенька, твоя улыбка делает мир ярче.', nameForm: 'Настенька' },
{ text: 'С Настей всегда интересно общаться.', nameForm: 'Настей' },
{ text: 'Настя, ты — источник вдохновения для всех вокруг.', nameForm: 'Настя' },
{ text: 'Мне нравится, как Настя смотрит на мир.', nameForm: 'Настя' },
{ text: 'Настюха, ты супер!', nameForm: 'Настюха' },
{ text: 'Талант Насти поражает!', nameForm: 'Насти' },
{ text: 'Настя, твоя энергия заряжает батарейки!', nameForm: 'Настя' },
{ text: 'С Настей хочется проводить каждую минуту.', nameForm: 'Настей' },
{ text: 'Настенька, ты — самое красивое, что случилось в этом мире.', nameForm: 'Настенька' },
{ text: 'У Насти золотое сердце.', nameForm: 'Насти' },
]
const randomIndex = Math.floor(Math.random() * compliments.length)
return compliments[randomIndex].text
}
</script>
<template>
<div ref="scrollContainer" class="center-block-custom">
<div class="width700">
<div class="places-container">
<n-alert
v-if="scenario.status == 'public'"
title="Сценарий опубликован"
type="warning"
style="margin-bottom: 20px"
>
Не рекомендуется редактировать сценарий когда он опубликован.
</n-alert>
<n-alert
v-if="authStore.user?.ID == 2"
:title="getRandomComplimentForNastya()"
type="info"
style="margin-bottom: 20px"
></n-alert>
<div class="settings-block">
Точек: {{ scenario.story?.places?.length }}, Вопросов:
{{ scenario.story?.questions?.length }}
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
<Icon class="settings-icon">
<Settings />
</Icon>
Настройки
</n-button>
</div>
<n-card title="Фон дела">
<n-space vertical>
<img
v-if="scenario.story?.background"
:src="scenario.story.background"
alt="Фон дела"
class="background-preview"
/>
<n-upload
v-model:file-list="backgroundFiles"
accept=".jpg,.jpeg,.png,.gif,.webp"
:max="1"
:default-upload="false"
:disabled="savingBackground"
>
<n-button :disabled="savingBackground">Выбрать фон</n-button>
</n-upload>
<n-flex>
<n-button
:loading="savingBackground"
:disabled="!backgroundFiles.length || savingBackground"
@click="updateBackground()"
>Сохранить фон</n-button
>
<n-button
v-if="scenario.story?.background"
:disabled="savingBackground"
@click="updateBackground(true)"
>Удалить фон</n-button
>
</n-flex>
</n-space>
</n-card>
<IntroBlock :introduction="intro" v-model:mode="introMode">
<n-flex justify="end">
<n-button @click="cancelUpdateIntro()">Отмена</n-button>
<n-button @click="updateIntro()">Сохранить вступление</n-button>
</n-flex>
</IntroBlock>
<n-card title="Закрывашка сценария">
<n-space vertical>
<n-text depth="3">Разгадка дела для автора. Игрокам не показывается.</n-text>
<n-input
v-model:value="conclusion.text"
type="textarea"
:autosize="{ minRows: 4, maxRows: 12 }"
placeholder="Текст закрывашки"
aria-label="Текст закрывашки"
:disabled="savingConclusion"
/>
<video
v-if="conclusionVideoURL"
:src="conclusionVideoURL"
controls
preload="none"
class="conclusion-video"
aria-label="Видео закрывашки"
></video>
<n-button
v-if="conclusion.video"
:disabled="savingConclusion"
@click="removeConclusionVideo()"
>Убрать видео</n-button
>
<n-upload
v-model:file-list="conclusionFiles"
accept=".mp4,.webm"
:max="1"
:default-upload="false"
:disabled="savingConclusion"
>
<n-button :disabled="savingConclusion">Выбрать видео MP4/WebM</n-button>
</n-upload>
<n-flex justify="end">
<n-button
v-if="scenario.story?.conclusion?.text || scenario.story?.conclusion?.video"
:disabled="savingConclusion"
@click="updateConclusion(true)"
>Удалить закрывашку</n-button
>
<n-button :disabled="savingConclusion" @click="resetConclusion()">Отмена</n-button>
<n-button
:loading="savingConclusion"
:disabled="savingConclusion || !scenario.story"
@click="updateConclusion()"
>Сохранить закрывашку</n-button
>
</n-flex>
</n-space>
</n-card>
<div class="questions-header">Вопросы дела</div>
<QuestionBlock
v-model:mode="questionMode"
v-model:newQuestionCode="newQuestionCode"
v-model:newQuestionText="newQuestionText"
v-model:newQuestionAnswer="newQuestionAnswer"
>
<n-flex justify="end">
<n-button @click="cancelAddQuestion()">Отмена</n-button>
<n-button @click="addQuestion()" :disabled="!newQuestionCode">Добавить вопрос</n-button>
</n-flex>
</QuestionBlock>
<QuestionBlock
:question="question"
v-model:mode="question.mode"
v-for="question in editorQuestions"
v-bind:key="question.oldCode ?? question.code ?? ''"
>
<n-flex justify="end">
<n-button @click="deleteQuestion(question)" type="error" ghost>Удалить</n-button>
<n-button @click="cancelUpdateQuestion(question)">Отмена</n-button>
<n-button @click="updateQuestion(question)" :disabled="question.code == ''"
>Сохранить</n-button
>
</n-flex>
</QuestionBlock>
<PlaceBlock
v-model:mode="mode"
v-model:newPlaceCode="code"
v-model:newPlaceName="name"
v-model:newPlaceText="text"
>
<n-flex justify="end">
<n-button @click="cancelAddPlace()">Отмена</n-button>
<n-button @click="addPlace()" :disabled="!code">Добавить точку</n-button>
</n-flex>
</PlaceBlock>
<div
v-for="place in editorPlaces"
:key="place.oldCode ?? place.code ?? ''"
:ref="(el) => el ? placeCards.set(place, el as HTMLElement) : placeCards.delete(place)"
class="place-card"
tabindex="-1"
role="group"
:aria-label="`${place.code}: ${place.name}`"
>
<PlaceBlock :place="place" v-model:mode="place.mode" @navigate="navigateToPlace">
<n-flex justify="end">
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
<n-button @click="updatePlace(place)" :disabled="place.code == ''">Сохранить</n-button>
</n-flex>
</PlaceBlock>
</div>
</div>
</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="showSettingsModal = false"> x </n-button>
</template>
<n-space vertical>
<p>Название</p>
<n-input
v-model:value="scenario.name"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<p class="settings-header">Описание</p>
<n-input
v-model:value="scenario.description"
type="textarea"
placeholder="Убийство пастуха..."
/>
<p class="settings-header">Картинка</p>
<n-input
v-model:value="scenario.image"
type="text"
placeholder="default_scenario.png"
disabled
/>
<n-upload directory-dnd :max="1" v-model:file-list="fileList">
<n-upload-dragger>
<n-text style="font-size: 16px">
Щелкните или перетащите файл в эту область для загрузки
</n-text>
</n-upload-dragger>
</n-upload>
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
<hr class="settings-hr" />
<p class="settings-header">Архив сценария</p>
<p class="settings-hint">
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом
аккаунте.
</p>
<n-button
:loading="isDownloadingArchive"
:disabled="!scenario.ID"
ghost
@click="downloadArchive()"
>
Скачать архив (.zip)
</n-button>
<hr class="settings-hr" />
<p>Создать тестовую игру</p>
<n-button @click="addGame(scenario.name || '', scenario.ID!)" ghost>
Создать игру
</n-button>
<hr class="settings-hr" />
<p>Публикация</p>
<p>
<n-tag
v-if="scenario.status == 'public'"
:bordered="false"
type="success"
class="status-block"
>
Опубликовано
</n-tag>
<n-tag
v-if="scenario.status == 'draft'"
:bordered="false"
type="warning"
class="status-block"
>
В разработке
</n-tag>
</p>
<p>
Введите название сценария <n-text class="name-text" strong>{{ scenario.name }}</n-text>
</p>
<n-input
v-model:value="statusScenarioName"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<n-button
v-if="scenario.status != 'public'"
@click="publicScenario(scenario.ID!)"
ghost
:disabled="statusScenarioName != scenario.name"
>
Опубликовать сценарий
</n-button>
<n-button
v-if="scenario.status != 'draft'"
@click="draftScenario(scenario.ID!)"
ghost
:disabled="statusScenarioName != scenario.name"
>
Снять сценарий с публикации
</n-button>
<hr class="settings-hr" />
<p>
Введите название сценария
<n-text class="name-text" strong>{{ scenario.name }}</n-text> чтобы удалить его
</p>
<n-input
v-model:value="deletedScenarioName"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<n-button
@click="deleteScenario(scenario.ID!)"
type="error"
ghost
:disabled="deletedScenarioName != scenario.name"
>
Удалить сценарий
</n-button>
</n-space>
<template #footer> </template>
</n-card>
</n-modal>
<HeaderMenu active="scenarios" />
</template>
<style scoped>
.place-card {
--place-block-margin: 0;
border-radius: 10px;
margin: 10px 0;
}
.place-card:focus {
outline: 2px solid #63e2b7;
}
.conclusion-video {
width: 100%;
}
.background-preview {
max-height: 200px;
max-width: 100%;
object-fit: contain;
}
.settings-block {
height: 40px;
/* background-color: brown; */
}
.settings-button {
float: right;
}
.settings-icon {
width: 20px;
}
.name-text {
background-color: rgb(255 255 255 / 10%);
border-radius: 3px;
padding: 3px;
}
.settings-header {
margin-top: 20px;
}
.settings-hint {
color: #999;
margin-bottom: 10px;
}
.settings-hr {
margin: 20px 0;
}
.questions-header {
color: #aaa;
font-size: 18px;
margin: 30px 0 5px;
}
.places-container {
display: flex;
flex-flow: column nowrap;
}
</style>