This commit is contained in:
2026-10-27 14:53:13 +07:00
parent 5234646edc
commit eb2d7c54af
8 changed files with 831 additions and 557 deletions
+29 -401
View File
@@ -1,17 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Edit, Pause, Play, Repeat, Reset, Stop } from '@vicons/carbon' import { Edit, Pause, Play, Repeat, Reset, Stop } from '@vicons/carbon'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { import { NButton, NSelect, NSlider, NSpace } from 'naive-ui'
NAlert,
NButton,
NInput,
NPopconfirm,
NSelect,
NSlider,
NSpace,
NUpload,
type UploadFileInfo,
} from 'naive-ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import type { import type {
@@ -19,7 +9,7 @@ import type {
Playlist, Playlist,
PlaylistGroup, PlaylistGroup,
} from '@/api/generated/crabs/evening_detective_server' } from '@/api/generated/crabs/evening_detective_server'
import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue' import PlaylistEditor from '@/components/PlaylistEditor.vue'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
const props = defineProps<{ const props = defineProps<{
@@ -52,57 +42,14 @@ const emit = defineEmits<{
}>() }>()
const isEditing = ref(false) const isEditing = ref(false)
const selectedId = ref<number | null>(null) const editor = ref<InstanceType<typeof PlaylistEditor>>()
const name = ref('')
const newName = ref('')
const newGroupName = ref('')
const dirtyGroups = ref<Record<number, boolean>>({})
const hasGroupDraft = computed(() => Object.values(dirtyGroups.value).some(Boolean))
const volume = ref(props.music?.volume ?? 100) const volume = ref(props.music?.volume ?? 100)
const originalName = ref('')
const order = ref<number[]>([])
const originalOrder = ref<number[]>([])
const lastSelected = ref<Playlist>()
const selected = computed(
() =>
props.playlists.find((item) => item.ID === selectedId.value) ??
(isEditing.value && hasGroupDraft.value ? lastSelected.value : undefined),
)
const isSelectedMissing = computed(
() => !!selectedId.value && !props.playlists.some((item) => item.ID === selectedId.value),
)
const cachedGroups = ref<PlaylistGroup[]>([])
const editorGroups = computed(() => [
...(selected.value?.groups ?? []),
...cachedGroups.value.filter(
(group) =>
group.ID != null &&
dirtyGroups.value[group.ID] &&
!selected.value?.groups?.some((item) => item.ID === group.ID),
),
])
const options = computed(() => const options = computed(() =>
props.playlists.map((item) => ({ label: item.name, value: item.ID })), props.playlists.map((item) => ({ label: item.name, value: item.ID })),
) )
const isOrderDirty = computed( function finish() {
() => JSON.stringify(order.value) !== JSON.stringify(originalOrder.value), if (editor.value?.confirmDiscard()) isEditing.value = false
) }
const isNameDirty = computed(() => name.value !== originalName.value)
const hasConflict = computed(
() =>
isSelectedMissing.value ||
(!!selected.value &&
JSON.stringify(selected.value.tracks?.map((track) => track.ID) ?? []) !==
JSON.stringify(originalOrder.value)),
)
const sections = computed(() => [
...editorGroups.value.map((group) => ({
...group,
ID: group.ID ?? 0,
endMode: group.endMode ?? 'PLAYLIST_GROUP_END_MODE_REPEAT',
})),
{ ID: 0, name: 'Без группы', endMode: undefined, position: undefined },
])
const playbackSections = computed(() => [ const playbackSections = computed(() => [
...(props.music?.playlist?.groups ?? []).map((group) => ({ ...(props.music?.playlist?.groups ?? []).map((group) => ({
...group, ...group,
@@ -111,22 +58,6 @@ const playbackSections = computed(() => [
})), })),
{ ID: 0, name: 'Без группы', endMode: undefined, position: undefined }, { ID: 0, name: 'Без группы', endMode: undefined, position: undefined },
]) ])
const groupOptions = computed(() =>
sections.value.map((group) => ({ label: group.name, value: group.ID })),
)
const isPlaylistActive = computed(
() =>
selectedId.value === props.music?.playlist?.ID &&
props.music?.playback !== 'MUSIC_PLAYBACK_STOPPED',
)
function isGroupLocked(groupID: number) {
return isPlaylistActive.value && (!props.music?.groupId || props.music.groupId === groupID)
}
function trackIds(groupID: number) {
return order.value.filter(
(id) => (selected.value?.tracks?.find((track) => track.ID === id)?.groupId ?? 0) === groupID,
)
}
function playbackTracks(groupID: number) { function playbackTracks(groupID: number) {
return (props.music?.playlist?.tracks ?? []) return (props.music?.playlist?.tracks ?? [])
.map((track, index) => ({ track, index })) .map((track, index) => ({ track, index }))
@@ -142,21 +73,6 @@ function playGroup(groupID: number) {
playback: 'MUSIC_PLAYBACK_PLAYING', playback: 'MUSIC_PLAYBACK_PLAYING',
}) })
} }
function saveGroup(groupID: number, groupName: string, endMode: PlaylistGroup['endMode']) {
const playlistID = selectedId.value
if (playlistID == null) return
emit('saveGroup', playlistID, groupID, groupName, endMode)
}
function moveGroup(groupID: number, offset: number) {
const ids = selected.value?.groups?.flatMap((group) => (group.ID == null ? [] : [group.ID])) ?? []
const index = ids.indexOf(groupID)
const target = index + offset
if (!selectedId.value || index < 0 || target < 0 || target >= ids.length) return
const moved = ids.splice(index, 1)[0]
if (moved === undefined) return
ids.splice(target, 0, moved)
emit('reorderGroups', selectedId.value, ids)
}
function modeLabel(mode: PlaylistGroup['endMode']) { function modeLabel(mode: PlaylistGroup['endMode']) {
if (mode === 'PLAYLIST_GROUP_END_MODE_STOP') return 'После группы — тишина' if (mode === 'PLAYLIST_GROUP_END_MODE_STOP') return 'После группы — тишина'
if (mode === 'PLAYLIST_GROUP_END_MODE_NEXT') return 'После группы — следующая' if (mode === 'PLAYLIST_GROUP_END_MODE_NEXT') return 'После группы — следующая'
@@ -173,46 +89,6 @@ const playbackLabel = computed(() =>
) )
const currentTrack = computed(() => props.music?.playlist?.tracks?.[props.music.trackIndex ?? 0]) const currentTrack = computed(() => props.music?.playlist?.tracks?.[props.music.trackIndex ?? 0])
function reset() {
if (!selected.value) selectedId.value = null
name.value = selected.value?.name ?? ''
originalName.value = name.value
order.value =
selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
originalOrder.value = [...order.value]
newGroupName.value = ''
}
function edit() {
selectedId.value = props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? null
lastSelected.value = selected.value
cachedGroups.value = selected.value?.groups ?? []
reset()
isEditing.value = true
}
function finish() {
if (
(isOrderDirty.value || isNameDirty.value || hasGroupDraft.value || newGroupName.value.trim()) &&
!window.confirm('Отменить несохранённые изменения плейлиста?')
)
return
reset()
isEditing.value = false
dirtyGroups.value = {}
}
function move(id: number, groupID: number, offset: number) {
const ids = trackIds(groupID)
const index = ids.indexOf(id)
const targetID = ids[index + offset]
if (index < 0 || targetID == null) return
const from = order.value.indexOf(id)
const to = order.value.indexOf(targetID)
order.value[from] = targetID
order.value[to] = id
}
function uploadTrack(options: { file: UploadFileInfo }) {
if (selectedId.value && options.file.file)
emit('uploadTrack', selectedId.value, options.file.file)
}
function selectTrack(index: number, groupID: number) { function selectTrack(index: number, groupID: number) {
if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return if (index === (props.music?.trackIndex ?? 0) && groupID === (props.music?.groupId ?? 0)) return
emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 }) emit('setMusic', { trackIndex: index, groupId: groupID, positionMs: 0 })
@@ -223,75 +99,7 @@ watch(
volume.value = value ?? 100 volume.value = value ?? 100
}, },
) )
watch(selectedId, () => {
lastSelected.value = props.playlists.find((item) => item.ID === selectedId.value)
cachedGroups.value = lastSelected.value?.groups ?? []
dirtyGroups.value = {}
reset()
})
watch(
() => props.playlists,
(items, previous) => {
if (!isEditing.value) return
const previousGroups = previous.find((item) => item.ID === selectedId.value)?.groups ?? []
const addedGroup = selected.value?.groups?.find(
(group) => !previousGroups.some((old) => old.ID === group.ID),
)
if (props.isBusy && addedGroup?.name === newGroupName.value.trim()) newGroupName.value = ''
const added = items.find((item) => !previous.some((old) => old.ID === item.ID))
if (
added &&
props.isBusy &&
added.name === newName.value.trim() &&
!isOrderDirty.value &&
!isNameDirty.value &&
!hasGroupDraft.value &&
!newGroupName.value.trim()
) {
selectedId.value = added.ID ?? null
newName.value = ''
reset()
return
}
if (!selected.value) {
if (
isOrderDirty.value ||
isNameDirty.value ||
hasGroupDraft.value ||
newGroupName.value.trim()
)
return
selectedId.value = null
reset()
return
}
const current = items.find((item) => item.ID === selectedId.value)
if (current) {
lastSelected.value = current
cachedGroups.value = [
...(current.groups ?? []),
...cachedGroups.value.filter(
(group) =>
group.ID != null &&
dirtyGroups.value[group.ID] &&
!current.groups?.some((item) => item.ID === group.ID),
),
]
}
if (!isNameDirty.value || name.value.trim() === selected.value.name) {
name.value = selected.value.name ?? ''
originalName.value = name.value
}
const ids =
selected.value.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
if (!isOrderDirty.value || JSON.stringify(ids) === JSON.stringify(order.value)) {
order.value = ids
originalOrder.value = [...ids]
}
},
)
</script> </script>
<template> <template>
<UiBlock class="music-panel" data-testid="music-plan"> <UiBlock class="music-panel" data-testid="music-plan">
<n-space justify="space-between" align="center"> <n-space justify="space-between" align="center">
@@ -303,210 +111,35 @@ watch(
aria-label="Редактировать музыку" aria-label="Редактировать музыку"
title="Редактировать музыку" title="Редактировать музыку"
:disabled="isBusy" :disabled="isBusy"
@click="edit" @click="isEditing = true"
> >
<Icon :size="18"><Edit /></Icon> <Icon :size="18"><Edit /></Icon>
</n-button> </n-button>
<n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button> <n-button v-else size="small" :disabled="isBusy" @click="finish">Готово</n-button>
</n-space> </n-space>
<n-space <PlaylistEditor
v-if="isEditing" v-if="isEditing"
vertical ref="editor"
:wrap-item="false" :playlists="playlists"
class="music-content" :music="music"
data-testid="music-editor" :is-busy="isBusy"
> @set-music="(change) => emit('setMusic', change)"
<n-space :wrap="false"> @save-group="
<n-input (playlistID, groupID, name, endMode) =>
v-model:value="newName" emit('saveGroup', playlistID, groupID, name, endMode)
placeholder="Новый плейлист" "
aria-label="Название нового плейлиста" @delete-group="(playlistID, groupID) => emit('deleteGroup', playlistID, groupID)"
:disabled="isBusy" @reorder-groups="(playlistID, groupIDs) => emit('reorderGroups', playlistID, groupIDs)"
/> @set-track-group="
<n-button (playlistID, trackID, groupID) => emit('setTrackGroup', playlistID, trackID, groupID)
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()" "
@click="emit('createPlaylist', newName.trim())" @create-playlist="(name) => emit('createPlaylist', name)"
>Создать</n-button @rename-playlist="(id, name) => emit('renamePlaylist', id, name)"
> @reorder-tracks="(playlistID, trackIDs) => emit('reorderTracks', playlistID, trackIDs)"
</n-space> @delete-playlist="(id) => emit('deletePlaylist', id)"
<n-select @upload-track="(playlistID, file) => emit('uploadTrack', playlistID, file)"
v-model:value="selectedId" @delete-track="(playlistID, trackID) => emit('deleteTrack', playlistID, trackID)"
:options="options" />
placeholder="Плейлист для редактирования"
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/>
<p v-if="!playlists.length">Плейлистов пока нет.</p>
<n-alert v-if="isSelectedMissing" type="warning">
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
<n-button size="small" :disabled="isBusy" @click="reset"
>Загрузить актуальный список</n-button
>
</n-alert>
<template v-if="selected && selectedId">
<label for="music-playlist-name">Название плейлиста</label>
<n-input
v-model:value="name"
:disabled="isBusy"
:input-props="{ id: 'music-playlist-name' }"
/>
<n-space>
<n-button
size="small"
:disabled="isBusy || !name.trim() || !isNameDirty"
@click="emit('renamePlaylist', selectedId, name.trim())"
>Сохранить название</n-button
>
<n-upload
accept=".mp3,audio/mpeg"
:default-upload="false"
:show-file-list="false"
:disabled="isBusy || isOrderDirty || hasConflict"
@change="uploadTrack"
>
<n-button size="small" :disabled="isBusy || isOrderDirty || hasConflict"
>Добавить MP3</n-button
>
</n-upload>
<n-button
size="small"
:disabled="isBusy || isOrderDirty || hasConflict || !order.length"
@click="
emit('setMusic', {
playlistID: selectedId ?? undefined,
trackIndex: 0,
positionMs: 0,
playback: 'MUSIC_PLAYBACK_STOPPED',
})
"
>Использовать в игре</n-button
>
<n-popconfirm @positive-click="emit('deletePlaylist', selectedId)">
<template #trigger
><n-button
size="small"
type="error"
ghost
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
>Удалить плейлист</n-button
></template
>
Удалить плейлист «{{ selected.name }}»?
</n-popconfirm>
</n-space>
<n-alert v-if="hasConflict" type="warning"
>Порядок или состав треков изменился на сервере. Загрузите актуальный список перед
сохранением.</n-alert
>
<n-space v-if="isOrderDirty || isNameDirty || hasConflict">
<n-button
size="small"
:disabled="isBusy || !isOrderDirty || hasConflict"
@click="emit('reorderTracks', selectedId, [...order])"
>Сохранить порядок</n-button
>
<n-button size="small" :disabled="isBusy" @click="reset">{{
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
}}</n-button>
</n-space>
<n-space :wrap="false">
<n-input
v-model:value="newGroupName"
placeholder="Новая группа"
aria-label="Название новой группы"
:maxlength="255"
:disabled="isBusy"
/>
<n-button
:disabled="isBusy || isOrderDirty || hasConflict || !newGroupName.trim()"
@click="saveGroup(0, newGroupName.trim(), 'PLAYLIST_GROUP_END_MODE_REPEAT')"
>Добавить группу</n-button
>
</n-space>
<p v-if="isPlaylistActive">
Для изменения активной группы и порядка групп остановите музыку. Другие игры также могут
блокировать редактирование.
</p>
<!-- Keep keyed editors outside NSpace's positional item wrappers. -->
<div key="music-groups" class="music-groups">
<section
v-for="(section, groupIndex) in sections"
:key="section.ID"
class="music-group"
:aria-label="section.name"
>
<h4>{{ section.name }}</h4>
<PlaylistGroupEditor
v-if="section.ID"
:group="section"
:is-missing="
isSelectedMissing || !selected.groups?.some((group) => group.ID === section.ID)
"
:is-busy="isBusy || isOrderDirty || hasConflict"
:is-locked="isGroupLocked(section.ID)"
:can-move-up="!isPlaylistActive && groupIndex > 0"
:can-move-down="!isPlaylistActive && groupIndex < (selected.groups?.length ?? 0) - 1"
@save="(groupName, endMode) => saveGroup(section.ID, groupName, endMode)"
@remove="emit('deleteGroup', selectedId, section.ID)"
@dirty="dirtyGroups[section.ID] = $event"
@move="moveGroup(section.ID, $event)"
/>
<p v-if="!trackIds(section.ID).length">Треков пока нет.</p>
<div v-for="(id, index) in trackIds(section.ID)" :key="id" class="track-row">
<span
>{{ index + 1 }}.
{{
selected.tracks?.find((track) => track.ID === id)?.name ??
'Трек удалён на сервере'
}}</span
>
<n-select
:value="section.ID"
:options="groupOptions"
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
:input-props="{
'aria-label': `Группа трека ${selected.tracks?.find((track) => track.ID === id)?.name}`,
}"
@update:value="emit('setTrackGroup', selectedId!, id, $event)"
/>
<n-space>
<n-button
size="small"
:disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0"
:aria-label="`Поднять трек ${index + 1} в группе ${section.name}`"
@click="move(id, section.ID, -1)"
>↑</n-button
>
<n-button
size="small"
:disabled="
isBusy ||
hasConflict ||
isGroupLocked(section.ID) ||
index === trackIds(section.ID).length - 1
"
:aria-label="`Опустить трек ${index + 1} в группе ${section.name}`"
@click="move(id, section.ID, 1)"
>↓</n-button
>
<n-popconfirm @positive-click="emit('deleteTrack', selectedId!, id)">
<template #trigger>
<n-button
size="small"
type="error"
ghost
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
>Удалить</n-button
>
</template>
Удалить трек из плейлиста?
</n-popconfirm>
</n-space>
</div>
</section>
</div>
</template>
</n-space>
<n-space v-else vertical class="music-content"> <n-space v-else vertical class="music-content">
<div class="playlist-picker"> <div class="playlist-picker">
<n-select <n-select
@@ -660,11 +293,6 @@ p {
margin: 0; margin: 0;
} }
.music-groups {
display: grid;
gap: 12px;
}
.music-group { .music-group {
border-top: 1px solid var(--brand-border); border-top: 1px solid var(--brand-border);
display: grid; display: grid;
+19 -156
View File
@@ -33,7 +33,6 @@ import type {
MusicPlaybackState, MusicPlaybackState,
MusicState, MusicState,
Playlist, Playlist,
PlaylistGroup,
Question, Question,
ResultsQuestion, ResultsQuestion,
ScreenCommand, ScreenCommand,
@@ -60,6 +59,7 @@ import TicketCheckModal from '@/components/TicketCheckModal.vue'
import UiBlock from '@/components/UiBlock.vue' import UiBlock from '@/components/UiBlock.vue'
import { useDeviceContext } from '@/composables/useDeviceContext' import { useDeviceContext } from '@/composables/useDeviceContext'
import { useGameState } from '@/composables/useGameState' import { useGameState } from '@/composables/useGameState'
import { usePlaylistActions } from '@/composables/usePlaylistActions'
import { useSimplePolling } from '@/composables/useSimplePolling' import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useUIStore } from '@/stores/ui' import { useUIStore } from '@/stores/ui'
@@ -253,6 +253,24 @@ const music = ref<MusicState>()
const isMusicBusy = ref(false) const isMusicBusy = ref(false)
let musicRequestVersion = 0 let musicRequestVersion = 0
let musicSyncedAt = Date.now() let musicSyncedAt = Date.now()
const {
saveGroup,
deleteGroup,
reorderGroups,
setTrackGroup,
createPlaylist,
renamePlaylist,
reorderTracks,
deletePlaylist,
uploadTrack,
deleteTrack,
} = usePlaylistActions(
isMusicBusy,
() => getGameMusic(true),
() => {
musicRequestVersion += 1
},
)
const isScreenBusy = ref(false) const isScreenBusy = ref(false)
const isScreensLoading = ref(true) const isScreensLoading = ref(true)
const screensError = ref('') const screensError = ref('')
@@ -704,161 +722,6 @@ async function setGameMusic(change: {
} }
} }
async function saveGroup(
playlistID: number,
groupID: number,
name: string,
endMode: PlaylistGroup['endMode'],
) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.SavePlaylistGroup({ playlistID, groupID, name, endMode })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function deleteGroup(playlistID: number, groupID: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.DeletePlaylistGroup({ playlistID, groupID })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function reorderGroups(playlistID: number, groupIDs: number[]) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.ReorderPlaylistGroups({ playlistID, groupIDs })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function setTrackGroup(playlistID: number, trackID: number, groupID: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.SetPlaylistTrackGroup({ playlistID, trackID, groupID })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function createPlaylist(name: string) {
if (isMusicBusy.value || !name.trim()) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.CreatePlaylist({ name: name.trim() })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
} finally {
isMusicBusy.value = false
}
}
async function renamePlaylist(id: number, name: string) {
if (isMusicBusy.value || !name.trim()) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.UpdatePlaylist({ ID: id, name: name.trim() })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось переименовать плейлист')
} finally {
isMusicBusy.value = false
}
}
async function reorderTracks(playlistID: number, trackIDs: number[]) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.ReorderPlaylistTracks({ playlistID, trackIDs })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось сохранить порядок треков')
} finally {
isMusicBusy.value = false
}
}
async function deletePlaylist(id: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.DeletePlaylist({ ID: id })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
} finally {
isMusicBusy.value = false
}
}
async function uploadTrack(playlistID: number, file: File) {
if (isMusicBusy.value) return
if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) {
message.error('Выберите непустой MP3 размером до 64 МиБ')
return
}
isMusicBusy.value = true
musicRequestVersion += 1
try {
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
} finally {
isMusicBusy.value = false
}
}
async function deleteTrack(playlistID: number, id: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
musicRequestVersion += 1
try {
await client.DeletePlaylistTrack({ playlistID, trackID: id })
await getGameMusic(true)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
} finally {
isMusicBusy.value = false
}
}
function uint8ToBase64(data: Uint8Array): string {
let binary = ''
for (let i = 0; i < data.length; i += 0x8000) binary += String.fromCharCode(...data.subarray(i, i + 0x8000))
return btoa(binary)
}
async function unbindScreen(screen: ScreenState) { async function unbindScreen(screen: ScreenState) {
if (isScreenBusy.value) return if (isScreenBusy.value) return
isScreenBusy.value = true isScreenBusy.value = true
+9
View File
@@ -104,6 +104,15 @@ const openMenu = ref(false)
> >
Экран Экран
</button> </button>
<button
v-if="authStore.hasAnyRole(['author', 'organizer'])"
type="button"
class="menu-item-block"
:class="{ active: props.active == 'playlists' }"
@click="router.push('/playlists')"
>
Плейлисты
</button>
<button <button
type="button" type="button"
class="menu-item-block" class="menu-item-block"
+465
View File
@@ -0,0 +1,465 @@
<script setup lang="ts">
import {
NAlert,
NButton,
NInput,
NPopconfirm,
NSelect,
NSpace,
NUpload,
type UploadFileInfo,
} from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import type {
MusicState,
Playlist,
PlaylistGroup,
} from '@/api/generated/crabs/evening_detective_server'
import PlaylistGroupEditor from '@/components/PlaylistGroupEditor.vue'
const props = defineProps<{
playlists: Playlist[]
music?: MusicState
isBusy: boolean
}>()
const emit = defineEmits<{
setMusic: [
change: {
groupId?: number
playlistID?: number
trackIndex?: number
positionMs?: number
playback?: MusicState['playback']
loop?: boolean
volume?: number
},
]
saveGroup: [playlistID: number, groupID: number, name: string, endMode: PlaylistGroup['endMode']]
deleteGroup: [playlistID: number, groupID: number]
reorderGroups: [playlistID: number, groupIDs: number[]]
setTrackGroup: [playlistID: number, trackID: number, groupID: number]
createPlaylist: [name: string]
renamePlaylist: [id: number, name: string]
reorderTracks: [playlistID: number, trackIDs: number[]]
deletePlaylist: [id: number]
uploadTrack: [playlistID: number, file: File]
deleteTrack: [playlistID: number, trackID: number]
}>()
const selectedId = ref<number | null>(props.music?.playlist?.ID ?? props.playlists[0]?.ID ?? null)
const name = ref('')
const newName = ref('')
const newGroupName = ref('')
const dirtyGroups = ref<Record<number, boolean>>({})
const hasGroupDraft = computed(() => Object.values(dirtyGroups.value).some(Boolean))
const originalName = ref('')
const order = ref<number[]>([])
const originalOrder = ref<number[]>([])
const lastSelected = ref(props.playlists.find((item) => item.ID === selectedId.value))
const selected = computed(
() =>
props.playlists.find((item) => item.ID === selectedId.value) ??
(hasGroupDraft.value || isNameDirty.value || isOrderDirty.value || newGroupName.value.trim()
? lastSelected.value
: undefined),
)
const isSelectedMissing = computed(
() => !!selectedId.value && !props.playlists.some((item) => item.ID === selectedId.value),
)
const cachedGroups = ref<PlaylistGroup[]>(lastSelected.value?.groups ?? [])
const editorGroups = computed(() => [
...(selected.value?.groups ?? []),
...cachedGroups.value.filter(
(group) =>
group.ID != null &&
dirtyGroups.value[group.ID] &&
!selected.value?.groups?.some((item) => item.ID === group.ID),
),
])
const options = computed(() =>
props.playlists.map((item) => ({ label: item.name, value: item.ID })),
)
const isOrderDirty = computed(
() => JSON.stringify(order.value) !== JSON.stringify(originalOrder.value),
)
const isNameDirty = computed(() => name.value !== originalName.value)
const hasConflict = computed(
() =>
isSelectedMissing.value ||
(!!selected.value &&
JSON.stringify(selected.value.tracks?.map((track) => track.ID) ?? []) !==
JSON.stringify(originalOrder.value)),
)
const sections = computed(() => [
...editorGroups.value.map((group) => ({
...group,
ID: group.ID ?? 0,
endMode: group.endMode ?? 'PLAYLIST_GROUP_END_MODE_REPEAT',
})),
{ ID: 0, name: 'Без группы', endMode: undefined, position: undefined },
])
const groupOptions = computed(() =>
sections.value.map((group) => ({ label: group.name, value: group.ID })),
)
const isPlaylistActive = computed(
() =>
selectedId.value === props.music?.playlist?.ID &&
props.music?.playback !== 'MUSIC_PLAYBACK_STOPPED',
)
function isGroupLocked(groupID: number) {
return isPlaylistActive.value && (!props.music?.groupId || props.music.groupId === groupID)
}
function trackIds(groupID: number) {
return order.value.filter(
(id) => (selected.value?.tracks?.find((track) => track.ID === id)?.groupId ?? 0) === groupID,
)
}
function saveGroup(groupID: number, groupName: string, endMode: PlaylistGroup['endMode']) {
const playlistID = selectedId.value
if (playlistID == null) return
emit('saveGroup', playlistID, groupID, groupName, endMode)
}
function moveGroup(groupID: number, offset: number) {
const ids = selected.value?.groups?.flatMap((group) => (group.ID == null ? [] : [group.ID])) ?? []
const index = ids.indexOf(groupID)
const target = index + offset
if (!selectedId.value || index < 0 || target < 0 || target >= ids.length) return
const moved = ids.splice(index, 1)[0]
if (moved === undefined) return
ids.splice(target, 0, moved)
emit('reorderGroups', selectedId.value, ids)
}
function reset() {
if (isSelectedMissing.value || !selected.value) selectedId.value = null
name.value = selected.value?.name ?? ''
originalName.value = name.value
order.value =
selected.value?.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
originalOrder.value = [...order.value]
newGroupName.value = ''
}
function move(id: number, groupID: number, offset: number) {
const ids = trackIds(groupID)
const index = ids.indexOf(id)
const targetID = ids[index + offset]
if (index < 0 || targetID == null) return
const from = order.value.indexOf(id)
const to = order.value.indexOf(targetID)
order.value[from] = targetID
order.value[to] = id
}
function uploadTrack(options: { file: UploadFileInfo }) {
if (selectedId.value && options.file.file)
emit('uploadTrack', selectedId.value, options.file.file)
}
watch(selectedId, () => {
lastSelected.value = props.playlists.find((item) => item.ID === selectedId.value)
cachedGroups.value = lastSelected.value?.groups ?? []
dirtyGroups.value = {}
reset()
})
watch(
() => props.playlists,
(items, previous) => {
const previousGroups = previous.find((item) => item.ID === selectedId.value)?.groups ?? []
const addedGroup = selected.value?.groups?.find(
(group) => !previousGroups.some((old) => old.ID === group.ID),
)
if (props.isBusy && addedGroup?.name === newGroupName.value.trim()) newGroupName.value = ''
const added = items.find((item) => !previous.some((old) => old.ID === item.ID))
if (
added &&
props.isBusy &&
added.name === newName.value.trim() &&
!isOrderDirty.value &&
!isNameDirty.value &&
!hasGroupDraft.value &&
!newGroupName.value.trim()
) {
selectedId.value = added.ID ?? null
newName.value = ''
reset()
return
}
if (!selected.value) {
if (
isOrderDirty.value ||
isNameDirty.value ||
hasGroupDraft.value ||
newGroupName.value.trim()
)
return
selectedId.value = null
reset()
return
}
const current = items.find((item) => item.ID === selectedId.value)
if (current) {
lastSelected.value = current
cachedGroups.value = [
...(current.groups ?? []),
...cachedGroups.value.filter(
(group) =>
group.ID != null &&
dirtyGroups.value[group.ID] &&
!current.groups?.some((item) => item.ID === group.ID),
),
]
}
if (!isNameDirty.value || name.value.trim() === selected.value.name) {
name.value = selected.value.name ?? ''
originalName.value = name.value
}
const ids =
selected.value.tracks?.flatMap((track) => (track.ID == null ? [] : [track.ID])) ?? []
if (!isOrderDirty.value || JSON.stringify(ids) === JSON.stringify(order.value)) {
order.value = ids
originalOrder.value = [...ids]
}
},
)
function confirmDiscard() {
return (
!(
isOrderDirty.value ||
isNameDirty.value ||
hasGroupDraft.value ||
newGroupName.value.trim() ||
newName.value.trim()
) || window.confirm('Отменить несохранённые изменения плейлиста?')
)
}
onBeforeRouteLeave(confirmDiscard)
defineExpose({ confirmDiscard })
reset()
</script>
<template>
<n-space vertical :wrap-item="false" class="music-content" data-testid="music-editor">
<n-space :wrap="false">
<n-input
v-model:value="newName"
placeholder="Новый плейлист"
aria-label="Название нового плейлиста"
:disabled="isBusy"
/>
<n-button
:disabled="isBusy || hasGroupDraft || !!newGroupName.trim() || !newName.trim()"
@click="emit('createPlaylist', newName.trim())"
>Создать</n-button
>
</n-space>
<n-select
v-model:value="selectedId"
:options="options"
placeholder="Плейлист для редактирования"
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
:input-props="{ 'aria-label': 'Плейлист для редактирования' }"
/>
<p v-if="!playlists.length">Плейлистов пока нет.</p>
<n-alert v-if="isSelectedMissing" type="warning">
Плейлист удалён на сервере. Несохранённый черновик сохранён до отмены.
<n-button size="small" :disabled="isBusy" @click="reset"
>Загрузить актуальный список</n-button
>
</n-alert>
<template v-if="selected && selectedId">
<label for="music-playlist-name">Название плейлиста</label>
<n-input
v-model:value="name"
:disabled="isBusy"
:input-props="{ id: 'music-playlist-name' }"
/>
<n-space>
<n-button
size="small"
:disabled="isBusy || !name.trim() || !isNameDirty"
@click="emit('renamePlaylist', selectedId, name.trim())"
>Сохранить название</n-button
>
<n-upload
accept=".mp3,audio/mpeg"
:default-upload="false"
:show-file-list="false"
:disabled="isBusy || isOrderDirty || hasConflict"
@change="uploadTrack"
>
<n-button size="small" :disabled="isBusy || isOrderDirty || hasConflict"
>Добавить MP3</n-button
>
</n-upload>
<n-button
v-if="music"
size="small"
:disabled="isBusy || isOrderDirty || hasConflict || !order.length"
@click="
emit('setMusic', {
playlistID: selectedId ?? undefined,
trackIndex: 0,
positionMs: 0,
playback: 'MUSIC_PLAYBACK_STOPPED',
})
"
>Использовать в игре</n-button
>
<n-popconfirm @positive-click="emit('deletePlaylist', selectedId)">
<template #trigger
><n-button
size="small"
type="error"
ghost
:disabled="isBusy || isOrderDirty || isNameDirty || hasGroupDraft"
>Удалить плейлист</n-button
></template
>
Удалить плейлист «{{ selected.name }}»?
</n-popconfirm>
</n-space>
<n-alert v-if="hasConflict" type="warning"
>Порядок или состав треков изменился на сервере. Загрузите актуальный список перед
сохранением.</n-alert
>
<n-space v-if="isOrderDirty || isNameDirty || hasConflict">
<n-button
size="small"
:disabled="isBusy || !isOrderDirty || hasConflict"
@click="emit('reorderTracks', selectedId, [...order])"
>Сохранить порядок</n-button
>
<n-button size="small" :disabled="isBusy" @click="reset">{{
hasConflict ? 'Загрузить актуальный список' : 'Отменить изменения'
}}</n-button>
</n-space>
<n-space :wrap="false">
<n-input
v-model:value="newGroupName"
placeholder="Новая группа"
aria-label="Название новой группы"
:maxlength="255"
:disabled="isBusy"
/>
<n-button
:disabled="isBusy || isOrderDirty || hasConflict || !newGroupName.trim()"
@click="saveGroup(0, newGroupName.trim(), 'PLAYLIST_GROUP_END_MODE_REPEAT')"
>Добавить группу</n-button
>
</n-space>
<p v-if="isPlaylistActive">
Для изменения активной группы и порядка групп остановите музыку. Другие игры также могут
блокировать редактирование.
</p>
<!-- Keep keyed editors outside NSpace's positional item wrappers. -->
<div key="music-groups" class="music-groups">
<section
v-for="(section, groupIndex) in sections"
:key="section.ID"
class="music-group"
:aria-label="section.name"
>
<h4>{{ section.name }}</h4>
<PlaylistGroupEditor
v-if="section.ID"
:group="section"
:is-missing="
isSelectedMissing || !selected.groups?.some((group) => group.ID === section.ID)
"
:is-busy="isBusy || isOrderDirty || hasConflict"
:is-locked="isGroupLocked(section.ID)"
:can-move-up="!isPlaylistActive && groupIndex > 0"
:can-move-down="!isPlaylistActive && groupIndex < (selected.groups?.length ?? 0) - 1"
@save="(groupName, endMode) => saveGroup(section.ID, groupName, endMode)"
@remove="emit('deleteGroup', selectedId, section.ID)"
@dirty="dirtyGroups[section.ID] = $event"
@move="moveGroup(section.ID, $event)"
/>
<p v-if="!trackIds(section.ID).length">Треков пока нет.</p>
<div v-for="(id, index) in trackIds(section.ID)" :key="id" class="track-row">
<span
>{{ index + 1 }}.
{{
selected.tracks?.find((track) => track.ID === id)?.name ?? 'Трек удалён на сервере'
}}</span
>
<n-select
:value="section.ID"
:options="groupOptions"
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
:input-props="{
'aria-label': `Группа трека ${selected.tracks?.find((track) => track.ID === id)?.name}`,
}"
@update:value="emit('setTrackGroup', selectedId!, id, $event)"
/>
<n-space>
<n-button
size="small"
:disabled="isBusy || hasConflict || isGroupLocked(section.ID) || index === 0"
:aria-label="`Поднять трек ${index + 1} в группе ${section.name}`"
@click="move(id, section.ID, -1)"
>↑</n-button
>
<n-button
size="small"
:disabled="
isBusy ||
hasConflict ||
isGroupLocked(section.ID) ||
index === trackIds(section.ID).length - 1
"
:aria-label="`Опустить трек ${index + 1} в группе ${section.name}`"
@click="move(id, section.ID, 1)"
>↓</n-button
>
<n-popconfirm @positive-click="emit('deleteTrack', selectedId!, id)">
<template #trigger>
<n-button
size="small"
type="error"
ghost
:disabled="isBusy || isOrderDirty || hasConflict || isGroupLocked(section.ID)"
>Удалить</n-button
>
</template>
Удалить трек из плейлиста?
</n-popconfirm>
</n-space>
</div>
</section>
</div>
</template>
</n-space>
</template>
<style scoped>
h4,
p {
margin: 0;
}
.music-groups {
display: grid;
gap: 12px;
}
.music-group {
border-top: 1px solid var(--brand-border);
display: grid;
gap: 8px;
padding-top: 12px;
}
.music-content {
margin-top: 12px;
}
.track-row {
align-items: center;
border: 1px solid var(--brand-border);
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
overflow-wrap: anywhere;
padding: 12px;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { NAlert, NButton, NConfigProvider, NSpin, ruRU } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { getAuthClient } from '@/api/auth_client'
import type { Playlist } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import PlaylistEditor from '@/components/PlaylistEditor.vue'
import { usePlaylistActions } from '@/composables/usePlaylistActions'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const client = getAuthClient()
const canManage = computed(() => auth.hasAnyRole(['author', 'organizer']))
const playlists = ref<Playlist[]>([])
const isLoading = ref(false)
const isBusy = ref(false)
const hasLoaded = ref(false)
const loadError = ref('')
let requestVersion = 0
async function loadPlaylists() {
if (!canManage.value) return
const version = ++requestVersion
isLoading.value = true
loadError.value = ''
try {
const response = await client.GetPlaylists({})
if (version !== requestVersion) return
playlists.value = response.playlists ?? []
hasLoaded.value = true
} catch (error) {
if (version === requestVersion)
loadError.value = error instanceof Error ? error.message : 'Не удалось загрузить плейлисты'
} finally {
if (version === requestVersion) isLoading.value = false
}
}
const {
saveGroup,
deleteGroup,
reorderGroups,
setTrackGroup,
createPlaylist,
renamePlaylist,
reorderTracks,
deletePlaylist,
uploadTrack,
deleteTrack,
} = usePlaylistActions(isBusy, loadPlaylists)
watch(
() => auth.user,
() => {
requestVersion += 1
playlists.value = []
hasLoaded.value = false
isLoading.value = false
loadError.value = ''
void loadPlaylists()
},
{ immediate: true },
)
</script>
<template>
<HeaderMenu active="playlists" />
<div class="center-block-custom">
<n-config-provider :locale="ruRU">
<main class="width1200 playlists-page">
<h1>Плейлисты</h1>
<n-alert v-if="!auth.isAuthenticated" type="warning"
>Войдите как автор или организатор для работы с плейлистами.</n-alert
>
<n-spin v-else-if="!auth.user && !auth.error" />
<n-alert v-else-if="!canManage" type="warning"
>Нет доступа к плейлистам. Нужна роль автора или организатора.</n-alert
>
<template v-else>
<n-alert v-if="loadError" type="error">
{{ loadError }}
<n-button :disabled="isBusy || isLoading" @click="loadPlaylists"
>Повторить загрузку</n-button
>
</n-alert>
<n-spin v-if="isLoading && !hasLoaded" />
<PlaylistEditor
v-if="hasLoaded"
:playlists="playlists"
:is-busy="isBusy || isLoading"
@save-group="saveGroup"
@delete-group="deleteGroup"
@reorder-groups="reorderGroups"
@set-track-group="setTrackGroup"
@create-playlist="createPlaylist"
@rename-playlist="renamePlaylist"
@reorder-tracks="reorderTracks"
@delete-playlist="deletePlaylist"
@upload-track="uploadTrack"
@delete-track="deleteTrack"
/>
</template>
</main>
</n-config-provider>
</div>
</template>
<style scoped>
.playlists-page {
font-family: 'font_old_typer', serif;
margin: 24px auto;
max-width: 100%;
}
</style>
+182
View File
@@ -0,0 +1,182 @@
import { useMessage } from 'naive-ui'
import type { Ref } from 'vue'
import { getAuthClient } from '@/api/auth_client'
import type { PlaylistGroup } from '@/api/generated/crabs/evening_detective_server'
export function usePlaylistActions(
isMusicBusy: Ref<boolean>,
onSaved: () => Promise<void>,
onBeforeSave: () => void = () => {},
) {
const client = getAuthClient()
const message = useMessage()
async function saveGroup(
playlistID: number,
groupID: number,
name: string,
endMode: PlaylistGroup['endMode'],
) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.SavePlaylistGroup({ playlistID, groupID, name, endMode })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function deleteGroup(playlistID: number, groupID: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.DeletePlaylistGroup({ playlistID, groupID })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function reorderGroups(playlistID: number, groupIDs: number[]) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.ReorderPlaylistGroups({ playlistID, groupIDs })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function setTrackGroup(playlistID: number, trackID: number, groupID: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.SetPlaylistTrackGroup({ playlistID, trackID, groupID })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить группы музыки')
} finally {
isMusicBusy.value = false
}
}
async function createPlaylist(name: string) {
if (isMusicBusy.value || !name.trim()) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.CreatePlaylist({ name: name.trim() })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось создать плейлист')
} finally {
isMusicBusy.value = false
}
}
async function renamePlaylist(id: number, name: string) {
if (isMusicBusy.value || !name.trim()) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.UpdatePlaylist({ ID: id, name: name.trim() })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось переименовать плейлист')
} finally {
isMusicBusy.value = false
}
}
async function reorderTracks(playlistID: number, trackIDs: number[]) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.ReorderPlaylistTracks({ playlistID, trackIDs })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось сохранить порядок треков')
} finally {
isMusicBusy.value = false
}
}
async function deletePlaylist(id: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.DeletePlaylist({ ID: id })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить плейлист')
} finally {
isMusicBusy.value = false
}
}
async function uploadTrack(playlistID: number, file: File) {
if (isMusicBusy.value) return
if (!/\.mp3$/i.test(file.name) || file.size === 0 || file.size > 64 * 1024 * 1024) {
message.error('Выберите непустой MP3 размером до 64 МиБ')
return
}
isMusicBusy.value = true
onBeforeSave()
try {
const data = uint8ToBase64(new Uint8Array(await file.arrayBuffer()))
await client.UploadPlaylistTrack({ playlistID, name: file.name, filename: file.name, data })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось загрузить MP3')
} finally {
isMusicBusy.value = false
}
}
async function deleteTrack(playlistID: number, id: number) {
if (isMusicBusy.value) return
isMusicBusy.value = true
onBeforeSave()
try {
await client.DeletePlaylistTrack({ playlistID, trackID: id })
await onSaved()
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить трек')
} finally {
isMusicBusy.value = false
}
}
function uint8ToBase64(data: Uint8Array): string {
let binary = ''
for (let i = 0; i < data.length; i += 0x8000)
binary += String.fromCharCode(...data.subarray(i, i + 0x8000))
return btoa(binary)
}
return {
saveGroup,
deleteGroup,
reorderGroups,
setTrackGroup,
createPlaylist,
renamePlaylist,
reorderTracks,
deletePlaylist,
uploadTrack,
deleteTrack,
}
}
+5
View File
@@ -3,6 +3,11 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{
path: '/playlists',
name: 'playlists',
component: () => import('../views/PlaylistsView.vue'),
},
...(import.meta.env.DEV ...(import.meta.env.DEV
? [ ? [
{ {
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import PlaylistsPage from '@/components/PlaylistsPage.vue'
</script>
<template>
<PlaylistsPage />
</template>