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