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 a129f7f882
commit 2b40707f4f
121 changed files with 1808 additions and 1442 deletions

View File

@ -28,7 +28,7 @@ Submitting a new language
1. Pull the latest version of ``develop`` 1. Pull the latest version of ``develop``
2. Create a new branch, e.g ``git checkout -b translations-new-fr-ca`` 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: 4. Create the ``po`` file from template:
.. code-block:: shell .. code-block:: shell

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
cd "$(dirname $0)/.." # change into base directory cd "$(dirname $0)/.." # change into base directory
source scripts/utils.sh 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 mkdir -p src/translations
for locale in $locales; do for locale in $locales; do

View File

@ -3,7 +3,7 @@
cd "$(dirname $0)/.." # change into base directory cd "$(dirname $0)/.." # change into base directory
source scripts/utils.sh 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" locales_dir="locales"
sources=$(find src -name '*.vue' -o -name '*.html' 2> /dev/null) sources=$(find src -name '*.vue' -o -name '*.html' 2> /dev/null)
js_sources=$(find src -name '*.vue' -o -name '*.js') 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 old_locales_dir=$1
new_locales_dir=$2 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. # Generate .po files for each available language.
echo $locales 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> <template>
<div <div
id="app" id="app"
:key="String($store.state.instance.instanceUrl)" :key="String(store.state.instance.instanceUrl)"
:class="[$store.state.ui.queueFocused ? 'queue-focused' : '', :class="[store.state.ui.queueFocused ? 'queue-focused' : '',
{'has-bottom-player': $store.state.queue.tracks.length > 0}]" {'has-bottom-player': store.state.queue.tracks.length > 0}]"
> >
<!-- here, we display custom stylesheets, if any --> <!-- here, we display custom stylesheets, if any -->
<link <link
@ -25,18 +180,18 @@
<service-messages /> <service-messages />
<transition name="queue"> <transition name="queue">
<queue <queue
v-if="$store.state.ui.queueFocused" v-if="store.state.ui.queueFocused"
@touch-progress="$refs.player.setCurrentTime($event)" @touch-progress="player.setCurrentTime($event)"
/> />
</transition> </transition>
<router-view <router-view
role="main" role="main"
:class="{hidden: $store.state.ui.queueFocused}" :class="{hidden: store.state.ui.queueFocused}"
/> />
<player ref="player" /> <audio-player ref="player" />
<playlist-modal v-if="$store.state.auth.authenticated" /> <playlist-modal v-if="store.state.auth.authenticated" />
<channel-upload-modal v-if="$store.state.auth.authenticated" /> <channel-upload-modal v-if="store.state.auth.authenticated" />
<filter-modal v-if="$store.state.auth.authenticated" /> <filter-modal v-if="store.state.auth.authenticated" />
<report-modal /> <report-modal />
<shortcuts-modal <shortcuts-modal
:show="showShortcutsModal" :show="showShortcutsModal"
@ -46,414 +201,6 @@
</div> </div>
</template> </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"> <style lang="scss">
@import "style/_main"; @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 // generated by unplugin-vue-components
// We suggest you to commit this file into source control // We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/vue-next/pull/3399 // Read more: https://github.com/vuejs/vue-next/pull/3399
import '@vue/runtime-core'
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
@ -161,4 +160,4 @@ declare module '@vue/runtime-core' {
} }
} }
export {} export { }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -161,7 +161,7 @@
</template> </template>
<script> <script>
import lodash from 'lodash' import { cloneDeep, tap, set } from 'lodash-es'
import SignupForm from '@/components/auth/SignupForm.vue' import SignupForm from '@/components/auth/SignupForm.vue'
@ -209,7 +209,7 @@ export default {
}, },
methods: { methods: {
addField () { 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), label: this.$pgettext('*/*/Form-builder', 'Additional field') + ' ' + (this.local.fields.length + 1),
required: true, required: true,
input_type: 'short_text' input_type: 'short_text'
@ -217,7 +217,7 @@ export default {
this.$emit('input', newValue) this.$emit('input', newValue)
}, },
remove (idx) { 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) { move (idx, incr) {
if (idx === 0 && incr < 0) { if (idx === 0 && incr < 0) {
@ -226,7 +226,7 @@ export default {
if (idx + incr >= this.local.fields.length) { if (idx + incr >= this.local.fields.length) {
return 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) this.update('fields', newFields)
}, },
update (key, value) { 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 PlayButton from '@/components/audio/PlayButton.vue'
import TagsList from '@/components/tags/List.vue' import TagsList from '@/components/tags/List.vue'
import { momentFormat } from '@/filters' import { momentFormat } from '@/modules/filters'
import moment from 'moment' import moment from 'moment'
export default { export default {

View File

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

View File

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

View File

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

View File

@ -106,7 +106,7 @@
<script> <script>
import { mapState } from 'vuex' import { mapState } from 'vuex'
import _ from 'lodash' import { get } from 'lodash-es'
export default { export default {
props: { props: {
@ -131,7 +131,7 @@ export default {
nodeinfo: state => state.instance.nodeinfo nodeinfo: state => state.instance.nodeinfo
}), }),
anonymousCanListen () { anonymousCanListen () {
return _.get(this.nodeinfo, 'metadata.library.anonymousCanListen', false) return get(this.nodeinfo, 'metadata.library.anonymousCanListen', false)
}, },
iframeSrc () { iframeSrc () {
let base = import.meta.env.BASE_URL 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 GlobalEvents from '@/components/utils/global-events.vue'
import { toLinearVolumeScale } from '@/audio/volume.js' import { toLinearVolumeScale } from '@/audio/volume.js'
import { Howl, Howler } from 'howler' import { Howl, Howler } from 'howler'
import _ from 'lodash' import { throttle, reverse } from 'lodash-es'
import url from '@/utils/url'
import axios from 'axios' import axios from 'axios'
import VolumeControl from './VolumeControl.vue' import VolumeControl from './VolumeControl.vue'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon.vue'
import updateQueryString from '@/composables/updateQueryString'
export default { export default {
components: { components: {
@ -399,7 +399,7 @@ export default {
progress: 'player/progress' progress: 'player/progress'
}), }),
updateProgressThrottled () { updateProgressThrottled () {
return _.throttle(this.updateProgress, 50) return throttle(this.updateProgress, 50)
}, },
labels () { labels () {
const audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', 'Media player') const audioPlayer = this.$pgettext('Sidebar/Player/Hidden text', 'Media player')
@ -660,7 +660,7 @@ export default {
// not support other codecs to be able to play it :) // not support other codecs to be able to play it :)
sources.push({ sources.push({
type: 'mp3', type: 'mp3',
url: url.updateQueryString( url: updateQueryString(
this.$store.getters['instance/absoluteUrl'](trackData.listen_url), this.$store.getters['instance/absoluteUrl'](trackData.listen_url),
'to', 'to',
'mp3' 'mp3'
@ -680,7 +680,7 @@ export default {
value = this.$store.state.auth.scopedTokens.listen value = this.$store.state.auth.scopedTokens.listen
} }
sources.forEach(e => { sources.forEach(e => {
e.url = url.updateQueryString(e.url, param, value) e.url = updateQueryString(e.url, param, value)
}) })
} }
return sources return sources
@ -821,14 +821,14 @@ export default {
checkCache () { checkCache () {
const self = this const self = this
const toKeep = [] const toKeep = []
_.reverse(this.soundsCache).forEach((e) => { reverse(this.soundsCache).forEach((e) => {
if (toKeep.length < self.maxPreloaded) { if (toKeep.length < self.maxPreloaded) {
toKeep.push(e) toKeep.push(e)
} else { } else {
e.sound.unload() e.sound.unload()
} }
}) })
this.soundsCache = _.reverse(toKeep) this.soundsCache = reverse(toKeep)
}, },
removeFromCache (sound) { removeFromCache (sound) {
const toKeep = [] const toKeep = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,7 +72,7 @@
<script> <script>
import axios from 'axios' import axios from 'axios'
import logger from '@/logging.js' import logger from '@/logging'
import ChannelsWidget from '@/components/audio/ChannelsWidget.vue' import ChannelsWidget from '@/components/audio/ChannelsWidget.vue'
import TrackWidget from '@/components/audio/track/Widget.vue' import TrackWidget from '@/components/audio/track/Widget.vue'
import AlbumWidget from '@/components/audio/album/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 axios from 'axios'
import $ from 'jquery' import $ from 'jquery'
import logger from '@/logging.js' import logger from '@/logging'
import OrderingMixin from '@/components/mixins/Ordering.vue' import OrderingMixin from '@/components/mixins/Ordering.vue'
import PaginationMixin from '@/components/mixins/Pagination.vue' import PaginationMixin from '@/components/mixins/Pagination.vue'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -200,8 +200,8 @@
<script> <script>
import axios from 'axios' import axios from 'axios'
import _ from 'lodash' import { merge } from 'lodash-es'
import time from '@/utils/time.js' import time from '@/utils/time'
import Pagination from '@/components/Pagination.vue' import Pagination from '@/components/Pagination.vue'
import ActionTable from '@/components/common/ActionTable.vue' import ActionTable from '@/components/common/ActionTable.vue'
import OrderingMixin from '@/components/mixins/Ordering.vue' import OrderingMixin from '@/components/mixins/Ordering.vue'
@ -261,7 +261,7 @@ export default {
q: this.search q: this.search
} }
if (this.filters) { if (this.filters) {
return _.merge(currentFilters, this.filters) return merge(currentFilters, this.filters)
} else { } else {
return currentFilters return currentFilters
} }
@ -296,7 +296,7 @@ export default {
}, },
methods: { methods: {
fetchData () { fetchData () {
const params = _.merge({ const params = merge({
page: this.page, page: this.page,
page_size: this.paginateBy, page_size: this.paginateBy,
q: this.search, 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> <script>
import TranslationsMixin from '@/components/mixins/Translations.vue' import TranslationsMixin from '@/components/mixins/Translations.vue'
import lodash from 'lodash'
export default { export default {
mixins: [TranslationsMixin], mixins: [TranslationsMixin],
props: { props: {
@ -52,7 +51,7 @@ export default {
if (this.restrictTo.length > 0) { if (this.restrictTo.length > 0) {
choices = this.restrictTo choices = this.restrictTo
} else { } else {
choices = lodash.keys(this.sharedLabels.fields.report_type.choices) choices = Object.keys(this.sharedLabels.fields.report_type.choices)
} }
return c.concat( return c.concat(
choices.sort().map((v) => { choices.sort().map((v) => {

View File

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

View File

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

View File

@ -13,7 +13,7 @@
<script> <script>
import lodash from 'lodash' import { isEqual } from 'lodash-es'
export default { export default {
props: { props: {
customRadioId: { type: Number, required: false, default: null }, customRadioId: { type: Number, required: false, default: null },
@ -29,7 +29,7 @@ export default {
if (!state.running) { if (!state.running) {
return false return false
} else { } 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
} }
}, },
label () { label () {

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 Vue from 'vue'
import EmbedFrame from './EmbedFrame.vue' import EmbedFrame from './EmbedFrame.vue'
import VuePlyr from 'vue-plyr' import VuePlyr from 'vue-plyr'

View File

@ -247,7 +247,7 @@
<script> <script>
import axios from 'axios' import axios from 'axios'
import Logo from '@/components/Logo.vue' import Logo from '@/components/Logo.vue'
import url from '@/utils/url' import updateQueryString from './composables/updateQueryString'
import time from '@/utils/time' import time from '@/utils/time'
function getURLParams () { function getURLParams () {
@ -516,7 +516,7 @@ export default {
// not support other codecs to be able to play it :) // not support other codecs to be able to play it :)
sources.push({ sources.push({
type: 'audio/mpeg', type: 'audio/mpeg',
src: url.updateQueryString( src: updateQueryString(
self.fullUrl(sources[0].src), self.fullUrl(sources[0].src),
'to', 'to',
'mp3' '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 Vue from 'vue'
import time from '~/utils/time'
import time from '@/utils/time'
import moment from 'moment' import moment from 'moment'
export function truncate (str, max, ellipsis, middle) { export function truncate (str: string, max = 100, ellipsis = '…', middle = false) {
if (max === 0) { if (max === 0) {
return return ''
} }
max = max || 100
ellipsis = ellipsis || '…'
if (str.length <= max) { if (str.length <= max) {
return str return str
} }
if (middle) { if (middle) {
const sepLen = 1 const sepLen = 1
const charsToShow = max - sepLen const charsToShow = max - sepLen
@ -27,9 +27,7 @@ export function truncate (str, max, ellipsis, middle) {
} }
} }
Vue.filter('truncate', truncate) export function ago (date: Date, locale: string) {
export function ago (date, locale) {
locale = locale || 'en' locale = locale || 'en'
const m = moment(date) const m = moment(date)
m.locale(locale) m.locale(locale)
@ -43,9 +41,7 @@ export function ago (date, locale) {
}) })
} }
Vue.filter('ago', ago) export function fromNow (date: Date, locale: string) {
export function fromNow (date, locale) {
locale = 'en' locale = 'en'
moment.locale('en', { moment.locale('en', {
relativeTime: { relativeTime: {
@ -70,9 +66,7 @@ export function fromNow (date, locale) {
return m.fromNow(true) return m.fromNow(true)
} }
Vue.filter('fromNow', fromNow) export function secondsToObject (seconds: number) {
export function secondsToObject (seconds) {
const m = moment.duration(seconds, 'seconds') const m = moment.duration(seconds, 'seconds')
return { return {
seconds: m.seconds(), seconds: m.seconds(),
@ -81,42 +75,30 @@ export function secondsToObject (seconds) {
} }
} }
Vue.filter('secondsToObject', secondsToObject) export function padDuration (duration: string) {
export function padDuration (duration) {
let s = String(duration) let s = String(duration)
while (s.length < 2) { s = '0' + s } while (s.length < 2) { s = '0' + s }
return s return s
} }
Vue.filter('padDuration', padDuration) export function duration (seconds: string) {
return time.parse(+seconds)
export function duration (seconds) {
return time.parse(seconds)
} }
Vue.filter('duration', duration) export function momentFormat (date: Date, format: string) {
export function momentFormat (date, format) {
format = format || 'lll' format = format || 'lll'
return moment(date).format(format) return moment(date).format(format)
} }
Vue.filter('moment', momentFormat) export function year (date: Date) {
export function year (date) {
return moment(date).year() return moment(date).year()
} }
Vue.filter('year', year) export function capitalize (str: string) {
export function capitalize (str) {
return str.charAt(0).toUpperCase() + str.slice(1) return str.charAt(0).toUpperCase() + str.slice(1)
} }
Vue.filter('capitalize', capitalize) export function humanSize (bytes: number) {
export function humanSize (bytes) {
const si = true const si = true
const thresh = si ? 1000 : 1024 const thresh = si ? 1000 : 1024
if (Math.abs(bytes) < thresh) { if (Math.abs(bytes) < thresh) {
@ -133,15 +115,24 @@ export function humanSize (bytes) {
return bytes.toFixed(1) + ' ' + units[u] return bytes.toFixed(1) + ' ' + units[u]
} }
Vue.filter('humanSize', humanSize)
// Removes duplicates from a list // Removes duplicates from a list
export function unique (list, property) { export function unique (list: { [key: string]: unknown }[], property: string) {
property = property || 'id' 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)) list.map(x => unique.filter(a => a[property] === x[property]).length > 0 ? null : unique.push(x))
return unique 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 HumanDate from '@/components/common/HumanDate.vue'
import HumanDuration from '@/components/common/HumanDuration.vue' import HumanDuration from '@/components/common/HumanDuration.vue'
import Username from '@/components/common/Username.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 ContentForm from '@/components/common/ContentForm.vue'
import InlineSearchBar from '@/components/common/InlineSearchBar.vue' import InlineSearchBar from '@/components/common/InlineSearchBar.vue'
Vue.component('HumanDate', HumanDate) export const install: AppModule = ({ app }) => {
Vue.component('HumanDuration', HumanDuration) app.component('HumanDate', HumanDate)
Vue.component('Username', Username) app.component('HumanDuration', HumanDuration)
Vue.component('UserLink', UserLink) app.component('Username', Username)
Vue.component('ActorLink', ActorLink) app.component('UserLink', UserLink)
Vue.component('ActorAvatar', ActorAvatar) app.component('ActorLink', ActorLink)
Vue.component('Duration', Duration) app.component('ActorAvatar', ActorAvatar)
Vue.component('DangerousButton', DangerousButton) app.component('Duration', Duration)
Vue.component('Message', Message) app.component('DangerousButton', DangerousButton)
Vue.component('CopyInput', CopyInput) app.component('Message', Message)
Vue.component('AjaxButton', AjaxButton) app.component('CopyInput', CopyInput)
Vue.component('Tooltip', Tooltip) app.component('AjaxButton', AjaxButton)
Vue.component('EmptyState', EmptyState) app.component('Tooltip', Tooltip)
Vue.component('ExpandableDiv', ExpandableDiv) app.component('EmptyState', EmptyState)
Vue.component('CollapseLink', CollapseLink) app.component('ExpandableDiv', ExpandableDiv)
Vue.component('ActionFeedback', ActionFeedback) app.component('CollapseLink', CollapseLink)
Vue.component('RenderedDescription', RenderedDescription) app.component('ActionFeedback', ActionFeedback)
Vue.component('ContentForm', ContentForm) app.component('RenderedDescription', RenderedDescription)
Vue.component('InlineSearchBar', InlineSearchBar) app.component('ContentForm', ContentForm)
app.component('InlineSearchBar', InlineSearchBar)
export default {} }

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

View File

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

View File

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

View File

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

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