feat: add sidebar
This commit is contained in:
parent
acfc4a687b
commit
3d7ccf8313
|
@ -24,10 +24,10 @@
|
|||
"@types/jsmediatags": "3.9.6",
|
||||
"@vue/runtime-core": "3.3.11",
|
||||
"@vueuse/components": "10.6.1",
|
||||
"@vueuse/core": "10.3.0",
|
||||
"@vueuse/integrations": "10.3.0",
|
||||
"@vueuse/math": "10.3.0",
|
||||
"@vueuse/router": "10.3.0",
|
||||
"@vueuse/core": "10.6.1",
|
||||
"@vueuse/integrations": "10.6.1",
|
||||
"@vueuse/math": "10.6.1",
|
||||
"@vueuse/router": "10.6.1",
|
||||
"axios": "1.7.2",
|
||||
"axios-auth-refresh": "3.3.6",
|
||||
"butterchurn": "3.0.0-beta.4",
|
||||
|
@ -107,6 +107,7 @@
|
|||
"sinon": "15.0.2",
|
||||
"standardized-audio-context-mock": "9.6.32",
|
||||
"typescript": "5.3.3",
|
||||
"unocss": "0.58.0",
|
||||
"unplugin-vue-macros": "2.4.6",
|
||||
"utility-types": "3.10.0",
|
||||
"vite": "5.2.12",
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import LegacyLayout from '~/LegacyLayout.vue'
|
||||
import UiApp from '~/ui/App.vue'
|
||||
|
||||
import type { QueueTrack } from '~/composables/audio/queue'
|
||||
|
||||
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, watchEffect, defineAsyncComponent } from 'vue'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
|
||||
import { generateTrackCreditStringFromQueue } from '~/utils/utils'
|
||||
|
@ -28,7 +29,7 @@ logger.debug('App setup()')
|
|||
const store = useStore()
|
||||
|
||||
// Tracks
|
||||
const { currentTrack, tracks } = useQueue()
|
||||
const { currentTrack } = useQueue()
|
||||
const getTrackInformationText = (track: QueueTrack | undefined) => {
|
||||
if (!track) {
|
||||
return null
|
||||
|
@ -81,53 +82,10 @@ store.dispatch('auth/fetchUser')
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:key="store.state.instance.instanceUrl"
|
||||
:class="{
|
||||
'has-bottom-player': tracks.length > 0,
|
||||
'queue-focused': store.state.ui.queueFocused
|
||||
}"
|
||||
>
|
||||
<!-- here, we display custom stylesheets, if any -->
|
||||
<link
|
||||
v-for="url in customStylesheets"
|
||||
:key="url"
|
||||
rel="stylesheet"
|
||||
property="stylesheet"
|
||||
:href="url"
|
||||
>
|
||||
|
||||
<sidebar
|
||||
:width="width"
|
||||
@show:shortcuts-modal="toggleShortcutsModal"
|
||||
/>
|
||||
<service-messages />
|
||||
<transition name="queue">
|
||||
<queue v-show="store.state.ui.queueFocused" />
|
||||
</transition>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<keep-alive :max="1">
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<!-- TODO (wvffle): Add loader -->
|
||||
{{ $t('App.loading') }}
|
||||
</template>
|
||||
</Suspense>
|
||||
</keep-alive>
|
||||
</template>
|
||||
</router-view>
|
||||
|
||||
<audio-player />
|
||||
<playlist-modal v-if="store.state.auth.authenticated" />
|
||||
<channel-upload-modal v-if="store.state.auth.authenticated" />
|
||||
<filter-modal v-if="store.state.auth.authenticated" />
|
||||
<report-modal />
|
||||
<shortcuts-modal v-model:show="showShortcutsModal" />
|
||||
</div>
|
||||
<UiApp v-if="route.fullPath.startsWith('/ui')" />
|
||||
<LegacyLayout v-else />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
font-size: 16px;
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import { useIntervalFn, useStyleTag, useToggle, useWindowSize } from '@vueuse/core'
|
||||
import { computed, ref, defineAsyncComponent, nextTick, onMounted } from 'vue'
|
||||
|
||||
import { useQueue } from '~/composables/audio/queue'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
|
||||
|
||||
const ChannelUploadModal = defineAsyncComponent(() => import('~/components/channels/UploadModal.vue'))
|
||||
const PlaylistModal = defineAsyncComponent(() => import('~/components/playlists/PlaylistModal.vue'))
|
||||
const FilterModal = defineAsyncComponent(() => import('~/components/moderation/FilterModal.vue'))
|
||||
const ReportModal = defineAsyncComponent(() => import('~/components/moderation/ReportModal.vue'))
|
||||
const SetInstanceModal = defineAsyncComponent(() => import('~/components/SetInstanceModal.vue'))
|
||||
const ServiceMessages = defineAsyncComponent(() => import('~/components/ServiceMessages.vue'))
|
||||
const ShortcutsModal = defineAsyncComponent(() => import('~/components/ShortcutsModal.vue'))
|
||||
const AudioPlayer = defineAsyncComponent(() => import('~/components/audio/Player.vue'))
|
||||
const Sidebar = defineAsyncComponent(() => import('~/components/Sidebar.vue'))
|
||||
const Queue = defineAsyncComponent(() => import('~/components/Queue.vue'))
|
||||
|
||||
const store = useStore()
|
||||
|
||||
// Tracks
|
||||
const { tracks } = useQueue()
|
||||
|
||||
// Fake content
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
document.getElementById('fake-content')?.classList.add('loaded')
|
||||
})
|
||||
|
||||
// Styles
|
||||
const customStylesheets = computed(() => {
|
||||
return store.state.instance.frontSettings.additionalStylesheets ?? []
|
||||
})
|
||||
|
||||
useStyleTag(computed(() => store.state.instance.settings.ui.custom_css.value))
|
||||
|
||||
// Time ago
|
||||
useIntervalFn(() => {
|
||||
// used to redraw ago dates every minute
|
||||
store.commit('ui/computeLastDate')
|
||||
}, 1000 * 60)
|
||||
|
||||
// Shortcuts
|
||||
const [showShortcutsModal, toggleShortcutsModal] = useToggle(false)
|
||||
onKeyboardShortcut('h', () => toggleShortcutsModal())
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const showSetInstanceModal = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:key="store.state.instance.instanceUrl"
|
||||
:class="{
|
||||
'has-bottom-player': tracks.length > 0,
|
||||
'queue-focused': store.state.ui.queueFocused
|
||||
}"
|
||||
>
|
||||
<!-- here, we display custom stylesheets, if any -->
|
||||
<link
|
||||
v-for="url in customStylesheets"
|
||||
:key="url"
|
||||
rel="stylesheet"
|
||||
property="stylesheet"
|
||||
:href="url"
|
||||
>
|
||||
|
||||
<sidebar
|
||||
:width="width"
|
||||
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
|
||||
@show:shortcuts-modal="toggleShortcutsModal"
|
||||
/>
|
||||
<set-instance-modal v-model:show="showSetInstanceModal" />
|
||||
<service-messages />
|
||||
<transition name="queue">
|
||||
<queue v-show="store.state.ui.queueFocused" />
|
||||
</transition>
|
||||
|
||||
<router-view v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<keep-alive :max="1">
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
<!-- TODO (wvffle): Add loader -->
|
||||
{{ $t('App.loading') }}
|
||||
</template>
|
||||
</Suspense>
|
||||
</keep-alive>
|
||||
</template>
|
||||
</router-view>
|
||||
|
||||
<audio-player />
|
||||
<playlist-modal v-if="store.state.auth.authenticated" />
|
||||
<channel-upload-modal v-if="store.state.auth.authenticated" />
|
||||
<filter-modal v-if="store.state.auth.authenticated" />
|
||||
<report-modal />
|
||||
<shortcuts-modal v-model:show="showShortcutsModal" />
|
||||
</div>
|
||||
</template>
|
|
@ -16,6 +16,8 @@ import '~/style/_main.scss'
|
|||
|
||||
import '~/api'
|
||||
|
||||
import 'virtual:uno.css'
|
||||
|
||||
// NOTE: Set the theme as fast as possible
|
||||
useTheme()
|
||||
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, nextTick } from 'vue'
|
||||
import Sidebar from '~/ui/components/Sidebar.vue'
|
||||
|
||||
// Fake content
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
document.getElementById('fake-app')?.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid">
|
||||
<Sidebar />
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: 300px 5fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,230 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUploadsStore } from '../stores/upload';
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Hide the fake app when the real one is loaded
|
||||
onMounted(() => {
|
||||
document.getElementById('fake-app')?.remove()
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const uploads = useUploadsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside>
|
||||
<div class="sticky-content">
|
||||
<nav class="quick-actions">
|
||||
<RouterLink to="/">
|
||||
<img src="../../assets/logo/logo.svg" alt="Logo" class="logo" />
|
||||
</RouterLink>
|
||||
|
||||
<FwButton icon="bi:wrench" color="secondary" variant="ghost" />
|
||||
|
||||
<FwButton icon="bi:upload" color="secondary" variant="ghost" :class="[{ active: route.name === 'ui.upload' }, 'icon-only']">
|
||||
<Transition>
|
||||
<div v-if="uploads.currentIndex < uploads.queue.length" class="upload-progress">
|
||||
<div class="progress fake" />
|
||||
<div class="progress" :style="{ maxWidth: `${uploads.currentIndex / uploads.queue.length * 100}%` }" />
|
||||
</div>
|
||||
</Transition>
|
||||
</FwButton>
|
||||
|
||||
<FwButton icon="bi:inbox" color="secondary" variant="ghost" />
|
||||
|
||||
<a
|
||||
@click.prevent
|
||||
href=""
|
||||
class="avatar"
|
||||
>
|
||||
<img
|
||||
v-if="$store.state.auth.authenticated && $store.state.auth.profile?.avatar?.urls.medium_square_crop"
|
||||
alt=""
|
||||
:src="$store.getters['instance/absoluteUrl']($store.state.auth.profile?.avatar.urls.medium_square_crop)"
|
||||
>
|
||||
<!--ActorAvatar
|
||||
v-else-if="$store.state.auth.authenticated"
|
||||
:actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}"
|
||||
/-->
|
||||
<i
|
||||
v-else
|
||||
class="cog icon"
|
||||
/>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="search">
|
||||
<FwInput
|
||||
v-model="searchQuery"
|
||||
icon="bi-search"
|
||||
:placeholder="$t('components.audio.SearchBar.placeholder.search')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3>Explore</h3>
|
||||
<nav class="button-list">
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-compass">All Funkwhale</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-music-note-beamed">Music</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-mic">Podcasts</FwButton>
|
||||
</nav>
|
||||
|
||||
<h3>Library</h3>
|
||||
<div class="pill-list">
|
||||
<FwPill>Music</FwPill>
|
||||
<FwPill outline>Podcasts</FwPill>
|
||||
<FwPill outline>Sharing</FwPill>
|
||||
</div>
|
||||
<nav class="button-list">
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-collection">Collections</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-person">Artists</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-disc">Albums</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-music-note-list">Playlists</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-question-diamond">Radios</FwButton>
|
||||
<FwButton color="secondary" variant="ghost" icon="bi-heart">Favorites</FwButton>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
aside {
|
||||
background: var(--fw-beige-300);
|
||||
height: 100%;
|
||||
|
||||
> .sticky-content {
|
||||
position: sticky;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
top: 0;
|
||||
|
||||
> .quick-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
font-size: 1.3rem;
|
||||
|
||||
.active {
|
||||
box-shadow: inset 0 0 0 2px var(--fw-blue-500);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(svg + span) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
background: var(--fw-blue-500);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 2px;
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
|
||||
&.v-enter-active,
|
||||
&.v-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&.v-leave-to,
|
||||
&.v-enter-from {
|
||||
transform: translateY(0.5rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
> .progress {
|
||||
height: 0.25rem;
|
||||
width: 100%;
|
||||
transition: max-width 0.1s ease;
|
||||
background: var(--fw-gray-100);
|
||||
border-radius: 100vh;
|
||||
position: relative;
|
||||
|
||||
&.fake {
|
||||
background: var(--fw-blue-700);
|
||||
}
|
||||
|
||||
&:not(.fake) {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
> :first-child {
|
||||
margin-right: auto;
|
||||
|
||||
}
|
||||
|
||||
.avatar,
|
||||
.logo {
|
||||
height: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
aspect-ratio: 1;
|
||||
background: var(--fw-beige-100);
|
||||
border-radius: 100%;
|
||||
|
||||
text-decoration: none !important;
|
||||
color: var(--fw-gray-700);
|
||||
|
||||
> i {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .search {
|
||||
padding: 0 16px 23px;
|
||||
}
|
||||
|
||||
> h3 {
|
||||
margin: 0;
|
||||
padding: 0 32px 8px;
|
||||
color: var(--fw-gray-700);
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
> .pill-list {
|
||||
padding: 0 16px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
> nav.button-list {
|
||||
padding: 0 16px 32px;
|
||||
|
||||
> button {
|
||||
margin: 2px 0;
|
||||
|
||||
/* TODO: Fix in UI: When icon is applied, the text should be aligned left */
|
||||
justify-content: start;
|
||||
|
||||
/* TODO: Fix in UI: Add `block` prop that spans 100% width */
|
||||
width: 100%;
|
||||
|
||||
:deep(i) {
|
||||
font-size: 1.4em;
|
||||
|
||||
/* TODO: Fix in UI: Add margin right to the icon, when content available */
|
||||
margin-right: 1ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -45,7 +45,6 @@ const currentTab = ref(tabs[0].label)
|
|||
|
||||
// Modals
|
||||
const libraryOpen = ref(false)
|
||||
const libraryModalAlertOpen = ref(true)
|
||||
|
||||
// Server import
|
||||
const serverPath = ref('/srv/funkwhale/data/music')
|
||||
|
@ -68,6 +67,20 @@ const cancel = () => {
|
|||
libraryOpen.value = false
|
||||
uploads.cancelAll()
|
||||
}
|
||||
|
||||
// Sorting
|
||||
const sortItems = reactive([
|
||||
{ label: 'Upload time', value: 'upload-time' },
|
||||
{ label: 'Upload time 2', value: 'upload-time-2' },
|
||||
{ label: 'Upload time 3', value: 'upload-time-3' }
|
||||
])
|
||||
const currentSort = ref(sortItems[0])
|
||||
|
||||
// Filtering
|
||||
const filterItems = reactive([
|
||||
{ label: 'All', value: 'all' }
|
||||
])
|
||||
const currentFilter = ref(filterItems[0])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -111,13 +124,13 @@ const cancel = () => {
|
|||
<div>
|
||||
<FwButton @click="libraryOpen = true">Open library</FwButton>
|
||||
<FwModal v-model="libraryOpen" title="Upload music to library">
|
||||
<template #alert v-if="libraryModalAlertOpen">
|
||||
<template #alert="{ closeAlert }">
|
||||
<FwAlert>
|
||||
Before uploading, please ensure your files are tagged properly.
|
||||
We recommend using Picard for that purpose.
|
||||
|
||||
<template #actions>
|
||||
<FwButton @click="libraryModalAlertOpen = false">Got it</FwButton>
|
||||
<FwButton @click="closeAlert">Got it</FwButton>
|
||||
</template>
|
||||
</FwAlert>
|
||||
</template>
|
||||
|
@ -136,8 +149,8 @@ const cancel = () => {
|
|||
{{ uploads.queue.length }} files, {{ combinedFileSize }}
|
||||
</div>
|
||||
|
||||
<FwButton color="secondary">All</FwButton>
|
||||
<FwButton color="secondary">Upload time</FwButton>
|
||||
<FwSelect icon="bi:filter" v-model="currentFilter" :items="filterItems" />
|
||||
<FwSelect icon="bi:sort-down" v-model="currentSort" :items="sortItems" />
|
||||
</div>
|
||||
|
||||
<div class="file-list">
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||
import { computed, reactive, readonly, ref, watchEffect, markRaw, toRaw, type Ref } from 'vue'
|
||||
import { computed, reactive, readonly, ref, markRaw, toRaw, unref } from 'vue'
|
||||
import { whenever, useWebWorker, type UseWebWorkerReturn } from '@vueuse/core'
|
||||
import { not } from '@vueuse/math'
|
||||
import axios from 'axios'
|
||||
|
||||
import FileMetadataParserWorker, { type MetadataParsingResult } from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||
import type { MetadataParsingResult } from '~/ui/workers/file-metadata-parser'
|
||||
|
||||
import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata'
|
||||
import type { Tags } from '~/ui/composables/metadata'
|
||||
|
||||
interface UploadQueueEntry {
|
||||
id: string
|
||||
id: number
|
||||
file: File
|
||||
|
||||
// Upload info
|
||||
|
@ -38,7 +39,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
// Tag extraction with a Web Worker
|
||||
const worker = ref<UseWebWorkerReturn<MetadataParsingResult>>()
|
||||
const retrieveMetadata = (entry: Pick<UploadQueueEntry, 'id' | 'file'>) => {
|
||||
if (!worker.value) worker.value = useWebWorker<MetadataParsingResult>(FileMetadataParserWorker)
|
||||
if (!worker.value) worker.value = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
||||
worker.value.post(entry)
|
||||
}
|
||||
|
||||
|
@ -47,18 +48,17 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
worker.value = undefined
|
||||
})
|
||||
|
||||
|
||||
whenever(() => worker.value?.data, (reactiveData) => {
|
||||
const data = toRaw(reactiveData)
|
||||
const data = toRaw(unref(reactiveData))
|
||||
if (data.status === 'success') {
|
||||
const id = data.id as number
|
||||
const tags = data.tags as Tags
|
||||
const coverUrl = data.coverUrl as string
|
||||
const id = data.id
|
||||
const tags = data.tags
|
||||
const coverUrl = data.coverUrl
|
||||
|
||||
uploadQueue[id].tags = markRaw(tags)
|
||||
uploadQueue[id].coverUrl = coverUrl
|
||||
} else {
|
||||
const id = data.id as number
|
||||
const id = data.id
|
||||
const entry = uploadQueue[id]
|
||||
|
||||
entry.error = data.error
|
||||
|
@ -74,15 +74,15 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
const body = new FormData()
|
||||
body.append('file', entry.file)
|
||||
|
||||
const uploadProgress = ref(0)
|
||||
|
||||
await axios.post('https://httpbin.org/post', body, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
},
|
||||
signal: entry.abortController.signal,
|
||||
onUploadProgress: (e) => {
|
||||
entry.progress = Math.floor(e.loaded / e.total * 100)
|
||||
// NOTE: If e.total is absent, we use the file size instead. This is only an approximation, as e.total is the total size of the request, not just the file.
|
||||
// see: https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/total
|
||||
entry.progress = Math.floor(e.loaded / (e.total ?? entry.file.size) * 100)
|
||||
|
||||
if (entry.progress === 100) {
|
||||
console.log(`[${entry.id}] upload complete!`)
|
||||
|
@ -127,7 +127,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
window.addEventListener('beforeunload', (event) => {
|
||||
if (isUploading.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'The upload is still in progress. Are you sure you want to leave?'
|
||||
return event.returnValue = 'The upload is still in progress. Are you sure you want to leave?'
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -144,6 +144,7 @@ export const useUploadsStore = defineStore('uploads', () => {
|
|||
return {
|
||||
isUploading,
|
||||
queueUpload,
|
||||
currentIndex: readonly(currentIndex),
|
||||
currentUpload,
|
||||
cancelAll,
|
||||
queue: readonly(uploadQueue)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// java String#hashCode
|
||||
export function hashCode (str: string) {
|
||||
export function hashCode (str = 'default') {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { defineConfig, type PluginOption } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
|
||||
import manifest from './pwa-manifest.json'
|
||||
|
||||
|
@ -26,7 +27,7 @@ export default defineConfig(({ mode }) => ({
|
|||
|
||||
// https://github.com/intlify/bundle-tools/tree/main/packages/vite-plugin-vue-i18n
|
||||
VueI18n({
|
||||
include: resolve(__dirname, './src/locales/**')
|
||||
include: fileURLToPath(new URL('./src/locales/**', import.meta.url))
|
||||
}),
|
||||
|
||||
// https://github.com/btd/rollup-plugin-visualizer
|
||||
|
@ -48,17 +49,21 @@ export default defineConfig(({ mode }) => ({
|
|||
|
||||
// https://github.com/davidmyersdev/vite-plugin-node-polyfills
|
||||
// see: https://github.com/Borewit/music-metadata-browser/issues/836
|
||||
nodePolyfills()
|
||||
nodePolyfills(),
|
||||
|
||||
|
||||
// https://unocss.dev/
|
||||
UnoCSS()
|
||||
],
|
||||
server: {
|
||||
port
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'#': resolve(__dirname, './src/worker'),
|
||||
'?': resolve(__dirname, './test'),
|
||||
'~': resolve(__dirname, './src')
|
||||
}
|
||||
alias: [
|
||||
{ find: '#', replacement: fileURLToPath(new URL('./src/ui/workers', import.meta.url)) },
|
||||
{ find: '?', replacement: fileURLToPath(new URL('./test', import.meta.url)) },
|
||||
{ find: '~', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
]
|
||||
},
|
||||
build: {
|
||||
sourcemap: true,
|
||||
|
|
775
front/yarn.lock
775
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue