Queue component enhancements
This commit provides following queue component enhancements: - Use a virtual list to render queue items. We now render ~25-30 items at once compared to `queue.length` items. (Fix #1471) - Faster queue opening and a smoother open animation. (Fix #1471) - Faster song enqueueing: When enqueueing a playlist or an album, we modify the state only once compared to `tracks.length` modifications before. - When opening the queue, current track is now already pre-scrolled and centered. This resolves an issue with big queues, where the track list was slowly scrolling to a random position. - Dragging a track onto the edge of the track list will now scroll the container. You reorder a track from the very top to the very bottom even when you have 3000 tracks in the queue! - Ability to use scroll wheel while reordering track items - Track reordering on mobile devices! Currently dragging to edge is disabled - Responsivity fixes - Allow click outside modal (Fix #1581)
This commit is contained in:
parent
53d9015e17
commit
c87bf7e6b8
|
@ -18,27 +18,26 @@
|
|||
"postinstall": "yarn run fix-fomantic-css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/runtime-core": "^3.2.37",
|
||||
"@vue/runtime-core": "3.2.37",
|
||||
"@vueuse/core": "8.9.4",
|
||||
"@vueuse/integrations": "8.9.4",
|
||||
"axios": "0.27.2",
|
||||
"axios-auth-refresh": "3.3.1",
|
||||
"diff": "5.1.0",
|
||||
"dompurify": "^2.3.8",
|
||||
"dompurify": "2.3.8",
|
||||
"focus-trap": "6.9.4",
|
||||
"fomantic-ui-css": "2.8.8",
|
||||
"howler": "2.2.3",
|
||||
"js-logger": "1.6.1",
|
||||
"lodash-es": "4.17.21",
|
||||
"moment": "2.29.4",
|
||||
"pinia": "^2.0.13",
|
||||
"qs": "6.11.0",
|
||||
"register-service-worker": "1.7.2",
|
||||
"sanitize-html": "2.7.1",
|
||||
"sass": "1.53.0",
|
||||
"showdown": "2.1.0",
|
||||
"text-clipper": "2.2.0",
|
||||
"transliteration": "^2.3.5",
|
||||
"transliteration": "2.3.5",
|
||||
"vue": "3.2.37",
|
||||
"vue-gettext": "2.1.12",
|
||||
"vue-plyr": "7.0.0",
|
||||
|
@ -47,34 +46,35 @@
|
|||
"vue-upload-component": "3.1.2",
|
||||
"vue3-gettext": "2.3.0",
|
||||
"vue3-lazyload": "0.3.5",
|
||||
"vue3-virtual-scroll-list": "0.2.0",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuex": "4.0.2",
|
||||
"vuex-persistedstate": "4.1.0",
|
||||
"vuex-router-sync": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^2.3.3",
|
||||
"@types/howler": "^2.2.7",
|
||||
"@types/dompurify": "2.3.3",
|
||||
"@types/howler": "2.2.7",
|
||||
"@types/jest": "28.1.6",
|
||||
"@types/jquery": "3.5.14",
|
||||
"@types/lodash-es": "4.17.6",
|
||||
"@types/qs": "6.9.7",
|
||||
"@types/semantic-ui": "^2.2.7",
|
||||
"@types/showdown": "^2.0.0",
|
||||
"@types/semantic-ui": "2.2.7",
|
||||
"@types/showdown": "2.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.30.7",
|
||||
"@vitejs/plugin-vue": "3.0.1",
|
||||
"@vue/compiler-sfc": "3.2.37",
|
||||
"@vue/eslint-config-standard": "7.0.0",
|
||||
"@vue/eslint-config-typescript": "11.0.0",
|
||||
"@vue/test-utils": "2.0.2",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"@vue/tsconfig": "0.1.3",
|
||||
"chai": "4.3.6",
|
||||
"easygettext": "2.17.0",
|
||||
"eslint": "8.20.0",
|
||||
"eslint-config-standard": "17.0.0",
|
||||
"eslint-plugin-html": "7.0.0",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"eslint-plugin-n": "^15.2.4",
|
||||
"eslint-plugin-n": "15.2.4",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"eslint-plugin-promise": "6.0.0",
|
||||
"eslint-plugin-vue": "9.2.0",
|
||||
|
|
|
@ -104,7 +104,7 @@ if (store.state.auth.authenticated) {
|
|||
<template v-if="Component">
|
||||
<keep-alive :max="1">
|
||||
<Suspense>
|
||||
<component :is="Component" v-show="!store.state.ui.queueFocused" />
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<!-- TODO (wvffle): Add loader -->
|
||||
Loading...
|
||||
|
|
|
@ -1,40 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import type { Track, QueueItemSource } from '~/types'
|
||||
|
||||
import { useStore } from '~/store'
|
||||
import { nextTick, ref, computed, onBeforeMount, onUnmounted } from 'vue'
|
||||
import { nextTick, ref, computed, watchEffect, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import time from '~/utils/time'
|
||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { whenever, watchDebounced } from '@vueuse/core'
|
||||
import { whenever, watchDebounced, useCurrentElement, useScrollLock } from '@vueuse/core'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import useQueue from '~/composables/audio/useQueue'
|
||||
import usePlayer from '~/composables/audio/usePlayer'
|
||||
|
||||
import VirtualList from '~/components/vui/list/VirtualList.vue'
|
||||
import QueueItem from '~/components/QueueItem.vue'
|
||||
|
||||
const queueModal = ref()
|
||||
|
||||
let savedScroll = 0
|
||||
onBeforeMount(() => (savedScroll = window.scrollY))
|
||||
onUnmounted(() => {
|
||||
document.body.parentElement?.setAttribute('style', 'scroll-behavior: auto')
|
||||
window.scrollTo({ top: savedScroll, behavior: undefined })
|
||||
document.body.parentElement?.removeAttribute('style')
|
||||
})
|
||||
|
||||
const { activate } = useFocusTrap(queueModal, { allowOutsideClick: true })
|
||||
activate()
|
||||
|
||||
const store = useStore()
|
||||
const currentIndex = computed(() => store.state.queue.currentIndex)
|
||||
|
||||
const scrollToCurrent = async () => {
|
||||
await nextTick()
|
||||
const item = queueModal.value?.querySelector('.queue-item.active')
|
||||
item?.scrollIntoView({ behavior: store.state.ui.queueFocused ? 'smooth' : 'auto' })
|
||||
}
|
||||
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
|
||||
|
||||
const { $pgettext } = useGettext()
|
||||
const scrollLock = useScrollLock(document.body)
|
||||
const store = useStore()
|
||||
|
||||
const {
|
||||
playing,
|
||||
|
@ -55,16 +42,15 @@ const {
|
|||
hasNext,
|
||||
isEmpty: emptyQueue,
|
||||
tracks,
|
||||
reorder: reorderTracks,
|
||||
reorder,
|
||||
endsIn: timeLeft,
|
||||
currentIndex,
|
||||
removeTrack,
|
||||
clear,
|
||||
next,
|
||||
previous
|
||||
} = useQueue()
|
||||
|
||||
const reorder = (event: { oldIndex: number, newIndex: number }) => reorderTracks(event.oldIndex, event.newIndex)
|
||||
|
||||
const labels = computed(() => ({
|
||||
queue: $pgettext('*/*/*', 'Queue'),
|
||||
duration: $pgettext('*/*/*', 'Duration'),
|
||||
|
@ -73,13 +59,42 @@ const labels = computed(() => ({
|
|||
previous: $pgettext('*/*/*', 'Previous track'),
|
||||
next: $pgettext('*/*/*', 'Next track'),
|
||||
pause: $pgettext('*/*/*', 'Pause'),
|
||||
play: $pgettext('*/*/*', 'Play'),
|
||||
remove: $pgettext('*/*/*', 'Remove'),
|
||||
selectTrack: $pgettext('*/*/*', 'Select track')
|
||||
play: $pgettext('*/*/*', 'Play')
|
||||
}))
|
||||
|
||||
watchDebounced(() => store.state.ui.queueFocused, scrollToCurrent, { debounce: 400 })
|
||||
whenever(currentTrack, scrollToCurrent, { immediate: true })
|
||||
watchEffect(async () => {
|
||||
scrollLock.value = !!store.state.ui.queueFocused
|
||||
if (store.state.ui.queueFocused) {
|
||||
await nextTick()
|
||||
activate()
|
||||
} else {
|
||||
deactivate()
|
||||
}
|
||||
})
|
||||
|
||||
const list = ref()
|
||||
const el = useCurrentElement()
|
||||
const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => {
|
||||
const item = el.value?.querySelector('.queue-item.active')
|
||||
item?.scrollIntoView({
|
||||
behavior,
|
||||
block: 'center'
|
||||
})
|
||||
}
|
||||
|
||||
watchDebounced(currentTrack, () => scrollToCurrent(), { debounce: 100 })
|
||||
whenever(
|
||||
() => store.state.ui.queueFocused,
|
||||
() => {
|
||||
list.value?.scrollToIndex(currentIndex.value)
|
||||
setTimeout(() => scrollToCurrent('auto'), 1)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
list.value?.scrollToIndex(currentIndex.value)
|
||||
})
|
||||
|
||||
whenever(
|
||||
() => tracks.value.length === 0,
|
||||
|
@ -96,19 +111,49 @@ const touchProgress = (event: MouseEvent) => {
|
|||
currentTime.value = time
|
||||
}
|
||||
|
||||
const play = (index: number) => {
|
||||
store.dispatch('queue/currentIndex', index)
|
||||
const play = (index: unknown) => {
|
||||
store.dispatch('queue/currentIndex', index as number)
|
||||
resume()
|
||||
}
|
||||
|
||||
const getCover = (track: Track) => {
|
||||
return store.getters['instance/absoluteUrl'](
|
||||
track.cover?.urls.medium_square_crop
|
||||
?? track.album?.cover?.urls.medium_square_crop
|
||||
?? new URL('../assets/audio/default-cover.png', import.meta.url).href
|
||||
)
|
||||
}
|
||||
|
||||
const queueItems = computed(() => tracks.value.map((track, index) => ({
|
||||
id: `${index}-${track.id}`,
|
||||
track,
|
||||
coverUrl: getCover(track),
|
||||
labels: {
|
||||
remove: $pgettext('*/*/*', 'Remove'),
|
||||
selectTrack: $pgettext('*/*/*', 'Select track')
|
||||
},
|
||||
duration: time.durationFormatted(track.uploads[0].duration ?? 0) ?? ''
|
||||
}) as QueueItemSource))
|
||||
|
||||
const reorderTracks = async (from: number, to: number) => {
|
||||
reorder(from, to)
|
||||
|
||||
await nextTick()
|
||||
if (to === currentIndex.value) {
|
||||
scrollToCurrent()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
ref="queueModal"
|
||||
class="main with-background component-queue"
|
||||
:aria-label="labels.queue"
|
||||
>
|
||||
<div id="queue-grid">
|
||||
<div
|
||||
id="queue-grid"
|
||||
:class="store.state.ui.queueFocused && `show-${store.state.ui.queueFocused}`"
|
||||
>
|
||||
<div
|
||||
id="player"
|
||||
class="ui basic segment"
|
||||
|
@ -296,7 +341,7 @@ const play = (index: number) => {
|
|||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<div id="queue">
|
||||
<div class="ui basic clearing segment">
|
||||
<h2 class="ui header">
|
||||
<div class="content">
|
||||
|
@ -336,107 +381,54 @@ const play = (index: number) => {
|
|||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<table class="ui compact very basic fixed single line selectable unstackable table">
|
||||
<draggable
|
||||
v-model="tracks"
|
||||
handle=".handle"
|
||||
item-key="id"
|
||||
tag="tbody"
|
||||
@update="reorder"
|
||||
<virtual-list
|
||||
:list="queueItems"
|
||||
:component="QueueItem"
|
||||
:size="50"
|
||||
:item-class="(index: number) => currentIndex === index ? 'active': ''"
|
||||
data-key="id"
|
||||
@play="play"
|
||||
@remove="removeTrack"
|
||||
@reorder="reorderTracks"
|
||||
/>
|
||||
<!-- <virtual-list
|
||||
ref="list"
|
||||
wrap-class="queue-sortable-container"
|
||||
item-class="queue-sortable-item"
|
||||
:data-key="'id'"
|
||||
:data-sources="queueItems"
|
||||
:data-component="QueueItem"
|
||||
:estimate-size="50"
|
||||
:extra-props="{
|
||||
onPlay: play,
|
||||
onRemove: removeTrack,
|
||||
itemClass: (index: number) => currentIndex === index ? 'active': ''
|
||||
}"
|
||||
@reorder="log"
|
||||
/> -->
|
||||
<div
|
||||
v-if="$store.state.radios.running"
|
||||
class="ui info message"
|
||||
>
|
||||
<div class="content">
|
||||
<h3 class="header">
|
||||
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
|
||||
You have a radio playing
|
||||
</translate>
|
||||
</h3>
|
||||
<p>
|
||||
<translate translate-context="Sidebar/Player/Paragraph">
|
||||
New tracks will be appended here automatically.
|
||||
</translate>
|
||||
</p>
|
||||
<button
|
||||
class="ui basic primary button"
|
||||
@click="$store.dispatch('radios/stop')"
|
||||
>
|
||||
<template #item="{ element: track, index }">
|
||||
<tr
|
||||
:key="track.id"
|
||||
:class="['queue-item', {'active': index === currentIndex}]"
|
||||
>
|
||||
<td class="handle">
|
||||
<i class="grip lines icon" />
|
||||
</td>
|
||||
<td
|
||||
class="image-cell"
|
||||
@click="play(index)"
|
||||
>
|
||||
<img
|
||||
v-if="track.cover && track.cover.urls.original"
|
||||
class="ui mini image"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.album && track.album.cover && track.album.cover.urls.original"
|
||||
class="ui mini image"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
class="ui mini image"
|
||||
alt=""
|
||||
src="../assets/audio/default-cover.png"
|
||||
>
|
||||
</td>
|
||||
<td
|
||||
colspan="3"
|
||||
@click="play(index)"
|
||||
>
|
||||
<button
|
||||
class="title reset ellipsis"
|
||||
:title="track.title"
|
||||
:aria-label="labels.selectTrack"
|
||||
>
|
||||
<strong>{{ track.title }}</strong><br>
|
||||
<span>
|
||||
{{ track.artist.name }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td class="duration-cell">
|
||||
<template v-if="track.uploads.length > 0">
|
||||
{{ time.durationFormatted(track.uploads[0].duration) }}
|
||||
</template>
|
||||
</td>
|
||||
<td class="controls">
|
||||
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
|
||||
<i class="pink heart icon" />
|
||||
</template>
|
||||
<button
|
||||
:aria-label="labels.remove"
|
||||
:title="labels.remove"
|
||||
:class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']"
|
||||
@click.stop="removeTrack(index)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</draggable>
|
||||
</table>
|
||||
<div
|
||||
v-if="$store.state.radios.running"
|
||||
class="ui info message"
|
||||
>
|
||||
<div class="content">
|
||||
<h3 class="header">
|
||||
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
|
||||
You have a radio playing
|
||||
</translate>
|
||||
</h3>
|
||||
<p>
|
||||
<translate translate-context="Sidebar/Player/Paragraph">
|
||||
New tracks will be appended here automatically.
|
||||
</translate>
|
||||
</p>
|
||||
<button
|
||||
class="ui basic primary button"
|
||||
@click="$store.dispatch('radios/stop')"
|
||||
>
|
||||
<translate translate-context="*/Player/Button.Label/Short, Verb">
|
||||
Stop radio
|
||||
</translate>
|
||||
</button>
|
||||
</div>
|
||||
<translate translate-context="*/Player/Button.Label/Short, Verb">
|
||||
Stop radio
|
||||
</translate>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
<script setup functional lang="ts">
|
||||
import type { QueueItemSource } from '~/types'
|
||||
|
||||
interface Props {
|
||||
source: QueueItemSource
|
||||
index: number
|
||||
itemClass: (index: number) => string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'play', index: number): void
|
||||
(e: 'remove', index: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="itemClass(index)"
|
||||
class="queue-item"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="handle">
|
||||
<i class="grip lines icon" />
|
||||
</div>
|
||||
<div
|
||||
class="image-cell"
|
||||
@click="$emit('play', index)"
|
||||
>
|
||||
<img
|
||||
class="ui mini image"
|
||||
alt=""
|
||||
:src="source.coverUrl"
|
||||
>
|
||||
</div>
|
||||
<div @click="$emit('play', index)">
|
||||
<button
|
||||
class="title reset ellipsis"
|
||||
:title="source.track.title"
|
||||
:aria-label="source.labels.selectTrack"
|
||||
>
|
||||
<strong>{{ source.track.title }}</strong><br>
|
||||
<span>
|
||||
{{ source.track.artist.name }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="duration-cell">
|
||||
<template v-if="source.track.uploads.length > 0">
|
||||
{{ source.duration }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<template v-if="$store.getters['favorites/isFavorite'](source.track.id)">
|
||||
<i class="pink heart icon" />
|
||||
</template>
|
||||
<button
|
||||
:aria-label="source.labels.remove"
|
||||
:title="source.labels.remove"
|
||||
class="ui really tiny basic circular icon button"
|
||||
@click.stop="$emit('remove', index)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -191,7 +191,7 @@ const touchProgress = (event: MouseEvent) => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls track-controls queue-not-focused tablet-and-below">
|
||||
<div class="controls track-controls queue-not-focused desktop-and-below">
|
||||
<div class="ui tiny image">
|
||||
<img
|
||||
v-if="currentTrack.cover && currentTrack.cover.urls.original"
|
||||
|
@ -370,7 +370,7 @@ const touchProgress = (event: MouseEvent) => {
|
|||
</translate>
|
||||
</button>
|
||||
<button
|
||||
class="position circular control button tablet-and-below"
|
||||
class="position circular control button desktop-and-below"
|
||||
@click.stop="switchTab"
|
||||
>
|
||||
<i class="stream icon" />
|
||||
|
@ -398,21 +398,21 @@ const touchProgress = (event: MouseEvent) => {
|
|||
</button>
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused === 'player'"
|
||||
class="circular control button close-control tablet-and-below"
|
||||
class="circular control button close-control desktop-and-below"
|
||||
@click.stop="switchTab"
|
||||
>
|
||||
<i class="large up angle icon" />
|
||||
</button>
|
||||
<button
|
||||
v-if="$store.state.ui.queueFocused === 'queue'"
|
||||
class="circular control button tablet-and-below"
|
||||
class="circular control button desktop-and-below"
|
||||
@click.stop="switchTab"
|
||||
>
|
||||
<i class="large down angle icon" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="circular control button close-control tablet-and-below"
|
||||
class="circular control button close-control desktop-and-below"
|
||||
@click.stop="$store.commit('ui/queueFocused', null)"
|
||||
>
|
||||
<i class="x icon" />
|
||||
|
|
|
@ -21,7 +21,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
const emit = defineEmits(['approved', 'deny', 'update:show', 'show', 'hide'])
|
||||
|
||||
const modal = ref()
|
||||
const { activate, deactivate, pause, unpause } = useFocusTrap(modal)
|
||||
const { activate, deactivate, pause, unpause } = useFocusTrap(modal, {
|
||||
allowOutsideClick: true
|
||||
})
|
||||
|
||||
const show = useVModel(props, 'show', emit)
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
interface Props {
|
||||
component: unknown
|
||||
index: number
|
||||
dragClassHandler: (index: number) => string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="component"
|
||||
:class="dragClassHandler(index)"
|
||||
:index="index"
|
||||
:data-index="index"
|
||||
/>
|
||||
</template>
|
|
@ -0,0 +1,233 @@
|
|||
<script setup lang="ts">
|
||||
import type { MaybeElementRef, MaybeElement } from '@vueuse/core'
|
||||
|
||||
import { useMouse, useCurrentElement, useResizeObserver, useRafFn, useElementByPoint } from '@vueuse/core'
|
||||
import { ref, watchEffect, reactive } from 'vue'
|
||||
|
||||
import VirtualItem from './VirtualItem.vue'
|
||||
// @ts-expect-error no typings
|
||||
import VirtualList from 'vue3-virtual-scroll-list'
|
||||
|
||||
interface Emits {
|
||||
(e: 'reorder', from: number, to: number): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
list: object[]
|
||||
size: number
|
||||
dataKey: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const ghostContainer = ref()
|
||||
const hoveredIndex = ref()
|
||||
const draggedItem = ref()
|
||||
const position = ref('after')
|
||||
|
||||
const getIndex = (element: HTMLElement) => +(element?.getAttribute('data-index') ?? 0)
|
||||
|
||||
const isTouch = ref(false)
|
||||
const onMousedown = (event: MouseEvent | TouchEvent) => {
|
||||
const element = event.target as HTMLElement
|
||||
const dragItem = element.closest('.drag-item')?.children[0] as HTMLElement
|
||||
if (!dragItem || !element.classList.contains('handle')) return
|
||||
|
||||
// Touch devices stop emitting touch events while container is scrolled
|
||||
isTouch.value = event instanceof TouchEvent
|
||||
|
||||
const ghost = dragItem.cloneNode(true) as HTMLElement
|
||||
ghost.classList.add('drag-ghost')
|
||||
ghostContainer.value.appendChild(ghost)
|
||||
|
||||
const index = getIndex(dragItem)
|
||||
document.body.classList.add('dragging')
|
||||
hoveredIndex.value = index
|
||||
draggedItem.value = {
|
||||
item: props.list[index],
|
||||
ghost,
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
// Touch and mobile devices support
|
||||
const onTouchmove = (event: TouchEvent) => {
|
||||
if (draggedItem.value) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('touchcancel', (event: TouchEvent) => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
const reorder = (event: MouseEvent | TouchEvent) => {
|
||||
const element = event.target as HTMLElement
|
||||
const dragItem = element.closest('.drag-item')?.children[0]
|
||||
|
||||
if (dragItem && draggedItem.value) {
|
||||
const from = draggedItem.value.index
|
||||
let to = hoveredIndex.value
|
||||
|
||||
if (from === to) return cleanup()
|
||||
to -= +(position.value === 'before')
|
||||
to += +(from > to)
|
||||
|
||||
if (from === to) return cleanup()
|
||||
emit('reorder', from, to)
|
||||
}
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
document.addEventListener('mouseup', reorder)
|
||||
document.addEventListener('touchend', reorder)
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.classList.remove('dragging')
|
||||
draggedItem.value?.ghost?.remove()
|
||||
draggedItem.value = undefined
|
||||
hoveredIndex.value = undefined
|
||||
scrollDirection.value = undefined
|
||||
}
|
||||
|
||||
const dragClassHandler = (index: number) => draggedItem.value && hoveredIndex.value === index
|
||||
? `drop-${position.value}`
|
||||
: ''
|
||||
|
||||
const scrollDirection = ref()
|
||||
const containerSize = reactive({ bottom: 0, top: 0 })
|
||||
const { x, y: screenY } = useMouse()
|
||||
const { element: hoveredElement } = useElementByPoint({ x, y: screenY })
|
||||
|
||||
// Find current index and position on both desktop and mobile devices
|
||||
watchEffect(() => {
|
||||
const dragItem = (hoveredElement.value as HTMLElement)?.closest('.drag-item')?.children[0] as HTMLElement
|
||||
if (!dragItem) return
|
||||
|
||||
hoveredIndex.value = getIndex(dragItem)
|
||||
const { y } = dragItem.getBoundingClientRect()
|
||||
position.value = screenY.value - y < props.size / 2 ? 'before' : 'after'
|
||||
})
|
||||
|
||||
// Automatically scroll when on the edge
|
||||
watchEffect(() => {
|
||||
const { top, bottom } = containerSize
|
||||
const y = Math.min(bottom, Math.max(top, screenY.value))
|
||||
|
||||
if (draggedItem.value) {
|
||||
ghostContainer.value.style.top = `${y}px`
|
||||
|
||||
scrollDirection.value = y === top
|
||||
? 'up'
|
||||
: y === bottom
|
||||
? 'down'
|
||||
: undefined
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scrollDirection.value = undefined
|
||||
})
|
||||
|
||||
const el = useCurrentElement()
|
||||
useResizeObserver(el as unknown as MaybeElementRef<MaybeElement>, ([entry]) => {
|
||||
const height = entry.borderBoxSize?.[0]?.blockSize ?? 0
|
||||
|
||||
if (height !== 0) {
|
||||
containerSize.top = (entry.target as HTMLElement).offsetTop
|
||||
containerSize.bottom = height + containerSize.top
|
||||
}
|
||||
})
|
||||
|
||||
let lastDate = +new Date()
|
||||
useRafFn(() => {
|
||||
const now = +new Date()
|
||||
const delta = now - lastDate
|
||||
const direction = scrollDirection.value
|
||||
|
||||
if (direction && el.value?.children[0] && !isTouch.value) {
|
||||
el.value.children[0].scrollTop += 200 / delta * (direction === 'up' ? -1 : 1)
|
||||
}
|
||||
|
||||
lastDate = now
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
cleanup
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<virtual-list
|
||||
class="virtual-list"
|
||||
wrap-class="drag-container"
|
||||
item-class="drag-item"
|
||||
:data-key="dataKey"
|
||||
:data-sources="list"
|
||||
:data-component="VirtualItem"
|
||||
:estimate-size="size"
|
||||
:extra-props="{
|
||||
dragClassHandler,
|
||||
...$attrs
|
||||
}"
|
||||
@mousedown="onMousedown"
|
||||
@touchstart="onMousedown"
|
||||
@touchmove="onTouchmove"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="ghostContainer"
|
||||
class="ghost-container"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.drag-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
user-select: none;
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.drop-before {
|
||||
box-shadow: 0 -1px 0 var(--vibrant-color),
|
||||
inset 0 1px 0 var(--vibrant-color);
|
||||
}
|
||||
|
||||
.drop-after {
|
||||
box-shadow: 0 1px 0 var(--vibrant-color),
|
||||
inset 0 -1px 0 var(--vibrant-color);
|
||||
}
|
||||
|
||||
.drag-ghost {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ghost-container {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 1002;
|
||||
width: 100%;
|
||||
transform: translateY(-50%);
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0.8;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .ghost-container {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
|
@ -3,7 +3,7 @@ import type { ContentFilter } from '~/store/moderation'
|
|||
|
||||
import { useStore } from '~/store'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, markRaw, ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
import usePlayer from '~/composables/audio/usePlayer'
|
||||
import useQueue from '~/composables/audio/useQueue'
|
||||
|
@ -129,7 +129,7 @@ export default (props: PlayOptionsProps) => {
|
|||
// TODO (wvffle): It was behind 250ms timeout, why?
|
||||
isLoading.value = false
|
||||
|
||||
return tracks.filter(track => track.uploads?.length)
|
||||
return tracks.filter(track => track.uploads?.length).map(markRaw)
|
||||
}
|
||||
|
||||
const el = useCurrentElement()
|
||||
|
@ -137,7 +137,8 @@ export default (props: PlayOptionsProps) => {
|
|||
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
||||
|
||||
const tracks = await getPlayableTracks()
|
||||
store.dispatch('queue/appendMany', { tracks }).then(() => addMessage(tracks))
|
||||
await store.dispatch('queue/appendMany', { tracks })
|
||||
addMessage(tracks)
|
||||
}
|
||||
|
||||
const enqueueNext = async (next = false) => {
|
||||
|
|
|
@ -100,7 +100,7 @@ const currentTime = computed({
|
|||
return
|
||||
}
|
||||
|
||||
if (!currentSound.value.getSource() || time === currentSound.value.seek()) {
|
||||
if (!currentSound.value?.getSource() || time === currentSound.value.seek()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -28,20 +28,13 @@ const focused = computed(() => store.state.ui.queueFocused === 'queue')
|
|||
//
|
||||
// Track list
|
||||
//
|
||||
const tracksChangeBuffer = ref<Track[] | null>(null)
|
||||
const tracks = computed<Track[]>({
|
||||
get: () => store.state.queue.tracks,
|
||||
set: (value) => (tracksChangeBuffer.value = value)
|
||||
})
|
||||
const tracks = computed<Track[]>(() => store.state.queue.tracks)
|
||||
|
||||
const reorder = (oldIndex: number, newIndex: number) => {
|
||||
store.commit('queue/reorder', {
|
||||
tracks: tracksChangeBuffer.value ?? tracks.value,
|
||||
oldIndex,
|
||||
newIndex
|
||||
})
|
||||
|
||||
tracksChangeBuffer.value = null
|
||||
}
|
||||
|
||||
//
|
||||
|
|
|
@ -38,21 +38,23 @@ const store: Module<State, RootState> = {
|
|||
tracks (state, value) {
|
||||
state.tracks = value
|
||||
},
|
||||
insert (state, { track, index }) {
|
||||
state.tracks.splice(index, 0, track)
|
||||
},
|
||||
reorder (state, { tracks, oldIndex, newIndex }) {
|
||||
reorder (state, { oldIndex, newIndex }) {
|
||||
// called when the user uses drag / drop to reorder
|
||||
// tracks in queue
|
||||
state.tracks = tracks
|
||||
|
||||
const [track] = state.tracks.splice(oldIndex, 1)
|
||||
state.tracks.splice(newIndex, 0, track)
|
||||
|
||||
if (oldIndex === state.currentIndex) {
|
||||
state.currentIndex = newIndex
|
||||
return
|
||||
}
|
||||
|
||||
if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) {
|
||||
// item before was moved after
|
||||
state.currentIndex -= 1
|
||||
}
|
||||
|
||||
if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) {
|
||||
// item after was moved before
|
||||
state.currentIndex += 1
|
||||
|
@ -72,41 +74,32 @@ const store: Module<State, RootState> = {
|
|||
isEmpty: state => state.tracks.length === 0
|
||||
},
|
||||
actions: {
|
||||
append ({ commit, state }, { track, index }) {
|
||||
index = index || state.tracks.length
|
||||
if (index > state.tracks.length - 1) {
|
||||
// we simply push to the end
|
||||
commit('insert', { track, index: state.tracks.length })
|
||||
} else {
|
||||
// we insert the track at given position
|
||||
commit('insert', { track, index })
|
||||
}
|
||||
append ({ dispatch, state }, { track, index = state.tracks.length }) {
|
||||
return dispatch('appendMany', { tracks: [track], index })
|
||||
},
|
||||
|
||||
appendMany ({ state, dispatch }, { tracks, index, callback }) {
|
||||
logger.info('Appending many tracks to the queue', tracks.map((track: Track) => track.title))
|
||||
let shouldPlay = false
|
||||
appendMany ({ state, dispatch }, { tracks, index = state.tracks.length }) {
|
||||
logger.info(
|
||||
'Enqueueing tracks',
|
||||
tracks.map((track: Track) => [track.artist?.name, track.title].join(' - '))
|
||||
)
|
||||
|
||||
if (state.tracks.length === 0) {
|
||||
const shouldPlay = state.tracks.length === 0
|
||||
if (shouldPlay) {
|
||||
index = 0
|
||||
shouldPlay = true
|
||||
} else {
|
||||
index = index ?? state.tracks.length
|
||||
}
|
||||
|
||||
const total = tracks.length
|
||||
tracks.forEach((track: Track, i: number) => {
|
||||
const promise = dispatch('append', { track, index })
|
||||
index += 1
|
||||
if (index >= state.tracks.length) {
|
||||
// we simply push to the end
|
||||
state.tracks.push(...tracks)
|
||||
} else {
|
||||
// we insert the track at given position
|
||||
state.tracks.splice(index, 0, ...tracks)
|
||||
}
|
||||
|
||||
if (callback && i + 1 === total) {
|
||||
promise.then(callback)
|
||||
}
|
||||
|
||||
if (shouldPlay && promise && i + 1 === total) {
|
||||
promise.then(() => dispatch('next'))
|
||||
}
|
||||
})
|
||||
if (shouldPlay) {
|
||||
return dispatch('next')
|
||||
}
|
||||
},
|
||||
|
||||
cleanTrack ({ state, dispatch, commit }, index) {
|
||||
|
|
|
@ -235,8 +235,23 @@
|
|||
display: grid;
|
||||
grid-template-columns: 37.5% 62.5%;
|
||||
|
||||
#queue {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@include media("<desktop") {
|
||||
grid-template-columns: 1fr 0;
|
||||
&.show-player {
|
||||
#queue {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.show-queue {
|
||||
#player {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#player {
|
||||
|
@ -246,6 +261,11 @@
|
|||
justify-content: center;
|
||||
text-align: center;
|
||||
|
||||
.ui.header {
|
||||
width: 100%;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.cover-container {
|
||||
width: 50vh;
|
||||
max-width: 90%;
|
||||
|
@ -282,10 +302,43 @@
|
|||
grid-template-rows: auto 1fr;
|
||||
|
||||
> :nth-child(2) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.virtual-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.queue-item {
|
||||
height: 50px;
|
||||
display: grid;
|
||||
grid-template-columns: 10% auto 1fr 10% auto;
|
||||
cursor: pointer;
|
||||
padding: 0 0.875rem;
|
||||
|
||||
.handle > .grip {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// NOTE: Taken from semantic ui
|
||||
&.active {
|
||||
background: #E0E0E0;
|
||||
color: #000000de;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drag-container:not(.dragging) .queue-item:hover {
|
||||
background: rgba(0,0,0,.05);
|
||||
color: #000000f2;
|
||||
}
|
|
@ -18,6 +18,19 @@ export interface InitModuleContext {
|
|||
|
||||
export type InitModule = (ctx: InitModuleContext) => void | Promise<void>
|
||||
|
||||
export interface QueueItemSource {
|
||||
id: string
|
||||
track: Track
|
||||
duration: string
|
||||
coverUrl: string
|
||||
|
||||
// TODO (wvffle): Maybe use <translate> component to avoid passing the labels
|
||||
labels: {
|
||||
remove: string
|
||||
selectTrack: string
|
||||
}
|
||||
}
|
||||
|
||||
// Theme stuff
|
||||
export type Theme = 'auto' | 'light' | 'dark'
|
||||
|
||||
|
|
|
@ -21,8 +21,8 @@ export default {
|
|||
? `${hours}:${pad(min)}:${pad(sec)}`
|
||||
: `${min}:${pad(sec)}`
|
||||
},
|
||||
durationFormatted (v: string) {
|
||||
const duration = parseInt(v)
|
||||
durationFormatted (v: string | number) {
|
||||
const duration = typeof v === 'number' ? v : parseInt(v)
|
||||
return this.parse(duration % 1 !== 0 ? 0 : Math.round(duration))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1363,10 +1363,10 @@
|
|||
dependencies:
|
||||
"@babel/types" "^7.3.0"
|
||||
|
||||
"@types/dompurify@^2.3.3":
|
||||
version "2.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4"
|
||||
integrity sha512-EXzDatIb5EspL2eb/xPGmaC8pePcTHrkDCONjeisusLFrVfl38Pjea/R0YJGu3k9ZQadSvMqW0WXPI2hEo2Ajg==
|
||||
"@types/dompurify@2.3.3":
|
||||
version "2.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.3.tgz#c24c92f698f77ed9cc9d9fa7888f90cf2bfaa23f"
|
||||
integrity sha512-nnVQSgRVuZ/843oAfhA25eRSNzUFcBPk/LOiw5gm8mD9/X7CNcbRkQu/OsjCewO8+VIYfPxUnXvPEVGenw14+w==
|
||||
dependencies:
|
||||
"@types/trusted-types" "*"
|
||||
|
||||
|
@ -1390,7 +1390,7 @@
|
|||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/howler@^2.2.7":
|
||||
"@types/howler@2.2.7":
|
||||
version "2.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.7.tgz#5acfbed57f9e1d99b8dabe1b824729e1c1ea1fae"
|
||||
integrity sha512-PEZldwZqJJw1PWRTpupyC7ajVTZA8aHd8nB/Y0n6zRZi5u8ktYDntsHj13ltEiBRqWwF06pASxBEvCTxniG8eA==
|
||||
|
@ -1632,7 +1632,7 @@
|
|||
dependencies:
|
||||
"@types/jquery" "*"
|
||||
|
||||
"@types/semantic-ui@^2.2.7":
|
||||
"@types/semantic-ui@2.2.7":
|
||||
version "2.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/semantic-ui/-/semantic-ui-2.2.7.tgz#4ae4242004aac11a21133d4f338e868b92605270"
|
||||
integrity sha512-Uj6rby2GnuVyO7pj8vgUFsv5eaxb0ktpfasYcB/vXnSAeJ4cRjIOvxka+EoPjw3tPCY4/WlxRss8hsh7kRWzQg==
|
||||
|
@ -1659,7 +1659,7 @@
|
|||
"@types/semantic-ui-transition" "*"
|
||||
"@types/semantic-ui-visibility" "*"
|
||||
|
||||
"@types/showdown@^2.0.0":
|
||||
"@types/showdown@2.0.0":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.0.tgz#3e800eca8573848cac4e5555f4377ba3a0e7b1f2"
|
||||
integrity sha512-70xBJoLv+oXjB5PhtA8vo7erjLDp9/qqI63SRHm4REKrwuPOLs8HhXwlZJBJaB4kC18cCZ1UUZ6Fb/PLFW4TCA==
|
||||
|
@ -1989,7 +1989,7 @@
|
|||
"@vue/compiler-dom" "3.2.38"
|
||||
"@vue/shared" "3.2.38"
|
||||
|
||||
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4", "@vue/devtools-api@^6.2.1":
|
||||
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4":
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.2.1.tgz#6f2948ff002ec46df01420dfeff91de16c5b4092"
|
||||
integrity sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==
|
||||
|
@ -2041,7 +2041,7 @@
|
|||
dependencies:
|
||||
"@vue/shared" "3.2.37"
|
||||
|
||||
"@vue/reactivity@3.2.38", "@vue/reactivity@^3.2.37":
|
||||
"@vue/reactivity@^3.2.37":
|
||||
version "3.2.38"
|
||||
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e"
|
||||
integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==
|
||||
|
@ -2056,14 +2056,6 @@
|
|||
"@vue/reactivity" "3.2.37"
|
||||
"@vue/shared" "3.2.37"
|
||||
|
||||
"@vue/runtime-core@^3.2.37":
|
||||
version "3.2.38"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.38.tgz#d19cf591c210713f80e6a94ffbfef307c27aea06"
|
||||
integrity sha512-kk0qiSiXUU/IKxZw31824rxmFzrLr3TL6ZcbrxWTKivadoKupdlzbQM4SlGo4MU6Zzrqv4fzyUasTU1jDoEnzg==
|
||||
dependencies:
|
||||
"@vue/reactivity" "3.2.38"
|
||||
"@vue/shared" "3.2.38"
|
||||
|
||||
"@vue/runtime-dom@3.2.37":
|
||||
version "3.2.37"
|
||||
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz#002bdc8228fa63949317756fb1e92cdd3f9f4bbd"
|
||||
|
@ -2096,7 +2088,7 @@
|
|||
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.2.tgz#0b5edd683366153d5bc5a91edc62f292118710eb"
|
||||
integrity sha512-E2P4oXSaWDqTZNbmKZFVLrNN/siVN78YkEqs7pHryWerrlZR9bBFLWdJwRoguX45Ru6HxIflzKl4vQvwRMwm5g==
|
||||
|
||||
"@vue/tsconfig@^0.1.3":
|
||||
"@vue/tsconfig@0.1.3":
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz#4a61dbd29783d01ddab504276dcf0c2b6988654f"
|
||||
integrity sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==
|
||||
|
@ -3113,10 +3105,10 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
|
|||
dependencies:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^2.3.8:
|
||||
version "2.4.0"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd"
|
||||
integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==
|
||||
dompurify@2.3.8:
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.8.tgz#224fe9ae57d7ebd9a1ae1ac18c1c1ca3f532226f"
|
||||
integrity sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw==
|
||||
|
||||
domutils@^2.5.2:
|
||||
version "2.8.0"
|
||||
|
@ -3484,18 +3476,18 @@ eslint-plugin-import@2.26.0:
|
|||
resolve "^1.22.0"
|
||||
tsconfig-paths "^3.14.1"
|
||||
|
||||
eslint-plugin-n@^15.2.4:
|
||||
version "15.2.5"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.5.tgz#aa7ff8d45bb8bf2df8ea3b7d3774ae570cb794b8"
|
||||
integrity sha512-8+BYsqiyZfpu6NXmdLOXVUfk8IocpCjpd8nMRRH0A9ulrcemhb2VI9RSJMEy5udx++A/YcVPD11zT8hpFq368g==
|
||||
eslint-plugin-n@15.2.4:
|
||||
version "15.2.4"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.4.tgz#d62021a0821ae650701ed459756aaf478a9b6056"
|
||||
integrity sha512-tjnVMv2fiXYMnuiIFI8QMtyUFI42SckEEWvi8h68SWGWshfqO6SSCASy24dGMGAiy7NUk6DZt90DM0iNUsmQ5w==
|
||||
dependencies:
|
||||
builtins "^5.0.1"
|
||||
eslint-plugin-es "^4.1.0"
|
||||
eslint-utils "^3.0.0"
|
||||
ignore "^5.1.1"
|
||||
is-core-module "^2.10.0"
|
||||
is-core-module "^2.9.0"
|
||||
minimatch "^3.1.2"
|
||||
resolve "^1.22.1"
|
||||
resolve "^1.10.1"
|
||||
semver "^7.3.7"
|
||||
|
||||
eslint-plugin-node@11.1.0:
|
||||
|
@ -4220,7 +4212,7 @@ is-callable@^1.1.4, is-callable@^1.2.4:
|
|||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
|
||||
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
|
||||
|
||||
is-core-module@^2.10.0, is-core-module@^2.7.0, is-core-module@^2.8.1, is-core-module@^2.9.0:
|
||||
is-core-module@^2.7.0, is-core-module@^2.8.1, is-core-module@^2.9.0:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
|
||||
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
|
||||
|
@ -5371,14 +5363,6 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatc
|
|||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
pinia@^2.0.13:
|
||||
version "2.0.21"
|
||||
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.21.tgz#2a6599ad3736fa71866f4b053ffb0073cd482270"
|
||||
integrity sha512-6ol04PtL29O0Z6JHI47O3JUSoyOJ7Og0rstXrHVMZSP4zAldsQBXJCNF0i/H7m8vp/Hjd/CSmuPl7C5QAwpeWQ==
|
||||
dependencies:
|
||||
"@vue/devtools-api" "^6.2.1"
|
||||
vue-demi "*"
|
||||
|
||||
pirates@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9"
|
||||
|
@ -6222,7 +6206,7 @@ tr46@^1.0.1:
|
|||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
transliteration@^2.3.5:
|
||||
transliteration@2.3.5:
|
||||
version "2.3.5"
|
||||
resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45"
|
||||
integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==
|
||||
|
@ -6549,6 +6533,11 @@ vue3-lazyload@0.3.5:
|
|||
dependencies:
|
||||
vue-demi "^0.12.5"
|
||||
|
||||
vue3-virtual-scroll-list@0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/vue3-virtual-scroll-list/-/vue3-virtual-scroll-list-0.2.0.tgz#72810f831a66b7b3a4c82924ff655213943450f4"
|
||||
integrity sha512-BPReKBDAVvJ+VgBAHK0qLLhZpOkl/bgB3q+8Do5moc9KUJKFDf8o0/tjAUKaim5ihj254IuJGFyZCUl4aWW/0w==
|
||||
|
||||
vue@3.2.37:
|
||||
version "3.2.37"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e"
|
||||
|
|
Loading…
Reference in New Issue