Commit 89f3b8de authored by Kasper Seweryn's avatar Kasper Seweryn 🥞 Committed by Kasper Seweryn
Browse files

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
parent 898a3e9c
......@@ -28,7 +28,7 @@ Submitting a new language
1. Pull the latest version of ``develop``
2. Create a new branch, e.g ``git checkout -b translations-new-fr-ca``
3. Add your new language code and name in ``front/src/locales.js``. Use the native language name, as it is what appears in the UI selector.
3. Add your new language code and name in ``front/src/locales.ts``. Use the native language name, as it is what appears in the UI selector.
4. Create the ``po`` file from template:
.. code-block:: shell
......
......@@ -24,6 +24,8 @@ module.exports = {
'vue/no-v-html': 'off', // TODO: tackle this properly
'vue/no-use-v-if-with-v-for': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'no-undef': 'off',
// TODO: Enable typescript rules later
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-empty-function': 'off'
......
......@@ -8,7 +8,6 @@
<meta name="generator" content="Funkwhale">
<link rel="icon" href="/favicon.png">
<title>Funkwhale</title>
<script type="module" src="/src/main.js"></script>
<style>
#fake-app {
width: 100vw;
......@@ -86,9 +85,8 @@
</div>
</div>
</div>
<div id="app">
</div>
<!-- built files will be auto injected -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
......@@ -18,7 +18,7 @@
},
"dependencies": {
"@vue/composition-api": "1.4.9",
"@vueuse/core": "8.2.6",
"@vueuse/core": "8.2.5",
"axios": "0.26.1",
"axios-auth-refresh": "3.2.2",
"diff": "5.0.0",
......@@ -26,10 +26,10 @@
"fomantic-ui-css": "2.8.8",
"howler": "2.2.3",
"js-logger": "1.6.1",
"lodash": "4.17.21",
"moment": "2.29.3",
"qs": "6.10.5",
"pinia": "^2.0.13",
"lodash-es": "4.17.21",
"register-service-worker": "1.7.2",
"sanitize-html": "2.7.0",
"sass": "1.49.11",
......@@ -42,6 +42,7 @@
"vue-router": "3.5.4",
"vue-upload-component": "2.8.22",
"vuedraggable": "2.24.3",
"vuex": "3.6.2",
"vuex-persistedstate": "4.1.0",
"vuex-router-sync": "5.0.0"
},
......@@ -49,6 +50,8 @@
"@babel/core": "7.17.12",
"@babel/plugin-transform-runtime": "7.17.12",
"@babel/preset-env": "7.16.11",
"@types/jquery": "^3.5.14",
"@types/lodash-es": "^4.17.6",
"@typescript-eslint/eslint-plugin": "^5.19.0",
"@vue/eslint-config-standard": "^6.1.0",
"@vue/eslint-config-typescript": "^10.0.0",
......@@ -60,7 +63,6 @@
"easygettext": "2.17.0",
"eslint": "8.11.0",
"eslint-config-standard": "16.0.3",
"eslint-config-standard-with-typescript": "^21.0.1",
"eslint-plugin-html": "6.2.0",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-node": "11.1.0",
......@@ -71,9 +73,8 @@
"moxios": "0.4.0",
"sinon": "13.0.2",
"typescript": "^4.6.3",
"unplugin-vue-components": "^0.19.3",
"unplugin-vue2-script-setup": "^0.10.2",
"vite": "2.9.5",
"vite": "2.8.6",
"vite-plugin-vue2": "1.9.3",
"vue-jest": "3.0.7",
"vue-template-compiler": "2.6.14"
......
......@@ -3,7 +3,7 @@
cd "$(dirname $0)/.." # change into base directory
source scripts/utils.sh
locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | grep -v 'en_US' | xargs echo)
locales=$(tail -n +3 src/locales.ts | sed -E 's/^[^[]+\[] =//' | jq -r '.[].code' | grep -v 'en_US')
mkdir -p src/translations
for locale in $locales; do
......
......@@ -3,7 +3,7 @@
cd "$(dirname $0)/.." # change into base directory
source scripts/utils.sh
locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | xargs echo)
locales=$(tail -n +3 src/locales.ts | sed -E 's/^[^[]+\[] =//' | jq -r '.[].code')
locales_dir="locales"
sources=$(find src -name '*.vue' -o -name '*.html' 2> /dev/null)
js_sources=$(find src -name '*.vue' -o -name '*.js')
......
......@@ -11,7 +11,7 @@ cd "$(dirname $0)/.." # change into base directory
old_locales_dir=$1
new_locales_dir=$2
locales=$(tail -n +2 src/locales.js | sed -e 's/export default //' | jq '.locales[].code' | xargs echo)
locales=$(tail -n +3 src/locales.ts | sed -E 's/^[^[]+\[] =//' | jq -r '.[].code')
# Generate .po files for each available language.
echo $locales
......
This diff is collapsed.
<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>
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/vue-next/pull/3399
import '@vue/runtime-core'
declare module '@vue/runtime-core' {
export interface GlobalComponents {
......@@ -161,4 +160,4 @@ declare module '@vue/runtime-core' {
}
}
export {}
export { }