diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 6bbfc50a5..c88c91d50 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -1,13 +1,145 @@ + + - - diff --git a/front/src/components/audio/album/Card.vue b/front/src/components/audio/album/Card.vue index 8ac0f7447..8ff0d5a3c 100644 --- a/front/src/components/audio/album/Card.vue +++ b/front/src/components/audio/album/Card.vue @@ -1,3 +1,23 @@ + + - - diff --git a/front/src/components/audio/podcast/MobileRow.vue b/front/src/components/audio/podcast/MobileRow.vue index b2e39126c..688a2fa3a 100644 --- a/front/src/components/audio/podcast/MobileRow.vue +++ b/front/src/components/audio/podcast/MobileRow.vue @@ -1,3 +1,49 @@ + + \ No newline at end of file diff --git a/front/src/components/library/ArtistBase.vue b/front/src/components/library/ArtistBase.vue index b2c14a18c..d184c8732 100644 --- a/front/src/components/library/ArtistBase.vue +++ b/front/src/components/library/ArtistBase.vue @@ -1,3 +1,96 @@ + + - - diff --git a/front/src/components/library/TrackBase.vue b/front/src/components/library/TrackBase.vue index a43aee07d..efd3ac8c8 100644 --- a/front/src/components/library/TrackBase.vue +++ b/front/src/components/library/TrackBase.vue @@ -1,3 +1,123 @@ + + - - diff --git a/front/src/components/manage/ChannelsTable.vue b/front/src/components/manage/ChannelsTable.vue index b734d45eb..9cd884263 100644 --- a/front/src/components/manage/ChannelsTable.vue +++ b/front/src/components/manage/ChannelsTable.vue @@ -24,6 +24,7 @@ const props = withDefaults(defineProps(), { const page = ref(1) type ResponseType = { count: number, results: any[] } const result = ref(null) +const search = ref() const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props.defaultQuery, props.updateUrl) const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props.orderingConfigName) @@ -81,7 +82,7 @@ const labels = computed(() => ({
-
+ -import axios from 'axios' -import jQuery from 'jquery' - -export default { - computed: { - playable () { - if (this.isPlayable) { - return true - } - if (this.track) { - return this.track.uploads && this.track.uploads.length > 0 - } else if (this.artist && this.artist.tracks_count) { - return this.artist.tracks_count > 0 - } else if (this.artist && this.artist.albums) { - return this.artist.albums.filter((a) => { - return a.is_playable === true - }).length > 0 - } else if (this.tracks) { - return this.tracks.filter((t) => { - return t.uploads && t.uploads.length > 0 - }).length > 0 - } - return false - }, - filterableArtist () { - if (this.track) { - return this.track.artist - } - if (this.album) { - return this.album.artist - } - if (this.artist) { - return this.artist - } - return null - } - }, - methods: { - filterArtist () { - this.$store.dispatch('moderation/hide', { type: 'artist', target: this.filterableArtist }) - }, - activateTrack (track, index) { - if ( - this.currentTrack && - this.isPlaying && - track.id === this.currentTrack.id - ) { - this.pausePlayback() - } else if ( - this.currentTrack && - !this.isPlaying && - track.id === this.currentTrack.id - ) { - this.resumePlayback() - } else { - this.replacePlay(this.tracks, index) - } - }, - getTracksPage (page, params, resolve, tracks) { - if (page > 10) { - // it's 10 * 100 tracks already, let's stop here - resolve(tracks) - } - // when fetching artists/or album tracks, sometimes, we may have to fetch - // multiple pages - const self = this - params.page_size = 100 - params.page = page - params.hidden = '' - params.playable = 'true' - tracks = tracks || [] - axios.get('tracks/', { params: params }).then((response) => { - response.data.results.forEach(t => { - tracks.push(t) - }) - if (response.data.next) { - self.getTracksPage(page + 1, params, resolve, tracks) - } else { - resolve(tracks) - } - }) - }, - getPlayableTracks () { - const self = this - this.isLoading = true - const getTracks = new Promise((resolve, reject) => { - if (self.tracks) { - resolve(self.tracks) - } else if (self.track) { - if (!self.track.uploads || self.track.uploads.length === 0) { - // fetch uploads from api - axios.get(`tracks/${self.track.id}/`).then((response) => { - resolve([response.data]) - }) - } else { - resolve([self.track]) - } - } else if (self.playlist) { - const url = 'playlists/' + self.playlist.id + '/' - axios.get(url + 'tracks/').then((response) => { - const artistIds = self.$store.getters['moderation/artistFilters']().map((f) => { - return f.target.id - }) - let tracks = response.data.results.map(plt => { - return plt.track - }) - if (artistIds.length > 0) { - // skip tracks from hidden artists - tracks = tracks.filter((t) => { - const matchArtist = artistIds.indexOf(t.artist.id) > -1 - return !((matchArtist || t.album) && artistIds.indexOf(t.album.artist.id) > -1) - }) - } - - resolve(tracks) - }) - } else if (self.artist) { - const params = { artist: self.artist.id, include_channels: 'true', ordering: 'album__release_date,disc_number,position' } - self.getTracksPage(1, params, resolve) - } else if (self.album) { - const params = { album: self.album.id, include_channels: 'true', ordering: 'disc_number,position' } - self.getTracksPage(1, params, resolve) - } else if (self.library) { - const params = { library: self.library.uuid, ordering: '-creation_date' } - self.getTracksPage(1, params, resolve) - } - }) - return getTracks.then((tracks) => { - setTimeout(e => { - self.isLoading = false - }, 250) - return tracks.filter(e => { - return e.uploads && e.uploads.length > 0 - }) - }) - }, - add () { - const self = this - this.getPlayableTracks().then((tracks) => { - self.$store.dispatch('queue/appendMany', { tracks: tracks }).then(() => self.addMessage(tracks)) - }) - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - }, - replacePlay () { - const self = this - self.$store.dispatch('queue/clean') - this.getPlayableTracks().then((tracks) => { - self.$store.dispatch('queue/appendMany', { tracks: tracks }).then(() => { - if (self.track) { - // set queue position to selected track - const trackIndex = self.tracks.findIndex(track => track.id === self.track.id) - self.$store.dispatch('queue/currentIndex', trackIndex) - } - self.addMessage(tracks) - }) - }) - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - }, - addNext (next) { - const self = this - const wasEmpty = this.$store.state.queue.tracks.length === 0 - this.getPlayableTracks().then((tracks) => { - self.$store.dispatch('queue/appendMany', { tracks: tracks, index: self.$store.state.queue.currentIndex + 1 }).then(() => self.addMessage(tracks)) - const goNext = next && !wasEmpty - if (goNext) { - self.$store.dispatch('queue/next') - } - }) - jQuery(self.$el).find('.ui.dropdown').dropdown('hide') - }, - addMessage (tracks) { - if (tracks.length < 1) { - return - } - const msg = this.$npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length) - this.$store.commit('ui/addMessage', { - content: this.$gettextInterpolate(msg, { count: tracks.length }), - date: new Date() - }) - } - } -} - diff --git a/front/src/components/mixins/Report.vue b/front/src/components/mixins/Report.vue deleted file mode 100644 index 66a49540b..000000000 --- a/front/src/components/mixins/Report.vue +++ /dev/null @@ -1,104 +0,0 @@ - diff --git a/front/src/components/mixins/SmartSearch.vue b/front/src/components/mixins/SmartSearch.vue deleted file mode 100644 index 97e308ed6..000000000 --- a/front/src/components/mixins/SmartSearch.vue +++ /dev/null @@ -1,68 +0,0 @@ - diff --git a/front/src/composables/audio/usePlayOptions.ts b/front/src/composables/audio/usePlayOptions.ts new file mode 100644 index 000000000..d93c6c2b9 --- /dev/null +++ b/front/src/composables/audio/usePlayOptions.ts @@ -0,0 +1,200 @@ +import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import { useStore } from '~/store' +import { useGettext } from 'vue3-gettext' +import { computed, ref } from "vue" +import axios from 'axios' +import { ContentFilter } from '~/store/moderation' +import usePlayer from '~/composables/audio/usePlayer' +import useQueue from '~/composables/audio/useQueue' +import { useCurrentElement } from '@vueuse/core' + +export interface PlayOptionsProps { + isPlayable?: boolean + tracks?: Track[] + track?: Track | null + artist?: Artist | null + album?: Album | null + playlist?: Playlist | null + library?: Library | null + channel?: Channel | null + account?: Actor | null +} + +export default (props: PlayOptionsProps) => { + // TODO (wvffle): Test if we can defineProps in composable + + const store = useStore() + const { resume, pause, playing } = usePlayer() + const { currentTrack } = useQueue() + + const playable = computed(() => { + if (props.isPlayable) { + return true + } + + if (props.track) { + return props.track.uploads?.length > 0 + } else if (props.artist) { + return props.artist.tracks_count > 0 + || props.artist.albums.some((album) => album.is_playable === true) + } else if (props.tracks) { + return props.tracks.some((track) => (track.uploads?.length ?? 0) > 0) + } + + return false + }) + + const filterableArtist = computed(() => props.track?.artist ?? props.album?.artist ?? props.artist) + const filterArtist = () => store.dispatch('moderation/hide', { type: 'artist', target: filterableArtist.value }) + + const { $npgettext } = useGettext() + const addMessage = (tracks: Track[]) => { + if (!tracks.length) { + return + } + + store.commit('ui/addMessage', { + content: $npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length, { + count: tracks.length.toString() + }), + date: new Date() + }) + } + + const getTracksPage = async (params: object, page = 1, tracks: Track[] = []): Promise => { + if (page > 11) { + // it's 10 * 100 tracks already, let's stop here + return tracks + } + + // when fetching artists/or album tracks, sometimes, we may have to fetch + // multiple pages + const response = await axios.get('tracks/', { + params: { + ...params, + page_size: 100, + page, + hidden: '', + playable: true + } + }) + + tracks.push(...response.data.results) + if (response.data.next) { + return getTracksPage(params, page + 1, tracks) + } + + return tracks + } + + const isLoading = ref(false) + const getPlayableTracks = async () => { + isLoading.value = true + + const tracks: Track[] = [] + + // TODO (wvffle): There is no channel? + if (props.tracks?.length) { + tracks.push(...props.tracks) + } else if (props.track) { + if (props.track.uploads?.length) { + tracks.push(props.track) + } else { + // fetch uploads from api + const response = await axios.get(`tracks/${props.track.id}/`) + tracks.push(response.data as Track) + } + } else if (props.playlist) { + const response = await axios.get(`playlists/${props.playlist.id}/tracks/`) + const playlistTracks = (response.data.results as Array<{ track: Track }>).map(({ track }) => track as Track) + + const artistIds = store.getters['moderation/artistFilters']().map((filter: ContentFilter) => filter.target.id) + if (artistIds.length) { + tracks.push(...playlistTracks.filter((track) => { + return !((artistIds.includes(track.artist?.id) || track.album) && artistIds.includes(track.album?.artist.id)) + })) + } else { + tracks.push(...playlistTracks) + } + } else if (props.artist) { + tracks.push(...await getTracksPage({ artist: props.artist.id, include_channels: 'true', ordering: 'album__release_date,disc_number,position' })) + } else if (props.album) { + tracks.push(...await getTracksPage({ album: props.album.id, include_channels: 'true', ordering: 'disc_number,position' })) + } else if (props.library) { + tracks.push(...await getTracksPage({ library: props.library.uuid, ordering: '-creation_date' })) + } + + // TODO (wvffle): It was behind 250ms timeout, why? + isLoading.value = false + + return tracks.filter(track => track.uploads?.length) + } + + const el = useCurrentElement() + const enqueue = async () => { + // @ts-expect-error dropdown is from semantic ui + jQuery(el.value).find('.ui.dropdown').dropdown('hide') + + const tracks = await getPlayableTracks() + store.dispatch('queue/appendMany', { tracks: tracks }).then(() => addMessage(tracks)) + } + + const enqueueNext = async (next = false) => { + // @ts-expect-error dropdown is from semantic ui + jQuery(el.value).find('.ui.dropdown').dropdown('hide') + + const tracks = await getPlayableTracks() + + const wasEmpty = store.state.queue.tracks.length === 0 + await store.dispatch('queue/appendMany', { tracks, index: store.state.queue.currentIndex + 1 }) + + if (next && !wasEmpty) { + await store.dispatch('queue/next') + resume() + } + + addMessage(tracks) + } + + const replacePlay = async () => { + store.dispatch('queue/clean') + + // @ts-expect-error dropdown is from semantic ui + jQuery(el.value).find('.ui.dropdown').dropdown('hide') + + const tracks = await getPlayableTracks() + await store.dispatch('queue/appendMany', { tracks }) + + if (props.track && props.tracks?.length) { + // set queue position to selected track + const trackIndex = props.tracks.findIndex(track => track.id === props.track?.id) + store.dispatch('queue/currentIndex', trackIndex) + } else { + store.dispatch('queue/currentIndex', 0) + } + + resume() + addMessage(tracks) + } + + const activateTrack = (track: Track, index: number) => { + if (playing.value && track.id === currentTrack.value?.id) { + pause() + } else if (!playing.value && track.id === currentTrack.value?.id) { + resume() + } else { + replacePlay() + } + } + + return { + playable, + filterableArtist, + filterArtist, + enqueue, + enqueueNext, + replacePlay, + activateTrack, + isLoading + } +} diff --git a/front/src/composables/audio/usePlayer.ts b/front/src/composables/audio/usePlayer.ts index f7dd306e1..0d27a8ed9 100644 --- a/front/src/composables/audio/usePlayer.ts +++ b/front/src/composables/audio/usePlayer.ts @@ -44,7 +44,7 @@ const playTrack = async (track: Track, oldTrack?: Track) => { .then(response => response.data, () => null) } - if (track == null) { + if (track === null) { store.commit('player/isLoadingAudio', false) store.dispatch('player/trackErrored') return diff --git a/front/src/composables/moderation/useReport.ts b/front/src/composables/moderation/useReport.ts new file mode 100644 index 000000000..1c97cf289 --- /dev/null +++ b/front/src/composables/moderation/useReport.ts @@ -0,0 +1,139 @@ +import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types' +import { gettext } from '~/init/locale' +import store from '~/store' +const { $pgettext } = gettext + +interface Objects { + track?: Track | null + album?: Album | null + artist?: Artist | null + playlist?: Playlist | null + account?: Actor | null + library?: Library | null + channel?: Channel | null +} + +interface ReportableObject { + label: string, + target: { + type: keyof Objects + label: string + typeLabel: string + _obj: Objects[keyof Objects] + + full_username?: string + id?: string + uuid?: string + } +} + +const getReportableObjects = ({ track, album, artist, playlist, account, library, channel }: Objects) => { + const reportableObjs: ReportableObject[] = [] + + if (account) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report @%{ username }…', { username: account.preferred_username }), + target: { + type: 'account', + _obj: account, + full_username: account.full_username, + label: account.full_username, + typeLabel: $pgettext('*/*/*/Noun', 'Account') + } + }) + } + + if (track) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this track…'), + target: { + type: 'track', + id: track.id, + _obj: track, + label: track.title, + typeLabel: $pgettext('*/*/*/Noun', 'Track') + } + }) + + album = track.album + artist = track.artist + } + + if (album) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this album…'), + target: { + type: 'album', + id: album.id, + label: album.title, + _obj: album, + typeLabel: $pgettext('*/*/*', 'Album') + } + }) + + if (!artist) { + artist = album.artist + } + } + + if (channel) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this channel…'), + target: { + type: 'channel', + uuid: channel.uuid, + label: channel.artist?.name ?? $pgettext('*/*/*', 'Unknown artist'), + _obj: channel, + typeLabel: $pgettext('*/*/*', 'Channel') + } + }) + } else if (artist) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this artist…'), + target: { + type: 'artist', + id: artist.id, + label: artist.name, + _obj: artist, + typeLabel: $pgettext('*/*/*/Noun', 'Artist') + } + }) + } + + if (playlist) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this playlist…'), + target: { + type: 'playlist', + id: playlist.id, + label: playlist.name, + _obj: playlist, + typeLabel: $pgettext('*/*/*', 'Playlist') + } + }) + } + + if (library) { + reportableObjs.push({ + label: $pgettext('*/Moderation/*/Verb', 'Report this library…'), + target: { + type: 'library', + uuid: library.uuid, + label: library.name, + _obj: library, + typeLabel: $pgettext('*/*/*/Noun', 'Library') + } + }) + } + + return reportableObjs +} + +const report = (obj: ReportableObject) => { + store.dispatch('moderation/report', obj.target) +} + +export default () => ({ + getReportableObjects, + report +}) diff --git a/front/src/router/guards.ts b/front/src/router/guards.ts new file mode 100644 index 000000000..9b0379b3e --- /dev/null +++ b/front/src/router/guards.ts @@ -0,0 +1,13 @@ + +import { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' +import { Permission } from '~/store/auth' +import store from '~/store' + +export const hasPermissions = (permission: Permission) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => { + if (store.state.auth.authenticated && store.state.auth.availablePermissions[permission]) { + return next() + } + + console.log('Not authenticated. Redirecting to library.') + next({ name: 'library.index' }) +} \ No newline at end of file diff --git a/front/src/router/routes/manage.ts b/front/src/router/routes/manage.ts index 6b4e544c3..b10eb2f0f 100644 --- a/front/src/router/routes/manage.ts +++ b/front/src/router/routes/manage.ts @@ -1,21 +1,11 @@ -import { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from 'vue-router' -import { Permission } from '~/store/auth' -import store from '~/store' - -const hasPermissions = (permission: Permission) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => { - if (store.state.auth.authenticated && store.state.auth.availablePermissions[permission]) { - return next() - } - - console.log('Not authenticated. Redirecting to library.') - next({ name: 'library.index' }) -} +import { RouteRecordRaw } from 'vue-router' +import { hasPermissions } from '~/router/guards' export default [ { path: '/manage/settings', name: 'manage.settings', - beforeEnter: hasPermissions('admin'), + beforeEnter: hasPermissions('settings'), component: () => import('~/views/admin/Settings.vue') }, { @@ -117,7 +107,7 @@ export default [ }, { path: '/manage/users', - beforeEnter: hasPermissions('admin'), + beforeEnter: hasPermissions('settings'), component: () => import('~/views/admin/users/Base.vue'), children: [ { diff --git a/front/src/router/routes/user.ts b/front/src/router/routes/user.ts index 59fe1dc38..b6c44d97f 100644 --- a/front/src/router/routes/user.ts +++ b/front/src/router/routes/user.ts @@ -1,4 +1,5 @@ import { RouteRecordRaw } from 'vue-router' +import store from '~/store' export default [ { suffix: '.full', path: '/@:username@:domain' }, @@ -8,6 +9,13 @@ export default [ 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: [ { diff --git a/front/src/store/auth.ts b/front/src/store/auth.ts index 0aea65cf4..f814e41e3 100644 --- a/front/src/store/auth.ts +++ b/front/src/store/auth.ts @@ -22,6 +22,7 @@ interface Profile { full_username: string instance_support_message_display_date: string funkwhale_support_message_display_date: string + is_superuser: boolean } interface ScopedTokens { @@ -140,7 +141,7 @@ const store: Module = { } for (const [key, value] of Object.entries(payload)) { - state.profile[key as keyof Profile] = value + state.profile[key as keyof Profile] = value as never } }, oauthApp: (state, payload) => { diff --git a/front/src/store/moderation.ts b/front/src/store/moderation.ts index 1033679a3..c73750ff0 100644 --- a/front/src/store/moderation.ts +++ b/front/src/store/moderation.ts @@ -19,11 +19,12 @@ export interface State { } } -interface ContentFilter { +export interface ContentFilter { uuid: string creation_date: Date target: { type: 'artist' + id: string } } diff --git a/front/src/types.ts b/front/src/types.ts index d80986942..cd95942d7 100644 --- a/front/src/types.ts +++ b/front/src/types.ts @@ -30,22 +30,30 @@ export interface ThemeEntry { } // Track stuff -export type ContentCategory = 'podcast' +export type ContentCategory = 'podcast' | 'music' export interface Artist { id: string + fid: string + mbid?: string name: string description: Content cover?: Cover + channel?: Channel tags: string[] content_category: ContentCategory albums: Album[] + tracks_count: number + attributed_to: Actor + is_local: boolean } export interface Album { id: string + fid: string + mbid?: string title: string description: Content @@ -56,10 +64,15 @@ export interface Album { artist: Artist tracks_count: number tracks: Track[] + + is_playable: boolean + is_local: boolean } export interface Track { id: string + fid: string + mbid?: string title: string description: Content @@ -72,19 +85,63 @@ export interface Track { album?: Album artist?: Artist + disc_number: number - // TODO (wvffle): Make sure it really has listen_url listen_url: string + creation_date: string + attributed_to: Actor + + is_playable: boolean + is_local: boolean } export interface Channel { id: string + uuid: string artist?: Artist + actor: Actor + attributed_to: Actor + url?: string + rss_url: string + subscriptions_count: number + downloads_count: number +} + +export interface Library { + id: string + uuid: string + fid?: string + name: string + actor: Actor + uploads_count: number + size: number + description: string + privacy_level: 'everyone' | 'instance' | 'me' + creation_date: string + follow?: LibraryFollow + latest_scan: LibraryScan +} + +export interface LibraryScan { + processed_files: number + total_files: number + status: 'scanning' | 'pending' | 'finished' | 'errored' + errored_files: number + modification_date: string +} + +export interface LibraryFollow { + uuid: string + approved: boolean } export interface Cover { uuid: string + urls: { + original: string + medium_square_crop: string + } } export interface License { @@ -188,8 +245,12 @@ export interface FSLogs { logs: string[] } -// Yet uncategorized stuff +// Profile stuff export interface Actor { + fid?: string + name?: string + icon?: Cover + summary: string preferred_username: string full_username: string is_local: boolean diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue index 036b83ef1..d4ac719d0 100644 --- a/front/src/views/auth/ProfileBase.vue +++ b/front/src/views/auth/ProfileBase.vue @@ -1,3 +1,64 @@ + + - - diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue index fdfd13627..b324a127d 100644 --- a/front/src/views/channels/DetailBase.vue +++ b/front/src/views/channels/DetailBase.vue @@ -1,3 +1,120 @@ + + - + \ No newline at end of file diff --git a/front/src/views/library/DetailBase.vue b/front/src/views/library/DetailBase.vue index d9b993324..86d05473d 100644 --- a/front/src/views/library/DetailBase.vue +++ b/front/src/views/library/DetailBase.vue @@ -1,3 +1,84 @@ + + - -