Cleanup a lot of stuff

I've replaced `lodash` with `lodash-es`, so it can be tree-shaken

`~/modules` is a directory with application modules that run before app is mounted. Useful for configuration, web socket connection, and other stuff

`~/composables` is a directory with our custom composables. Much like `~/utils` but each util is in its own file
This commit is contained in:
Kasper Seweryn 2022-04-18 00:43:58 +02:00 committed by Georg Krause
parent e8d767f79d
commit d9ef5a9b6b
No known key found for this signature in database
GPG Key ID: 2970D504B2183D22
121 changed files with 1801 additions and 1449 deletions

View File

@ -28,7 +28,7 @@ Submitting a new language
1. Pull the latest version of ``develop``
2. Create a new branch, e.g ``git checkout -b translations-new-fr-ca``
3. Add your new language code and name in ``front/src/locales.js``. Use the native language name, as it is what appears in the UI selector.
3. Add your new language code and name in ``front/src/locales.ts``. Use the native language name, as it is what appears in the UI selector.
4. Create the ``po`` file from template:
.. code-block:: shell

View File

@ -24,6 +24,8 @@ module.exports = {
'vue/no-v-html': 'off', // TODO: tackle this properly
'vue/no-use-v-if-with-v-for': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'no-undef': 'off',
// TODO: Enable typescript rules later
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-empty-function': 'off'

View File

@ -8,7 +8,6 @@
<meta name="generator" content="Funkwhale">
<link rel="icon" href="/favicon.png">
<title>Funkwhale</title>
<script type="module" src="/src/main.js"></script>
<style>
#fake-app {
width: 100vw;
@ -86,9 +85,8 @@
</div>
</div>
</div>
<div id="app">
</div>
<!-- built files will be auto injected -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -18,7 +18,7 @@
},
"dependencies": {
"@vue/composition-api": "1.4.9",
"@vueuse/core": "8.2.6",
"@vueuse/core": "8.2.5",
"axios": "0.26.1",
"axios-auth-refresh": "3.2.2",
"diff": "5.0.0",
@ -26,10 +26,10 @@
"fomantic-ui-css": "2.8.8",
"howler": "2.2.3",
"js-logger": "1.6.1",
"lodash": "4.17.21",
"moment": "2.29.3",
"qs": "6.10.5",
"pinia": "^2.0.13",
"lodash-es": "4.17.21",
"register-service-worker": "1.7.2",
"sanitize-html": "2.7.0",
"sass": "1.49.11",
@ -42,6 +42,7 @@
"vue-router": "3.5.4",
"vue-upload-component": "2.8.22",
"vuedraggable": "2.24.3",
"vuex": "3.6.2",
"vuex-persistedstate": "4.1.0",
"vuex-router-sync": "5.0.0"
},
@ -49,6 +50,8 @@
"@babel/core": "7.17.12",
"@babel/plugin-transform-runtime": "7.17.12",
"@babel/preset-env": "7.16.11",
"@types/jquery": "^3.5.14",
"@types/lodash-es": "^4.17.6",
"@typescript-eslint/eslint-plugin": "^5.19.0",
"@vue/eslint-config-standard": "^6.1.0",
"@vue/eslint-config-typescript": "^10.0.0",
@ -60,7 +63,6 @@
"easygettext": "2.17.0",
"eslint": "8.11.0",
"eslint-config-standard": "16.0.3",
"eslint-config-standard-with-typescript": "^21.0.1",
"eslint-plugin-html": "6.2.0",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-node": "11.1.0",
@ -71,9 +73,8 @@
"moxios": "0.4.0",
"sinon": "13.0.2",
"typescript": "^4.6.3",
"unplugin-vue-components": "^0.19.3",
"unplugin-vue2-script-setup": "^0.10.2",
"vite": "2.9.5",
"vite": "2.8.6",
"vite-plugin-vue2": "1.9.3",
"vue-jest": "3.0.7",
"vue-template-compiler": "2.6.14"

View File

@ -3,7 +3,7 @@
cd "$(dirname $0)/.." # change into base directory
source scripts/utils.sh
locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | grep -v 'en_US' | xargs echo)
locales=$(tail -n +3 src/locales.ts | sed -E 's/^[^[]+\[] =//' | jq -r '.[].code' | grep -v 'en_US')
mkdir -p src/translations
for locale in $locales; do

View File

@ -3,7 +3,7 @@
cd "$(dirname $0)/.." # change into base directory
source scripts/utils.sh
locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | xargs echo)
locales=$(tail -n +3 src/locales.ts | sed -E 's/^[^[]+\[] =//' | jq -r '.[].code')
locales_dir="locales"
sources=$(find src -name '*.vue' -o -name '*.html' 2> /dev/null)
js_sources=$(find src -name '*.vue' -o -name '*.js')

View File

@ -11,7 +11,7 @@ cd "$(dirname $0)/.." # change into base directory
old_locales_dir=$1
new_locales_dir=$2
locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | xargs echo)
locales=$(tail -n +3 src/locales.ts | sed -E 's/^[^[]+\[] =//' | jq -r '.[].code')
# Generate .po files for each available language.
echo $locales

View File

@ -1,9 +1,164 @@
<script setup lang="ts">
import AudioPlayer from '@/components/audio/Player.vue'
import Queue from '@/components/Queue.vue'
import PlaylistModal from '@/components/playlists/PlaylistModal.vue'
import ChannelUploadModal from '@/components/channels/UploadModal.vue'
import Sidebar from '@/components/Sidebar.vue'
import ServiceMessages from '@/components/ServiceMessages.vue'
import SetInstanceModal from '@/components/SetInstanceModal.vue'
import ShortcutsModal from '@/components/ShortcutsModal.vue'
import FilterModal from '@/components/moderation/FilterModal.vue'
import ReportModal from '@/components/moderation/ReportModal.vue'
import {useIntervalFn, useWindowSize} from '@vueuse/core'
import GlobalEvents from '@/components/utils/global-events.vue'
import { computed, nextTick, onMounted, ref, watchEffect } from '@vue/composition-api'
import store from '@/store'
import { PendingReviewReports, Track } from '@/types'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
import { getClientOnlyRadio } from '@/radios'
// Tracks
const currentTrack = computed(() => store.getters['queue/currentTrack'])
const getTrackInformationText = (track: Track | undefined) => {
if (!track) {
return null
}
const artist = track.artist ?? track.album?.artist
return `${track.title} ${artist?.name}`
}
// Update title
const initialTitle = document.title
watchEffect(() => {
const parts = [
getTrackInformationText(currentTrack.value),
store.state.ui.pageTitle,
initialTitle || 'Funkwhale'
]
document.title = parts.filter(i => i).join(' ')
})
// Styles
const customStylesheets = computed(() => {
return store.state.instance?.frontSettings?.additionalStylesheets ?? []
})
// Fake content
onMounted(async () => {
await nextTick()
document.getElementById('fake-content')?.classList.add('loaded')
})
// WebSocket handlers
useWebSocketHandler('inbox.item_added', () => {
store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 })
})
useWebSocketHandler('mutation.created', (event) => {
store.commit('ui/incrementNotifications', { type: 'pendingReviewEdits', value: event.pending_review_count })
})
useWebSocketHandler('mutation.updated', (event) => {
store.commit('ui/incrementNotifications', { type: 'pendingReviewEdits', value: event.pending_review_count })
})
useWebSocketHandler('report.created', (event) => {
store.commit('ui/incrementNotifications', { type: 'pendingReviewReports', value: event.unresolved_count })
})
useWebSocketHandler('user_request.created', (event) => {
store.commit('ui/incrementNotifications', { type: 'pendingReviewRequests', value: event.pending_count })
})
useWebSocketHandler('Listen', (event) => {
if (store.state.radios.current && store.state.radios.running) {
const { current } = store.state.radios
if (current.clientOnly && current.type === 'account') {
getClientOnlyRadio(current).handleListen(current, event, store)
}
}
})
// Time ago
useIntervalFn(() => {
// used to redraw ago dates every minute
store.commit('ui/computeLastDate')
}, 1000 * 60)
const { width } = useWindowSize()
const player = ref()
const showShortcutsModal = ref(false)
const showSetInstanceModal = ref(false)
// export default {
// computed: {
// ...mapState({
// serviceWorker: state => state.ui.serviceWorker
// }),
// },
// watch: {
// 'serviceWorker.updateAvailable': {
// handler (v) {
// if (!v) {
// return
// }
// const self = this
// this.$store.commit('ui/addMessage', {
// content: this.$pgettext('App/Message/Paragraph', 'A new version of the app is available.'),
// date: new Date(),
// key: 'refreshApp',
// displayTime: 0,
// classActions: 'bottom attached opaque',
// actions: [
// {
// text: this.$pgettext('App/Message/Paragraph', 'Update'),
// class: 'primary',
// click: function () {
// self.updateApp()
// }
// },
// {
// text: this.$pgettext('App/Message/Paragraph', 'Later'),
// class: 'basic'
// }
// ]
// })
// },
// immediate: true
// }
// },
// async created () {
// if (navigator.serviceWorker) {
// navigator.serviceWorker.addEventListener(
// 'controllerchange', () => {
// if (this.serviceWorker.refreshing) return
// this.$store.commit('ui/serviceWorker', {
// refreshing: true
// })
// window.location.reload()
// }
// )
// }
// },
// methods: {
// updateApp () {
// this.$store.commit('ui/serviceWorker', { updateAvailable: false })
// if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return }
// this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' })
// },
// }
// }
</script>
<template>
<div
id="app"
:key="String($store.state.instance.instanceUrl)"
:class="[$store.state.ui.queueFocused ? 'queue-focused' : '',
{'has-bottom-player': $store.state.queue.tracks.length > 0}]"
:key="String(store.state.instance.instanceUrl)"
:class="[store.state.ui.queueFocused ? 'queue-focused' : '',
{'has-bottom-player': store.state.queue.tracks.length > 0}]"
>
<!-- here, we display custom stylesheets, if any -->
<link
@ -25,18 +180,18 @@
<service-messages />
<transition name="queue">
<queue
v-if="$store.state.ui.queueFocused"
@touch-progress="$refs.player.setCurrentTime($event)"
v-if="store.state.ui.queueFocused"
@touch-progress="player.setCurrentTime($event)"
/>
</transition>
<router-view
role="main"
:class="{hidden: $store.state.ui.queueFocused}"
:class="{hidden: store.state.ui.queueFocused}"
/>
<player ref="player" />
<playlist-modal v-if="$store.state.auth.authenticated" />
<channel-upload-modal v-if="$store.state.auth.authenticated" />
<filter-modal v-if="$store.state.auth.authenticated" />
<audio-player ref="player" />
<playlist-modal v-if="store.state.auth.authenticated" />
<channel-upload-modal v-if="store.state.auth.authenticated" />
<filter-modal v-if="store.state.auth.authenticated" />
<report-modal />
<shortcuts-modal
:show="showShortcutsModal"
@ -46,414 +201,6 @@
</div>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
import { mapState, mapGetters } from 'vuex'
import { useWebSocket, whenever } from '@vueuse/core'
import GlobalEvents from '@/components/utils/global-events.vue'
import locales from './locales'
import { getClientOnlyRadio } from '@/radios'
import Player from '@/components/audio/Player.vue'
import Queue from '@/components/Queue.vue'
import PlaylistModal from '@/components/playlists/PlaylistModal.vue'
import ChannelUploadModal from '@/components/channels/UploadModal.vue'
import Sidebar from '@/components/Sidebar.vue'
import ServiceMessages from '@/components/ServiceMessages.vue'
import SetInstanceModal from '@/components/SetInstanceModal.vue'
import ShortcutsModal from '@/components/ShortcutsModal.vue'
import FilterModal from '@/components/moderation/FilterModal.vue'
import ReportModal from '@/components/moderation/ReportModal.vue'
import { watch, watchEffect } from '@vue/composition-api'
export default {
name: 'App',
components: {
Player,
Queue,
PlaylistModal,
ChannelUploadModal,
Sidebar,
ServiceMessages,
SetInstanceModal,
ShortcutsModal,
FilterModal,
ReportModal,
GlobalEvents
},
setup (props, { root }) {
const store = root.$store
const url = store.getters['instance/absoluteUrl']('api/v1/activity')
.replace(/^http/, 'ws')
const { data, status, open, close } = useWebSocket(url, {
autoReconnect: true,
immediate: false
})
watch(() => store.state.auth.authenticated, (authenticated) => {
if (authenticated) return open()
close()
})
whenever(data, () => {
store.dispatch('ui/websocketEvent', JSON.parse(data.value))
})
watchEffect(() => {
console.log('Websocket status:', status.value)
})
},
data () {
return {
instanceUrl: null,
showShortcutsModal: false,
showSetInstanceModal: false,
initialTitle: document.title,
width: window.innerWidth
}
},
computed: {
...mapState({
messages: state => state.ui.messages,
nodeinfo: state => state.instance.nodeinfo,
playing: state => state.player.playing,
bufferProgress: state => state.player.bufferProgress,
isLoadingAudio: state => state.player.isLoadingAudio,
serviceWorker: state => state.ui.serviceWorker
}),
...mapGetters({
hasNext: 'queue/hasNext',
currentTrack: 'queue/currentTrack',
progress: 'player/progress'
}),
labels () {
const play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play track')
const pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause track')
const next = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Next track')
const expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue')
return {
play,
pause,
next,
expandQueue
}
},
suggestedInstances () {
const instances = this.$store.state.instance.knownInstances.slice(0)
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
if (!serverUrl.endsWith('/')) {
serverUrl = serverUrl + '/'
}
instances.push(serverUrl)
}
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
return _.uniq(instances.filter((e) => { return e }))
},
version () {
if (!this.nodeinfo) {
return null
}
return _.get(this.nodeinfo, 'software.version')
},
customStylesheets () {
if (this.$store.state.instance.frontSettings) {
return this.$store.state.instance.frontSettings.additionalStylesheets || []
}
return null
},
matchDarkColorScheme () {
if (window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)')
}
return null
}
},
watch: {
'$store.state.instance.instanceUrl' (v) {
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
},
'$store.state.ui.theme': {
immediate: true,
handler (newValue) {
const matchesDark = this.matchDarkColorScheme
if (matchesDark) {
if (newValue === 'system') {
newValue = matchesDark.matches ? 'dark' : 'light'
matchesDark.addEventListener('change', this.handleThemeChange)
} else {
matchesDark.removeEventListener('change', this.handleThemeChange)
}
} else {
if (newValue === 'system') {
newValue = 'light'
}
}
this.setTheme(newValue)
}
},
'$store.state.ui.currentLanguage': {
immediate: true,
handler (newValue) {
const self = this
const htmlLocale = newValue.toLowerCase().replace('_', '-')
document.documentElement.setAttribute('lang', htmlLocale)
if (newValue === 'en_US') {
self.$language.current = 'noop'
self.$language.current = newValue
return self.$store.commit('ui/momentLocale', 'en')
}
}
},
currentTrack: {
immediate: true,
handler (newValue) {
this.updateDocumentTitle()
}
},
'$store.state.ui.pageTitle': {
immediate: true,
handler (newValue) {
this.updateDocumentTitle()
}
},
'serviceWorker.updateAvailable': {
handler (v) {
if (!v) {
return
}
const self = this
this.$store.commit('ui/addMessage', {
content: this.$pgettext('App/Message/Paragraph', 'A new version of the app is available.'),
date: new Date(),
key: 'refreshApp',
displayTime: 0,
classActions: 'bottom attached opaque',
actions: [
{
text: this.$pgettext('App/Message/Paragraph', 'Update'),
class: 'primary',
click: function () {
self.updateApp()
}
},
{
text: this.$pgettext('App/Message/Paragraph', 'Later'),
class: 'basic'
}
]
})
},
immediate: true
}
},
async created () {
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (this.serviceWorker.refreshing) return
this.$store.commit('ui/serviceWorker', {
refreshing: true
})
window.location.reload()
}
)
}
window.addEventListener('resize', this.handleResize)
this.handleResize()
const self = this
if (!this.$store.state.ui.selectedLanguage) {
this.autodetectLanguage()
}
setInterval(() => {
// used to redraw ago dates every minute
self.$store.commit('ui/computeLastDate')
}, 1000 * 60)
const urlParams = new URLSearchParams(window.location.search)
const serverUrl = urlParams.get('_server')
if (serverUrl) {
this.$store.commit('instance/instanceUrl', serverUrl)
}
const url = urlParams.get('_url')
if (url) {
await this.$router.replace(url)
} else if (!this.$store.state.instance.instanceUrl) {
// we have several way to guess the API server url. By order of precedence:
// 1. use the url provided in settings.json, if any
// 2. use the url specified when building via VUE_APP_INSTANCE_URL
// 3. use the current url
const defaultInstanceUrl =
this.$store.state.instance.frontSettings.defaultServerUrl ||
import.meta.env.VUE_APP_INSTANCE_URL || this.$store.getters['instance/defaultUrl']()
this.$store.commit('instance/instanceUrl', defaultInstanceUrl)
} else {
// needed to trigger initialization of axios / service worker
this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl)
}
await this.fetchNodeInfo()
this.$store.dispatch('instance/fetchSettings')
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'sidebarCount',
handler: this.incrementNotificationCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'mutation.created',
id: 'sidebarReviewEditCount',
handler: this.incrementReviewEditCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarReviewEditCount',
handler: this.incrementReviewEditCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'report.created',
id: 'sidebarPendingReviewReportCount',
handler: this.incrementPendingReviewReportsCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'user_request.created',
id: 'sidebarPendingReviewRequestCount',
handler: this.incrementPendingReviewRequestsCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'Listen',
id: 'handleListen',
handler: this.handleListen
})
},
mounted () {
const self = this
// slight hack to allow use to have internal links in <translate> tags
// while preserving router behaviour
document.documentElement.addEventListener('click', function (event) {
if (!event.target.matches('a.internal')) return
self.$router.push(event.target.getAttribute('href'))
event.preventDefault()
}, false)
this.$nextTick(() => {
document.getElementById('fake-content').classList.add('loaded')
})
},
destroyed () {
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'sidebarCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.created',
id: 'sidebarReviewEditCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarReviewEditCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarPendingReviewReportCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'user_request.created',
id: 'sidebarPendingReviewRequestCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'Listen',
id: 'handleListen'
})
},
methods: {
incrementNotificationCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 })
},
incrementReviewEditCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewEdits', value: event.pending_review_count })
},
incrementPendingReviewReportsCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewReports', value: event.unresolved_count })
},
incrementPendingReviewRequestsCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewRequests', value: event.pending_count })
},
handleListen (event) {
if (this.$store.state.radios.current && this.$store.state.radios.running) {
const current = this.$store.state.radios.current
if (current.clientOnly && current.type === 'account') {
getClientOnlyRadio(current).handleListen(current, event, this.$store)
}
}
},
async fetchNodeInfo () {
const response = await axios.get('instance/nodeinfo/2.0/')
this.$store.commit('instance/nodeinfo', response.data)
},
autodetectLanguage () {
const userLanguage = navigator.language || navigator.userLanguage
const available = locales.locales.map(e => { return e.code })
let candidate
const matching = available.filter((a) => {
return userLanguage.replace('-', '_') === a
})
const almostMatching = available.filter((a) => {
return userLanguage.replace('-', '_').split('_')[0] === a.split('_')[0]
})
if (matching.length > 0) {
candidate = matching[0]
} else if (almostMatching.length > 0) {
candidate = almostMatching[0]
} else {
return
}
this.$store.commit('ui/currentLanguage', candidate)
},
getTrackInformationText (track) {
const trackTitle = track.title
const albumArtist = (track.album) ? track.album.artist.name : null
const artistName = (
(track.artist) ? track.artist.name : albumArtist)
const text = `${trackTitle} ${artistName}`
return text
},
updateDocumentTitle () {
const parts = []
const currentTrackPart = (
(this.currentTrack)
? this.getTrackInformationText(this.currentTrack)
: null)
if (currentTrackPart) {
parts.push(currentTrackPart)
}
if (this.$store.state.ui.pageTitle) {
parts.push(this.$store.state.ui.pageTitle)
}
parts.push(this.initialTitle || 'Funkwhale')
document.title = parts.join(' ')
},
updateApp () {
this.$store.commit('ui/serviceWorker', { updateAvailable: false })
if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return }
this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' })
},
handleResize () {
this.width = window.innerWidth
},
handleThemeChange (event) {
this.setTheme(event.matches ? 'dark' : 'light')
},
setTheme (theme) {
const oldTheme = (theme === 'light') ? 'dark' : 'light'
document.body.classList.remove(`theme-${oldTheme}`)
document.body.classList.add(`theme-${theme}`)
}
}
}
</script>
<style lang="scss">
@import "style/_main";

460
front/src/AppOld.vue Normal file
View File

@ -0,0 +1,460 @@
<template>
<div
id="app"
:key="String($store.state.instance.instanceUrl)"
:class="[$store.state.ui.queueFocused ? 'queue-focused' : '',
{'has-bottom-player': $store.state.queue.tracks.length > 0}]"
>
<!-- here, we display custom stylesheets, if any -->
<link
v-for="url in customStylesheets"
:key="url"
rel="stylesheet"
property="stylesheet"
:href="url"
>
<sidebar
:width="width"
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal"
/>
<set-instance-modal
:show="showSetInstanceModal"
@update:show="showSetInstanceModal = $event"
/>
<service-messages />
<transition name="queue">
<queue
v-if="$store.state.ui.queueFocused"
@touch-progress="$refs.player.setCurrentTime($event)"
/>
</transition>
<router-view
role="main"
:class="{hidden: $store.state.ui.queueFocused}"
/>
<player ref="player" />
<playlist-modal v-if="$store.state.auth.authenticated" />
<channel-upload-modal v-if="$store.state.auth.authenticated" />
<filter-modal v-if="$store.state.auth.authenticated" />
<report-modal />
<shortcuts-modal
:show="showShortcutsModal"
@update:show="showShortcutsModal = $event"
/>
<GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal" />
</div>
</template>
<script>
import axios from 'axios'
import { uniq, get } from 'lodash-es'
import { mapState, mapGetters } from 'vuex'
import { useWebSocket, whenever } from '@vueuse/core'
import GlobalEvents from '@/components/utils/global-events.vue'
import locales from './locales'
import { getClientOnlyRadio } from '@/radios'
import Player from '@/components/audio/Player.vue'
import Queue from '@/components/Queue.vue'
import PlaylistModal from '@/components/playlists/PlaylistModal.vue'
import ChannelUploadModal from '@/components/channels/UploadModal.vue'
import Sidebar from '@/components/Sidebar.vue'
import ServiceMessages from '@/components/ServiceMessages.vue'
import SetInstanceModal from '@/components/SetInstanceModal.vue'
import ShortcutsModal from '@/components/ShortcutsModal.vue'
import FilterModal from '@/components/moderation/FilterModal.vue'
import ReportModal from '@/components/moderation/ReportModal.vue'
import { watch, watchEffect } from '@vue/composition-api'
export default {
name: 'App',
components: {
Player,
Queue,
PlaylistModal,
ChannelUploadModal,
Sidebar,
ServiceMessages,
SetInstanceModal,
ShortcutsModal,
FilterModal,
ReportModal,
GlobalEvents
},
setup (props, { root }) {
const store = root.$store
const url = store.getters['instance/absoluteUrl']('api/v1/activity')
.replace(/^http/, 'ws')
const { data, status, open, close } = useWebSocket(url, {
autoReconnect: true,
immediate: false
})
watch(() => store.state.auth.authenticated, (authenticated) => {
if (authenticated) return open()
close()
})
whenever(data, () => {
store.dispatch('ui/websocketEvent', JSON.parse(data.value))
})
watchEffect(() => {
console.log('Websocket status:', status.value)
})
},
data () {
return {
instanceUrl: null,
showShortcutsModal: false,
showSetInstanceModal: false,
initialTitle: document.title,
width: window.innerWidth
}
},
computed: {
...mapState({
messages: state => state.ui.messages,
nodeinfo: state => state.instance.nodeinfo,
playing: state => state.player.playing,
bufferProgress: state => state.player.bufferProgress,
isLoadingAudio: state => state.player.isLoadingAudio,
serviceWorker: state => state.ui.serviceWorker
}),
...mapGetters({
hasNext: 'queue/hasNext',
currentTrack: 'queue/currentTrack',
progress: 'player/progress'
}),
labels () {
const play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play track')
const pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause track')
const next = this.$pgettext('Sidebar/Player/Icon.Tooltip', 'Next track')
const expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Expand queue')
return {
play,
pause,
next,
expandQueue
}
},
suggestedInstances () {
const instances = this.$store.state.instance.knownInstances.slice(0)
if (this.$store.state.instance.frontSettings.defaultServerUrl) {
let serverUrl = this.$store.state.instance.frontSettings.defaultServerUrl
if (!serverUrl.endsWith('/')) {
serverUrl = serverUrl + '/'
}
instances.push(serverUrl)
}
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
return uniq(instances.filter((e) => { return e }))
},
version () {
if (!this.nodeinfo) {
return null
}
return get(this.nodeinfo, 'software.version')
},
customStylesheets () {
if (this.$store.state.instance.frontSettings) {
return this.$store.state.instance.frontSettings.additionalStylesheets || []
}
return null
},
matchDarkColorScheme () {
if (window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)')
}
return null
}
},
watch: {
'$store.state.instance.instanceUrl' (v) {
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
},
'$store.state.ui.theme': {
immediate: true,
handler (newValue) {
const matchesDark = this.matchDarkColorScheme
if (matchesDark) {
if (newValue === 'system') {
newValue = matchesDark.matches ? 'dark' : 'light'
matchesDark.addEventListener('change', this.handleThemeChange)
} else {
matchesDark.removeEventListener('change', this.handleThemeChange)
}
} else {
if (newValue === 'system') {
newValue = 'light'
}
}
this.setTheme(newValue)
}
},
'$store.state.ui.currentLanguage': {
immediate: true,
handler (newValue) {
const self = this
const htmlLocale = newValue.toLowerCase().replace('_', '-')
document.documentElement.setAttribute('lang', htmlLocale)
if (newValue === 'en_US') {
self.$language.current = 'noop'
self.$language.current = newValue
return self.$store.commit('ui/momentLocale', 'en')
}
}
},
currentTrack: {
immediate: true,
handler (newValue) {
this.updateDocumentTitle()
}
},
'$store.state.ui.pageTitle': {
immediate: true,
handler (newValue) {
this.updateDocumentTitle()
}
},
'serviceWorker.updateAvailable': {
handler (v) {
if (!v) {
return
}
const self = this
this.$store.commit('ui/addMessage', {
content: this.$pgettext('App/Message/Paragraph', 'A new version of the app is available.'),
date: new Date(),
key: 'refreshApp',
displayTime: 0,
classActions: 'bottom attached opaque',
actions: [
{
text: this.$pgettext('App/Message/Paragraph', 'Update'),
class: 'primary',
click: function () {
self.updateApp()
}
},
{
text: this.$pgettext('App/Message/Paragraph', 'Later'),
class: 'basic'
}
]
})
},
immediate: true
}
},
async created () {
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (this.serviceWorker.refreshing) return
this.$store.commit('ui/serviceWorker', {
refreshing: true
})
window.location.reload()
}
)
}
window.addEventListener('resize', this.handleResize)
this.handleResize()
const self = this
if (!this.$store.state.ui.selectedLanguage) {
this.autodetectLanguage()
}
setInterval(() => {
// used to redraw ago dates every minute
self.$store.commit('ui/computeLastDate')
}, 1000 * 60)
const urlParams = new URLSearchParams(window.location.search)
const serverUrl = urlParams.get('_server')
if (serverUrl) {
this.$store.commit('instance/instanceUrl', serverUrl)
}
const url = urlParams.get('_url')
if (url) {
await this.$router.replace(url)
} else if (!this.$store.state.instance.instanceUrl) {
// we have several way to guess the API server url. By order of precedence:
// 1. use the url provided in settings.json, if any
// 2. use the url specified when building via VUE_APP_INSTANCE_URL
// 3. use the current url
const defaultInstanceUrl =
this.$store.state.instance.frontSettings.defaultServerUrl ||
import.meta.env.VUE_APP_INSTANCE_URL || this.$store.getters['instance/defaultUrl']()
this.$store.commit('instance/instanceUrl', defaultInstanceUrl)
} else {
// needed to trigger initialization of axios / service worker
this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl)
}
await this.fetchNodeInfo()
this.$store.dispatch('instance/fetchSettings')
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'sidebarCount',
handler: this.incrementNotificationCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'mutation.created',
id: 'sidebarReviewEditCount',
handler: this.incrementReviewEditCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarReviewEditCount',
handler: this.incrementReviewEditCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'report.created',
id: 'sidebarPendingReviewReportCount',
handler: this.incrementPendingReviewReportsCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'user_request.created',
id: 'sidebarPendingReviewRequestCount',
handler: this.incrementPendingReviewRequestsCountInSidebar
})
this.$store.commit('ui/addWebsocketEventHandler', {
eventName: 'Listen',
id: 'handleListen',
handler: this.handleListen
})
},
mounted () {
const self = this
// slight hack to allow use to have internal links in <translate> tags
// while preserving router behaviour
document.documentElement.addEventListener('click', function (event) {
if (!event.target.matches('a.internal')) return
self.$router.push(event.target.getAttribute('href'))
event.preventDefault()
}, false)
this.$nextTick(() => {
document.getElementById('fake-content').classList.add('loaded')
})
},
destroyed () {
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'inbox.item_added',
id: 'sidebarCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.created',
id: 'sidebarReviewEditCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarReviewEditCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'mutation.updated',
id: 'sidebarPendingReviewReportCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'user_request.created',
id: 'sidebarPendingReviewRequestCount'
})
this.$store.commit('ui/removeWebsocketEventHandler', {
eventName: 'Listen',
id: 'handleListen'
})
},
methods: {
incrementNotificationCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 })
},
incrementReviewEditCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewEdits', value: event.pending_review_count })
},
incrementPendingReviewReportsCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewReports', value: event.unresolved_count })
},
incrementPendingReviewRequestsCountInSidebar (event) {
this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewRequests', value: event.pending_count })
},
handleListen (event) {
if (this.$store.state.radios.current && this.$store.state.radios.running) {
const current = this.$store.state.radios.current
if (current.clientOnly && current.type === 'account') {
getClientOnlyRadio(current).handleListen(current, event, this.$store)
}
}
},
async fetchNodeInfo () {
const response = await axios.get('instance/nodeinfo/2.0/')
this.$store.commit('instance/nodeinfo', response.data)
},
autodetectLanguage () {
const userLanguage = navigator.language || navigator.userLanguage
const available = locales.locales.map(e => { return e.code })
let candidate
const matching = available.filter((a) => {
return userLanguage.replace('-', '_') === a
})
const almostMatching = available.filter((a) => {
return userLanguage.replace('-', '_').split('_')[0] === a.split('_')[0]
})
if (matching.length > 0) {
candidate = matching[0]
} else if (almostMatching.length > 0) {
candidate = almostMatching[0]
} else {
return
}
this.$store.commit('ui/currentLanguage', candidate)
},
getTrackInformationText (track) {
const trackTitle = track.title
const albumArtist = (track.album) ? track.album.artist.name : null
const artistName = (
(track.artist) ? track.artist.name : albumArtist)
const text = `${trackTitle} ${artistName}`
return text
},
updateDocumentTitle () {
const parts = []
const currentTrackPart = (
(this.currentTrack)
? this.getTrackInformationText(this.currentTrack)
: null)
if (currentTrackPart) {
parts.push(currentTrackPart)
}
if (this.$store.state.ui.pageTitle) {
parts.push(this.$store.state.ui.pageTitle)
}
parts.push(this.initialTitle || 'Funkwhale')
document.title = parts.join(' ')
},
updateApp () {
this.$store.commit('ui/serviceWorker', { updateAvailable: false })
if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return }
this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' })
},
handleResize () {
this.width = window.innerWidth
},
handleThemeChange (event) {
this.setTheme(event.matches ? 'dark' : 'light')
},
setTheme (theme) {
const oldTheme = (theme === 'light') ? 'dark' : 'light'
document.body.classList.remove(`theme-${oldTheme}`)
document.body.classList.add(`theme-${theme}`)
}
}
}
</script>
<style lang="scss">
@import "style/_main";
</style>

View File

@ -1,7 +1,6 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/vue-next/pull/3399
import '@vue/runtime-core'
declare module '@vue/runtime-core' {
export interface GlobalComponents {
@ -161,4 +160,4 @@ declare module '@vue/runtime-core' {
}
}
export {}
export { }

View File

@ -250,9 +250,9 @@
<script>
import { mapState } from 'vuex'
import _ from 'lodash'
import { get } from 'lodash-es'
import showdown from 'showdown'
import { humanSize } from '@/filters'
import { humanSize } from '@/modules/filters'
import SignupForm from '@/components/auth/SignupForm.vue'
import LogoText from '@/components/LogoText.vue'
@ -279,31 +279,31 @@ export default {
}
},
podName () {
return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
return get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
},
banner () {
return _.get(this.nodeinfo, 'metadata.banner')
return get(this.nodeinfo, 'metadata.banner')
},
shortDescription () {
return _.get(this.nodeinfo, 'metadata.shortDescription')
return get(this.nodeinfo, 'metadata.shortDescription')
},
longDescription () {
return _.get(this.nodeinfo, 'metadata.longDescription')
return get(this.nodeinfo, 'metadata.longDescription')
},
rules () {
return _.get(this.nodeinfo, 'metadata.rules')
return get(this.nodeinfo, 'metadata.rules')
},
terms () {
return _.get(this.nodeinfo, 'metadata.terms')
return get(this.nodeinfo, 'metadata.terms')
},
stats () {
const data = {
users: _.get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null),
artists: _.get(this.nodeinfo, 'metadata.library.artists.total', null),
albums: _.get(this.nodeinfo, 'metadata.library.albums.total', null),
tracks: _.get(this.nodeinfo, 'metadata.library.tracks.total', null),
listenings: _.get(this.nodeinfo, 'metadata.usage.listenings.total', null)
users: get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: get(this.nodeinfo, 'metadata.library.music.hours', null),
artists: get(this.nodeinfo, 'metadata.library.artists.total', null),
albums: get(this.nodeinfo, 'metadata.library.albums.total', null),
tracks: get(this.nodeinfo, 'metadata.library.tracks.total', null),
listenings: get(this.nodeinfo, 'metadata.usage.listenings.total', null)
}
if (data.users === null || data.artists === null) {
return
@ -311,28 +311,28 @@ export default {
return data
},
contactEmail () {
return _.get(this.nodeinfo, 'metadata.contactEmail')
return get(this.nodeinfo, 'metadata.contactEmail')
},
anonymousCanListen () {
return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen')
return get(this.nodeinfo, 'metadata.library.anonymousCanListen')
},
allowListEnabled () {
return _.get(this.nodeinfo, 'metadata.allowList.enabled')
return get(this.nodeinfo, 'metadata.allowList.enabled')
},
allowListDomains () {
return _.get(this.nodeinfo, 'metadata.allowList.domains')
return get(this.nodeinfo, 'metadata.allowList.domains')
},
version () {
return _.get(this.nodeinfo, 'software.version')
return get(this.nodeinfo, 'software.version')
},
openRegistrations () {
return _.get(this.nodeinfo, 'openRegistrations')
return get(this.nodeinfo, 'openRegistrations')
},
defaultUploadQuota () {
return humanSize(_.get(this.nodeinfo, 'metadata.defaultUploadQuota') * 1000 * 1000)
return humanSize(get(this.nodeinfo, 'metadata.defaultUploadQuota') * 1000 * 1000)
},
federationEnabled () {
return _.get(this.nodeinfo, 'metadata.library.federationEnabled')
return get(this.nodeinfo, 'metadata.library.federationEnabled')
},
headerStyle () {
if (!this.banner) {

View File

@ -434,7 +434,7 @@ We render some markdown to html here, the content is set by the admin so we shou
<script>
import { mapState } from 'vuex'
import _ from 'lodash'
import { get } from 'lodash-es'
import showdown from 'showdown'
export default {
@ -455,31 +455,31 @@ export default {
}
},
podName () {
return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
return get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
},
banner () {
return _.get(this.nodeinfo, 'metadata.banner')
return get(this.nodeinfo, 'metadata.banner')
},
shortDescription () {
return _.get(this.nodeinfo, 'metadata.shortDescription')
return get(this.nodeinfo, 'metadata.shortDescription')
},
longDescription () {
return _.get(this.nodeinfo, 'metadata.longDescription')
return get(this.nodeinfo, 'metadata.longDescription')
},
rules () {
return _.get(this.nodeinfo, 'metadata.rules')
return get(this.nodeinfo, 'metadata.rules')
},
terms () {
return _.get(this.nodeinfo, 'metadata.terms')
return get(this.nodeinfo, 'metadata.terms')
},
stats () {
const data = {
users: _.get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null),
artists: _.get(this.nodeinfo, 'metadata.library.artists.total', null),
albums: _.get(this.nodeinfo, 'metadata.library.albums.total', null),
tracks: _.get(this.nodeinfo, 'metadata.library.tracks.total', null),
listenings: _.get(this.nodeinfo, 'metadata.usage.listenings.total', null)
users: get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: get(this.nodeinfo, 'metadata.library.music.hours', null),
artists: get(this.nodeinfo, 'metadata.library.artists.total', null),
albums: get(this.nodeinfo, 'metadata.library.albums.total', null),
tracks: get(this.nodeinfo, 'metadata.library.tracks.total', null),
listenings: get(this.nodeinfo, 'metadata.usage.listenings.total', null)
}
if (data.users === null || data.artists === null) {
return
@ -487,28 +487,28 @@ export default {
return data
},
contactEmail () {
return _.get(this.nodeinfo, 'metadata.contactEmail')
return get(this.nodeinfo, 'metadata.contactEmail')
},
anonymousCanListen () {
return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen')
return get(this.nodeinfo, 'metadata.library.anonymousCanListen')
},
allowListEnabled () {
return _.get(this.nodeinfo, 'metadata.allowList.enabled')
return get(this.nodeinfo, 'metadata.allowList.enabled')
},
allowListDomains () {
return _.get(this.nodeinfo, 'metadata.allowList.domains')
return get(this.nodeinfo, 'metadata.allowList.domains')
},
version () {
return _.get(this.nodeinfo, 'software.version')
return get(this.nodeinfo, 'software.version')
},
openRegistrations () {
return _.get(this.nodeinfo, 'openRegistrations')
return get(this.nodeinfo, 'openRegistrations')
},
defaultUploadQuota () {
return _.get(this.nodeinfo, 'metadata.defaultUploadQuota')
return get(this.nodeinfo, 'metadata.defaultUploadQuota')
},
federationEnabled () {
return _.get(this.nodeinfo, 'metadata.library.federationEnabled')
return get(this.nodeinfo, 'metadata.library.federationEnabled')
},
headerStyle () {
if (!this.banner) {

View File

@ -325,14 +325,14 @@
</template>
<script>
import _ from 'lodash'
import { get } from 'lodash-es'
import { mapState } from 'vuex'
import showdown from 'showdown'
import AlbumWidget from '@/components/audio/album/Widget.vue'
import ChannelsWidget from '@/components/audio/ChannelsWidget.vue'
import LoginForm from '@/components/auth/LoginForm.vue'
import SignupForm from '@/components/auth/SignupForm.vue'
import { humanSize } from '@/filters'
import { humanSize } from '@/modules/filters'
export default {
components: {
@ -358,19 +358,19 @@ export default {
}
},
podName () {
return _.get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
return get(this.nodeinfo, 'metadata.nodeName') || 'Funkwhale'
},
banner () {
return _.get(this.nodeinfo, 'metadata.banner')
return get(this.nodeinfo, 'metadata.banner')
},
shortDescription () {
return _.get(this.nodeinfo, 'metadata.shortDescription')
return get(this.nodeinfo, 'metadata.shortDescription')
},
longDescription () {
return _.get(this.nodeinfo, 'metadata.longDescription')
return get(this.nodeinfo, 'metadata.longDescription')
},
rules () {
return _.get(this.nodeinfo, 'metadata.rules')
return get(this.nodeinfo, 'metadata.rules')
},
renderedDescription () {
if (!this.longDescription) {
@ -381,8 +381,8 @@ export default {
},
stats () {
const data = {
users: _.get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: _.get(this.nodeinfo, 'metadata.library.music.hours', null)
users: get(this.nodeinfo, 'usage.users.activeMonth', null),
hours: get(this.nodeinfo, 'metadata.library.music.hours', null)
}
if (data.users === null || data.artists === null) {
return
@ -390,16 +390,16 @@ export default {
return data
},
contactEmail () {
return _.get(this.nodeinfo, 'metadata.contactEmail')
return get(this.nodeinfo, 'metadata.contactEmail')
},
defaultUploadQuota () {
return _.get(this.nodeinfo, 'metadata.defaultUploadQuota')
return get(this.nodeinfo, 'metadata.defaultUploadQuota')
},
anonymousCanListen () {
return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen')
return get(this.nodeinfo, 'metadata.library.anonymousCanListen')
},
openRegistrations () {
return _.get(this.nodeinfo, 'openRegistrations')
return get(this.nodeinfo, 'openRegistrations')
},
headerStyle () {
if (!this.banner) {

View File

@ -37,7 +37,7 @@
</template>
<script>
import _ from 'lodash'
import { range as lodashRange, sortBy, uniq } from 'lodash-es'
export default {
props: {
@ -57,15 +57,15 @@ export default {
pages: function () {
const range = 2
const current = this.current
const beginning = _.range(1, Math.min(this.maxPage, 1 + range))
const middle = _.range(
const beginning = lodashRange(1, Math.min(this.maxPage, 1 + range))
const middle = lodashRange(
Math.max(1, current - range + 1),
Math.min(this.maxPage, current + range)
)
const end = _.range(this.maxPage, Math.max(1, this.maxPage - range))
const end = lodashRange(this.maxPage, Math.max(1, this.maxPage - range))
let allowed = beginning.concat(middle, end)
allowed = _.uniq(allowed)
allowed = _.sortBy(allowed, [
allowed = uniq(allowed)
allowed = sortBy(allowed, [
e => {
return e
}

View File

@ -345,8 +345,8 @@
import { mapState, mapGetters, mapActions } from 'vuex'
import $ from 'jquery'
import moment from 'moment'
import lodash from 'lodash'
import time from '@/utils/time.js'
import { sum } from 'lodash-es'
import time from '@/utils/time'
import { createFocusTrap } from 'focus-trap'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue'
@ -405,7 +405,7 @@ export default {
}
},
timeLeft () {
const seconds = lodash.sum(
const seconds = sum(
this.queue.tracks.slice(this.queue.currentIndex).map((t) => {
return (t.uploads || []).map((u) => {
return u.duration || 0

View File

@ -107,7 +107,7 @@
<script>
import Modal from '@/components/semantic/Modal.vue'
import axios from 'axios'
import _ from 'lodash'
import { uniq } from 'lodash-es'
export default {
components: {
@ -135,7 +135,7 @@ export default {
}
const self = this
instances.push(this.$store.getters['instance/defaultUrl'](), 'https://demo.funkwhale.audio/')
return _.uniq(instances.filter((e) => { return e !== self.$store.state.instance.instanceUrl }))
return uniq(instances.filter((e) => { return e !== self.$store.state.instance.instanceUrl }))
},
instanceHostname () {
const url = this.$store.state.instance.instanceUrl

View File

@ -208,17 +208,17 @@
</div>
<div class="content">
<fieldset
v-for="theme in themes"
:key="theme.key"
v-for="t in themes"
:key="t.key"
>
<input
:id="theme.key"
v-model="themeSelection"
:id="t.key"
v-model="theme"
type="radio"
name="theme"
:value="theme.key"
:value="t.key"
>
<label :for="theme.key">{{ theme.name }}</label>
<label :for="t.key">{{ t.name }}</label>
</fieldset>
</div>
</modal>
@ -475,11 +475,11 @@ import { mapState, mapActions, mapGetters } from 'vuex'
import UserModal from '@/components/common/UserModal.vue'
import Logo from '@/components/Logo.vue'
import SearchBar from '@/components/audio/SearchBar.vue'
import ThemesMixin from '@/components/mixins/Themes.vue'
import UserMenu from '@/components/common/UserMenu.vue'
import Modal from '@/components/semantic/Modal.vue'
import $ from 'jquery'
import useThemeList from '@/composables/useThemeList'
export default {
name: 'Sidebar',
@ -490,10 +490,15 @@ export default {
UserModal,
Modal
},
mixins: [ThemesMixin],
props: {
width: { type: Number, required: true }
},
setup () {
const theme = useTheme()
const themes = useThemeList()
return { theme, themes }
},
data () {
return {
selectedTab: 'library',
@ -504,8 +509,7 @@ export default {
showUserModal: false,
showLanguageModal: false,
showThemeModal: false,
languageSelection: this.$language.current,
themeSelection: this.$store.state.ui.theme
languageSelection: this.$language.current
}
},
destroy () {
@ -645,10 +649,6 @@ export default {
languageSelection: function (v) {
this.$store.dispatch('ui/currentLanguage', v)
this.$refs.languageModal.closeModal()
},
themeSelection: function (v) {
this.$store.dispatch('ui/theme', v)
this.$refs.themeModal.closeModal()
}
},
mounted () {
@ -691,11 +691,14 @@ export default {
// works as expected
const link = $($el).closest('a')
const url = link.attr('href')
if (url.startsWith('http')) {
window.open(url, '_blank').focus()
} else {
self.$router.push(url)
if (url) {
if (url.startsWith('http')) {
window.open(url, '_blank').focus()
} else {
self.$router.push(url)
}
}
$(self.$el).find(selector).dropdown('hide')
}
})

View File

@ -157,7 +157,7 @@
<script>
import axios from 'axios'
import lodash from 'lodash'
import { cloneDeep } from 'lodash-es'
import SignupFormBuilder from '@/components/admin/SignupFormBuilder.vue'
export default {
@ -241,7 +241,7 @@ export default {
},
set (key, value) {
// otherwise reactivity doesn't trigger :/
this.values = lodash.cloneDeep(this.values)
this.values = cloneDeep(this.values)
this.$set(this.values, key, value)
}
}

View File

@ -161,7 +161,7 @@
</template>
<script>
import lodash from 'lodash'
import { cloneDeep, tap, set } from 'lodash-es'
import SignupForm from '@/components/auth/SignupForm.vue'
@ -209,7 +209,7 @@ export default {
},
methods: {
addField () {
const newValue = lodash.tap(lodash.cloneDeep(this.local), v => v.fields.push({
const newValue = tap(cloneDeep(this.local), v => v.fields.push({
label: this.$pgettext('*/*/Form-builder', 'Additional field') + ' ' + (this.local.fields.length + 1),
required: true,
input_type: 'short_text'
@ -217,7 +217,7 @@ export default {
this.$emit('input', newValue)
},
remove (idx) {
this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => v.fields.splice(idx, 1)))
this.$emit('input', tap(cloneDeep(this.local), v => v.fields.splice(idx, 1)))
},
move (idx, incr) {
if (idx === 0 && incr < 0) {
@ -226,7 +226,7 @@ export default {
if (idx + incr >= this.local.fields.length) {
return
}
const newFields = arrayMove(lodash.cloneDeep(this.local).fields, idx, idx + incr)
const newFields = arrayMove(cloneDeep(this.local).fields, idx, idx + incr)
this.update('fields', newFields)
},
update (key, value) {
@ -241,7 +241,7 @@ export default {
}
}
}
this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => lodash.set(v, key, value)))
this.$emit('input', tap(cloneDeep(this.local), v => set(v, key, value)))
}
}
}

View File

@ -78,7 +78,7 @@
import PlayButton from '@/components/audio/PlayButton.vue'
import TagsList from '@/components/tags/List.vue'
import { momentFormat } from '@/filters'
import { momentFormat } from '@/modules/filters'
import moment from 'moment'
export default {

View File

@ -55,7 +55,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import PodcastTable from '@/components/audio/podcast/Table.vue'
import TrackTable from '@/components/audio/track/Table.vue'
@ -96,7 +96,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.limit
params.page = this.page
params.include_channels = true

View File

@ -53,7 +53,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import ChannelSerieCard from '@/components/audio/ChannelSerieCard.vue'
import AlbumCard from '@/components/audio/album/Card.vue'
@ -87,7 +87,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, { params: params }).then((response) => {

View File

@ -37,7 +37,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import ChannelCard from '@/components/audio/ChannelCard.vue'
@ -68,7 +68,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.limit
params.include_channels = true
axios.get(url, { params: params }).then((response) => {

View File

@ -106,7 +106,7 @@
<script>
import { mapState } from 'vuex'
import _ from 'lodash'
import { get } from 'lodash-es'
export default {
props: {
@ -131,7 +131,7 @@ export default {
nodeinfo: state => state.instance.nodeinfo
}),
anonymousCanListen () {
return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen', false)
return get(this.nodeinfo, 'metadata.library.anonymousCanListen', false)
},
iframeSrc () {
let base = import.meta.env.BASE_URL

View File

@ -342,12 +342,12 @@ import { mapState, mapGetters, mapActions } from 'vuex'
import GlobalEvents from '@/components/utils/global-events.vue'
import { toLinearVolumeScale } from '@/audio/volume.js'
import { Howl, Howler } from 'howler'
import _ from 'lodash'
import url from '@/utils/url'
import { throttle, reverse } from 'lodash-es'
import axios from 'axios'
import VolumeControl from './VolumeControl.vue'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue'
import updateQueryString from '@/composables/updateQueryString'
export default {
components: {
@ -399,7 +399,7 @@ export default {
progress: 'player/progress'
}),
updateProgressThrottled () {
return _.throttle(this.updateProgress, 50)
return throttle(this.updateProgress, 50)
},
labels () {
const audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', 'Media player')
@ -655,7 +655,7 @@ export default {
// not support other codecs to be able to play it :)
sources.push({
type: 'mp3',
url: url.updateQueryString(
url: updateQueryString(
this.$store.getters['instance/absoluteUrl'](trackData.listen_url),
'to',
'mp3'
@ -675,7 +675,7 @@ export default {
value = this.$store.state.auth.scopedTokens.listen
}
sources.forEach(e => {
e.url = url.updateQueryString(e.url, param, value)
e.url = updateQueryString(e.url, param, value)
})
}
return sources
@ -816,14 +816,14 @@ export default {
checkCache () {
const self = this
const toKeep = []
_.reverse(this.soundsCache).forEach((e) => {
reverse(this.soundsCache).forEach((e) => {
if (toKeep.length < self.maxPreloaded) {
toKeep.push(e)
} else {
e.sound.unload()
}
})
this.soundsCache = _.reverse(toKeep)
this.soundsCache = reverse(toKeep)
},
removeFromCache (sound) {
const toKeep = []

View File

@ -69,7 +69,7 @@
</template>
<script>
import _ from 'lodash'
import { debounce } from 'lodash-es'
import axios from 'axios'
import logger from '@/logging'
import AlbumCard from '@/components/audio/album/Card.vue'
@ -112,7 +112,7 @@ export default {
this.search()
},
methods: {
search: _.debounce(function () {
search: debounce(function () {
if (this.query.length < 1) {
return
}

View File

@ -23,7 +23,7 @@
<script>
import jQuery from 'jquery'
import router from '@/router'
import lodash from 'lodash'
import { trim } from 'lodash-es'
import GlobalEvents from '@/components/utils/global-events.vue'
export default {
@ -248,8 +248,8 @@ export default {
this.$refs.search.focus()
},
extractObjId (query) {
query = lodash.trim(query)
query = lodash.trim(query, '@')
query = trim(query)
query = trim(query, '@')
if (query.indexOf(' ') > -1) {
return
}

View File

@ -158,12 +158,12 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import TrackRow from '@/components/audio/track/Row.vue'
import TrackMobileRow from '@/components/audio/track/MobileRow.vue'
import Pagination from '@/components/Pagination.vue'
import { unique } from '@/filters'
import { unique } from '@/modules/filters'
export default {
components: {
@ -228,7 +228,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.paginateBy
params.page = this.currentPage
params.include_channels = true

View File

@ -135,7 +135,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton.vue'
import TagsList from '@/components/tags/List.vue'
@ -184,7 +184,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.limit
params.offset = this.offset
axios.get(url, { params: params }).then((response) => {

View File

@ -119,7 +119,7 @@
</template>
<script>
import _ from 'lodash'
import { uniq } from 'lodash-es'
import axios from 'axios'
import TranslationsMixin from '@/components/mixins/Translations.vue'
@ -163,7 +163,7 @@ export default {
return this.fields.scopes.split(' ')
},
set (v) {
this.fields.scopes = _.uniq(v).join(' ')
this.fields.scopes = uniq(v).join(' ')
}
},
allScopes () {

View File

@ -229,8 +229,8 @@ export default {
})
}
},
created () {
checkRedirectToLogin(this.$store, this.$router)
async created () {
await checkRedirectToLogin(this.$store, this.$router)
if (this.clientId) {
this.fetchApplication()
}

View File

@ -166,7 +166,7 @@
<script>
import axios from 'axios'
import lodash from 'lodash'
import { clone } from 'lodash-es'
import showdown from 'showdown'
export default {
props: {
@ -178,7 +178,7 @@ export default {
markdown: new showdown.Converter(),
isLoading: false,
enabled: this.plugin.enabled,
values: lodash.clone(this.plugin.values || {}),
values: clone(this.plugin.values || {}),
errors: []
}
},

View File

@ -144,7 +144,7 @@
<script>
import Modal from '@/components/semantic/Modal.vue'
import ChannelUploadForm from '@/components/channels/UploadForm.vue'
import { humanSize } from '@/filters'
import { humanSize } from '@/modules/filters'
export default {
components: {

View File

@ -13,7 +13,7 @@
</span>
</template>
<script>
import { secondsToObject } from '@/filters'
import { secondsToObject } from '@/modules/filters'
export default {
props: { seconds: { type: Number, default: null } },

View File

@ -26,14 +26,14 @@
class="menu"
>
<a
v-for="theme in themes"
:key="theme.key"
:class="[{'active': $store.state.ui.theme === theme.key}, 'item']"
:value="theme.key"
@click="$store.dispatch('ui/theme', theme.key)"
v-for="t in themes"
:key="t.key"
:class="[{'active': theme === t.key}, 'item']"
:value="t.key"
@click="theme = t.key"
>
<i :class="theme.icon" />
{{ theme.name }}
<i :class="t.icon" />
{{ t.name }}
</a>
</div>
</div>
@ -154,11 +154,16 @@
<script>
import { mapGetters } from 'vuex'
import ThemesMixin from '@/components/mixins/Themes.vue'
import useThemeList from '@/composables/useThemeList'
import useTheme from '@/composables/useTheme'
export default {
mixins: [ThemesMixin],
setup () {
return {
theme: useTheme(),
themes: useThemeList()
}
},
computed: {
labels () {
return {

View File

@ -57,7 +57,7 @@
<i class="palette icon user-modal list-icon" />
<span class="user-modal list-item">{{ labels.theme }}:</span>
<div class="right floated">
<span class="user-modal list-item"> {{ themes.find(x => x.key ===$store.state.ui.theme).name }}</span>
<span class="user-modal list-item"> {{ themes.find(x => x.key === theme).name }}</span>
<i class="action-hint chevron right icon user-modal" />
</div>
</div>
@ -175,17 +175,23 @@
<script>
import Modal from '@/components/semantic/Modal.vue'
import ThemesMixin from '@/components/mixins/Themes.vue'
import { mapGetters } from 'vuex'
import useThemeList from '@/composables/useThemeList'
import useTheme from '@/composables/useTheme'
export default {
components: {
Modal
},
mixins: [ThemesMixin],
props: {
show: { type: Boolean, required: true }
},
setup () {
return {
theme: useTheme(),
themes: useThemeList()
}
},
computed: {
labels () {
return {

View File

@ -191,8 +191,8 @@ export default {
this.updateQueryString()
}
},
created () {
checkRedirectToLogin(this.$store, this.$router)
async created () {
await checkRedirectToLogin(this.$store, this.$router)
this.fetchFavorites(FAVORITES_URL)
},
mounted () {

View File

@ -53,7 +53,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import LibraryCard from '@/views/content/remote/Card.vue'
@ -86,7 +86,7 @@ export default {
fetchData (url) {
this.isLoading = true
const self = this
const params = _.clone({})
const params = clone({})
params.page_size = this.limit
params.offset = this.offset
axios.get(url, { params: params }).then((response) => {

View File

@ -249,7 +249,7 @@
<script>
import axios from 'axios'
import lodash from 'lodash'
import { sum } from 'lodash-es'
import PlayButton from '@/components/audio/PlayButton.vue'
import TagsList from '@/components/tags/List.vue'
import ArtistLabel from '@/components/audio/ArtistLabel.vue'
@ -307,7 +307,7 @@ export default {
durations.push(t.uploads[0].duration)
}
})
return lodash.sum(durations)
return sum(durations)
},
labels () {
return {

View File

@ -92,7 +92,7 @@
<script>
import time from '@/utils/time.js'
import time from '@/utils/time'
import LibraryWidget from '@/components/federation/LibraryWidget.vue'
import ChannelEntries from '@/components/audio/ChannelEntries.vue'
import TrackTable from '@/components/audio/track/Table.vue'

View File

@ -215,7 +215,7 @@
<script>
import axios from 'axios'
import logger from '@/logging.js'
import logger from '@/logging'
import PlayButton from '@/components/audio/PlayButton.vue'
import EmbedWizard from '@/components/audio/EmbedWizard.vue'
import Modal from '@/components/semantic/Modal.vue'
@ -223,7 +223,7 @@ import RadioButton from '@/components/radios/Button.vue'
import TagsList from '@/components/tags/List.vue'
import ReportMixin from '@/components/mixins/Report.vue'
import { getDomain } from '@/utils.js'
import { getDomain } from '@/utils'
export default {
components: {

View File

@ -167,7 +167,7 @@ import qs from 'qs'
import axios from 'axios'
import $ from 'jquery'
import logger from '@/logging.js'
import logger from '@/logging'
import OrderingMixin from '@/components/mixins/Ordering.vue'
import PaginationMixin from '@/components/mixins/Pagination.vue'

View File

@ -240,7 +240,7 @@
<script>
import $ from 'jquery'
import _ from 'lodash'
import { isEqual, clone } from 'lodash-es'
import axios from 'axios'
import AttachmentInput from '@/components/common/AttachmentInput.vue'
import EditList from '@/components/library/EditList.vue'
@ -296,7 +296,7 @@ export default {
mutationPayload () {
const self = this
const changedFields = this.config.fields.filter(f => {
return !_.isEqual(self.values[f.id], self.initialValues[f.id])
return !isEqual(self.values[f.id], self.initialValues[f.id])
})
if (changedFields.length === 0) {
return null
@ -339,15 +339,15 @@ export default {
setValues () {
const self = this
this.config.fields.forEach(f => {
self.$set(self.values, f.id, _.clone(f.getValue(self.object)))
self.$set(self.initialValues, f.id, _.clone(self.values[f.id]))
self.$set(self.values, f.id, clone(f.getValue(self.object)))
self.$set(self.initialValues, f.id, clone(self.values[f.id]))
})
},
submit () {
const self = this
self.isLoading = true
self.errors = []
const payload = _.clone(this.mutationPayload || {})
const payload = clone(this.mutationPayload || {})
if (this.canEdit) {
payload.is_approved = true
}
@ -363,10 +363,10 @@ export default {
)
},
fieldValuesChanged (fieldId) {
return !_.isEqual(this.values[fieldId], this.initialValues[fieldId])
return !isEqual(this.values[fieldId], this.initialValues[fieldId])
},
resetField (fieldId) {
this.values[fieldId] = _.clone(this.initialValues[fieldId])
this.values[fieldId] = clone(this.initialValues[fieldId])
}
}

View File

@ -42,7 +42,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import EditCard from '@/components/library/EditCard.vue'
@ -84,7 +84,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.limit
axios.get(url, { params: params }).then((response) => {
self.previousPage = response.data.previous

View File

@ -311,7 +311,7 @@
</template>
<script>
import _ from 'lodash'
import { sortBy, debounce } from 'lodash-es'
import axios from 'axios'
import FileUploadWidget from './FileUploadWidget.vue'
import FsBrowser from './FsBrowser.vue'
@ -435,7 +435,7 @@ export default {
sortedFiles () {
// return errored files on top
return _.sortBy(this.files.map(f => {
return sortBy(this.files.map(f => {
let statusIndex = 0
if (f.errored) {
statusIndex = -1
@ -467,7 +467,7 @@ export default {
}
},
watch: {
importReference: _.debounce(function () {
importReference: debounce(function () {
this.$router.replace({ query: { import: this.importReference } })
}, 500),
remainingSpace (newValue) {

View File

@ -1,6 +1,6 @@
<script>
import FileUpload from 'vue-upload-component'
import { setCsrf } from '@/utils.js'
import { setCsrf } from '@/utils'
export default {
extends: FileUpload,

View File

@ -72,7 +72,7 @@
<script>
import axios from 'axios'
import logger from '@/logging.js'
import logger from '@/logging'
import ChannelsWidget from '@/components/audio/ChannelsWidget.vue'
import TrackWidget from '@/components/audio/track/Widget.vue'
import AlbumWidget from '@/components/audio/album/Widget.vue'

View File

@ -199,7 +199,7 @@ import qs from 'qs'
import axios from 'axios'
import $ from 'jquery'
import logger from '@/logging.js'
import logger from '@/logging'
import OrderingMixin from '@/components/mixins/Ordering.vue'
import PaginationMixin from '@/components/mixins/Pagination.vue'

View File

@ -178,7 +178,7 @@
import axios from 'axios'
import $ from 'jquery'
import logger from '@/logging.js'
import logger from '@/logging'
import OrderingMixin from '@/components/mixins/Ordering.vue'
import PaginationMixin from '@/components/mixins/Pagination.vue'

View File

@ -20,14 +20,14 @@
<script>
import $ from 'jquery'
import lodash from 'lodash'
import { isEqual } from 'lodash-es'
export default {
props: { value: { type: Array, required: true } },
watch: {
value: {
handler (v) {
const current = $(this.$refs.dropdown).dropdown('get value').split(',').sort()
if (!lodash.isEqual([...v].sort(), current)) {
if (!isEqual([...v].sort(), current)) {
$(this.$refs.dropdown).dropdown('set exactly', v)
}
},

View File

@ -219,18 +219,18 @@
</template>
<script>
import time from '@/utils/time.js'
import time from '@/utils/time'
import axios from 'axios'
import url from '@/utils/url.js'
import { getDomain } from '@/utils.js'
import logger from '@/logging.js'
import { getDomain } from '@/utils'
import logger from '@/logging'
import PlayButton from '@/components/audio/PlayButton.vue'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue'
import Modal from '@/components/semantic/Modal.vue'
import EmbedWizard from '@/components/audio/EmbedWizard.vue'
import ReportMixin from '@/components/mixins/Report.vue'
import { momentFormat } from '@/filters'
import { momentFormat } from '@/modules/filters'
import updateQueryString from '@/composables/updateQueryString'
const FETCH_URL = 'tracks/'
@ -322,7 +322,7 @@ export default {
param = 'token'
value = this.$store.state.auth.scopedTokens.listen
}
u = url.updateQueryString(
u = updateQueryString(
u,
param,
encodeURI(value)

View File

@ -186,7 +186,7 @@
<script>
import axios from 'axios'
import $ from 'jquery'
import _ from 'lodash'
import { clone } from 'lodash-es'
import BuilderFilter from './Filter.vue'
import TrackTable from '@/components/audio/track/Table.vue'
import RadioButton from '@/components/radios/Button.vue'
@ -309,7 +309,7 @@ export default {
const self = this
const url = 'radios/radios/validate/'
let final = this.filters.map(f => {
const c = _.clone(f.config)
const c = clone(f.config)
c.type = f.filter.type
return c
})
@ -326,7 +326,7 @@ export default {
self.isLoading = true
let final = this.filters.map(f => {
const c = _.clone(f.config)
const c = clone(f.config)
c.type = f.filter.type
return c
})

View File

@ -107,7 +107,7 @@
<script>
import axios from 'axios'
import $ from 'jquery'
import _ from 'lodash'
import { clone } from 'lodash-es'
import Modal from '@/components/semantic/Modal.vue'
import TrackTable from '@/components/audio/track/Table.vue'
@ -181,7 +181,7 @@ export default {
fetchCandidates: function () {
const self = this
const url = 'radios/radios/validate/'
let final = _.clone(this.config)
let final = clone(this.config)
final.type = this.filter.type
final = { filters: [final] }
axios.post(url, final).then((response) => {

View File

@ -201,8 +201,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -247,7 +247,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -287,7 +287,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -186,8 +186,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -233,7 +233,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -273,7 +273,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -185,8 +185,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -230,7 +230,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -276,7 +276,7 @@ export default {
return { name: 'manage.library.artists.detail', params: { id: artist.id } }
},
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -131,8 +131,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { uniq, merge } from 'lodash-es'
import time from '@/utils/time'
import Pagination from '@/components/Pagination.vue'
import OrderingMixin from '@/components/mixins/Ordering.vue'
import TranslationsMixin from '@/components/mixins/Translations.vue'
@ -197,7 +197,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,
@ -236,7 +236,7 @@ export default {
if (config.ids.length === 0) {
return
}
axios.get(config.url, { params: { id: _.uniq(config.ids), hidden: 'null' } }).then((response) => {
axios.get(config.url, { params: { id: uniq(config.ids), hidden: 'null' } }).then((response) => {
response.data.results.forEach((e) => {
self.$set(self.targets[k], e.id, {
payload: e,

View File

@ -216,8 +216,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -262,7 +262,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -302,7 +302,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -147,8 +147,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -198,7 +198,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -238,7 +238,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -199,8 +199,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -243,7 +243,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -283,7 +283,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -308,8 +308,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -361,7 +361,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -401,7 +401,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -172,8 +172,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import { normalizeQuery, parseTokens } from '@/search'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
@ -220,7 +220,7 @@ export default {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -255,7 +255,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search.query,

View File

@ -183,8 +183,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
import OrderingMixin from '@/components/mixins/Ordering.vue'
@ -229,7 +229,7 @@ export default {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -290,7 +290,7 @@ export default {
if (this.allowed !== null) {
baseFilters.allowed = this.allowed
}
const params = _.merge(baseFilters, this.filters)
const params = merge(baseFilters, this.filters)
const self = this
self.isLoading = true
self.checked = []

View File

@ -169,7 +169,7 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import { get } from 'lodash-es'
export default {
props: {
@ -183,12 +183,12 @@ export default {
isLoading: false,
errors: [],
current: {
summary: _.get(current, 'summary', ''),
isActive: _.get(current, 'is_active', true),
blockAll: _.get(current, 'block_all', true),
silenceActivity: _.get(current, 'silence_activity', false),
silenceNotifications: _.get(current, 'silence_notifications', false),
rejectMedia: _.get(current, 'reject_media', false)
summary: get(current, 'summary', ''),
isActive: get(current, 'is_active', true),
blockAll: get(current, 'block_all', true),
silenceActivity: get(current, 'silence_activity', false),
silenceNotifications: get(current, 'silence_notifications', false),
rejectMedia: get(current, 'reject_media', false)
},
fieldConfig: [
// we hide those until we actually have the related features implemented :)

View File

@ -158,7 +158,7 @@
<script>
import axios from 'axios'
import moment from 'moment'
import _ from 'lodash'
import { merge } from 'lodash-es'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
import OrderingMixin from '@/components/mixins/Ordering.vue'
@ -199,7 +199,7 @@ export default {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -243,7 +243,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search,

View File

@ -200,8 +200,8 @@
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time.js'
import { merge } from 'lodash-es'
import time from '@/utils/time'
import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue'
import OrderingMixin from '@/components/mixins/Ordering.vue'
@ -261,7 +261,7 @@ export default {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
return merge(currentFilters, this.filters)
} else {
return currentFilters
}
@ -296,7 +296,7 @@ export default {
},
methods: {
fetchData () {
const params = _.merge({
const params = merge({
page: this.page,
page_size: this.paginateBy,
q: this.search,

View File

@ -1,25 +0,0 @@
<script>
export default {
computed: {
themes () {
return [
{
icon: 'palette icon',
name: this.$pgettext('*/Settings/Dropdown.Label/Theme name', 'Browser default'),
key: 'system'
},
{
icon: 'sun icon',
name: this.$pgettext('*/Settings/Dropdown.Label/Theme name', 'Light'),
key: 'light'
},
{
icon: 'moon icon',
name: this.$pgettext('*/Settings/Dropdown.Label/Theme name', 'Dark'),
key: 'dark'
}
]
}
}
}
</script>

View File

@ -26,7 +26,6 @@
<script>
import TranslationsMixin from '@/components/mixins/Translations.vue'
import lodash from 'lodash'
export default {
mixins: [TranslationsMixin],
props: {
@ -52,7 +51,7 @@ export default {
if (this.restrictTo.length > 0) {
choices = this.restrictTo
} else {
choices = lodash.keys(this.sharedLabels.fields.report_type.choices)
choices = Object.keys(this.sharedLabels.fields.report_type.choices)
}
return c.concat(
choices.sort().map((v) => {

View File

@ -192,9 +192,7 @@
</template>
<script>
import filter from 'lodash/fp/filter'
import sortBy from 'lodash/fp/sortBy'
import flow from 'lodash/fp/flow'
import { filter, sortBy, flow } from 'lodash-es'
import axios from 'axios'
import { mapState } from 'vuex'

View File

@ -59,7 +59,7 @@
</template>
<script>
import _ from 'lodash'
import { clone } from 'lodash-es'
import axios from 'axios'
import PlaylistCard from '@/components/playlists/Card.vue'
@ -104,7 +104,7 @@ export default {
}
this.isLoading = true
const self = this
const params = _.clone(this.filters)
const params = clone(this.filters)
params.page_size = this.limit
params.offset = this.offset
axios.get(url, { params: params }).then((response) => {

View File

@ -22,7 +22,7 @@
<script>
import lodash from 'lodash'
import { isEqual } from 'lodash-es'
export default {
props: {
customRadioId: { type: Number, required: false, default: null },
@ -37,7 +37,7 @@ export default {
if (!state.running) {
return false
} else {
return current.type === this.type && lodash.isEqual(current.objectId, this.objectId) && current.customRadioId === this.customRadioId
return current.type === this.type && isEqual(current.objectId, this.objectId) && current.customRadioId === this.customRadioId
}
}
},

View File

@ -0,0 +1,9 @@
export default (uri: string, key: string, value: string) => {
const re = new RegExp(`([?&])${key}=.*?(&|$)`, 'i')
if (uri.match(re)) {
return uri.replace(re, `$1${key}=${value}$2`)
} else {
const separator = uri.indexOf('?') !== -1 ? '&' : '?'
return `${uri}${separator}${key}=${value}`
}
}

View File

@ -0,0 +1,12 @@
import { useColorMode } from '@vueuse/core'
import { watch } from '@vue/composition-api'
const theme = useColorMode()
document.body.classList.add(`theme-${theme.value}`)
watch(theme, (newValue, oldValue) => {
document.body.classList.remove(`theme-${oldValue}`)
document.body.classList.add(`theme-${newValue}`)
})
export default () => theme

View File

@ -0,0 +1,24 @@
import type { ThemeEntry } from '@/types'
import Vue from 'vue'
const { $pgettext } = Vue.prototype
const themeList: ThemeEntry[] = [
{
icon: 'palette icon',
name: $pgettext('*/Settings/Dropdown.Label/Theme name', 'Browser default'),
key: 'auto'
},
{
icon: 'sun icon',
name: $pgettext('*/Settings/Dropdown.Label/Theme name', 'Light'),
key: 'light'
},
{
icon: 'moon icon',
name: $pgettext('*/Settings/Dropdown.Label/Theme name', 'Dark'),
key: 'dark'
}
]
export default () => themeList

View File

@ -0,0 +1,15 @@
import store from '~/store'
import { tryOnScopeDispose } from '@vueuse/core'
import { WebSocketEvent } from '~/types'
export default (eventName: string, handler: (event: WebSocketEvent) => void) => {
const id = `${+new Date() + Math.random()}`
store.commit('ui/addWebsocketEventHandler', { eventName, handler, id })
const stop = () => {
store.commit('ui/removeWebsocketEventHandler', { eventName, id })
}
tryOnScopeDispose(stop)
return stop
}

View File

@ -1,4 +1,3 @@
import Vue from 'vue'
import EmbedFrame from './EmbedFrame.vue'
import VuePlyr from 'vue-plyr'

View File

@ -247,7 +247,7 @@
<script>
import axios from 'axios'
import Logo from '@/components/Logo.vue'
import url from '@/utils/url'
import updateQueryString from './composables/updateQueryString'
import time from '@/utils/time'
function getURLParams () {
@ -516,7 +516,7 @@ export default {
// not support other codecs to be able to play it :)
sources.push({
type: 'audio/mpeg',
src: url.updateQueryString(
src: updateQueryString(
self.fullUrl(sources[0].src),
'to',
'mp3'

View File

@ -1,129 +0,0 @@
/* eslint-disable */
export default {
"locales": [
{
"code": "ar",
"label": "العربية"
},
{
"code": "ca",
"label": "Català"
},
{
"code": "cs",
"label": "Čeština"
},
{
"code": "de",
"label": "Deutsch"
},
{
"code": "en_GB",
"label": "English (UK)"
},
{
"code": "en_US",
"label": "English (United-States)"
},
{
"code": "eo",
"label": "Esperanto"
},
{
"code": "es",
"label": "Español"
},
{
"code": "eu",
"label": "Euskara"
},
{
"code": "fr_FR",
"label": "Français"
},
{
"code": "gl",
"label": "Galego"
},
{
"code": "hu",
"label": "Magyar"
},
{
"code": "it",
"label": "Italiano"
},
{
"code": "ja_JP",
"label": "日本語"
},
{
"code": "kab_DZ",
"label": "Taqbaylit"
},
{
"code": "ko_KR",
"label": "한국어"
},
{
"code": "nb_NO",
"label": "Bokmål"
},
{
"code": "nn_NO",
"label": "Nynorsk"
},
{
"code": "nl",
"label": "Nederlands"
},
{
"code": "oc",
"label": "Occitan"
},
{
"code": "pl",
"label": "Polski"
},
{
"code": "pt_BR",
"label": "Português (Brasil)"
},
{
"code": "pt_PT",
"label": "Português (Portugal)"
},
{
"code": "ru",
"label": "Русский"
},
{
"code": "sq",
"label": "Shqip"
},
{
"code": "zh_Hans",
"label": "中文(简体)"
},
{
"code": "zh_Hant",
"label": "中文(繁體)"
},
{
"code": "fa_IR",
"label": "فارسی"
},
{
"code": "ml",
"label": "മലയാളം"
},
{
"code": "sv",
"label": "Svenska"
},
{
"code": "el",
"label": "Ελληνικά"
}
]
}

129
front/src/locales.ts Normal file
View File

@ -0,0 +1,129 @@
import type { Locale } from '@/types'
/* eslint-disable */
export const locales: Locale[] = [
{
"code": "ar",
"label": "العربية"
},
{
"code": "ca",
"label": "Català"
},
{
"code": "cs",
"label": "Čeština"
},
{
"code": "de",
"label": "Deutsch"
},
{
"code": "en_GB",
"label": "English (UK)"
},
{
"code": "en_US",
"label": "English (United-States)"
},
{
"code": "eo",
"label": "Esperanto"
},
{
"code": "es",
"label": "Español"
},
{
"code": "eu",
"label": "Euskara"
},
{
"code": "fr_FR",
"label": "Français"
},
{
"code": "gl",
"label": "Galego"
},
{
"code": "hu",
"label": "Magyar"
},
{
"code": "it",
"label": "Italiano"
},
{
"code": "ja_JP",
"label": "日本語"
},
{
"code": "kab_DZ",
"label": "Taqbaylit"
},
{
"code": "ko_KR",
"label": "한국어"
},
{
"code": "nb_NO",
"label": "Bokmål"
},
{
"code": "nn_NO",
"label": "Nynorsk"
},
{
"code": "nl",
"label": "Nederlands"
},
{
"code": "oc",
"label": "Occitan"
},
{
"code": "pl",
"label": "Polski"
},
{
"code": "pt_BR",
"label": "Português (Brasil)"
},
{
"code": "pt_PT",
"label": "Português (Portugal)"
},
{
"code": "ru",
"label": "Русский"
},
{
"code": "sq",
"label": "Shqip"
},
{
"code": "zh_Hans",
"label": "中文(简体)"
},
{
"code": "zh_Hant",
"label": "中文(繁體)"
},
{
"code": "fa_IR",
"label": "فارسی"
},
{
"code": "ml",
"label": "മലയാളം"
},
{
"code": "sv",
"label": "Svenska"
},
{
"code": "el",
"label": "Ελληνικά"
}
]

View File

@ -1,199 +0,0 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import logger from '@/logging'
import jQuery from '@/jquery'
import Vue from 'vue'
import moment from 'moment'
import App from './App.vue'
import router from './router'
import axios from 'axios'
import VueLazyload from 'vue-lazyload'
import store from './store'
import GetTextPlugin from 'vue-gettext'
import { sync } from 'vuex-router-sync'
import locales from '@/locales'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import VueCompositionAPI from '@vue/composition-api'
import filters from '@/filters' // eslint-disable-line
import { parseAPIErrors } from '@/utils'
import globals from '@/components/globals' // eslint-disable-line
import './registerServiceWorker'
import '@/semantic'
logger.default.info('Loading environment:', import.meta.env.MODE)
logger.default.debug('Environment variables:', import.meta.env)
sync(store, router)
let APP = null
const availableLanguages = (function () {
const l = {}
locales.locales.forEach(c => {
l[c.code] = c.label
})
return l
})()
let defaultLanguage = 'en_US'
if (availableLanguages[store.state.ui.currentLanguage]) {
defaultLanguage = store.state.ui.currentLanguage
}
Vue.use(GetTextPlugin, {
availableLanguages: availableLanguages,
defaultLanguage: defaultLanguage,
// cf https://github.com/Polyconseil/vue-gettext#configuration
// not recommended but this is fixing weird bugs with translation nodes
// not being updated when in v-if/v-else clauses
autoAddKeyAttributes: true,
languageVmMixin: {
computed: {
currentKebabCase: function () {
return this.current.toLowerCase().replace('_', '-')
}
}
},
translations: {},
silent: true
})
Vue.use(VueCompositionAPI)
Vue.use(VueLazyload)
Vue.directive('title', function (el, binding) {
store.commit('ui/pageTitle', binding.value)
})
Vue.directive('dropdown', function (el, binding) {
jQuery(el).dropdown({
selectOnKeydown: false,
action: function (text, value, $el) {
// used to ensure focusing the dropdown and clicking via keyboard
// works as expected
const button = $el[0]
button.click()
jQuery(el).find('.ui.dropdown').dropdown('hide')
},
...(binding.value || {})
})
})
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
axios.interceptors.request.use(function (config) {
// Do something before request is sent
if (store.state.auth.oauth.accessToken) {
config.headers.Authorization = store.getters['auth/header']
}
return config
}, function (error) {
// Do something with request error
return Promise.reject(error)
})
// Add a response interceptor
axios.interceptors.response.use(function (response) {
return response
}, function (error) {
error.backendErrors = []
if (store.state.auth.authenticated && !store.state.auth.oauth.accessToken && error.response.status === 401) {
store.commit('auth/authenticated', false)
logger.default.warn('Received 401 response from API, redirecting to login form', router.currentRoute.fullPath)
router.push({ name: 'login', query: { next: router.currentRoute.fullPath } })
}
if (error.response.status === 404) {
error.backendErrors.push('Resource not found')
const message = error.response.data
store.commit('ui/addMessage', {
content: message,
class: 'error'
})
} else if (error.response.status === 403) {
error.backendErrors.push('Permission denied')
} else if (error.response.status === 429) {
let message
const rateLimitStatus = {
limit: error.response.headers['x-ratelimit-limit'],
scope: error.response.headers['x-ratelimit-scope'],
remaining: error.response.headers['x-ratelimit-remaining'],
duration: error.response.headers['x-ratelimit-duration'],
availableSeconds: error.response.headers['retry-after'],
reset: error.response.headers['x-ratelimit-reset'],
resetSeconds: error.response.headers['x-ratelimit-resetseconds']
}
if (rateLimitStatus.availableSeconds) {
rateLimitStatus.availableSeconds = parseInt(rateLimitStatus.availableSeconds)
const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
message = APP.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again in %{ delay }')
message = APP.$gettextInterpolate(message, { delay: tryAgain })
} else {
message = APP.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again later')
}
error.backendErrors.push(message)
store.commit('ui/addMessage', {
content: message,
date: new Date(),
class: 'error'
})
logger.default.error('This client is rate-limited!', rateLimitStatus)
} else if (error.response.status === 500) {
error.backendErrors.push('A server error occured')
} else if (error.response.data) {
if (error.response.data.detail) {
error.backendErrors.push(error.response.data.detail)
} else {
error.rawPayload = error.response.data
const parsedErrors = parseAPIErrors(error.response.data)
error.backendErrors = [...error.backendErrors, ...parsedErrors]
}
}
if (error.backendErrors.length === 0) {
error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
}
// Do something with response error
return Promise.reject(error)
})
const refreshAuth = (failedRequest) => {
if (store.state.auth.oauth.accessToken) {
console.log('Failed request, refreshing auth…')
// maybe the token was expired, let's try to refresh it
return store.dispatch('auth/refreshOauthToken').then(() => {
failedRequest.response.config.headers.Authorization = store.getters['auth/header']
return Promise.resolve()
})
} else {
return Promise.resolve()
}
}
createAuthRefreshInterceptor(axios, refreshAuth)
store.dispatch('instance/fetchFrontSettings').finally(() => {
/* eslint-disable no-new */
new Vue({
el: '#app',
router,
store,
components: { App },
created () {
APP = this
window.addEventListener('resize', this.handleResize)
this.handleResize()
},
destroyed () {
window.removeEventListener('resize', this.handleResize)
},
methods: {
handleResize () {
this.$store.commit('ui/window', {
width: window.innerWidth,
height: window.innerHeight
})
}
},
render (h) {
return h('App')
}
})
logger.default.info('Everything loaded!')
})

35
front/src/main.ts Normal file
View File

@ -0,0 +1,35 @@
import logger from '~/logging'
import App from '~/App.vue'
import router from '~/router'
import VueLazyload from 'vue-lazyload'
import store from '~/store'
import { sync } from 'vuex-router-sync'
import VueCompositionAPI, { createApp } from '@vue/composition-api'
import { CreateElement } from 'vue'
logger.default.info('Loading environment:', import.meta.env.MODE)
logger.default.debug('Environment variables:', import.meta.env)
sync(store, router)
const app = createApp({
store,
router,
render: (h: CreateElement) => h(App)
})
app.use(VueCompositionAPI)
app.use(VueLazyload)
for (const module of Object.values(import.meta.globEager('./modules/*.ts'))) {
module.install?.({
app,
router,
store
})
}
store.dispatch('instance/fetchFrontSettings').finally(() => {
app.mount('#app')
logger.default.info('Everything loaded!')
})

109
front/src/modules/axios.ts Normal file
View File

@ -0,0 +1,109 @@
import { AppModule } from '@/types'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
import axios, { AxiosError } from 'axios'
import moment from 'moment'
import logger from '@/logging'
import { parseAPIErrors } from '@/utils'
import Vue from 'vue'
export const install: AppModule = ({ app, store, router }) => {
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
axios.interceptors.request.use(function (config) {
// Do something before request is sent
if (store.state.auth.oauth.accessToken) {
config.headers ??= {}
config.headers.Authorization = store.getters['auth/header']
}
return config
}, function (error) {
// Do something with request error
return Promise.reject(error)
})
// Add a response interceptor
axios.interceptors.response.use(function (response) {
return response
}, async (error) => {
error.backendErrors = []
if (store.state.auth.authenticated && !store.state.auth.oauth.accessToken && error.response.status === 401) {
store.commit('auth/authenticated', false)
logger.default.warn('Received 401 response from API, redirecting to login form', router.currentRoute.fullPath)
await router.push({ name: 'login', query: { next: router.currentRoute.fullPath } })
}
if (error.response.status === 404) {
error.backendErrors.push('Resource not found')
const message = error.response.data
store.commit('ui/addMessage', {
content: message,
class: 'error'
})
} else if (error.response.status === 403) {
error.backendErrors.push('Permission denied')
} else if (error.response.status === 429) {
let message
const rateLimitStatus = {
limit: error.response.headers['x-ratelimit-limit'],
scope: error.response.headers['x-ratelimit-scope'],
remaining: error.response.headers['x-ratelimit-remaining'],
duration: error.response.headers['x-ratelimit-duration'],
availableSeconds: error.response.headers['retry-after'],
reset: error.response.headers['x-ratelimit-reset'],
resetSeconds: error.response.headers['x-ratelimit-resetseconds']
}
if (rateLimitStatus.availableSeconds) {
rateLimitStatus.availableSeconds = parseInt(rateLimitStatus.availableSeconds)
const tryAgain = moment().add(rateLimitStatus.availableSeconds, 's').toNow(true)
message = Vue.prototype.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again in %{ delay }')
message = Vue.prototype.$gettextInterpolate(message, { delay: tryAgain })
} else {
message = Vue.prototype.$pgettext('*/Error/Paragraph', 'You sent too many requests and have been rate limited, please try again later')
}
error.backendErrors.push(message)
store.commit('ui/addMessage', {
content: message,
date: new Date(),
class: 'error'
})
logger.default.error('This client is rate-limited!', rateLimitStatus)
} else if (error.response.status === 500) {
error.backendErrors.push('A server error occured')
} else if (error.response.data) {
if (error.response.data.detail) {
error.backendErrors.push(error.response.data.detail)
} else {
error.rawPayload = error.response.data
const parsedErrors = parseAPIErrors(error.response.data)
error.backendErrors = [...error.backendErrors, ...parsedErrors]
}
}
if (error.backendErrors.length === 0) {
error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
}
// Do something with response error
return Promise.reject(error)
})
const refreshAuth = (failedRequest: AxiosError) => {
if (store.state.auth.oauth.accessToken) {
console.log('Failed request, refreshing auth…')
// maybe the token was expired, let's try to refresh it
return store.dispatch('auth/refreshOauthToken').then(() => {
if (failedRequest.response) {
failedRequest.response.config.headers ??= {}
failedRequest.response.config.headers.Authorization = store.getters['auth/header']
}
return Promise.resolve()
})
}
return Promise.resolve()
}
createAuthRefreshInterceptor(axios, refreshAuth)
}

View File

@ -0,0 +1,24 @@
import { AppModule } from '@/types'
import jQuery from '@/jquery'
export const install: AppModule = ({ app, store }) => {
app.directive('title', function (el, binding) {
store.commit('ui/pageTitle', binding.value)
})
app.directive('dropdown', function (el, binding) {
// @ts-ignore
jQuery(el).dropdown({
selectOnKeydown: false,
action (text: string, value: string, $el: JQuery<HTMLElement>) {
// used to ensure focusing the dropdown and clicking via keyboard
// works as expected
const button = $el[0]
button.click()
// @ts-ignore
jQuery(el).find('.ui.dropdown').dropdown('hide')
},
...(binding.value || {})
})
})
}

View File

@ -1,18 +1,18 @@
import { AppModule } from '~/types'
import Vue from 'vue'
import time from '@/utils/time'
import time from '~/utils/time'
import moment from 'moment'
export function truncate (str, max, ellipsis, middle) {
export function truncate (str: string, max = 100, ellipsis = '…', middle = false) {
if (max === 0) {
return
return ''
}
max = max || 100
ellipsis = ellipsis || '…'
if (str.length <= max) {
return str
}
if (middle) {
const sepLen = 1
const charsToShow = max - sepLen
@ -27,9 +27,7 @@ export function truncate (str, max, ellipsis, middle) {
}
}
Vue.filter('truncate', truncate)
export function ago (date, locale) {
export function ago (date: Date, locale: string) {
locale = locale || 'en'
const m = moment(date)
m.locale(locale)
@ -43,9 +41,7 @@ export function ago (date, locale) {
})
}
Vue.filter('ago', ago)
export function fromNow (date, locale) {
export function fromNow (date: Date, locale: string) {
locale = 'en'
moment.locale('en', {
relativeTime: {
@ -70,9 +66,7 @@ export function fromNow (date, locale) {
return m.fromNow(true)
}
Vue.filter('fromNow', fromNow)
export function secondsToObject (seconds) {
export function secondsToObject (seconds: number) {
const m = moment.duration(seconds, 'seconds')
return {
seconds: m.seconds(),
@ -81,42 +75,30 @@ export function secondsToObject (seconds) {
}
}
Vue.filter('secondsToObject', secondsToObject)
export function padDuration (duration) {
export function padDuration (duration: string) {
let s = String(duration)
while (s.length < 2) { s = '0' + s }
return s
}
Vue.filter('padDuration', padDuration)
export function duration (seconds) {
return time.parse(seconds)
export function duration (seconds: string) {
return time.parse(+seconds)
}
Vue.filter('duration', duration)
export function momentFormat (date, format) {
export function momentFormat (date: Date, format: string) {
format = format || 'lll'
return moment(date).format(format)
}
Vue.filter('moment', momentFormat)
export function year (date) {
export function year (date: Date) {
return moment(date).year()
}
Vue.filter('year', year)
export function capitalize (str) {
export function capitalize (str: string) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
Vue.filter('capitalize', capitalize)
export function humanSize (bytes) {
export function humanSize (bytes: number) {
const si = true
const thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh) {
@ -133,15 +115,24 @@ export function humanSize (bytes) {
return bytes.toFixed(1) + ' ' + units[u]
}
Vue.filter('humanSize', humanSize)
// Removes duplicates from a list
export function unique (list, property) {
export function unique (list: { [key: string]: unknown }[], property: string) {
property = property || 'id'
const unique = []
const unique: { [key: string]: unknown }[] = []
list.map(x => unique.filter(a => a[property] === x[property]).length > 0 ? null : unique.push(x))
return unique
}
Vue.filter('unique', unique)
export default {}
export const install: AppModule = () => {
Vue.filter('humanSize', humanSize)
Vue.filter('unique', unique)
Vue.filter('capitalize', capitalize)
Vue.filter('moment', momentFormat)
Vue.filter('year', year)
Vue.filter('duration', duration)
Vue.filter('padDuration', padDuration)
Vue.filter('secondsToObject', secondsToObject)
Vue.filter('fromNow', fromNow)
Vue.filter('ago', ago)
Vue.filter('truncate', truncate)
}

View File

@ -1,4 +1,5 @@
import Vue from 'vue'
import { AppModule } from '@/types'
import HumanDate from '@/components/common/HumanDate.vue'
import HumanDuration from '@/components/common/HumanDuration.vue'
import Username from '@/components/common/Username.vue'
@ -19,24 +20,24 @@ import RenderedDescription from '@/components/common/RenderedDescription.vue'
import ContentForm from '@/components/common/ContentForm.vue'
import InlineSearchBar from '@/components/common/InlineSearchBar.vue'
Vue.component('HumanDate', HumanDate)
Vue.component('HumanDuration', HumanDuration)
Vue.component('Username', Username)
Vue.component('UserLink', UserLink)
Vue.component('ActorLink', ActorLink)
Vue.component('ActorAvatar', ActorAvatar)
Vue.component('Duration', Duration)
Vue.component('DangerousButton', DangerousButton)
Vue.component('Message', Message)
Vue.component('CopyInput', CopyInput)
Vue.component('AjaxButton', AjaxButton)
Vue.component('Tooltip', Tooltip)
Vue.component('EmptyState', EmptyState)
Vue.component('ExpandableDiv', ExpandableDiv)
Vue.component('CollapseLink', CollapseLink)
Vue.component('ActionFeedback', ActionFeedback)
Vue.component('RenderedDescription', RenderedDescription)
Vue.component('ContentForm', ContentForm)
Vue.component('InlineSearchBar', InlineSearchBar)
export default {}
export const install: AppModule = ({ app }) => {
app.component('HumanDate', HumanDate)
app.component('HumanDuration', HumanDuration)
app.component('Username', Username)
app.component('UserLink', UserLink)
app.component('ActorLink', ActorLink)
app.component('ActorAvatar', ActorAvatar)
app.component('Duration', Duration)
app.component('DangerousButton', DangerousButton)
app.component('Message', Message)
app.component('CopyInput', CopyInput)
app.component('AjaxButton', AjaxButton)
app.component('Tooltip', Tooltip)
app.component('EmptyState', EmptyState)
app.component('ExpandableDiv', ExpandableDiv)
app.component('CollapseLink', CollapseLink)
app.component('ActionFeedback', ActionFeedback)
app.component('RenderedDescription', RenderedDescription)
app.component('ContentForm', ContentForm)
app.component('InlineSearchBar', InlineSearchBar)
}

View File

@ -0,0 +1,40 @@
import { AppModule } from '@/types'
import { watch } from '@vue/composition-api'
import axios from 'axios'
export const install: AppModule = async ({ store, router }) => {
watch(() => store.state.instance.instanceUrl, async () => {
const [{ data }] = await Promise.all([
axios.get('instance/nodeinfo/2.0/'),
store.dispatch('instance/fetchSettings')
])
store.commit('instance/nodeinfo', data)
})
const urlParams = new URLSearchParams(window.location.search)
const serverUrl = urlParams.get('_server')
if (serverUrl) {
store.commit('instance/instanceUrl', serverUrl)
}
const url = urlParams.get('_url')
if (url) {
return router.replace(url)
}
if (!store.state.instance.instanceUrl) {
// We have several way to guess the API server url. By order of precedence:
// 1. use the url provided in settings.json, if any
// 2. use the url specified when building via VUE_APP_INSTANCE_URL
// 3. use the current url
const defaultInstanceUrl = store.state.instance.frontSettings.defaultServerUrl ||
import.meta.env.VUE_APP_INSTANCE_URL ||
store.getters['instance/defaultUrl']()
store.commit('instance/instanceUrl', defaultInstanceUrl)
} else {
// needed to trigger initialization of axios / service worker / web socket
store.commit('instance/instanceUrl', store.state.instance.instanceUrl)
}
}

View File

@ -0,0 +1,13 @@
import { AppModule } from '@/types'
// slight hack to allow use to have internal links in <translate> tags
// while preserving router behaviour
export const install: AppModule = ({ router }) => {
document.documentElement.addEventListener('click', async (event) => {
const target = <HTMLAnchorElement> event.target
if (!target.matches('a.internal')) return
event.preventDefault()
return router.push(target.href)
}, false)
}

View File

@ -0,0 +1,65 @@
import Vue from 'vue'
import GetText from 'vue-gettext'
import { locales } from '@/locales'
import { usePreferredLanguages } from '@vueuse/core'
import { watch } from '@vue/composition-api'
import { AppModule } from '@/types'
export const install: AppModule = ({ store, app }) => {
const defaultLanguage = store.state.ui.currentLanguage ?? 'en_US'
const availableLanguages = locales.reduce((map: { [key: string]: string }, locale) => {
map[locale.code] = locale.label
return map
}, {})
app.use(GetText, {
availableLanguages,
defaultLanguage,
// cf https://github.com/Polyconseil/vue-gettext#configuration
// not recommended but this is fixing weird bugs with translation nodes
// not being updated when in v-if/v-else clauses
autoAddKeyAttributes: true,
languageVmMixin: {
computed: {
currentKebabCase (): string {
// @ts-ignore
return this.current.toLowerCase().replace('_', '-')
}
}
},
translations: {},
silent: true
})
// Set default language
if (!store.state.ui.selectedLanguage) {
// NOTE: We're selecting the language only once, hence we don't need to make it reactive
const languages = usePreferredLanguages().value.map((code) => {
return code.replace(/-/g, '_')
})
let language = Object.keys(availableLanguages).find(code => {
return languages.includes(code)
})
if (!language) {
language = Object.keys(availableLanguages).find(code => {
return languages.map(lang => lang.split('_')[0]).includes(code.split('_')[0])
})
}
store.commit('ui/currentLanguage', language ?? defaultLanguage)
}
// Handle language change
watch(() => store.state.ui.currentLanguage, (locale) => {
const htmlLocale = locale.toLowerCase().replace('_', '-')
document.documentElement.setAttribute('lang', htmlLocale)
if (locale === 'en_US') {
Vue.prototype.$language.current = locale
store.commit('ui/momentLocale', 'en')
}
}, { immediate: true })
}

View File

@ -0,0 +1,45 @@
import { AppModule } from '@/types'
import { register } from 'register-service-worker'
export const install: AppModule = ({ store }) => {
if (import.meta.env.PROD) {
register(`${import.meta.env.BASE_URL}service-worker.js`, {
registrationOptions: { scope: '/' },
ready () {
console.log(
'App is being served from cache by a service worker.'
)
},
registered (registration) {
console.log('Service worker has been registered.')
// check for updates every 2 hours
const checkInterval = 1000 * 60 * 60 * 2
// var checkInterval = 1000 * 5
setInterval(() => {
console.log('Checking for service worker update…')
registration.update()
}, checkInterval)
store.commit('ui/serviceWorker', { registration: registration })
if (registration.active) {
registration.active.postMessage({ command: 'serverChosen', serverUrl: store.state.instance.instanceUrl })
}
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated (registration) {
console.log('New content is available; please refresh!')
store.commit('ui/serviceWorker', { updateAvailable: true, registration: registration })
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}
}

View File

@ -0,0 +1,28 @@
import { AppModule } from '@/types'
import { watchEffect, watch } from '@vue/composition-api'
import { useWebSocket, whenever } from '@vueuse/core'
export const install: AppModule = ({ store }) => {
watch(() => store.state.instance.instanceUrl, () => {
const url = store.getters['instance/absoluteUrl']('api/v1/activity')
.replace(/^http/, 'ws')
const { data, status, open, close } = useWebSocket(url, {
autoReconnect: true,
immediate: false
})
watch(() => store.state.auth.authenticated, (authenticated) => {
if (authenticated) return open()
close()
})
whenever(data, () => {
return store.dispatch('ui/websocketEvent', JSON.parse(data.value))
})
watchEffect(() => {
console.log('Websocket status:', status.value)
})
})
}

View File

@ -0,0 +1,16 @@
import { AppModule } from '@/types'
import { useWindowSize } from '@vueuse/core'
import { watchEffect } from '@vue/composition-api'
export const install: AppModule = ({ store }) => {
// NOTE: Due to Vuex 3, when using store in watchEffect, it results in an infinite loop after committing
const { commit } = store
const { width, height } = useWindowSize()
watchEffect(() => {
commit('ui/window', {
width: width.value,
height: height.value
})
})
}

View File

@ -1,46 +0,0 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
import store from './store'
if (import.meta.env.PROD) {
register(`${import.meta.env.BASE_URL}service-worker.js`, {
registrationOptions: { scope: '/' },
ready () {
console.log(
'App is being served from cache by a service worker.'
)
},
registered (registration) {
console.log('Service worker has been registered.')
// check for updates every 2 hours
const checkInterval = 1000 * 60 * 60 * 2
// var checkInterval = 1000 * 5
setInterval(() => {
console.log('Checking for service worker update…')
registration.update()
}, checkInterval)
store.commit('ui/serviceWorker', { registration: registration })
if (registration.active) {
registration.active.postMessage({ command: 'serverChosen', serverUrl: store.state.instance.instanceUrl })
}
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated (registration) {
console.log('New content is available; please refresh!')
store.commit('ui/serviceWorker', { updateAvailable: true, registration: registration })
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

View File

@ -1,7 +1,6 @@
import Vue from 'vue'
import axios from 'axios'
import logger from '@/logging'
import lodash from 'lodash'
function getDefaultScopedTokens () {
return {
@ -110,7 +109,7 @@ export default {
state.availablePermissions[key] = status
},
profilePartialUpdate: (state, payload) => {
lodash.keys(payload).forEach((k) => {
Object.keys(payload).forEach((k) => {
Vue.set(state.profile, k, payload[k])
})
},

View File

@ -1,5 +1,5 @@
import Vue from 'vue'
import Vuex from 'vuex'
import Vuex, { Store } from 'vuex'
import createPersistedState from 'vuex-persistedstate'
import favorites from './favorites'
@ -16,7 +16,7 @@ import ui from './ui'
Vue.use(Vuex)
export default new Vuex.Store({
export default <Store<any>> new Vuex.Store({
modules: {
ui,
auth,
@ -44,7 +44,7 @@ export default new Vuex.Store({
}),
createPersistedState({
key: 'ui',
paths: ['ui.currentLanguage', 'ui.selectedLanguage', 'ui.momentLocale', 'ui.theme', 'ui.routePreferences']
paths: ['ui.currentLanguage', 'ui.selectedLanguage', 'ui.momentLocale', 'ui.routePreferences']
}),
createPersistedState({
key: 'radios',

View File

@ -1,6 +1,6 @@
import axios from 'axios'
import logger from '@/logging'
import _ from 'lodash'
import { merge } from 'lodash-es'
function getDefaultUrl () {
return (
@ -65,7 +65,7 @@ export default {
},
mutations: {
settings: (state, value) => {
_.merge(state.settings, value)
merge(state.settings, value)
},
event: (state, value) => {
state.events.unshift(value)
@ -112,11 +112,11 @@ export default {
if (relativeUrl.startsWith('http')) {
return relativeUrl
}
if (state.instanceUrl.endsWith('/') && relativeUrl.startsWith('/')) {
if (state.instanceUrl?.endsWith('/') && relativeUrl.startsWith('/')) {
relativeUrl = relativeUrl.slice(1)
}
const instanceUrl = state.instanceUrl || getDefaultUrl()
const instanceUrl = state.instanceUrl ?? getDefaultUrl()
return instanceUrl + relativeUrl
},
domain: (state) => {
@ -149,17 +149,15 @@ export default {
fetchSettings ({ commit }, payload) {
return axios.get('instance/settings/').then(response => {
logger.default.info('Successfully fetched instance settings')
const sections = {}
response.data.forEach(e => {
sections[e.section] = {}
})
response.data.forEach(e => {
sections[e.section][e.name] = e
})
const sections = response.data.reduce((map, entry) => {
map[entry.section] ??= {}
map[entry.section][entry.name] = entry
return map
}, {})
commit('settings', sections)
if (payload && payload.callback) {
payload.callback()
}
payload?.callback?.()
}, response => {
logger.default.error('Error while fetching settings', response.data)
})

View File

@ -1,6 +1,6 @@
import axios from 'axios'
import logger from '@/logging'
import _ from 'lodash'
import {sortBy} from "lodash-es";
export default {
namespaced: true,
@ -70,7 +70,7 @@ export default {
const f = state.filters.filter((f) => {
return f.target.type === 'artist'
})
const p = _.sortBy(f, [(e) => { return e.creation_date }])
const p = sortBy(f, [(e) => { return e.creation_date }])
p.reverse()
return p
}

Some files were not shown because too many files have changed in this diff Show More