Feat(front): miscellaneous updates
Co-Authored-By: ArneBo <arne@ecobasa.org> Co-Authored-By: Flupsi <upsiflu@gmail.com> Co-Authored-By: jon r <jon@allmende.io>
This commit is contained in:
parent
f3fa4f13d4
commit
f01f7d4793
|
@ -0,0 +1,3 @@
|
||||||
|
# Funkwhale Frontend
|
||||||
|
|
||||||
|
Please follow the instructions in [Set up your development environment — funkwhale 1.4.0 documentation](https://docs.funkwhale.audio/developer/setup/index.html).
|
|
@ -43,7 +43,7 @@ export const isPlaying = ref(false)
|
||||||
// Use Player
|
// Use Player
|
||||||
export const usePlayer = createGlobalState(() => {
|
export const usePlayer = createGlobalState(() => {
|
||||||
const { currentSound } = useTracks()
|
const { currentSound } = useTracks()
|
||||||
const { playNext } = useQueue()
|
const { playNext, playPrevious } = useQueue()
|
||||||
|
|
||||||
const pauseReason = ref(PauseReason.UserInput)
|
const pauseReason = ref(PauseReason.UserInput)
|
||||||
|
|
||||||
|
@ -228,6 +228,52 @@ export const usePlayer = createGlobalState(() => {
|
||||||
watch(currentIndex, stopErrorTimeout)
|
watch(currentIndex, stopErrorTimeout)
|
||||||
whenever(errored, startErrorTimeout)
|
whenever(errored, startErrorTimeout)
|
||||||
|
|
||||||
|
// Mobile controls and lockscreen cover art
|
||||||
|
const updateMediaSession = () => {
|
||||||
|
if ('mediaSession' in navigator && currentTrack.value) {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: currentTrack.value.title,
|
||||||
|
artist: currentTrack.value.artistCredit?.map(ac => ac.credit).join(', ') || 'Unknown Artist',
|
||||||
|
album: currentTrack.value.albumTitle || 'Unknown Album',
|
||||||
|
artwork: [
|
||||||
|
{ src: currentTrack.value.coverUrl, sizes: '1200x1200', type: 'image/jpeg' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('play', () => {
|
||||||
|
isPlaying.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('pause', () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||||
|
playPrevious()
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||||
|
playNext()
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('seekbackward', (details) => {
|
||||||
|
seekBy(details.seekOffset || -10)
|
||||||
|
})
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('seekforward', (details) => {
|
||||||
|
seekBy(details.seekOffset || 10)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentTrack, () => {
|
||||||
|
updateMediaSession()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isPlaying, () => {
|
||||||
|
navigator.mediaSession.playbackState = isPlaying.value ? 'playing' : 'paused'
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initializeFirstTrack,
|
initializeFirstTrack,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
import type { ContentFilter } from '~/store/moderation'
|
import type { ContentFilter } from '~/store/moderation'
|
||||||
|
|
||||||
import { useCurrentElement } from '@vueuse/core'
|
|
||||||
import { computed, markRaw, ref } from 'vue'
|
import { computed, markRaw, ref } from 'vue'
|
||||||
import { i18n } from '~/init/locale'
|
import { i18n } from '~/init/locale'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
|
@ -9,19 +9,18 @@ import { useStore } from '~/store'
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
import { useQueue } from '~/composables/audio/queue'
|
import { useQueue } from '~/composables/audio/queue'
|
||||||
|
|
||||||
import jQuery from 'jquery'
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
export interface PlayOptionsProps {
|
export interface PlayOptionsProps {
|
||||||
isPlayable?: boolean
|
isPlayable?: boolean
|
||||||
tracks?: Track[]
|
tracks?: Track[]
|
||||||
track?: Track | null
|
track?: Track | null
|
||||||
artist?: Artist | null
|
artist?: Artist | components["schemas"]["SimpleChannelArtist"] | components['schemas']['ArtistWithAlbums'] | null
|
||||||
album?: Album | null
|
album?: Album | null
|
||||||
playlist?: Playlist | null
|
playlist?: Playlist | null
|
||||||
library?: Library | null
|
library?: Library | null
|
||||||
channel?: Channel | null
|
channel?: Channel | null
|
||||||
account?: Actor | null
|
account?: Actor | components['schemas']['APIActor'] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (props: PlayOptionsProps) => {
|
export default (props: PlayOptionsProps) => {
|
||||||
|
@ -37,8 +36,12 @@ export default (props: PlayOptionsProps) => {
|
||||||
if (props.track) {
|
if (props.track) {
|
||||||
return props.track.uploads?.length > 0
|
return props.track.uploads?.length > 0
|
||||||
} else if (props.artist) {
|
} else if (props.artist) {
|
||||||
|
// TODO: Find out how to get tracks, album from Artist
|
||||||
|
|
||||||
|
/*
|
||||||
return props.artist.tracks_count > 0
|
return props.artist.tracks_count > 0
|
||||||
|| props.artist?.albums?.some((album) => album.is_playable === true)
|
|| props.artist?.albums?.some((album) => album.is_playable === true)
|
||||||
|
*/
|
||||||
} else if (props.tracks) {
|
} else if (props.tracks) {
|
||||||
return props.tracks?.some((track) => (track.uploads?.length ?? 0) > 0)
|
return props.tracks?.some((track) => (track.uploads?.length ?? 0) > 0)
|
||||||
}
|
}
|
||||||
|
@ -150,18 +153,15 @@ export default (props: PlayOptionsProps) => {
|
||||||
return tracks.filter(track => track.uploads?.length).map(markRaw)
|
return tracks.filter(track => track.uploads?.length).map(markRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = useCurrentElement()
|
// const el = useCurrentElement()
|
||||||
const enqueue = async () => {
|
|
||||||
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
|
||||||
|
|
||||||
|
const enqueue = async () => {
|
||||||
const tracks = await getPlayableTracks()
|
const tracks = await getPlayableTracks()
|
||||||
await addToQueue(...tracks)
|
await addToQueue(...tracks)
|
||||||
addMessage(tracks)
|
addMessage(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enqueueNext = async (next = false) => {
|
const enqueueNext = async (next = false) => {
|
||||||
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
|
||||||
|
|
||||||
const tracks = await getPlayableTracks()
|
const tracks = await getPlayableTracks()
|
||||||
|
|
||||||
const wasEmpty = queue.value.length === 0
|
const wasEmpty = queue.value.length === 0
|
||||||
|
@ -177,9 +177,6 @@ export default (props: PlayOptionsProps) => {
|
||||||
|
|
||||||
const replacePlay = async (index?: number) => {
|
const replacePlay = async (index?: number) => {
|
||||||
await clear()
|
await clear()
|
||||||
|
|
||||||
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
|
||||||
|
|
||||||
const tracksToPlay = await getPlayableTracks()
|
const tracksToPlay = await getPlayableTracks()
|
||||||
await addToQueue(...tracksToPlay)
|
await addToQueue(...tracksToPlay)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { PrivacyLevel, ImportStatus } from '~/types'
|
import type { ImportStatus } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
import type { ScopeId } from '~/composables/auth/useScopes'
|
import type { ScopeId } from '~/composables/auth/useScopes'
|
||||||
|
|
||||||
import { i18n } from '~/init/locale'
|
import { i18n } from '~/init/locale'
|
||||||
|
@ -13,13 +14,15 @@ export default () => ({
|
||||||
choices: {
|
choices: {
|
||||||
me: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.private'),
|
me: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.private'),
|
||||||
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.instance'),
|
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.instance'),
|
||||||
|
followers: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.followers'),
|
||||||
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.public')
|
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.public')
|
||||||
} as Record<PrivacyLevel, string>,
|
} satisfies Record<components['schemas']['PrivacyLevelEnum'], string>,
|
||||||
shortChoices: {
|
shortChoices: {
|
||||||
me: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.private'),
|
me: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.private'),
|
||||||
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.instance'),
|
instance: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.instance'),
|
||||||
|
followers: t('composables.locale.useSharedLabels.fields.privacyLevel.choices.followers'),
|
||||||
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.public')
|
everyone: t('composables.locale.useSharedLabels.fields.privacyLevel.shortChoices.public')
|
||||||
} as Record<PrivacyLevel, string>
|
} satisfies Record<components['schemas']['PrivacyLevelEnum'], string>
|
||||||
},
|
},
|
||||||
import_status: {
|
import_status: {
|
||||||
label: t('composables.locale.useSharedLabels.fields.importStatus.label'),
|
label: t('composables.locale.useSharedLabels.fields.importStatus.label'),
|
||||||
|
|
|
@ -16,7 +16,7 @@ export interface EditableConfigField extends ConfigField {
|
||||||
id: EditObjectType
|
id: EditObjectType
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to: Actor }
|
export type EditObject = (Partial<Artist> | Partial<Album> | Partial<Track>) & { attributed_to?: Actor }
|
||||||
export type EditObjectType = 'artist' | 'album' | 'track'
|
export type EditObjectType = 'artist' | 'album' | 'track'
|
||||||
type Configs = Record<EditObjectType, { fields: (EditableConfigField|ConfigField)[] }>
|
type Configs = Record<EditObjectType, { fields: (EditableConfigField|ConfigField)[] }>
|
||||||
|
|
||||||
|
@ -79,6 +79,7 @@ export default (): Configs => {
|
||||||
description,
|
description,
|
||||||
{
|
{
|
||||||
id: 'release_date',
|
id: 'release_date',
|
||||||
|
// TODO: Change type to date and offer date select input in form
|
||||||
type: 'text',
|
type: 'text',
|
||||||
required: false,
|
required: false,
|
||||||
label: t('composables.moderation.useEditConfigs.album.releaseDate'),
|
label: t('composables.moderation.useEditConfigs.album.releaseDate'),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor, ArtistCredit } from '~/types'
|
import type { Track, Artist, Album, Playlist, Library, Channel, Actor, ArtistCredit } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
|
|
||||||
import { i18n } from '~/init/locale'
|
import { i18n } from '~/init/locale'
|
||||||
|
|
||||||
|
@ -8,11 +9,11 @@ const { t } = i18n.global
|
||||||
|
|
||||||
interface Objects {
|
interface Objects {
|
||||||
track?: Track | null
|
track?: Track | null
|
||||||
album?: Album | null
|
album?: Album | components['schemas']['TrackAlbum'] | null
|
||||||
artist?: Artist | null
|
artist?: Artist | components['schemas']['ArtistWithAlbums'] | components["schemas"]["SimpleChannelArtist"] | null
|
||||||
artistCredit?: ArtistCredit[] | null
|
artistCredit?: ArtistCredit[] | null
|
||||||
playlist?: Playlist | null
|
playlist?: Playlist | null
|
||||||
account?: Actor | null
|
account?: Actor | components['schemas']['APIActor'] | null
|
||||||
library?: Library | null
|
library?: Library | null
|
||||||
channel?: Channel | null
|
channel?: Channel | null
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,12 @@ import { ref } from 'vue'
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const pageQuery = useRouteQuery<string>('page', '1')
|
const pageQuery = useRouteQuery<string>('page', '1')
|
||||||
const page = ref()
|
const page = ref<number>()
|
||||||
syncRef(pageQuery, page, {
|
syncRef(pageQuery, page, {
|
||||||
transform: {
|
transform: {
|
||||||
ltr: (left) => +left,
|
ltr: (left) => +left,
|
||||||
|
// TODO: Why toString?
|
||||||
|
// @ts-expect-error string vs. number
|
||||||
rtl: (right) => right.toString()
|
rtl: (right) => right.toString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -20,7 +20,7 @@ useEventListener(window, 'keydown', (event) => {
|
||||||
if (!event.key) return
|
if (!event.key) return
|
||||||
|
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
||||||
|
|
||||||
current.add(event.key.toLowerCase())
|
current.add(event.key.toLowerCase())
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
import {
|
||||||
|
type MaybeRefOrGetter,
|
||||||
|
createGlobalState,
|
||||||
|
toValue,
|
||||||
|
useWindowSize
|
||||||
|
} from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const MOBILE_WIDTH = 640
|
||||||
|
|
||||||
|
export const useScreenSize = createGlobalState(() =>
|
||||||
|
useWindowSize({ includeScrollbar: false })
|
||||||
|
)
|
||||||
|
export const isMobileView = (
|
||||||
|
width: MaybeRefOrGetter<number> = useScreenSize().width
|
||||||
|
) =>
|
||||||
|
computed(() => (toValue(width) ?? Number.POSITIVE_INFINITY) <= MOBILE_WIDTH)
|
|
@ -50,7 +50,7 @@ function useWebSocketHandler (eventName: 'mutation.created', handler: (event: Pe
|
||||||
function useWebSocketHandler (eventName: 'mutation.updated', handler: (event: PendingReviewEdits) => void): stopFn
|
function useWebSocketHandler (eventName: 'mutation.updated', handler: (event: PendingReviewEdits) => void): stopFn
|
||||||
function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn
|
function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn
|
||||||
function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn
|
function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn
|
||||||
function useWebSocketHandler (eventName: 'Listen', handler: (event: ListenWS) => void): stopFn
|
function useWebSocketHandler (eventName: 'Listen', handler: (event: unknown) => void): stopFn
|
||||||
|
|
||||||
function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn {
|
function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn {
|
||||||
const id = `${+new Date() + Math.random()}`
|
const id = `${+new Date() + Math.random()}`
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { parseAPIErrors } from '~/utils'
|
||||||
import { i18n } from './locale'
|
import { i18n } from './locale'
|
||||||
|
|
||||||
import createAuthRefreshInterceptor from 'axios-auth-refresh'
|
import createAuthRefreshInterceptor from 'axios-auth-refresh'
|
||||||
import moment from 'moment'
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import useLogger from '~/composables/useLogger'
|
import useLogger from '~/composables/useLogger'
|
||||||
|
@ -61,23 +60,31 @@ export const install: InitModule = ({ store, router }) => {
|
||||||
break
|
break
|
||||||
|
|
||||||
case 429: {
|
case 429: {
|
||||||
let message
|
let message = ''
|
||||||
|
|
||||||
|
// TODO: Find out if the following fields are still relevant
|
||||||
const rateLimitStatus: RateLimitStatus = {
|
const rateLimitStatus: RateLimitStatus = {
|
||||||
limit: error.response?.headers['x-ratelimit-limit'],
|
limit: error.response?.headers['x-ratelimit-limit'],
|
||||||
scope: error.response?.headers['x-ratelimit-scope'],
|
// scope: error.response?.headers['x-ratelimit-scope'],
|
||||||
remaining: error.response?.headers['x-ratelimit-remaining'],
|
remaining: error.response?.headers['x-ratelimit-remaining'],
|
||||||
duration: error.response?.headers['x-ratelimit-duration'],
|
duration: error.response?.headers['x-ratelimit-duration'],
|
||||||
availableSeconds: parseInt(error.response?.headers['retry-after'] ?? '60'),
|
// availableSeconds: parseInt(error.response?.headers['retry-after'] ?? '60'),
|
||||||
reset: error.response?.headers['x-ratelimit-reset'],
|
reset: error.response?.headers['x-ratelimit-reset'],
|
||||||
resetSeconds: error.response?.headers['x-ratelimit-resetseconds']
|
// resetSeconds: error.response?.headers['x-ratelimit-resetseconds']
|
||||||
|
// The following fields were missing:
|
||||||
|
id: '',
|
||||||
|
rate: '',
|
||||||
|
description: '',
|
||||||
|
available: 0,
|
||||||
|
available_seconds: 0,
|
||||||
|
reset_seconds: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rateLimitStatus.availableSeconds) {
|
message = t('init.axios.rateLimitLater')
|
||||||
const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
|
// if (rateLimitStatus.availableSeconds) {
|
||||||
message = t('init.axios.rateLimitDelay', { delay: tryAgain })
|
// const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
|
||||||
} else {
|
// message = t('init.axios.rateLimitDelay', { delay: tryAgain })
|
||||||
message = t('init.axios.rateLimitLater')
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
error.backendErrors.push(message)
|
error.backendErrors.push(message)
|
||||||
error.isHandled = true
|
error.isHandled = true
|
||||||
|
|
|
@ -70,6 +70,8 @@ export const install: InitModule = ({ store }) => {
|
||||||
const { current } = store.state.radios
|
const { current } = store.state.radios
|
||||||
|
|
||||||
if (current.clientOnly) {
|
if (current.clientOnly) {
|
||||||
|
// TODO: Type this event
|
||||||
|
// @ts-expect-error untyped event
|
||||||
await CLIENT_RADIOS[current.type].handleListen(current, event)
|
await CLIENT_RADIOS[current.type].handleListen(current, event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { VueI18nOptions } from 'vue-i18n'
|
import type { VueI18nOptions } from 'vue-i18n'
|
||||||
|
|
||||||
export type SupportedLanguages = 'ar' | 'ca' | 'cs' | 'de' | 'en_GB' | 'en_US' | 'eo' | 'es' | 'eu' | 'fr_FR'
|
export type SupportedLanguages = 'ar' | 'ca' | 'ca@valencia' | 'cs' | 'de' | 'en_GB' | 'en_US' | 'eo' | 'es' | 'eu' | 'fr_FR'
|
||||||
| 'gl' | 'hu' | 'it' | 'ja_JP' | 'kab_DZ' | 'ko_KR' | 'nb_NO' | 'nl' | 'oc' | 'pl' | 'pt_BR' | 'pt_PT'
|
| 'gl' | 'hu' | 'it' | 'ja_JP' | 'kab_DZ' | 'ko_KR' | 'nb_NO' | 'nl' | 'oc' | 'pl' | 'pt_BR' | 'pt_PT'
|
||||||
| 'ru' | 'sq' | 'zh_Hans' | 'zh_Hant' | 'fa_IR' | 'ml' | 'sv' | 'el' | 'nn_NO'
|
| 'ru' | 'sq' | 'zh_Hans' | 'zh_Hant' | 'fa_IR' | 'ml' | 'sv' | 'el' | 'nn_NO'
|
||||||
|
|
||||||
|
@ -16,6 +16,9 @@ export const locales: Record<SupportedLanguages, Locale> = {
|
||||||
ca: {
|
ca: {
|
||||||
label: 'Català'
|
label: 'Català'
|
||||||
},
|
},
|
||||||
|
'ca@valencia': {
|
||||||
|
label: 'Català (Valencia)'
|
||||||
|
},
|
||||||
cs: {
|
cs: {
|
||||||
label: 'Čeština'
|
label: 'Čeština'
|
||||||
},
|
},
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
src?: string | { coverUrl?: string }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const coverUrl = computed(() => {
|
||||||
|
if (typeof props.src === 'string') return props.src
|
||||||
|
return props.src?.coverUrl
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="cover-art">
|
||||||
|
<Transition mode="out-in">
|
||||||
|
<img
|
||||||
|
v-if="coverUrl"
|
||||||
|
:src="coverUrl"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
icon="bi:disc"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cover-art {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
background: var(--fw-gray-200);
|
||||||
|
color: var(--fw-gray-500);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from,
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,232 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { UploadGroup } from '~/ui/stores/upload'
|
||||||
|
import VerticalCollapse from '~/ui/components/VerticalCollapse.vue'
|
||||||
|
import UploadList from '~/ui/components/UploadList.vue'
|
||||||
|
import { UseTimeAgo } from '@vueuse/components'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
// TODO: Delete this file, please.
|
||||||
|
|
||||||
|
defineProps<{ groups: UploadGroup[], isUploading?: boolean }>()
|
||||||
|
|
||||||
|
const openUploadGroup = ref<UploadGroup>()
|
||||||
|
const toggle = (group: UploadGroup) => {
|
||||||
|
openUploadGroup.value = openUploadGroup.value === group
|
||||||
|
? undefined
|
||||||
|
: group
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
'music-library': 'Music library',
|
||||||
|
'music-channel': 'Music channel',
|
||||||
|
'podcast-channel': 'Podcast channel'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDescription = (group: UploadGroup) => {
|
||||||
|
if (group.queue.length === 0) return 'Unknown album'
|
||||||
|
|
||||||
|
return group.queue.reduce((acc, { metadata }) => {
|
||||||
|
if (!metadata) return acc
|
||||||
|
|
||||||
|
let element = group.type === 'music-library'
|
||||||
|
? metadata.tags.album
|
||||||
|
: metadata.tags.title
|
||||||
|
|
||||||
|
element = acc.length < 3
|
||||||
|
? element
|
||||||
|
: '...'
|
||||||
|
|
||||||
|
if (!acc.includes(element)) {
|
||||||
|
acc.push(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, [] as string[]).join(', ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-for="group of groups"
|
||||||
|
:key="group.guid"
|
||||||
|
class="upload-group"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="upload-group-header">
|
||||||
|
<div class="upload-group-title">
|
||||||
|
{{ labels[group.type] }}
|
||||||
|
</div>
|
||||||
|
<div class="upload-group-albums">
|
||||||
|
{{ getDescription(group) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeago">
|
||||||
|
<UseTimeAgo
|
||||||
|
v-slot="{ timeAgo }"
|
||||||
|
:time="group.createdAt"
|
||||||
|
>
|
||||||
|
{{ timeAgo }}
|
||||||
|
</UseTimeAgo>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FwPill
|
||||||
|
v-if="group.failedCount > 0"
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
{{ group.failedCount }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
failed
|
||||||
|
</FwPill>
|
||||||
|
|
||||||
|
<FwPill
|
||||||
|
v-if="group.importedCount > 0"
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
{{ group.importedCount }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
imported
|
||||||
|
</FwPill>
|
||||||
|
|
||||||
|
<FwPill
|
||||||
|
v-if="group.processingCount > 0"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
{{ group.processingCount }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
processing
|
||||||
|
</FwPill>
|
||||||
|
|
||||||
|
<FwButton
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
class="icon-only"
|
||||||
|
@click="toggle(group)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon
|
||||||
|
icon="bi:chevron-right"
|
||||||
|
:rotate="group === openUploadGroup ? 1 : 0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</FwButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isUploading"
|
||||||
|
class="flex items-center upload-progress"
|
||||||
|
>
|
||||||
|
<FwButton
|
||||||
|
v-if="group.processingCount === 0 && group.failedCount > 0"
|
||||||
|
color="secondary"
|
||||||
|
@click="group.retry()"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</FwButton>
|
||||||
|
<FwButton
|
||||||
|
v-else-if="group.queue.length !== group.importedCount"
|
||||||
|
color="secondary"
|
||||||
|
@click="group.cancel()"
|
||||||
|
>
|
||||||
|
Interrupt
|
||||||
|
</FwButton>
|
||||||
|
|
||||||
|
<div class="progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:style="{ width: `${group.progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0">
|
||||||
|
{{ group.importedCount }} / {{ group.queue.length }} files imported
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VerticalCollapse
|
||||||
|
:open="openUploadGroup === group"
|
||||||
|
class="collapse"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<UploadList :uploads="group.queue" />
|
||||||
|
</VerticalCollapse>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.upload-group {
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--fw-gray-200);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-group-header {
|
||||||
|
.upload-group-title {
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-group-albums {
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeago {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-progress {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
|
||||||
|
> :deep(.funkwhale.button) {
|
||||||
|
margin: 0rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> :deep(.funkwhale.button) + .progress {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: var(--fw-gray-200);
|
||||||
|
border-radius: 1rem;
|
||||||
|
margin: 0 1rem 0 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--fw-primary);
|
||||||
|
border-radius: 1rem;
|
||||||
|
width: 0;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse {
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,192 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadGroupEntry } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import { UseTimeAgo } from '@vueuse/components'
|
||||||
|
import CoverArt from '~/ui/components/CoverArt.vue'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
uploads: UploadGroupEntry[]
|
||||||
|
wide?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// TODO: Delete this file, please.
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<div class="file-list">
|
||||||
|
<div
|
||||||
|
v-for="track in uploads"
|
||||||
|
:key="track.id"
|
||||||
|
class="list-track"
|
||||||
|
:class="{ wide }"
|
||||||
|
>
|
||||||
|
<CoverArt
|
||||||
|
:src="track.metadata"
|
||||||
|
class="track-cover"
|
||||||
|
/>
|
||||||
|
<Transition mode="out-in">
|
||||||
|
<div
|
||||||
|
v-if="track.metadata?.tags"
|
||||||
|
class="track-data"
|
||||||
|
>
|
||||||
|
<div class="track-title">
|
||||||
|
{{ track.metadata.tags.title }}
|
||||||
|
</div>
|
||||||
|
{{ `${track.metadata.tags.artist} / ${track.metadata.tags.album}` }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="track-title"
|
||||||
|
>
|
||||||
|
{{ track.file.name }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<div class="upload-state">
|
||||||
|
<FwTooltip
|
||||||
|
v-if="track.failReason"
|
||||||
|
:tooltip="track.failReason"
|
||||||
|
>
|
||||||
|
<FwPill color="red">
|
||||||
|
<template #image>
|
||||||
|
<Icon
|
||||||
|
icon="bi:question"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
failed
|
||||||
|
</FwPill>
|
||||||
|
</FwTooltip>
|
||||||
|
<FwPill
|
||||||
|
v-else
|
||||||
|
:color="track.importedAt ? 'blue' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
track.importedAt
|
||||||
|
? 'imported'
|
||||||
|
: track.progress === 100
|
||||||
|
? 'processing'
|
||||||
|
: 'uploading'
|
||||||
|
}}
|
||||||
|
</FwPill>
|
||||||
|
<div
|
||||||
|
v-if="track.importedAt"
|
||||||
|
class="track-timeago"
|
||||||
|
>
|
||||||
|
<UseTimeAgo
|
||||||
|
v-slot="{ timeAgo }"
|
||||||
|
:time="track.importedAt"
|
||||||
|
>
|
||||||
|
{{ timeAgo }}
|
||||||
|
</UseTimeAgo>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="track-progress"
|
||||||
|
>
|
||||||
|
{{ `${bytesToHumanSize(track.file.size / 100 * track.progress)}
|
||||||
|
/ ${bytesToHumanSize(track.file.size)}
|
||||||
|
⋅ ${track.progress}%` }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FwButton
|
||||||
|
v-if="track.failReason"
|
||||||
|
icon="bi:arrow-repeat"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
@click="track.retry()"
|
||||||
|
/>
|
||||||
|
<FwButton
|
||||||
|
v-else
|
||||||
|
icon="bi:chevron-right"
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
:is-loading="!track.importedAt"
|
||||||
|
:disabled="!track.importedAt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem 0;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--fw-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :deep(.track-cover) {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-data,
|
||||||
|
.track-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(-1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.track-timeago,
|
||||||
|
.track-progress {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-state {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 1ch;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
:deep(.funkwhale.pill) {
|
||||||
|
margin-right: -0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.funkwhale.button):not(:hover) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
.upload-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 1rem;
|
||||||
|
|
||||||
|
.track-timeago,
|
||||||
|
.track-progress {
|
||||||
|
order: -1;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,201 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, reactive } from 'vue'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import UploadList from '~/ui/components/UploadList.vue'
|
||||||
|
import Alert from '~/components/ui/Alert.vue'
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
|
||||||
|
// TODO: Delete this file once all upload functionality is moved to the new UI.
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
|
||||||
|
const libraryOpen = computed({
|
||||||
|
get: () => !!uploads.currentUploadGroup,
|
||||||
|
set: (value) => {
|
||||||
|
if (!value) {
|
||||||
|
uploads.currentUploadGroup = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Server import
|
||||||
|
const serverPath = ref('/srv/funkwhale/data/music')
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const queue = computed(() => {
|
||||||
|
return uploads.currentUploadGroup?.queue ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const combinedFileSize = computed(() => bytesToHumanSize(
|
||||||
|
queue.value.reduce((acc, { file }) => acc + file.size, 0)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
|
||||||
|
// TODO: Is this needed?
|
||||||
|
// const processFiles = (fileList: FileList) => {
|
||||||
|
// if (!uploads.currentUploadGroup) return
|
||||||
|
|
||||||
|
// for (const file of fileList) {
|
||||||
|
// uploads.currentUploadGroup.queueUpload(file)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const cancel = () => {
|
||||||
|
libraryOpen.value = false
|
||||||
|
uploads.currentUploadGroup?.cancel()
|
||||||
|
uploads.currentUploadGroup = undefined
|
||||||
|
|
||||||
|
if (uploads.queue.length > 0) {
|
||||||
|
router.push('/upload/running')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const continueInBackground = () => {
|
||||||
|
libraryOpen.value = false
|
||||||
|
uploads.currentUploadGroup = undefined
|
||||||
|
router.push('/upload/running')
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO (whole file): Delete this file, please.
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
const filterItems = reactive([
|
||||||
|
{ label: 'All', value: 'all' }
|
||||||
|
])
|
||||||
|
const currentFilter = ref(filterItems[0])
|
||||||
|
|
||||||
|
const modalName = 'upload'
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get () {
|
||||||
|
return store.state.ui.modalsOpen.has(modalName)
|
||||||
|
},
|
||||||
|
set (value) {
|
||||||
|
store.commit('ui/setModal', [modalName, value])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<Modal
|
||||||
|
v-model="isOpen"
|
||||||
|
title="Upload..."
|
||||||
|
>
|
||||||
|
<template #alert>
|
||||||
|
<Alert yellow>
|
||||||
|
{{ `${t('components.library.FileUpload.message.local.tag')}
|
||||||
|
${t('components.library.FileUpload.link.picard')}` }}
|
||||||
|
</Alert>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- TODO: Use a file input. We haven't implemented this yet.
|
||||||
|
We could say v-model can be of type `string | number | File | File[]`
|
||||||
|
and then implement this functionality. -->
|
||||||
|
<!-- v-model="processFiles" -->
|
||||||
|
<!-- @vue-ignore -->
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
:accept="['.flac', '.ogg', '.opus', '.mp3', '.aac', '.aif', '.aiff', '.m4a'].join(', ')"
|
||||||
|
multiple
|
||||||
|
auto-reset
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Upload path -->
|
||||||
|
<div v-if="queue.length > 0">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="file-count">
|
||||||
|
{{ queue.length }} files, {{ combinedFileSize }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FwSelect
|
||||||
|
v-model="currentFilter"
|
||||||
|
icon="bi:filter"
|
||||||
|
:items="filterItems"
|
||||||
|
/>
|
||||||
|
<FwSelect
|
||||||
|
v-model="currentSort"
|
||||||
|
icon="bi:sort-down"
|
||||||
|
:items="sortItems"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadList :uploads="queue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import path -->
|
||||||
|
<template v-else>
|
||||||
|
<label>Import from server directory</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<FwInput
|
||||||
|
v-model="serverPath"
|
||||||
|
class="w-full mr-4"
|
||||||
|
/>
|
||||||
|
<Button color="secondary">
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
@click="cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button @click="continueInBackground">
|
||||||
|
{{ uploads.queue.length ? 'Continue in background' : 'Save and close' }}
|
||||||
|
//TODO: Translations
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
|
||||||
|
> .file-count {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex:not(.flex-col) {
|
||||||
|
.funkwhale.button {
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,228 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import useThemeList from '~/composables/useThemeList'
|
||||||
|
import useTheme from '~/composables/useTheme'
|
||||||
|
|
||||||
|
import { useModal } from '~/ui/composables/useModal.ts'
|
||||||
|
|
||||||
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
import Popover from '~/components/ui/Popover.vue'
|
||||||
|
import PopoverItem from '~/components/ui/popover/PopoverItem.vue'
|
||||||
|
import PopoverSubmenu from '~/components/ui/popover/PopoverSubmenu.vue'
|
||||||
|
import Spacer from '~/components/ui/Spacer.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const themes = useThemeList()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
const labels = computed(() => ({
|
||||||
|
profile: t('components.common.UserMenu.link.profile'),
|
||||||
|
settings: t('components.common.UserMenu.link.settings'),
|
||||||
|
logout: t('components.common.UserMenu.link.logout'),
|
||||||
|
about: t('components.common.UserMenu.link.about'),
|
||||||
|
shortcuts: t('components.common.UserMenu.label.shortcuts'),
|
||||||
|
support: t('components.common.UserMenu.link.support'),
|
||||||
|
forum: t('components.common.UserMenu.link.forum'),
|
||||||
|
docs: t('components.common.UserMenu.link.docs'),
|
||||||
|
language: t('components.common.UserMenu.label.language'),
|
||||||
|
theme: t('components.common.UserMenu.label.theme'),
|
||||||
|
chat: t('components.common.UserMenu.link.chat'),
|
||||||
|
git: t('components.common.UserMenu.link.git'),
|
||||||
|
login: t('components.common.UserMenu.link.login'),
|
||||||
|
signup: t('components.common.UserMenu.link.signup'),
|
||||||
|
notifications: t('components.common.UserMenu.link.notifications')
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Popover
|
||||||
|
v-model="isOpen"
|
||||||
|
raised
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
square-small
|
||||||
|
ghost
|
||||||
|
class="user-menu"
|
||||||
|
:aria-pressed="isOpen ? true : undefined"
|
||||||
|
@click="isOpen = !isOpen"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="store.state.auth.authenticated && store.state.auth.profile?.avatar?.urls.small_square_crop"
|
||||||
|
alt=""
|
||||||
|
:src="store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.small_square_crop)"
|
||||||
|
class="avatar"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-else-if="store.state.auth.authenticated"
|
||||||
|
class="ui tiny avatar circular label"
|
||||||
|
>
|
||||||
|
{{ store.state.auth.profile?.full_username?.[0] || "" }}
|
||||||
|
</span>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="bi bi-gear-fill"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<template #items>
|
||||||
|
<PopoverItem
|
||||||
|
v-if="store.state.auth.authenticated"
|
||||||
|
:to="{name: 'profile.overview', params: { username: store.state.auth.username },}"
|
||||||
|
>
|
||||||
|
<i class="bi bi-person-fill" />
|
||||||
|
{{ labels.profile }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem
|
||||||
|
v-if="store.state.auth.authenticated"
|
||||||
|
:to="{name: 'notifications'}"
|
||||||
|
>
|
||||||
|
<i class="bi bi-inbox-fill" />
|
||||||
|
{{ labels.notifications }}
|
||||||
|
<Spacer grow />
|
||||||
|
<div
|
||||||
|
v-if="store.state.ui.notifications.inbox > 0"
|
||||||
|
:title="labels.notifications"
|
||||||
|
style="
|
||||||
|
background: var(--fw-gray-400);
|
||||||
|
color: var(--fw-gray-800);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ store.state.ui.notifications.inbox }}
|
||||||
|
</div>
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem
|
||||||
|
v-if="store.state.auth.authenticated"
|
||||||
|
:to="{ path: '/settings' }"
|
||||||
|
>
|
||||||
|
<i class="bi bi-gear-fill" />
|
||||||
|
{{ labels.settings }}
|
||||||
|
</PopoverItem>
|
||||||
|
<hr v-if="store.state.auth.authenticated">
|
||||||
|
<PopoverItem :to="useModal('language').to">
|
||||||
|
<i class="bi bi-translate" />
|
||||||
|
{{ `${labels.language}...` }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverSubmenu>
|
||||||
|
<i class="bi bi-palette-fill" />
|
||||||
|
{{ labels.theme }}
|
||||||
|
<template #items>
|
||||||
|
<PopoverItem
|
||||||
|
v-for="th in themes"
|
||||||
|
:key="th.key"
|
||||||
|
@click="theme=th.key"
|
||||||
|
>
|
||||||
|
<i :class="th.icon" />
|
||||||
|
{{ th.name }}
|
||||||
|
</PopoverItem>
|
||||||
|
</template>
|
||||||
|
</PopoverSubmenu>
|
||||||
|
<hr>
|
||||||
|
<PopoverSubmenu>
|
||||||
|
<i class="bi bi-question-square-fill" />
|
||||||
|
{{ labels.support }}
|
||||||
|
<template #items>
|
||||||
|
<PopoverItem to="https://forum.funkwhale.audio">
|
||||||
|
<i class="bi bi-gear-fill" />
|
||||||
|
{{ labels.forum }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem to="https://matrix.to/#/#funkwhale-support:matrix.org">
|
||||||
|
<i class="bi bi-chat-left-fill" />
|
||||||
|
{{ labels.chat }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem to="https://dev.funkwhale.audio/funkwhale/funkwhale/issues">
|
||||||
|
<i class="bi bi-gitlab" />
|
||||||
|
{{ labels.git }}
|
||||||
|
</PopoverItem>
|
||||||
|
</template>
|
||||||
|
</PopoverSubmenu>
|
||||||
|
<PopoverItem to="https://docs.funkwhale.audio">
|
||||||
|
<i class="bi bi-book" />
|
||||||
|
{{ labels.docs }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem :to="useModal('shortcuts').to">
|
||||||
|
<i class="bi bi-keyboard" />
|
||||||
|
{{ labels.shortcuts }}
|
||||||
|
</PopoverItem>
|
||||||
|
<hr v-if="store.state.auth.authenticated">
|
||||||
|
<PopoverItem
|
||||||
|
v-if="store.state.auth.authenticated && route.path != '/logout'"
|
||||||
|
:to="{ name: 'logout' }"
|
||||||
|
>
|
||||||
|
<i class="bi bi-box-arrow-right" />
|
||||||
|
{{ labels.logout }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem
|
||||||
|
v-if="!store.state.auth.authenticated && route.path != '/login'"
|
||||||
|
:to="{ name: 'login' }"
|
||||||
|
>
|
||||||
|
<i class="bi bi-box-arrow-in-right" />
|
||||||
|
{{ labels.login }}
|
||||||
|
</PopoverItem>
|
||||||
|
<PopoverItem
|
||||||
|
v-if="!store.state.auth.authenticated && store.state.instance.settings.users.registration_enabled.value"
|
||||||
|
:to="{ name: 'signup' }"
|
||||||
|
>
|
||||||
|
<i class="bi bi-person-square" />
|
||||||
|
{{ labels.signup }}
|
||||||
|
</PopoverItem>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
header > nav button.button {
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
&.user-menu {
|
||||||
|
padding: 0px !important;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
&.label {
|
||||||
|
background-color: var(--fw-gray-900);
|
||||||
|
color: var(--fw-beige-300);
|
||||||
|
&:hover {
|
||||||
|
color: var(--fw-color);
|
||||||
|
background-color: var(--hover-background-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
&.label {
|
||||||
|
background-color: var(--fw-beige-400);
|
||||||
|
color: var(--fw-gray-900);
|
||||||
|
&:hover {
|
||||||
|
color: var(--fw-color);
|
||||||
|
background-color: var(--hover-background-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
nav.button-list {
|
||||||
|
width: 100%;
|
||||||
|
a:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ open: boolean }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="v-collapse"
|
||||||
|
:class="{ open }"
|
||||||
|
>
|
||||||
|
<div class="v-collapse-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.v-collapse {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-collapse-body {
|
||||||
|
height: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,7 @@
|
||||||
|
export const bytesToHumanSize = (bytes: number) => {
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
if (i === 0) return `${bytes} ${sizes[i]}`
|
||||||
|
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
// TODO: use when Firefox issue is resolved, see: https://github.com/Borewit/music-metadata-browser/issues/948
|
||||||
|
// import * as Metadata from 'music-metadata-browser'
|
||||||
|
// import type { ICommonTagsResult } from 'music-metadata-browser'
|
||||||
|
//
|
||||||
|
// export type Tags = ICommonTagsResult
|
||||||
|
//
|
||||||
|
// export const getCoverUrl = async (tags: ICommonTagsResult[] | undefined): Promise<string | undefined> => {
|
||||||
|
// if (pictures.length === 0) return undefined
|
||||||
|
//
|
||||||
|
// const picture = Metadata.selectCover(pictures)
|
||||||
|
//
|
||||||
|
// return await new Promise((resolve, reject) => {
|
||||||
|
// const reader = Object.assign(new FileReader(), {
|
||||||
|
// onload: () => resolve(reader.result as string),
|
||||||
|
// onerror: () => reject(reader.error)
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// reader.readAsDataURL(new File([picture.data], "", { type: picture.type }))
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// export const getTags = async (file: File) => {
|
||||||
|
// return Metadata.parseBlob(file).then(metadata => metadata.common)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @ts-expect-error This is not installed...?
|
||||||
|
import * as jsmediaTags from 'jsmediatags/dist/jsmediatags.min.js'
|
||||||
|
// @ts-expect-error This is not installed...?
|
||||||
|
import type { ShortcutTags } from 'jsmediatags'
|
||||||
|
|
||||||
|
const REQUIRED_TAGS = ['title', 'artist', 'album']
|
||||||
|
|
||||||
|
export type Tags = ShortcutTags
|
||||||
|
|
||||||
|
export const getCoverUrl = async (tags: Tags): Promise<string | undefined> => {
|
||||||
|
if (!tags.picture) return undefined
|
||||||
|
const { picture } = tags
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const reader = Object.assign(new FileReader(), {
|
||||||
|
onload: () => resolve(reader.result as string),
|
||||||
|
onerror: () => reject(reader.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
reader.readAsDataURL(new File([picture.data], '', { type: picture.type }))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTags = async (file: File) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
jsmediaTags.read(file, {
|
||||||
|
// @ts-expect-error Please type `tags`
|
||||||
|
onSuccess: ({ tags }) => {
|
||||||
|
if (tags.picture?.data) {
|
||||||
|
tags.picture.data = new Uint8Array(tags.picture.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingTags = REQUIRED_TAGS.filter(tag => !tags[tag])
|
||||||
|
if (missingTags.length > 0) {
|
||||||
|
return reject(new Error(`Missing tags: ${missingTags.join(', ')}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(tags)
|
||||||
|
},
|
||||||
|
// @ts-expect-error Please type `error`
|
||||||
|
onError: (error) => reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,164 @@
|
||||||
|
import type { paths } from '~/generated/types.ts'
|
||||||
|
import type { APIErrorResponse, BackendError, RateLimitStatus } from '~/types'
|
||||||
|
|
||||||
|
import createClient from 'openapi-fetch'
|
||||||
|
|
||||||
|
import { parseAPIErrors } from '~/utils'
|
||||||
|
import { i18n } from '~/init/locale'
|
||||||
|
|
||||||
|
import moment from 'moment'
|
||||||
|
|
||||||
|
import useLogger from '~/composables/useLogger'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
// Note [WIP] that this module is Work in Progress!
|
||||||
|
// TODO: Replace all `axios` calls with this client
|
||||||
|
|
||||||
|
const { t } = i18n.global
|
||||||
|
const logger = useLogger()
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const prefix = '/api/v2/' as const
|
||||||
|
|
||||||
|
const client = createClient<paths>({ baseUrl: `${store.state.instance.instanceUrl}${prefix}` })
|
||||||
|
client.use({
|
||||||
|
/*
|
||||||
|
TODO: Check if we need these:
|
||||||
|
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||||
|
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
|
||||||
|
*/
|
||||||
|
|
||||||
|
async onRequest ({ request, options }) {
|
||||||
|
if (store.state.auth.oauth.accessToken) {
|
||||||
|
request.headers.set('Authorization', store.getters['auth/header'])
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
},
|
||||||
|
|
||||||
|
async onResponse ({ request, response, options }) {
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
|
||||||
|
async onError ({ error: unknownError }) {
|
||||||
|
const error = unknownError as BackendError
|
||||||
|
error.backendErrors = []
|
||||||
|
error.isHandled = false
|
||||||
|
|
||||||
|
if (store.state.auth.authenticated && !store.state.auth.oauth.accessToken && error.response?.status === 401) {
|
||||||
|
store.commit('auth/authenticated', false)
|
||||||
|
logger.warn('Received 401 response from API, redirecting to login form', router.currentRoute.value.fullPath)
|
||||||
|
await router.push({ name: 'login', query: { next: router.currentRoute.value.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (error.response?.status) {
|
||||||
|
case 404:
|
||||||
|
if (error.response?.data === 'Radio doesn\'t have more candidates') {
|
||||||
|
error.backendErrors.push(error.response.data)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
error.backendErrors.push('Resource not found')
|
||||||
|
error.isHandled = true
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
// @ts-expect-error TS does not know about .data structure
|
||||||
|
content: error.response?.data?.detail ?? error.response?.data ?? 'Resource not found',
|
||||||
|
class: 'error'
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case 403:
|
||||||
|
error.backendErrors.push('Permission denied')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 429: {
|
||||||
|
let message
|
||||||
|
const rateLimitStatus: RateLimitStatus = {
|
||||||
|
limit: error.response?.headers['x-ratelimit-limit'],
|
||||||
|
description: error.response?.headers['x-ratelimit-scope'],
|
||||||
|
remaining: error.response?.headers['x-ratelimit-remaining'],
|
||||||
|
duration: error.response?.headers['x-ratelimit-duration'],
|
||||||
|
available_seconds: parseInt(error.response?.headers['retry-after'] ?? '60'),
|
||||||
|
reset: error.response?.headers['x-ratelimit-reset'],
|
||||||
|
reset_seconds: error.response?.headers['x-ratelimit-resetseconds'],
|
||||||
|
/* The following threewere not defined. TODO: research correct values */
|
||||||
|
id: '',
|
||||||
|
available: 100,
|
||||||
|
rate: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimitStatus.available_seconds) {
|
||||||
|
const tryAgain = moment().add(rateLimitStatus.available_seconds, 's').toNow(true)
|
||||||
|
message = t('init.axios.rateLimitDelay', { delay: tryAgain })
|
||||||
|
} else {
|
||||||
|
message = t('init.axios.rateLimitLater')
|
||||||
|
}
|
||||||
|
|
||||||
|
error.backendErrors.push(message)
|
||||||
|
error.isHandled = true
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
content: message,
|
||||||
|
date: new Date(),
|
||||||
|
class: 'error'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.error('This client is rate-limited!', rateLimitStatus)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 500:
|
||||||
|
error.backendErrors.push('A server error occurred')
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (error.response?.data as object) {
|
||||||
|
const data = error.response?.data as Record<string, unknown>
|
||||||
|
if (data?.detail) {
|
||||||
|
error.backendErrors.push(data.detail as string)
|
||||||
|
} else {
|
||||||
|
error.rawPayload = data as APIErrorResponse
|
||||||
|
const parsedErrors = parseAPIErrors(data as APIErrorResponse)
|
||||||
|
error.backendErrors = [...error.backendErrors, ...parsedErrors]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.backendErrors.length === 0) {
|
||||||
|
error.backendErrors.push('An unknown error occurred, ensure your are connected to the internet and your funkwhale instance is up and running')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do something with response error
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: Check if we need to handle refreshAuth = async (failedRequest: AxiosError) */
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* Returns `get` and `post` clients for the chosen path (endpoint).
|
||||||
|
```ts
|
||||||
|
const { get, post } = useClient('manage/tags');
|
||||||
|
|
||||||
|
const result0 = post({ name: 'test' })
|
||||||
|
const result1 = get({ query: { q: 'test' } })
|
||||||
|
```
|
||||||
|
*
|
||||||
|
* @param path The path to create a client for. Check the `paths` type in '~/generated/types.ts' to find all available paths.
|
||||||
|
* @param variable
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const useClient = ({
|
||||||
|
|
||||||
|
get: client.GET,
|
||||||
|
|
||||||
|
post: client.POST,
|
||||||
|
|
||||||
|
put: client.PUT,
|
||||||
|
|
||||||
|
patch: client.PATCH,
|
||||||
|
|
||||||
|
delete: client.DELETE
|
||||||
|
|
||||||
|
})
|
|
@ -0,0 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
main {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="main">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
main {
|
||||||
|
padding: 56px 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,312 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, computed } from 'vue'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import UploadModal from '~/ui/components/UploadModal.vue'
|
||||||
|
|
||||||
|
// TODO: Delete this file?
|
||||||
|
|
||||||
|
const filesystemStats = reactive({
|
||||||
|
total: 10737418240,
|
||||||
|
used: 3e9
|
||||||
|
})
|
||||||
|
|
||||||
|
const filesystemProgress = computed(() => {
|
||||||
|
if (filesystemStats.used === 0) return 0
|
||||||
|
return filesystemStats.used / filesystemStats.total * 100
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{
|
||||||
|
label: 'Running',
|
||||||
|
key: 'running',
|
||||||
|
enabled: uploads.uploadGroups.length > 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'New',
|
||||||
|
key: '',
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'History',
|
||||||
|
key: 'history',
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'All files',
|
||||||
|
key: 'all',
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
].filter(tab => tab.enabled))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="mr-auto">
|
||||||
|
Upload
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="filesystem-stats">
|
||||||
|
<div
|
||||||
|
class="filesystem-stats--progress"
|
||||||
|
:style="`--progress: ${filesystemProgress}%`"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{{ bytesToHumanSize(filesystemStats.total) }} total
|
||||||
|
|
||||||
|
<div class="filesystem-stats--label full" />
|
||||||
|
{{ bytesToHumanSize(filesystemStats.used) }} used
|
||||||
|
|
||||||
|
<div class="filesystem-stats--label" />
|
||||||
|
{{ bytesToHumanSize(filesystemStats.total - filesystemStats.used) }} available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 -ml-2">
|
||||||
|
<RouterLink
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:to="`/upload/${tab.key}`"
|
||||||
|
custom
|
||||||
|
#="{ navigate, isExactActive }"
|
||||||
|
>
|
||||||
|
<FwPill
|
||||||
|
:color="isExactActive ? 'primary' : 'secondary'"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</FwPill>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
|
||||||
|
<UploadModal />
|
||||||
|
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-family: Lato, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex:not(.flex-col) {
|
||||||
|
.funkwhale.button {
|
||||||
|
&:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats {
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
> .flex {
|
||||||
|
padding: 1ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats--progress {
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid var(--fw-gray-600);
|
||||||
|
border-radius: 100vw;
|
||||||
|
padding: 4px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats--label.full::after,
|
||||||
|
.filesystem-stats--progress::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
background: var(--fw-gray-600);
|
||||||
|
border-radius: 100vw;
|
||||||
|
min-width: 4px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: var(--progress, 100);
|
||||||
|
transition: max-width 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filesystem-stats--label {
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid var(--fw-gray-600);
|
||||||
|
border-radius: 100vw;
|
||||||
|
padding: 2px 3px;
|
||||||
|
width: 2em;
|
||||||
|
margin: 0 1ch 0 3ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funkwhale.card {
|
||||||
|
--fw-card-width: 12.5rem;
|
||||||
|
--fw-border-radius: 1rem;
|
||||||
|
padding: 1.3rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px 2px rgba(#000, 0.1);
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
:deep(.card-content) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-icon {
|
||||||
|
background: var(--fw-pastel-blue-1);
|
||||||
|
color: var(--fw-pastel-blue-3);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.funkwhale.card {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
border: 1px solid var(--fw-gray-700);
|
||||||
|
border-radius: 1rem;
|
||||||
|
position: relative;
|
||||||
|
margin: 0.5rem auto 0;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
background: var(--fw-blue-400);
|
||||||
|
border: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 900;
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
display: block;
|
||||||
|
color: var(--fw-gray-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
|
||||||
|
> .file-count {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem 0;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
border-top: 1px solid var(--fw-gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .track-cover {
|
||||||
|
height: 3rem;
|
||||||
|
width: 3rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
background: var(--fw-gray-200);
|
||||||
|
color: var(--fw-gray-500);
|
||||||
|
font-size: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from,
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-data,
|
||||||
|
.track-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.v-enter-active,
|
||||||
|
&.v-leave-active {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-enter-from {
|
||||||
|
transform: translateY(1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.v-leave-to {
|
||||||
|
transform: translateY(-1rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.track-progress {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-state {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 1ch;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
|
:deep(.funkwhale.pill) {
|
||||||
|
margin-right: -0.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.funkwhale.button):not(:hover) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { bytesToHumanSize } from '~/ui/composables/bytes'
|
||||||
|
import { useUploadsStore, type UploadGroupEntry } from '~/ui/stores/upload'
|
||||||
|
import CoverArt from '~/ui/components/CoverArt.vue'
|
||||||
|
|
||||||
|
// TODO: Delete this file?
|
||||||
|
|
||||||
|
interface Recording {
|
||||||
|
guid: string
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
album: string
|
||||||
|
uploadDate: Date
|
||||||
|
format: string
|
||||||
|
size: string
|
||||||
|
metadata: UploadGroupEntry['metadata']
|
||||||
|
}
|
||||||
|
|
||||||
|
const intl = new Intl.DateTimeFormat('en', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Fetch tracks from server
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
const allTracks = computed<Recording[]>(() => {
|
||||||
|
return uploads.uploadGroups.flatMap(group => group.queue.map<Recording>((entry) => ({
|
||||||
|
guid: entry.id,
|
||||||
|
title: entry.metadata?.tags.title || 'Unknown title',
|
||||||
|
artist: entry.metadata?.tags.artist || 'Unknown artist',
|
||||||
|
album: entry.metadata?.tags.album || 'Unknown album',
|
||||||
|
uploadDate: group.createdAt,
|
||||||
|
format: 'flac',
|
||||||
|
size: bytesToHumanSize(entry.file.size),
|
||||||
|
metadata: entry.metadata
|
||||||
|
})))
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: '>index', label: '#' },
|
||||||
|
{ key: 'title', label: 'Title' },
|
||||||
|
{ key: 'artist', label: 'Artist' },
|
||||||
|
{ key: 'album', label: 'Album' },
|
||||||
|
{ key: 'uploadDate', label: 'Upload date' },
|
||||||
|
{ key: 'format', label: 'Format' },
|
||||||
|
{ key: 'size', label: 'Size' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<div
|
||||||
|
v-if="allTracks.length === 0"
|
||||||
|
class="flex flex-col items-center py-32"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="bi:file-earmark-music"
|
||||||
|
class="h-16 w-16"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3>There is no file in your library</h3>
|
||||||
|
<p>Try uploading some before coming back here!</p>
|
||||||
|
</div>
|
||||||
|
<FwTable
|
||||||
|
v-else
|
||||||
|
id-key="guid"
|
||||||
|
:columns="columns"
|
||||||
|
:rows="allTracks"
|
||||||
|
>
|
||||||
|
<template #col-title="{ row, value }">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<CoverArt
|
||||||
|
:src="row.metadata"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ value }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #col-upload-date="{ value }">
|
||||||
|
{{ intl.format(value) }}
|
||||||
|
</template>
|
||||||
|
</FwTable>
|
||||||
|
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--fw-gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--fw-gray-960);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--fw-gray-600);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAsyncState } from '@vueuse/core'
|
||||||
|
import axios from 'axios'
|
||||||
|
import UploadGroupList from '~/ui/components/UploadGroupList.vue'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
|
||||||
|
// TODO: Fetch upload history from server
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
const history = uploads.uploadGroups
|
||||||
|
|
||||||
|
const { state: data } = useAsyncState(axios.post('/api/v2/upload-groups', { baseUrl: '/' }).then(t => t.data), [])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{ data }}
|
||||||
|
<UploadGroupList :groups="history" />
|
||||||
|
</template>
|
|
@ -0,0 +1,201 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { useUploadsStore, type UploadGroupType } from '~/ui/stores/upload'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAsyncState } from '@vueuse/core'
|
||||||
|
|
||||||
|
// TODO: Delete this file?
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
description: string
|
||||||
|
key: UploadGroupType
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Tab[] = [
|
||||||
|
{
|
||||||
|
label: 'Music library',
|
||||||
|
icon: 'headphones',
|
||||||
|
description: 'Host music you listen to.',
|
||||||
|
key: 'music-library'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Music channel',
|
||||||
|
icon: 'music-note-beamed',
|
||||||
|
description: 'Publish music you make.',
|
||||||
|
key: 'music-channel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Podcast channel',
|
||||||
|
icon: 'mic',
|
||||||
|
description: 'Publish podcast you make.',
|
||||||
|
key: 'podcast-channel'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentTab = ref(tabs[0])
|
||||||
|
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
const openLibrary = () => {
|
||||||
|
uploads.createUploadGroup(currentTab.value.key, target.value?.uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = ref()
|
||||||
|
const { state: items } = useAsyncState(
|
||||||
|
axios.get('/libraries/?scope=me')
|
||||||
|
.then(t => t.data.results),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
<div class="upload">
|
||||||
|
<p> Select a destination for your audio files: </p>
|
||||||
|
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<FwCard
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:title="tab.label"
|
||||||
|
:class="currentTab.key === tab.key && 'active'"
|
||||||
|
@click="currentTab = tab"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<div class="image-icon">
|
||||||
|
<Icon :icon="'bi:' + tab.icon" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
{{ tab.description }}
|
||||||
|
<div class="radio-button" />
|
||||||
|
</FwCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FwSelect
|
||||||
|
v-model="target"
|
||||||
|
:items="items"
|
||||||
|
id-key="uuid"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="library-item">
|
||||||
|
<div class="box" />
|
||||||
|
<div>
|
||||||
|
<div>{{ item.name }}</div>
|
||||||
|
<div>
|
||||||
|
Shared with <fw-pill color="blue">
|
||||||
|
{{ item.privacy_level }}
|
||||||
|
</fw-pill>
|
||||||
|
<div>{{ item.uploads_count }} uploads</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FwSelect>
|
||||||
|
|
||||||
|
<FwButton
|
||||||
|
:disabled="!target"
|
||||||
|
@click="openLibrary"
|
||||||
|
>
|
||||||
|
Open library
|
||||||
|
</FwButton>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.funkwhale.select) {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.funkwhale.card {
|
||||||
|
--fw-card-width: 12.5rem;
|
||||||
|
--fw-border-radius: 1rem;
|
||||||
|
padding: 1.3rem 2rem;
|
||||||
|
box-shadow: 0 2px 4px 2px rgba(#000, 0.1);
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
|
||||||
|
:deep(.card-content) {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
border: 1px solid var(--fw-gray-700);
|
||||||
|
border-radius: 1rem;
|
||||||
|
position: relative;
|
||||||
|
margin: 0.5rem auto 0;
|
||||||
|
transition: margin-bottom 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.radio-button {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
background: var(--fw-blue-400);
|
||||||
|
border: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-icon {
|
||||||
|
background: var(--fw-pastel-blue-1);
|
||||||
|
color: var(--fw-pastel-blue-3);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload > .funkwhale.button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> .box {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--fw-pastel-blue-1);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-right:8px;
|
||||||
|
|
||||||
|
+ div {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import UploadGroupList from '~/ui/components/UploadGroupList.vue'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UploadGroupList
|
||||||
|
:groups="uploads.uploadGroups"
|
||||||
|
:is-uploading="true"
|
||||||
|
/>
|
||||||
|
</template>
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import { requireLoggedOut, requireLoggedIn } from '~/router/guards'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('~/views/auth/Login.vue'),
|
||||||
|
props: route => ({ next: route.query.next || '/library' }),
|
||||||
|
beforeEnter: requireLoggedOut({ name: 'library.index' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth/password/reset',
|
||||||
|
name: 'auth.password-reset',
|
||||||
|
component: () => import('~/views/auth/PasswordReset.vue'),
|
||||||
|
props: route => ({ defaultEmail: route.query.email })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth/callback',
|
||||||
|
name: 'auth.callback',
|
||||||
|
component: () => import('~/views/auth/Callback.vue'),
|
||||||
|
props: route => ({
|
||||||
|
code: route.query.code,
|
||||||
|
state: route.query.state
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth/email/confirm',
|
||||||
|
name: 'auth.email-confirm',
|
||||||
|
component: () => import('~/views/auth/EmailConfirm.vue'),
|
||||||
|
props: route => ({ defaultKey: route.query.key })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'auth/password/reset/confirm',
|
||||||
|
name: 'auth.password-reset-confirm',
|
||||||
|
component: () => import('~/views/auth/PasswordResetConfirm.vue'),
|
||||||
|
props: route => ({
|
||||||
|
defaultUid: route.query.uid,
|
||||||
|
defaultToken: route.query.token
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'authorize',
|
||||||
|
name: 'authorize',
|
||||||
|
component: () => import('~/components/auth/Authorize.vue'),
|
||||||
|
props: route => ({
|
||||||
|
clientId: route.query.client_id,
|
||||||
|
redirectUri: route.query.redirect_uri,
|
||||||
|
scope: route.query.scope,
|
||||||
|
responseType: route.query.response_type,
|
||||||
|
nonce: route.query.nonce,
|
||||||
|
state: route.query.state
|
||||||
|
}),
|
||||||
|
beforeEnter: requireLoggedIn()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'signup',
|
||||||
|
name: 'signup',
|
||||||
|
component: () => import('~/views/auth/Signup.vue'),
|
||||||
|
props: route => ({ defaultInvitation: route.query.invitation })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'logout',
|
||||||
|
name: 'logout',
|
||||||
|
component: () => import('~/components/auth/Logout.vue')
|
||||||
|
}
|
||||||
|
] as RouteRecordRaw[]
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'content',
|
||||||
|
component: () => import('~/views/content/Base.vue'),
|
||||||
|
children: [{
|
||||||
|
path: '',
|
||||||
|
name: 'content.index',
|
||||||
|
component: () => import('~/views/content/Home.vue')
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'content/libraries/tracks',
|
||||||
|
component: () => import('~/views/content/Base.vue'),
|
||||||
|
children: [{
|
||||||
|
path: '',
|
||||||
|
name: 'content.libraries.files',
|
||||||
|
component: () => import('~/views/content/libraries/Files.vue'),
|
||||||
|
props: route => ({ query: route.query.q })
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'content/libraries',
|
||||||
|
component: () => import('~/views/content/Base.vue'),
|
||||||
|
children: [{
|
||||||
|
path: '',
|
||||||
|
name: 'content.libraries.index',
|
||||||
|
component: () => import('~/views/content/libraries/Home.vue')
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'content/remote',
|
||||||
|
component: () => import('~/views/content/Base.vue'),
|
||||||
|
children: [{
|
||||||
|
path: '',
|
||||||
|
name: 'content.remote.index',
|
||||||
|
component: () => import('~/views/content/remote/Home.vue')
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
] as RouteRecordRaw[]
|
|
@ -0,0 +1,140 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import settings from './settings'
|
||||||
|
import library from './library'
|
||||||
|
import content from './content'
|
||||||
|
import manage from './manage'
|
||||||
|
import auth from './auth'
|
||||||
|
import user from './user'
|
||||||
|
import store from '~/store'
|
||||||
|
import { requireLoggedIn } from '~/router/guards'
|
||||||
|
import { useUploadsStore } from '~/ui/stores/upload'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'root',
|
||||||
|
component: () => import('~/ui/layouts/constrained.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'index',
|
||||||
|
component: () => import('~/components/Home.vue'),
|
||||||
|
beforeEnter (to, from, next) {
|
||||||
|
if (store.state.auth.authenticated) return next('/library')
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/index.html',
|
||||||
|
redirect: to => {
|
||||||
|
const { hash, query } = to
|
||||||
|
return { name: 'index', hash, query }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'upload',
|
||||||
|
name: 'upload',
|
||||||
|
component: () => import('~/ui/pages/upload.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'upload.index',
|
||||||
|
component: () => import('~/ui/pages/upload/index.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'running',
|
||||||
|
name: 'upload.running',
|
||||||
|
component: () => import('~/ui/pages/upload/running.vue'),
|
||||||
|
beforeEnter: (_to, _from, next) => {
|
||||||
|
const uploads = useUploadsStore()
|
||||||
|
if (uploads.uploadGroups.length === 0) {
|
||||||
|
next('/upload')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'history',
|
||||||
|
name: 'upload.history',
|
||||||
|
component: () => import('~/ui/pages/upload/history.vue')
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: 'all',
|
||||||
|
name: 'upload.all',
|
||||||
|
component: () => import('~/ui/pages/upload/all.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'about',
|
||||||
|
name: 'about',
|
||||||
|
component: () => import('~/components/About.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// TODO (wvffle): Make it a child of /about to have the active style on the sidebar link
|
||||||
|
path: 'about/pod',
|
||||||
|
name: 'about-pod',
|
||||||
|
component: () => import('~/components/AboutPod.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
name: 'notifications',
|
||||||
|
component: () => import('~/views/Notifications.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
name: 'search',
|
||||||
|
component: () => import('~/views/Search.vue')
|
||||||
|
},
|
||||||
|
...auth,
|
||||||
|
...settings,
|
||||||
|
...user,
|
||||||
|
{
|
||||||
|
path: 'favorites',
|
||||||
|
name: 'favorites',
|
||||||
|
component: () => import('~/components/favorites/List.vue'),
|
||||||
|
props: route => ({
|
||||||
|
defaultOrdering: route.query.ordering,
|
||||||
|
defaultPage: route.query.page ? +route.query.page : undefined
|
||||||
|
}),
|
||||||
|
beforeEnter: requireLoggedIn()
|
||||||
|
},
|
||||||
|
...content,
|
||||||
|
...manage,
|
||||||
|
...library,
|
||||||
|
{
|
||||||
|
path: 'channels/:id',
|
||||||
|
props: true,
|
||||||
|
component: () => import('~/views/channels/DetailBase.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'channels.detail',
|
||||||
|
component: () => import('~/views/channels/DetailOverview.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'episodes',
|
||||||
|
name: 'channels.detail.episodes',
|
||||||
|
component: () => import('~/views/channels/DetailEpisodes.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subscriptions',
|
||||||
|
name: 'subscriptions',
|
||||||
|
component: () => import('~/views/channels/SubscriptionsList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: '404',
|
||||||
|
component: () => import('~/components/PageNotFound.vue')
|
||||||
|
}
|
||||||
|
] as RouteRecordRaw[]
|
|
@ -0,0 +1,246 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'library',
|
||||||
|
component: () => import('~/components/library/Library.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: () => import('~/components/library/Home.vue'),
|
||||||
|
name: 'library.index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'me',
|
||||||
|
component: () => import('~/components/library/Home.vue'),
|
||||||
|
name: 'library.me',
|
||||||
|
props: () => ({ scope: 'me' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'artists/',
|
||||||
|
name: 'library.artists.browse',
|
||||||
|
component: () => import('~/components/library/Artists.vue'),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'me/artists',
|
||||||
|
name: 'library.artists.me',
|
||||||
|
component: () => import('~/components/library/Artists.vue'),
|
||||||
|
props: { scope: 'me' },
|
||||||
|
meta: {
|
||||||
|
paginateBy: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums/',
|
||||||
|
name: 'library.albums.browse',
|
||||||
|
component: () => import('~/components/library/Albums.vue'),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'me/albums',
|
||||||
|
name: 'library.albums.me',
|
||||||
|
component: () => import('~/components/library/Albums.vue'),
|
||||||
|
props: { scope: 'me' },
|
||||||
|
meta: {
|
||||||
|
paginateBy: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'podcasts/',
|
||||||
|
name: 'library.podcasts.browse',
|
||||||
|
component: () => import('~/components/library/Podcasts.vue'),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'channels/',
|
||||||
|
name: 'library.channels.browse',
|
||||||
|
component: () => import('~/views/channels/List.vue'),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 30
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'radios/',
|
||||||
|
name: 'library.radios.browse',
|
||||||
|
component: () => import('~/components/library/Radios.vue'),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'me/radios/',
|
||||||
|
name: 'library.radios.me',
|
||||||
|
component: () => import('~/components/library/Radios.vue'),
|
||||||
|
props: { scope: 'me' },
|
||||||
|
meta: {
|
||||||
|
paginateBy: 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'radios/build',
|
||||||
|
name: 'library.radios.build',
|
||||||
|
component: () => import('~/components/library/radios/Builder.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'radios/build/:id',
|
||||||
|
name: 'library.radios.edit',
|
||||||
|
component: () => import('~/components/library/radios/Builder.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'radios/:id',
|
||||||
|
name: 'library.radios.detail',
|
||||||
|
component: () => import('~/views/radios/Detail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'playlists/',
|
||||||
|
name: 'library.playlists.browse',
|
||||||
|
component: () => import('~/views/playlists/List.vue'),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'me/playlists/',
|
||||||
|
name: 'library.playlists.me',
|
||||||
|
component: () => import('~/views/playlists/List.vue'),
|
||||||
|
props: { scope: 'me' },
|
||||||
|
meta: {
|
||||||
|
paginateBy: 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'playlists/:id',
|
||||||
|
name: 'library.playlists.detail',
|
||||||
|
component: () => import('~/views/playlists/Detail.vue'),
|
||||||
|
props: route => ({
|
||||||
|
id: route.params.id,
|
||||||
|
defaultEdit: route.query.mode === 'edit'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags/:id',
|
||||||
|
name: 'library.tags.detail',
|
||||||
|
component: () => import('~/components/library/TagDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'artists/:id',
|
||||||
|
component: () => import('~/components/library/ArtistBase.vue'),
|
||||||
|
props: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'library.artists.detail',
|
||||||
|
component: () => import('~/components/library/ArtistDetail.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit',
|
||||||
|
name: 'library.artists.edit',
|
||||||
|
component: () => import('~/components/library/ArtistEdit.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit/:editId',
|
||||||
|
name: 'library.artists.edit.detail',
|
||||||
|
component: () => import('~/components/library/EditDetail.vue'),
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums/:id',
|
||||||
|
component: () => import('~/components/library/AlbumBase.vue'),
|
||||||
|
props: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'library.albums.detail',
|
||||||
|
component: () => import('~/components/library/AlbumDetail.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit',
|
||||||
|
name: 'library.albums.edit',
|
||||||
|
component: () => import('~/components/library/AlbumEdit.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit/:editId',
|
||||||
|
name: 'library.albums.edit.detail',
|
||||||
|
component: () => import('~/components/library/EditDetail.vue'),
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tracks/:id',
|
||||||
|
component: () => import('~/components/library/TrackBase.vue'),
|
||||||
|
props: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'library.tracks.detail',
|
||||||
|
component: () => import('~/components/library/TrackDetail.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit',
|
||||||
|
name: 'library.tracks.edit',
|
||||||
|
component: () => import('~/components/library/TrackEdit.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit/:editId',
|
||||||
|
name: 'library.tracks.edit.detail',
|
||||||
|
component: () => import('~/components/library/EditDetail.vue'),
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'uploads/:id',
|
||||||
|
name: 'library.uploads.detail',
|
||||||
|
props: true,
|
||||||
|
component: () => import('~/components/library/UploadDetail.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// browse a single library via it's uuid
|
||||||
|
path: ':id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})',
|
||||||
|
props: true,
|
||||||
|
component: () => import('~/views/library/LibraryBase.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'library.detail',
|
||||||
|
component: () => import('~/views/library/DetailOverview.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums',
|
||||||
|
name: 'library.detail.albums',
|
||||||
|
component: () => import('~/views/library/DetailAlbums.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tracks',
|
||||||
|
name: 'library.detail.tracks',
|
||||||
|
component: () => import('~/views/library/DetailTracks.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit',
|
||||||
|
name: 'library.detail.edit',
|
||||||
|
component: () => import('~/views/library/Edit.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'upload',
|
||||||
|
name: 'library.detail.upload',
|
||||||
|
redirect: () => '/upload'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as RouteRecordRaw[]
|
|
@ -0,0 +1,188 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import { hasPermissions } from '~/router/guards'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'manage/settings',
|
||||||
|
name: 'manage.settings',
|
||||||
|
beforeEnter: hasPermissions('settings'),
|
||||||
|
component: () => import('~/views/admin/Settings.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manage/library',
|
||||||
|
beforeEnter: hasPermissions('library'),
|
||||||
|
component: () => import('~/views/admin/library/Base.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'edits',
|
||||||
|
name: 'manage.library.edits',
|
||||||
|
component: () => import('~/views/admin/library/EditsList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'artists',
|
||||||
|
name: 'manage.library.artists',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'artists' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'artists/:id',
|
||||||
|
name: 'manage.library.artists.detail',
|
||||||
|
component: () => import('~/views/admin/library/ArtistDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'channels',
|
||||||
|
name: 'manage.channels',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'channels' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'channels/:id',
|
||||||
|
name: 'manage.channels.detail',
|
||||||
|
component: () => import('~/views/admin/ChannelDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums',
|
||||||
|
name: 'manage.library.albums',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'albums' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums/:id',
|
||||||
|
name: 'manage.library.albums.detail',
|
||||||
|
component: () => import('~/views/admin/library/AlbumDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tracks',
|
||||||
|
name: 'manage.library.tracks',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'tracks' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tracks/:id',
|
||||||
|
name: 'manage.library.tracks.detail',
|
||||||
|
component: () => import('~/views/admin/library/TrackDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'libraries',
|
||||||
|
name: 'manage.library.libraries',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'libraries' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'libraries/:id',
|
||||||
|
name: 'manage.library.libraries.detail',
|
||||||
|
component: () => import('~/views/admin/library/LibraryDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'uploads',
|
||||||
|
name: 'manage.library.uploads',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'uploads' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'uploads/:id',
|
||||||
|
name: 'manage.library.uploads.detail',
|
||||||
|
component: () => import('~/views/admin/library/UploadDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags',
|
||||||
|
name: 'manage.library.tags',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'tags' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tags/:id',
|
||||||
|
name: 'manage.library.tags.detail',
|
||||||
|
component: () => import('~/views/admin/library/TagDetail.vue'),
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manage/users',
|
||||||
|
beforeEnter: hasPermissions('settings'),
|
||||||
|
component: () => import('~/views/admin/users/Base.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'users',
|
||||||
|
name: 'manage.users.users.list',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ type: 'users' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'invitations',
|
||||||
|
name: 'manage.users.invitations.list',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ type: 'invitations' })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manage/moderation',
|
||||||
|
beforeEnter: hasPermissions('moderation'),
|
||||||
|
component: () => import('~/views/admin/moderation/Base.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'domains',
|
||||||
|
name: 'manage.moderation.domains.list',
|
||||||
|
component: () => import('~/views/admin/moderation/DomainsList.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'domains/:id',
|
||||||
|
name: 'manage.moderation.domains.detail',
|
||||||
|
component: () => import('~/views/admin/moderation/DomainsDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts',
|
||||||
|
name: 'manage.moderation.accounts.list',
|
||||||
|
component: () => import('~/views/admin/CommonList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q, type: 'accounts' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:id',
|
||||||
|
name: 'manage.moderation.accounts.detail',
|
||||||
|
component: () => import('~/views/admin/moderation/AccountsDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reports',
|
||||||
|
name: 'manage.moderation.reports.list',
|
||||||
|
component: () => import('~/views/admin/moderation/ReportsList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q }),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reports/:id',
|
||||||
|
name: 'manage.moderation.reports.detail',
|
||||||
|
component: () => import('~/views/admin/moderation/ReportDetail.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests',
|
||||||
|
name: 'manage.moderation.requests.list',
|
||||||
|
component: () => import('~/views/admin/moderation/RequestsList.vue'),
|
||||||
|
props: route => ({ defaultQuery: route.query.q }),
|
||||||
|
meta: {
|
||||||
|
paginateBy: 25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'requests/:id',
|
||||||
|
name: 'manage.moderation.requests.detail',
|
||||||
|
component: () => import('~/views/admin/moderation/RequestDetail.vue'),
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as RouteRecordRaw[]
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('~/components/auth/Settings.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/applications/new',
|
||||||
|
name: 'settings.applications.new',
|
||||||
|
props: route => ({
|
||||||
|
scopes: route.query.scopes,
|
||||||
|
name: route.query.name,
|
||||||
|
redirect_uris: route.query.redirect_uris
|
||||||
|
}),
|
||||||
|
component: () => import('~/components/auth/ApplicationNew.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/plugins',
|
||||||
|
name: 'settings.plugins',
|
||||||
|
component: () => import('~/views/auth/Plugins.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/applications/:id/edit',
|
||||||
|
name: 'settings.applications.edit',
|
||||||
|
component: () => import('~/components/auth/ApplicationEdit.vue'),
|
||||||
|
props: true
|
||||||
|
}
|
||||||
|
] as RouteRecordRaw[]
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import store from '~/store'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ suffix: '.full', path: '@:username@:domain' },
|
||||||
|
{ suffix: '', path: '@:username' }
|
||||||
|
].map((route) => {
|
||||||
|
return {
|
||||||
|
path: route.path,
|
||||||
|
name: `profile${route.suffix}`,
|
||||||
|
component: () => import('~/views/auth/ProfileBase.vue'),
|
||||||
|
beforeEnter (to, from, next) {
|
||||||
|
if (!store.state.auth.authenticated && to.query.domain && store.getters['instance/domain'] !== to.query.domain) {
|
||||||
|
return next({ name: 'login', query: { next: to.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
props: true,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: `profile${route.suffix}.overview`,
|
||||||
|
component: () => import('~/views/auth/ProfileOverview.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'activity',
|
||||||
|
name: `profile${route.suffix}.activity`,
|
||||||
|
component: () => import('~/views/auth/ProfileActivity.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'manageUploads',
|
||||||
|
name: `profile${route.suffix}.manageUploads`,
|
||||||
|
component: () => import('~/views/auth/ManageUploads.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}) as RouteRecordRaw[]
|
|
@ -0,0 +1,258 @@
|
||||||
|
import { defineStore, acceptHMRUpdate } from 'pinia'
|
||||||
|
import { computed, reactive, readonly, ref, markRaw, toRaw, unref, watch } from 'vue'
|
||||||
|
import { whenever, useWebWorker } from '@vueuse/core'
|
||||||
|
import { nanoid } from 'nanoid'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import FileMetadataParserWorker from '~/ui/workers/file-metadata-parser.ts?worker'
|
||||||
|
import type { MetadataParsingResult } from '~/ui/workers/file-metadata-parser'
|
||||||
|
|
||||||
|
import type { Tags } from '~/ui/composables/metadata'
|
||||||
|
import useLogger from '~/composables/useLogger'
|
||||||
|
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
||||||
|
|
||||||
|
export type UploadGroupType = 'music-library' | 'music-channel' | 'podcast-channel'
|
||||||
|
export type FailReason = 'missing-tags' | 'upload-failed' | 'upload-cancelled' | 'import-failed'
|
||||||
|
|
||||||
|
export class UploadGroupEntry {
|
||||||
|
id = nanoid()
|
||||||
|
abortController = new AbortController()
|
||||||
|
progress = 0
|
||||||
|
guid?: string
|
||||||
|
|
||||||
|
error?: Error
|
||||||
|
failReason?: FailReason
|
||||||
|
importedAt?: Date
|
||||||
|
|
||||||
|
metadata?: {
|
||||||
|
tags: Tags,
|
||||||
|
coverUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor (public file: File, public uploadGroup: UploadGroup) {
|
||||||
|
UploadGroup.entries[this.id] = this
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload () {
|
||||||
|
if (!this.metadata) return
|
||||||
|
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('metadata', JSON.stringify({
|
||||||
|
title: this.metadata.tags.title,
|
||||||
|
album: { name: this.metadata.tags.album },
|
||||||
|
artist: { name: this.metadata.tags.artist }
|
||||||
|
}))
|
||||||
|
|
||||||
|
body.append('target', JSON.stringify({
|
||||||
|
library: this.uploadGroup.targetGUID
|
||||||
|
}))
|
||||||
|
|
||||||
|
body.append('audioFile', this.file)
|
||||||
|
|
||||||
|
const logger = useLogger()
|
||||||
|
const { data } = await axios.post(this.uploadGroup.uploadUrl, body, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
signal: this.abortController.signal,
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
// 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
|
||||||
|
this.progress = Math.floor(e.loaded / (e.total ?? this.file.size) * 100)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`[${this.id}] upload complete!`)
|
||||||
|
this.guid = data.guid
|
||||||
|
}
|
||||||
|
|
||||||
|
fail (reason: FailReason, error: Error) {
|
||||||
|
this.error = error
|
||||||
|
this.failReason = reason
|
||||||
|
this.importedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel (reason: FailReason = 'upload-cancelled', error: Error = new Error('Upload cancelled')) {
|
||||||
|
this.fail(reason, error)
|
||||||
|
this.abortController.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
retry () {
|
||||||
|
this.error = undefined
|
||||||
|
this.failReason = undefined
|
||||||
|
this.importedAt = undefined
|
||||||
|
this.progress = 0
|
||||||
|
this.abortController = new AbortController()
|
||||||
|
|
||||||
|
if (!this.metadata) {
|
||||||
|
this.fail('missing-tags', new Error('Missing metadata'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadQueue.push(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadGroup {
|
||||||
|
static entries = reactive(Object.create(null))
|
||||||
|
|
||||||
|
queue: UploadGroupEntry[] = []
|
||||||
|
createdAt = new Date()
|
||||||
|
|
||||||
|
constructor (
|
||||||
|
public guid: string,
|
||||||
|
public type: UploadGroupType,
|
||||||
|
public targetGUID: string,
|
||||||
|
public uploadUrl: string
|
||||||
|
) { }
|
||||||
|
|
||||||
|
get progress () {
|
||||||
|
return this.queue.reduce((total, entry) => total + entry.progress, 0) / this.queue.length
|
||||||
|
}
|
||||||
|
|
||||||
|
get failedCount () {
|
||||||
|
return this.queue.filter((entry) => entry.failReason).length
|
||||||
|
}
|
||||||
|
|
||||||
|
get importedCount () {
|
||||||
|
return this.queue.filter((entry) => entry.importedAt && !entry.failReason).length
|
||||||
|
}
|
||||||
|
|
||||||
|
get processingCount () {
|
||||||
|
return this.queue.filter((entry) => !entry.importedAt && !entry.failReason).length
|
||||||
|
}
|
||||||
|
|
||||||
|
queueUpload (file: File) {
|
||||||
|
const entry = new UploadGroupEntry(file, this)
|
||||||
|
this.queue.push(entry)
|
||||||
|
|
||||||
|
const { id, metadata } = entry
|
||||||
|
if (!metadata) {
|
||||||
|
const logger = useLogger()
|
||||||
|
logger.log('sending message to worker', id)
|
||||||
|
retrieveMetadata({ id, file })
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadQueue.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel () {
|
||||||
|
for (const entry of this.queue) {
|
||||||
|
if (entry.importedAt) continue
|
||||||
|
entry.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
retry () {
|
||||||
|
for (const entry of this.queue) {
|
||||||
|
if (!entry.failReason) continue
|
||||||
|
entry.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadQueue: UploadGroupEntry[] = reactive([])
|
||||||
|
const uploadGroups: UploadGroup[] = reactive([])
|
||||||
|
const currentUploadGroup = ref<UploadGroup>()
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
|
||||||
|
// Remove the upload group from the list if there are no uploads
|
||||||
|
watch(currentUploadGroup, (_, from) => {
|
||||||
|
if (from && from.queue.length === 0) {
|
||||||
|
const index = uploadGroups.indexOf(from)
|
||||||
|
if (index === -1) return
|
||||||
|
uploadGroups.splice(index, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tag extraction with a Web Worker
|
||||||
|
const { post: retrieveMetadata, data: workerMetadata } = useWebWorker<MetadataParsingResult>(() => new FileMetadataParserWorker())
|
||||||
|
whenever(workerMetadata, (reactiveData) => {
|
||||||
|
const data = toRaw(unref(reactiveData))
|
||||||
|
const entry = UploadGroup.entries[data.id]
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
entry.metadata = {
|
||||||
|
tags: markRaw(data.tags),
|
||||||
|
coverUrl: data.coverUrl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entry.cancel('missing-tags', data.error)
|
||||||
|
const logger = useLogger()
|
||||||
|
logger.warn(`Failed to parse metadata for file ${entry.file.name}:`, data.error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useUploadsStore = defineStore('uploads', () => {
|
||||||
|
const logger = useLogger()
|
||||||
|
|
||||||
|
useWebSocketHandler('import.status_updated', (event) => {
|
||||||
|
for (const group of uploadGroups) {
|
||||||
|
const upload = group.queue.find(entry => entry.guid === event.upload.uuid)
|
||||||
|
if (!upload) continue
|
||||||
|
|
||||||
|
if (event.new_status !== 'errored') {
|
||||||
|
// TODO: Find out what other field to use here
|
||||||
|
// @ts-expect-error wrong field
|
||||||
|
upload.importedAt = event.upload.import_date
|
||||||
|
} else {
|
||||||
|
// TODO: Add second parameter `error`
|
||||||
|
// @ts-expect-error missing parameter
|
||||||
|
upload.fail('import-failed')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const createUploadGroup = async (type: UploadGroupType, targetGUID: string) => {
|
||||||
|
const { data } = await axios.post('/api/v2/upload-groups', { baseUrl: '/' })
|
||||||
|
const uploadGroup = new UploadGroup(data.guid, type, targetGUID, data.uploadUrl)
|
||||||
|
uploadGroups.push(uploadGroup)
|
||||||
|
currentUploadGroup.value = uploadGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUpload = computed(() => uploadQueue[currentIndex.value])
|
||||||
|
const isUploading = computed(() => !!currentUpload.value)
|
||||||
|
const currentUploadWithMetadata = computed(() => currentUpload.value?.metadata ? currentUpload.value : undefined)
|
||||||
|
|
||||||
|
// Upload the file whenever it is available
|
||||||
|
whenever(currentUploadWithMetadata, (entry) => entry.upload().catch((error) => {
|
||||||
|
// The tags were missing, so we have cancelled the upload
|
||||||
|
if (error.code === 'ERR_CANCELED') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.fail('upload-failed', error)
|
||||||
|
logger.error(error)
|
||||||
|
}).finally(() => {
|
||||||
|
// Move to the next upload despite failing
|
||||||
|
currentIndex.value += 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Prevent the user from leaving the page while uploading
|
||||||
|
window.addEventListener('beforeunload', (event) => {
|
||||||
|
if (isUploading.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
return (event.returnValue = 'The upload is still in progress. Are you sure you want to leave?')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const progress = computed(() => {
|
||||||
|
return uploadGroups.reduce((acc, group) => acc + group.progress, 0) / uploadGroups.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return public API
|
||||||
|
return {
|
||||||
|
isUploading,
|
||||||
|
currentIndex: readonly(currentIndex),
|
||||||
|
currentUpload,
|
||||||
|
queue: readonly(uploadQueue),
|
||||||
|
uploadGroups,
|
||||||
|
createUploadGroup,
|
||||||
|
currentUploadGroup,
|
||||||
|
progress
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useUploadsStore, import.meta.hot))
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { getCoverUrl, getTags, type Tags } from '~/ui/composables/metadata'
|
||||||
|
|
||||||
|
export interface MetadataParsingSuccess {
|
||||||
|
id: string
|
||||||
|
status: 'success'
|
||||||
|
tags: Tags
|
||||||
|
coverUrl: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataParsingFailure {
|
||||||
|
id: string
|
||||||
|
status: 'failure'
|
||||||
|
error: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MetadataParsingResult = MetadataParsingSuccess | MetadataParsingFailure
|
||||||
|
|
||||||
|
const parse = async (id: string, file: File) => {
|
||||||
|
try {
|
||||||
|
const tags = await getTags(file)
|
||||||
|
const coverUrl = await getCoverUrl(tags)
|
||||||
|
|
||||||
|
postMessage({ id, status: 'success', tags, coverUrl })
|
||||||
|
} catch (error) {
|
||||||
|
postMessage({ id, status: 'failure', error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener('message', async (event) => {
|
||||||
|
parse(event.data.id, event.data.file)
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
// java String#hashCode
|
// java String#hashCode
|
||||||
export function hashCode (str: string) {
|
export function hashCode (str = 'default') {
|
||||||
let hash = 0
|
let hash = 0
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const preventNonNumeric = (e: KeyboardEvent) => {
|
||||||
|
if (!e.key.match(/![0-9]$/)) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ export function humanSize (bytes: number, isSI = true) {
|
||||||
const threshold = isSI ? 1000 : 1024
|
const threshold = isSI ? 1000 : 1024
|
||||||
|
|
||||||
if (Math.abs(bytes) < threshold) {
|
if (Math.abs(bytes) < threshold) {
|
||||||
return `${bytes} B`
|
return `${Math.floor(bytes)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const units = HUMAN_UNITS[isSI ? 'SI' : 'powerOf2']
|
const units = HUMAN_UNITS[isSI ? 'SI' : 'powerOf2']
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
/// <reference types="semantic-ui" />
|
|
||||||
|
|
||||||
import $ from 'jquery'
|
|
||||||
|
|
||||||
export const setupDropdown = (selector: string | HTMLElement = '.ui.dropdown', el: Element = document.body) => {
|
|
||||||
const $dropdown = typeof selector === 'string'
|
|
||||||
? $(el).find(selector)
|
|
||||||
: $(selector)
|
|
||||||
|
|
||||||
$dropdown.dropdown({
|
|
||||||
selectOnKeydown: false,
|
|
||||||
action (text: unknown, value: unknown, $el: JQuery) {
|
|
||||||
// used to ensure focusing the dropdown and clicking via keyboard
|
|
||||||
// works as expected
|
|
||||||
$el[0]?.click()
|
|
||||||
|
|
||||||
$dropdown.dropdown('hide')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return $dropdown
|
|
||||||
}
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Track, Album, ArtistCredit, QueueItemSource } from '~/types'
|
import type { Track, Album, ArtistCredit, QueueItemSource } from '~/types'
|
||||||
|
import type { components } from '~/generated/types'
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import type { QueueTrack } from '~/composables/audio/queue'
|
import type { QueueTrack } from '~/composables/audio/queue'
|
||||||
|
|
||||||
|
@ -30,8 +31,7 @@ export function generateTrackCreditStringFromQueue (track: QueueTrack | QueueIte
|
||||||
|
|
||||||
export function getArtistCoverUrl (artistCredits: ArtistCredit[]): string | undefined {
|
export function getArtistCoverUrl (artistCredits: ArtistCredit[]): string | undefined {
|
||||||
for (const artistCredit of artistCredits) {
|
for (const artistCredit of artistCredits) {
|
||||||
const cover = artistCredit.artist.cover
|
const mediumSquareCrop = getSimpleArtistCoverUrl(artistCredit.artist, 'medium_square_crop')
|
||||||
const mediumSquareCrop = cover?.urls?.medium_square_crop
|
|
||||||
|
|
||||||
if (mediumSquareCrop) {
|
if (mediumSquareCrop) {
|
||||||
return store.getters['instance/absoluteUrl'](mediumSquareCrop)
|
return store.getters['instance/absoluteUrl'](mediumSquareCrop)
|
||||||
|
@ -39,3 +39,17 @@ export function getArtistCoverUrl (artistCredits: ArtistCredit[]): string | unde
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSimpleArtistCover = (artist: components['schemas']['SimpleChannelArtist'] | components['schemas']['Artist'] | components['schemas']['ArtistWithAlbums']) =>
|
||||||
|
(field: 'original' | 'small_square_crop' | 'medium_square_crop' | 'large_square_crop') =>
|
||||||
|
artist.cover
|
||||||
|
? (field in artist.cover ? artist.cover.urls[field] : null)
|
||||||
|
: null
|
||||||
|
|
||||||
|
/** Returns the absolute Url of this artist's cover on this instance
|
||||||
|
*
|
||||||
|
* @param artist: a simple artist
|
||||||
|
* @param field: the size you want
|
||||||
|
*/
|
||||||
|
export const getSimpleArtistCoverUrl = (artist: components['schemas']['SimpleChannelArtist'] | components['schemas']['Artist'] | components['schemas']['ArtistWithAlbums'], field: 'original' | 'small_square_crop' | 'medium_square_crop' | 'large_square_crop') =>
|
||||||
|
store.getters['instance/absoluteUrl'](getSimpleArtistCover(artist)(field))
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,32 +0,0 @@
|
||||||
import DangerousButton from '~/components/common/DangerousButton.vue'
|
|
||||||
import AlbumDetail from '~/views/admin/library/AlbumDetail.vue'
|
|
||||||
import SanitizedHtml from '~/components/SanitizedHtml.vue'
|
|
||||||
import HumanDate from '~/components/common/HumanDate.vue'
|
|
||||||
|
|
||||||
import { shallowMount } from '@vue/test-utils'
|
|
||||||
import { vi } from 'vitest'
|
|
||||||
|
|
||||||
import router from '~/router'
|
|
||||||
import store from '~/store'
|
|
||||||
|
|
||||||
describe('views/admin/library', () => {
|
|
||||||
describe('Album details', () => {
|
|
||||||
it('displays default cover', async () => {
|
|
||||||
const wrapper = shallowMount(AlbumDetail, {
|
|
||||||
props: { id: 1 },
|
|
||||||
directives: {
|
|
||||||
dropdown: () => null,
|
|
||||||
title: () => null,
|
|
||||||
lazy: () => null
|
|
||||||
},
|
|
||||||
global: {
|
|
||||||
stubs: { DangerousButton, HumanDate, SanitizedHtml },
|
|
||||||
plugins: [router, store]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
await vi.waitUntil(() => wrapper.find('img').exists())
|
|
||||||
expect(wrapper.find('img').attributes('src')).to.include('default-cover')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
Loading…
Reference in New Issue