diff --git a/front/scripts/fix-fomantic-css.py b/front/scripts/fix-fomantic-css.py index 80b8644166d068ee85b23095955bf95ceac9da9d..e2a9b6b0a4141a2fc040c31b1d9264bd595b3894 100755 --- a/front/scripts/fix-fomantic-css.py +++ b/front/scripts/fix-fomantic-css.py @@ -165,6 +165,16 @@ def discard_unused_icons(rule): ".wrench", ".x", ".key", + ".cog", + ".life.ring", + ".language", + ".palette", + ".sun", + ".moon", + ".gitlab", + ".chevron", + ".right", + ".left" ] if ":before" not in rule["lines"][0]: return False diff --git a/front/src/App.vue b/front/src/App.vue index 5ac09cb8ea9997bed9d7ac906a20a73447862458..2db0fbc262826eeb8c34436a7f32f16440a44627 100644 --- a/front/src/App.vue +++ b/front/src/App.vue @@ -1,35 +1,47 @@ <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}, `is-${ $store.getters['ui/windowSize']}`]"> + <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}, `is-${ $store.getters['ui/windowSize']}`]" + > <!-- here, we display custom stylesheets, if any --> <link v-for="url in customStylesheets" + :key="url" rel="stylesheet" property="stylesheet" :href="url" - :key="url" > - <template> - <sidebar></sidebar> - <set-instance-modal @update:show="showSetInstanceModal = $event" :show="showSetInstanceModal"></set-instance-modal> - <service-messages></service-messages> - <transition name="queue"> - <queue @touch-progress="$refs.player.setCurrentTime($event)" v-if="$store.state.ui.queueFocused"></queue> - </transition> - <router-view role="main" :class="{hidden: $store.state.ui.queueFocused}"></router-view> - <player ref="player"></player> - <app-footer - :class="{hidden: $store.state.ui.queueFocused}" - :version="version" - @show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" - @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" - ></app-footer> - <playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal> - <channel-upload-modal v-if="$store.state.auth.authenticated"></channel-upload-modal> - <filter-modal v-if="$store.state.auth.authenticated"></filter-modal> - <report-modal></report-modal> - <shortcuts-modal @update:show="showShortcutsModal = $event" :show="showShortcutsModal"></shortcuts-modal> - <GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal"/> - </template> + <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> @@ -37,28 +49,26 @@ import Vue from 'vue' import axios from 'axios' import _ from '@/lodash' -import {mapState, mapGetters, mapActions} from 'vuex' +import { mapState, mapGetters } from 'vuex' import { WebSocketBridge } from 'django-channels' import GlobalEvents from '@/components/utils/global-events' -import moment from 'moment' import locales from './locales' -import {getClientOnlyRadio} from '@/radios' +import { getClientOnlyRadio } from '@/radios' export default { - name: 'app', + name: 'App', components: { - Player: () => import(/* webpackChunkName: "audio" */ "@/components/audio/Player"), - Queue: () => import(/* webpackChunkName: "audio" */ "@/components/Queue"), - PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/playlists/PlaylistModal"), - ChannelUploadModal: () => import(/* webpackChunkName: "auth-audio" */ "@/components/channels/UploadModal"), - Sidebar: () => import(/* webpackChunkName: "core" */ "@/components/Sidebar"), - AppFooter: () => import(/* webpackChunkName: "core" */ "@/components/Footer"), - ServiceMessages: () => import(/* webpackChunkName: "core" */ "@/components/ServiceMessages"), - SetInstanceModal: () => import(/* webpackChunkName: "core" */ "@/components/SetInstanceModal"), - ShortcutsModal: () => import(/* webpackChunkName: "core" */ "@/components/ShortcutsModal"), - FilterModal: () => import(/* webpackChunkName: "moderation" */ "@/components/moderation/FilterModal"), - ReportModal: () => import(/* webpackChunkName: "moderation" */ "@/components/moderation/ReportModal"), - GlobalEvents, + Player: () => import(/* webpackChunkName: "audio" */ '@/components/audio/Player'), + Queue: () => import(/* webpackChunkName: "audio" */ '@/components/Queue'), + PlaylistModal: () => import(/* webpackChunkName: "auth-audio" */ '@/components/playlists/PlaylistModal'), + ChannelUploadModal: () => import(/* webpackChunkName: "auth-audio" */ '@/components/channels/UploadModal'), + Sidebar: () => import(/* webpackChunkName: "core" */ '@/components/Sidebar'), + ServiceMessages: () => import(/* webpackChunkName: "core" */ '@/components/ServiceMessages'), + SetInstanceModal: () => import(/* webpackChunkName: "core" */ '@/components/SetInstanceModal'), + ShortcutsModal: () => import(/* webpackChunkName: "core" */ '@/components/ShortcutsModal'), + FilterModal: () => import(/* webpackChunkName: "moderation" */ '@/components/moderation/FilterModal'), + ReportModal: () => import(/* webpackChunkName: "moderation" */ '@/components/moderation/ReportModal'), + GlobalEvents }, data () { return { @@ -70,23 +80,168 @@ export default { 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 + } + }, + watch: { + '$store.state.instance.instanceUrl' (v) { + this.$store.dispatch('instance/fetchSettings') + this.fetchNodeInfo() + }, + '$store.state.ui.theme': { + immediate: true, + handler (newValue, oldValue) { + const oldTheme = oldValue || 'light' + document.body.classList.remove(`theme-${oldTheme}`) + document.body.classList.add(`theme-${newValue}`) + } + }, + '$store.state.auth.authenticated' (newValue) { + if (!newValue) { + this.disconnect() + } else { + this.openWebsocket() + } + }, + '$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') + } + import(/* webpackChunkName: "locale-[request]" */ `./translations/${newValue}.json`).then((response) => { + Vue.$translations[newValue] = response.default[newValue] + }).finally(() => { + // set current language twice, otherwise we seem to have a cache somewhere + // and rendering does not happen + self.$language.current = 'noop' + self.$language.current = newValue + }) + const momentLocale = newValue.replace('_', '-').toLowerCase() + import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${momentLocale}.js`).then(() => { + self.$store.commit('ui/momentLocale', momentLocale) + }).catch(() => { + console.log('No momentjs locale available for', momentLocale) + const shortLocale = momentLocale.split('-')[0] + import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${shortLocale}.js`).then(() => { + self.$store.commit('ui/momentLocale', shortLocale) + }).catch(() => { + console.log('No momentjs locale available for', shortLocale) + }) + }) + } + }, + 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; + if (this.serviceWorker.refreshing) return this.$store.commit('ui/serviceWorker', { refreshing: true }) - window.location.reload(); + window.location.reload() } - ); + ) } - window.addEventListener('resize', this.handleResize); - this.handleResize(); + window.addEventListener('resize', this.handleResize) + this.handleResize() this.openWebsocket() - let self = this + const self = this if (!this.$store.state.ui.selectedLanguage) { this.autodetectLanguage() } @@ -94,7 +249,7 @@ export default { // used to redraw ago dates every minute self.$store.commit('ui/computeLastDate') }, 1000 * 60) - const urlParams = new URLSearchParams(window.location.search); + const urlParams = new URLSearchParams(window.location.search) const serverUrl = urlParams.get('_server') if (serverUrl) { this.$store.commit('instance/instanceUrl', serverUrl) @@ -102,13 +257,12 @@ export default { const url = urlParams.get('_url') if (url) { this.$router.replace(url) - } - else if (!this.$store.state.instance.instanceUrl) { + } 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 - let defaultInstanceUrl = this.$store.state.instance.frontSettings.defaultServerUrl || process.env.VUE_APP_INSTANCE_URL || this.$store.getters['instance/defaultUrl']() + const defaultInstanceUrl = this.$store.state.instance.frontSettings.defaultServerUrl || process.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 @@ -153,80 +307,78 @@ export default { }) }, mounted () { - let self = this + 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; + if (!event.target.matches('a.internal')) return self.$router.push(event.target.getAttribute('href')) - event.preventDefault(); - }, false); + event.preventDefault() + }, false) this.$nextTick(() => { document.getElementById('fake-content').classList.add('loaded') }) - }, destroyed () { this.$store.commit('ui/removeWebsocketEventHandler', { eventName: 'inbox.item_added', - id: 'sidebarCount', + id: 'sidebarCount' }) this.$store.commit('ui/removeWebsocketEventHandler', { eventName: 'mutation.created', - id: 'sidebarReviewEditCount', + id: 'sidebarReviewEditCount' }) this.$store.commit('ui/removeWebsocketEventHandler', { eventName: 'mutation.updated', - id: 'sidebarReviewEditCount', + id: 'sidebarReviewEditCount' }) this.$store.commit('ui/removeWebsocketEventHandler', { eventName: 'mutation.updated', - id: 'sidebarPendingReviewReportCount', + id: 'sidebarPendingReviewReportCount' }) this.$store.commit('ui/removeWebsocketEventHandler', { eventName: 'user_request.created', - id: 'sidebarPendingReviewRequestCount', + id: 'sidebarPendingReviewRequestCount' }) this.$store.commit('ui/removeWebsocketEventHandler', { eventName: 'Listen', - id: 'handleListen', + id: 'handleListen' }) this.disconnect() }, methods: { incrementNotificationCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', {type: 'inbox', count: 1}) + this.$store.commit('ui/incrementNotifications', { type: 'inbox', count: 1 }) }, incrementReviewEditCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewEdits', value: event.pending_review_count}) + 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}) + this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewReports', value: event.unresolved_count }) }, incrementPendingReviewRequestsCountInSidebar (event) { - this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewRequests', value: event.pending_count}) + this.$store.commit('ui/incrementNotifications', { type: 'pendingReviewRequests', value: event.pending_count }) }, handleListen (event) { if (this.$store.state.radios.current && this.$store.state.radios.running) { - let current = this.$store.state.radios.current + const current = this.$store.state.radios.current if (current.clientOnly && current.type === 'account') { getClientOnlyRadio(current).handleListen(current, event, this.$store) } } }, async fetchNodeInfo () { - let response = await axios.get('instance/nodeinfo/2.0/') + const response = await axios.get('instance/nodeinfo/2.0/') this.$store.commit('instance/nodeinfo', response.data) }, autodetectLanguage () { - let userLanguage = navigator.language || navigator.userLanguage - let available = locales.locales.map(e => { return e.code }) - let self = this + const userLanguage = navigator.language || navigator.userLanguage + const available = locales.locales.map(e => { return e.code }) let candidate - let matching = available.filter((a) => { + const matching = available.filter((a) => { return userLanguage.replace('-', '_') === a }) - let almostMatching = available.filter((a) => { + const almostMatching = available.filter((a) => { return userLanguage.replace('-', '_').split('_')[0] === a.split('_')[0] }) if (matching.length > 0) { @@ -242,15 +394,15 @@ export default { if (!this.bridge) { return } - this.bridge.socket.close(1000, 'goodbye', {keepClosed: true}) + this.bridge.socket.close(1000, 'goodbye', { keepClosed: true }) }, openWebsocket () { if (!this.$store.state.auth.authenticated) { return } this.disconnect() - let self = this - let token = this.$store.state.auth.token + const self = this + const token = this.$store.state.auth.token // let token = 'test' const bridge = new WebSocketBridge() this.bridge = bridge @@ -260,7 +412,7 @@ export default { bridge.connect( url, [], - {reconnectInterval: 1000 * 60}) + { reconnectInterval: 1000 * 60 }) bridge.listen(function (event) { self.$store.dispatch('ui/websocketEvent', event) }) @@ -268,7 +420,7 @@ export default { console.log('Connected to WebSocket') }) }, - getTrackInformationText(track) { + getTrackInformationText (track) { const trackTitle = track.title const albumArtist = (track.album) ? track.album.artist.name : null const artistName = ( @@ -276,11 +428,12 @@ export default { const text = `♫ ${trackTitle} – ${artistName} ♫` return text }, - updateDocumentTitle() { - let parts = [] + updateDocumentTitle () { + const parts = [] const currentTrackPart = ( - (this.currentTrack) ? this.getTrackInformationText(this.currentTrack) - : null) + (this.currentTrack) + ? this.getTrackInformationText(this.currentTrack) + : null) if (currentTrackPart) { parts.push(currentTrackPart) } @@ -292,158 +445,13 @@ export default { }, updateApp () { - this.$store.commit('ui/serviceWorker', {updateAvailable: false}) - if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return; } - this.serviceWorker.registration.waiting.postMessage({command: 'skipWaiting'}) + this.$store.commit('ui/serviceWorker', { updateAvailable: false }) + if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return } + this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' }) }, - handleResize() { + handleResize () { this.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() { - let play = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Play track") - let pause = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Pause track") - let next = this.$pgettext('Sidebar/Player/Icon.Tooltip', "Next track") - let expandQueue = this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', "Expand queue") - return { - play, - pause, - next, - expandQueue, - } - }, - suggestedInstances () { - let 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 || [] - } - }, - }, - watch: { - '$store.state.instance.instanceUrl' (v) { - this.$store.dispatch('instance/fetchSettings') - this.fetchNodeInfo() - }, - '$store.state.ui.theme': { - immediate: true, - handler (newValue, oldValue) { - let oldTheme = oldValue || 'light' - document.body.classList.remove(`theme-${oldTheme}`) - document.body.classList.add(`theme-${newValue}`) - }, - }, - '$store.state.auth.authenticated' (newValue) { - if (!newValue) { - this.disconnect() - } else { - this.openWebsocket() - } - }, - '$store.state.ui.currentLanguage': { - immediate: true, - handler(newValue) { - let self = this - let 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') - } - import(/* webpackChunkName: "locale-[request]" */ `./translations/${newValue}.json`).then((response) =>{ - Vue.$translations[newValue] = response.default[newValue] - }).finally(() => { - // set current language twice, otherwise we seem to have a cache somewhere - // and rendering does not happen - self.$language.current = 'noop' - self.$language.current = newValue - }) - let momentLocale = newValue.replace('_', '-').toLowerCase() - import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${momentLocale}.js`).then(() => { - self.$store.commit('ui/momentLocale', momentLocale) - }).catch(() => { - console.log('No momentjs locale available for', momentLocale) - let shortLocale = momentLocale.split('-')[0] - import(/* webpackChunkName: "moment-locale-[request]" */ `moment/locale/${shortLocale}.js`).then(() => { - self.$store.commit('ui/momentLocale', shortLocale) - }).catch(() => { - console.log('No momentjs locale available for', shortLocale) - }) - }) - } - }, - 'currentTrack': { - immediate: true, - handler(newValue) { - this.updateDocumentTitle() - }, - }, - '$store.state.ui.pageTitle': { - immediate: true, - handler(newValue) { - this.updateDocumentTitle() - }, - }, - 'serviceWorker.updateAvailable': { - handler (v) { - if (!v) { - return - } - let 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, - } } } </script> diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 9713c3cdb940c11810dbd2b5c352bb530e2cce25..b3b2fa6fe3652f846cb40e199a748e18ba9ba704 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -1,252 +1,575 @@ <template> -<aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar', 'component-sidebar']"> - <header class="ui basic segment header-wrapper"> - <router-link :title="'Funkwhale'" :to="{name: logoUrl}"> - <i class="logo bordered inverted vibrant big icon"> - <logo class="logo"></logo> - <span class="visually-hidden">Home</span> - </i> - </router-link> - <router-link v-if="!$store.state.auth.authenticated" class="logo-wrapper" :to="{name: logoUrl}" :title="'Funkwhale'"> - <img src="../assets/logo/text-white.svg" alt="" /> - </router-link> - <nav class="top ui compact right aligned inverted text menu"> - <template v-if="$store.state.auth.authenticated"> - + <aside :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar', 'component-sidebar']"> + <header class="ui basic segment header-wrapper"> + <router-link + :title="'Funkwhale'" + :to="{name: logoUrl}" + > + <i class="logo bordered inverted vibrant big icon"> + <logo class="logo" /> + <span class="visually-hidden">Home</span> + </i> + </router-link> + <nav class="top ui compact right aligned inverted text menu"> <div class="right menu"> - <div class="item" :title="labels.administration" v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']"> + <div + v-if="$store.state.auth.availablePermissions['settings'] || $store.state.auth.availablePermissions['moderation']" + class="item" + :title="labels.administration" + > <div class="item ui inline admin-dropdown dropdown"> - <i class="wrench icon"></i> + <i class="wrench icon" /> <div v-if="moderationNotifications > 0" - :class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']">{{ moderationNotifications }}</div> + :class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']" + > + {{ moderationNotifications }} + </div> <div class="menu"> <h3 class="header"> - <translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate> + <translate translate-context="Sidebar/Admin/Title/Noun"> + Administration + </translate> </h3> - <div class="divider"></div> + <div class="divider" /> <router-link v-if="$store.state.auth.availablePermissions['library']" class="item" - :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}"> + :to="{name: 'manage.library.edits', query: {q: 'is_approved:null'}}" + > <div v-if="$store.state.ui.notifications.pendingReviewEdits > 0" :title="labels.pendingReviewEdits" - :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']"> - {{ $store.state.ui.notifications.pendingReviewEdits }}</div> - <translate translate-context="*/*/*/Noun">Library</translate> + :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']" + > + {{ $store.state.ui.notifications.pendingReviewEdits }} + </div> + <translate translate-context="*/*/*/Noun"> + Library + </translate> </router-link> <router-link v-if="$store.state.auth.availablePermissions['moderation']" class="item" - :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}"> + :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}" + > <div v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests> 0" :title="labels.pendingReviewReports" - :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']">{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}</div> - <translate translate-context="*/Moderation/*">Moderation</translate> + :class="['ui', 'circular', 'mini', 'right floated', 'accent', 'label']" + > + {{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }} + </div> + <translate translate-context="*/Moderation/*"> + Moderation + </translate> </router-link> <router-link v-if="$store.state.auth.availablePermissions['settings']" class="item" - :to="{name: 'manage.users.users.list'}"> - <translate translate-context="*/*/*/Noun">Users</translate> + :to="{name: 'manage.users.users.list'}" + > + <translate translate-context="*/*/*/Noun"> + Users + </translate> </router-link> <router-link v-if="$store.state.auth.availablePermissions['settings']" class="item" - :to="{path: '/manage/settings'}"> - <translate translate-context="*/*/*/Noun">Settings</translate> + :to="{path: '/manage/settings'}" + > + <translate translate-context="*/*/*/Noun"> + Settings + </translate> </router-link> </div> </div> </div> </div> <router-link - class="item" v-if="$store.state.auth.authenticated" - :to="{name: 'content.index'}"> - <i class="upload icon"></i> - <span class="visually-hidden">{{ labels.addContent }}</span> + class="item" + :to="{name: 'content.index'}" + > + <i class="upload icon" /> + <span class="visually-hidden">{{ labels.addContent }}</span> </router-link> - <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'notifications'}"> - <i class="bell icon"></i> - <div v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" :class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']"> - {{ $store.state.ui.notifications.inbox + additionalNotifications }} + <template v-if="width > 768"> + <div class="item"> + <div class="ui user-dropdown dropdown"> + <img + v-if="$store.state.auth.authenticated && $store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" + class="ui avatar image" + alt="" + :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" + > + <actor-avatar + v-else-if="$store.state.auth.authenticated" + :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}" + /> + <i + v-else + class="cog icon" + /> + <div + v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" + :class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']" + > + {{ $store.state.ui.notifications.inbox + additionalNotifications }} + </div> + <user-menu + :width="width" + v-on="$listeners" + /> + </div> </div> - <span v-else class="visually-hidden">{{ labels.notifications }}</span> - </router-link> - <div class="item"> - <div class="ui user-dropdown dropdown" > - <img class="ui avatar image" alt="" v-if="$store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" /> - <actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" /> - <div class="menu"> - <router-link class="item" :to="{name: 'profile.overview', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link> - <router-link class="item" :to="{path: '/settings'}"><translate translate-context="*/*/*/Noun">Settings</translate></router-link> - <router-link class="item" :to="{name: 'logout'}"><translate translate-context="Sidebar/Login/List item.Link/Verb">Logout</translate></router-link> + </template> + <template v-else> + <a + href="" + class="item" + @click.prevent.exact="showUserModal = !showUserModal" + > + <img + v-if="$store.state.auth.authenticated && $store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" + class="ui avatar image" + alt="" + :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" + > + <actor-avatar + v-else-if="$store.state.auth.authenticated" + :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}" + /> + <i + v-else + class="cog icon" + /> + <div + v-if="$store.state.ui.notifications.inbox + additionalNotifications > 0" + :class="['ui', 'accent', 'mini', 'bottom floating', 'circular', 'label']" + > + {{ $store.state.ui.notifications.inbox + additionalNotifications }} </div> + </a> + </template> + <user-modal + :show="showUserModal" + @showThemeModalEvent="showThemeModal=true" + @showLanguageModalEvent="showLanguageModal=true" + @update:show="showUserModal = $event" + /> + <modal + ref="languageModal" + :fullscreen="false" + :show="showLanguageModal" + @update:show="showLanguageModal = $event" + > + <i + role="button" + class="left chevron back inside icon" + @click.prevent.exact="showUserModal = !showUserModal" + /> + <div class="header"> + <h3 class="title"> + {{ labels.language }} + </h3> </div> - </div> - </template> - <div class="item collapse-button-wrapper"> - - <button - @click="isCollapsed = !isCollapsed" - :class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']"> - <i class="sidebar icon"></i></button> - </div> - </nav> - </header> - <div class="ui basic search-wrapper segment"> - <search-bar @search="isCollapsed = false"></search-bar> - </div> - <div v-if="!$store.state.auth.authenticated" class="ui basic signup segment"> - <router-link class="ui fluid tiny primary button" :to="{name: 'login'}"><translate translate-context="*/Login/*/Verb">Login</translate></router-link> - <div class="ui small hidden divider"></div> - <router-link class="ui fluid tiny button" :to="{path: '/signup'}"> - <translate translate-context="*/Signup/Link/Verb">Create an account</translate> - </router-link> - </div> - <nav class="secondary" role="navigation" aria-labelledby="navigation-label"> - <h1 id="navigation-label" class="visually-hidden"> - <translate translate-context="*/*/*">Main navigation</translate> - </h1> - <div class="ui small hidden divider"></div> - <section :class="['ui', 'bottom', 'attached', {active: selectedTab === 'library'}, 'tab']" :aria-label="labels.mainMenu"> - <nav class="ui vertical large fluid inverted menu" role="navigation" :aria-label="labels.mainMenu"> - <div :class="[{collapsed: !exploreExpanded}, 'collapsible item']"> - <h2 class="header" role="button" @click="exploreExpanded = true" tabindex="0" @focus="exploreExpanded = true"> - <translate translate-context="*/*/*/Verb">Explore</translate> - <i class="angle right icon" v-if="!exploreExpanded"></i> - </h2> - <div class="menu"> - <router-link class="item" :to="{name: 'search'}"><i class="search icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Search</translate></router-link> - <router-link class="item" :exact="true" :to="{name: 'library.index'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link> - <router-link class="item" :to="{name: 'library.podcasts.browse'}"><i class="podcast icon"></i><translate translate-context="*/*/*">Podcasts</translate></router-link> - <router-link class="item" :to="{name: 'library.albums.browse'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link> - <router-link class="item" :to="{name: 'library.artists.browse'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link> - <router-link class="item" :to="{name: 'library.playlists.browse'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link> - <router-link class="item" :to="{name: 'library.radios.browse'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link> + <div class="content"> + <fieldset + v-for="(language, key) in $language.available" + :key="key" + > + <input + :id="key" + v-model="languageSelection" + type="radio" + name="language" + :value="key" + > + <label :for="key">{{ language }}</label> + </fieldset> </div> - </div> - <div :class="[{collapsed: !myLibraryExpanded}, 'collapsible item']" v-if="$store.state.auth.authenticated"> - <h3 class="header" role="button" @click="myLibraryExpanded = true" tabindex="0" @focus="myLibraryExpanded = true"> - <translate translate-context="*/*/*/Noun">My Library</translate> - <i class="angle right icon" v-if="!myLibraryExpanded"></i> - </h3> - <div class="menu"> - <router-link class="item" :exact="true" :to="{name: 'library.me'}"><i class="music icon"></i><translate translate-context="Sidebar/Navigation/List item.Link/Verb">Browse</translate></router-link> - <router-link class="item" :to="{name: 'library.albums.me'}"><i class="compact disc icon"></i><translate translate-context="*/*/*">Albums</translate></router-link> - <router-link class="item" :to="{name: 'library.artists.me'}"><i class="user icon"></i><translate translate-context="*/*/*">Artists</translate></router-link> - <router-link class="item" :to="{name: 'library.playlists.me'}"><i class="list icon"></i><translate translate-context="*/*/*">Playlists</translate></router-link> - <router-link class="item" :to="{name: 'library.radios.me'}"><i class="feed icon"></i><translate translate-context="*/*/*">Radios</translate></router-link> - <router-link class="item" :to="{name: 'favorites'}"><i class="heart icon"></i><translate translate-context="Sidebar/Favorites/List item.Link/Noun">Favorites</translate></router-link> + </modal> + <modal + ref="themeModal" + :fullscreen="false" + :show="showThemeModal" + @update:show="showThemeModal = $event" + > + <i + role="button" + class="left chevron back inside icon" + @click.prevent.exact="showUserModal = !showUserModal" + /> + <div class="header"> + <h3 class="title"> + {{ labels.theme }} + </h3> </div> - </div> - <router-link class="header item" :to="{name: 'subscriptions'}" v-if="$store.state.auth.authenticated"> - <translate translate-context="*/*/*">Channels</translate> - </router-link> - <div class="item"> - <h3 class="header"> - <translate translate-context="Footer/About/List item.Link">More</translate> - </h3> - <div class="menu"> - <router-link class="item" to="/about"> - <i class="info icon"></i><translate translate-context="Sidebar/*/List item.Link">About this pod</translate> - </router-link> + <div class="content"> + <fieldset + v-for="theme in themes" + :key="theme.key" + > + <input + :id="theme.key" + v-model="themeSelection" + type="radio" + name="theme" + :value="theme.key" + > + <label :for="theme.key">{{ theme.name }}</label> + </fieldset> </div> + </modal> + <div class="item collapse-button-wrapper"> + <button + :class="['ui', 'basic', 'big', {'vibrant': !isCollapsed}, 'inverted icon', 'collapse', 'button']" + @click="isCollapsed = !isCollapsed" + > + <i class="sidebar icon" /> + </button> </div> </nav> - </section> - </nav> -</aside> + </header> + <div class="ui basic search-wrapper segment"> + <search-bar @search="isCollapsed = false" /> + </div> + <div + v-if="!$store.state.auth.authenticated" + class="ui basic signup segment" + > + <router-link + class="ui fluid tiny primary button" + :to="{name: 'login'}" + > + <translate translate-context="*/Login/*/Verb"> + Login + </translate> + </router-link> + <div class="ui small hidden divider" /> + <router-link + class="ui fluid tiny button" + :to="{path: '/signup'}" + > + <translate translate-context="*/Signup/Link/Verb"> + Create an account + </translate> + </router-link> + </div> + <nav + class="secondary" + role="navigation" + aria-labelledby="navigation-label" + > + <h1 + id="navigation-label" + class="visually-hidden" + > + <translate translate-context="*/*/*"> + Main navigation + </translate> + </h1> + <div class="ui small hidden divider" /> + <section + :class="['ui', 'bottom', 'attached', {active: selectedTab === 'library'}, 'tab']" + :aria-label="labels.mainMenu" + > + <nav + class="ui vertical large fluid inverted menu" + role="navigation" + :aria-label="labels.mainMenu" + > + <div :class="[{collapsed: !exploreExpanded}, 'collapsible item']"> + <h2 + class="header" + role="button" + tabindex="0" + @click="exploreExpanded = true" + @focus="exploreExpanded = true" + > + <translate translate-context="*/*/*/Verb"> + Explore + </translate> + <i + v-if="!exploreExpanded" + class="angle right icon" + /> + </h2> + <div class="menu"> + <router-link + class="item" + :to="{name: 'search'}" + > + <i class="search icon" /><translate translate-context="Sidebar/Navigation/List item.Link/Verb"> + Search + </translate> + </router-link> + <router-link + class="item" + :exact="true" + :to="{name: 'library.index'}" + > + <i class="music icon" /><translate translate-context="Sidebar/Navigation/List item.Link/Verb"> + Browse + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.podcasts.browse'}" + > + <i class="podcast icon" /><translate translate-context="*/*/*"> + Podcasts + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.albums.browse'}" + > + <i class="compact disc icon" /><translate translate-context="*/*/*"> + Albums + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.artists.browse'}" + > + <i class="user icon" /><translate translate-context="*/*/*"> + Artists + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.playlists.browse'}" + > + <i class="list icon" /><translate translate-context="*/*/*"> + Playlists + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.radios.browse'}" + > + <i class="feed icon" /><translate translate-context="*/*/*"> + Radios + </translate> + </router-link> + </div> + </div> + <div + v-if="$store.state.auth.authenticated" + :class="[{collapsed: !myLibraryExpanded}, 'collapsible item']" + > + <h3 + class="header" + role="button" + tabindex="0" + @click="myLibraryExpanded = true" + @focus="myLibraryExpanded = true" + > + <translate translate-context="*/*/*/Noun"> + My Library + </translate> + <i + v-if="!myLibraryExpanded" + class="angle right icon" + /> + </h3> + <div class="menu"> + <router-link + class="item" + :exact="true" + :to="{name: 'library.me'}" + > + <i class="music icon" /><translate translate-context="Sidebar/Navigation/List item.Link/Verb"> + Browse + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.albums.me'}" + > + <i class="compact disc icon" /><translate translate-context="*/*/*"> + Albums + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.artists.me'}" + > + <i class="user icon" /><translate translate-context="*/*/*"> + Artists + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.playlists.me'}" + > + <i class="list icon" /><translate translate-context="*/*/*"> + Playlists + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'library.radios.me'}" + > + <i class="feed icon" /><translate translate-context="*/*/*"> + Radios + </translate> + </router-link> + <router-link + class="item" + :to="{name: 'favorites'}" + > + <i class="heart icon" /><translate translate-context="Sidebar/Favorites/List item.Link/Noun"> + Favorites + </translate> + </router-link> + </div> + </div> + <router-link + v-if="$store.state.auth.authenticated" + class="header item" + :to="{name: 'subscriptions'}" + > + <translate translate-context="*/*/*"> + Channels + </translate> + </router-link> + <div class="item"> + <h3 class="header"> + <translate translate-context="Footer/About/List item.Link"> + More + </translate> + </h3> + <div class="menu"> + <router-link + class="item" + to="/about" + > + <i class="info icon" /><translate translate-context="Sidebar/*/List item.Link"> + About this pod + </translate> + </router-link> + </div> + </div> + <div + v-if="!production" + class="item" + > + <a + role="button" + href="" + class="link item" + @click.prevent="$emit('show:set-instance-modal')" + >Switch instance</a> + </div> + </nav> + </section> + </nav> + </aside> </template> <script> -import { mapState, mapActions, mapGetters } from "vuex" +import { mapState, mapActions, mapGetters } from 'vuex' +import UserModal from '@/components/common/UserModal' +import Logo from '@/components/Logo' +import SearchBar from '@/components/audio/SearchBar' +import UserMenu from '@/components/common/UserMenu' +import Modal from '@/components/semantic/Modal' -import Logo from "@/components/Logo" -import SearchBar from "@/components/audio/SearchBar" - -import $ from "jquery" +import $ from 'jquery' export default { - name: "sidebar", + name: 'Sidebar', components: { SearchBar, - Logo + Logo, + UserMenu, + UserModal, + Modal + }, + props: { + width: { type: Number, required: true } }, - data() { + data () { return { - selectedTab: "library", + selectedTab: 'library', isCollapsed: true, fetchInterval: null, exploreExpanded: false, myLibraryExpanded: false, + showUserModal: false, + showLanguageModal: false, + showThemeModal: false, + languageSelection: this.$language.current, + themeSelection: this.$store.state.ui.theme } }, - destroy() { + destroy () { if (this.fetchInterval) { clearInterval(this.fetchInterval) } }, - mounted () { - this.$nextTick(() => { - document.getElementById('fake-sidebar').classList.add('loaded') - }) - }, computed: { - ...mapGetters({ - additionalNotifications: "ui/additionalNotifications", - }), ...mapState({ queue: state => state.queue, url: state => state.route.path }), - labels() { - let mainMenu = this.$pgettext('Sidebar/*/Hidden text', "Main menu") - let selectTrack = this.$pgettext('Sidebar/Player/Hidden text', "Play this track") - let pendingFollows = this.$pgettext('Sidebar/Notifications/Hidden text', "Pending follow requests") - let pendingReviewEdits = this.$pgettext('Sidebar/Moderation/Hidden text', "Pending review edits") + ...mapGetters({ + additionalNotifications: 'ui/additionalNotifications' + }), + labels () { + const mainMenu = this.$pgettext('Sidebar/*/Hidden text', 'Main menu') + const selectTrack = this.$pgettext('Sidebar/Player/Hidden text', 'Play this track') + const pendingFollows = this.$pgettext('Sidebar/Notifications/Hidden text', 'Pending follow requests') + const pendingReviewEdits = this.$pgettext('Sidebar/Moderation/Hidden text', 'Pending review edits') + const language = this.$pgettext( + 'Sidebar/Settings/Dropdown.Label/Short, Verb', + 'Language') + const theme = this.$pgettext( + 'Sidebar/Settings/Dropdown.Label/Short, Verb', + 'Theme') return { pendingFollows, mainMenu, selectTrack, pendingReviewEdits, - addContent: this.$pgettext("*/Library/*/Verb", 'Add content'), - notifications: this.$pgettext("*/Notifications/*", 'Notifications'), - administration: this.$pgettext("Sidebar/Admin/Title/Noun", 'Administration'), + language, + theme, + addContent: this.$pgettext('*/Library/*/Verb', 'Add content'), + administration: this.$pgettext('Sidebar/Admin/Title/Noun', 'Administration') } }, - logoUrl() { + logoUrl () { if (this.$store.state.auth.authenticated) { - return "library.index" + return 'library.index' } else { - return "index" + return 'index' } }, focusedMenu () { - let mapping = { - "search": 'exploreExpanded', - "library.index": 'exploreExpanded', - "library.podcasts.browse": 'exploreExpanded', - "library.albums.browse": 'exploreExpanded', - "library.albums.detail": 'exploreExpanded', - "library.artists.browse": 'exploreExpanded', - "library.artists.detail": 'exploreExpanded', - "library.tracks.detail": 'exploreExpanded', - "library.playlists.browse": 'exploreExpanded', - "library.playlists.detail": 'exploreExpanded', - "library.radios.browse": 'exploreExpanded', - "library.radios.detail": 'exploreExpanded', - 'library.me': "myLibraryExpanded", - 'library.albums.me': "myLibraryExpanded", - 'library.artists.me': "myLibraryExpanded", - 'library.playlists.me': "myLibraryExpanded", - 'library.radios.me': "myLibraryExpanded", - 'favorites': "myLibraryExpanded", + const mapping = { + search: 'exploreExpanded', + 'library.index': 'exploreExpanded', + 'library.podcasts.browse': 'exploreExpanded', + 'library.albums.browse': 'exploreExpanded', + 'library.albums.detail': 'exploreExpanded', + 'library.artists.browse': 'exploreExpanded', + 'library.artists.detail': 'exploreExpanded', + 'library.tracks.detail': 'exploreExpanded', + 'library.playlists.browse': 'exploreExpanded', + 'library.playlists.detail': 'exploreExpanded', + 'library.radios.browse': 'exploreExpanded', + 'library.radios.detail': 'exploreExpanded', + 'library.me': 'myLibraryExpanded', + 'library.albums.me': 'myLibraryExpanded', + 'library.artists.me': 'myLibraryExpanded', + 'library.playlists.me': 'myLibraryExpanded', + 'library.radios.me': 'myLibraryExpanded', + favorites: 'myLibraryExpanded' } - let m = mapping[this.$route.name] + const m = mapping[this.$route.name] if (m) { return m } @@ -263,57 +586,31 @@ export default { this.$store.state.ui.notifications.pendingReviewReports + this.$store.state.ui.notifications.pendingReviewRequests ) - } - }, - methods: { - ...mapActions({ - cleanTrack: "queue/cleanTrack" - }), - applyContentFilters () { - let artistIds = this.$store.getters['moderation/artistFilters']().map((f) => { - return f.target.id - }) - - if (artistIds.length === 0) { - return - } - let self = this - let tracks = this.tracks.slice().reverse() - tracks.forEach(async (t, i) => { - // we loop from the end because removing index from the start can lead to removing the wrong tracks - let realIndex = tracks.length - i - 1 - let matchArtist = artistIds.indexOf(t.artist.id) > -1 - if (matchArtist) { - return await self.cleanTrack(realIndex) - } - if (t.album && artistIds.indexOf(t.album.artist.id) > -1) { - return await self.cleanTrack(realIndex) - } - }) }, - setupDropdown (selector) { - let self = this - $(self.$el).find(selector).dropdown({ - selectOnKeydown: false, - action: function (text, value, $el) { - // used ton ensure focusing the dropdown and clicking via keyboard - // works as expected - let link = $($el).closest('a') - let url = link.attr('href') - self.$router.push(url) - $(self.$el).find(selector).dropdown('hide') + production () { + return process.env.NODE_ENV === 'production' + }, + themes () { + return [ + { + name: this.$pgettext('Sidebar/Settings/Dropdown.Label/Theme name', 'Light'), + key: 'light' + }, + { + name: this.$pgettext('Sidebar/Settings/Dropdown.Label/Theme name', 'Dark'), + key: 'dark' } - }) + ] } }, watch: { - url: function() { + url: function () { this.isCollapsed = true }, - "$store.state.moderation.lastUpdate": function () { + '$store.state.moderation.lastUpdate': function () { this.applyContentFilters() }, - "$store.state.auth.authenticated": { + '$store.state.auth.authenticated': { immediate: true, handler (v) { if (v) { @@ -321,17 +618,21 @@ export default { this.setupDropdown('.user-dropdown') this.setupDropdown('.admin-dropdown') }) + } else { + this.$nextTick(() => { + this.setupDropdown('.user-dropdown') + }) } } }, - "$store.state.auth.availablePermissions": { + '$store.state.auth.availablePermissions': { immediate: true, handler (v) { this.$nextTick(() => { this.setupDropdown('.admin-dropdown') }) }, - deep: true, + deep: true }, focusedMenu: { immediate: true, @@ -351,6 +652,93 @@ export default { this.myLibraryExpanded = false } }, + languageSelection: function (v) { + this.$store.dispatch('ui/currentLanguage', v) + this.$refs.languageModal.closeModal() + }, + themeSelection: function (v) { + this.$store.dispatch('ui/theme', v) + this.$refs.themeModal.closeModal() + } + }, + mounted () { + this.$nextTick(() => { + document.getElementById('fake-sidebar').classList.add('loaded') + }) + }, + methods: { + ...mapActions({ + cleanTrack: 'queue/cleanTrack' + }), + applyContentFilters () { + const artistIds = this.$store.getters['moderation/artistFilters']().map((f) => { + return f.target.id + }) + + if (artistIds.length === 0) { + return + } + const self = this + const tracks = this.tracks.slice().reverse() + tracks.forEach(async (t, i) => { + // we loop from the end because removing index from the start can lead to removing the wrong tracks + const realIndex = tracks.length - i - 1 + const matchArtist = artistIds.indexOf(t.artist.id) > -1 + if (matchArtist) { + return await self.cleanTrack(realIndex) + } + if (t.album && artistIds.indexOf(t.album.artist.id) > -1) { + return await self.cleanTrack(realIndex) + } + }) + }, + setupDropdown (selector) { + const self = this + $(self.$el).find(selector).dropdown({ + selectOnKeydown: false, + action: function (text, value, $el) { + // used ton ensure focusing the dropdown and clicking via keyboard + // works as expected + const link = $($el).closest('a') + const url = link.attr('href') + self.$router.push(url) + $(self.$el).find(selector).dropdown('hide') + } + }) + } } } </script> +<style> +[type="radio"] { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} +[type="radio"] + label::after { + content: ""; + font-size: 1.4em; +} +[type="radio"]:checked + label::after { + margin-left: 10px; + content: "\2713"; /* Checkmark */ + font-size: 1.4em; +} +[type="radio"]:checked + label { + font-weight: bold; +} +fieldset { + border: none; +} +.back { + font-size: 1.25em !important; + position: absolute; + top: 0.5rem; + left: 0.5rem; + width: 2.25rem !important; + height: 2.25rem !important; + padding: 0.625rem 0 0 0; +} +</style> diff --git a/front/src/components/audio/podcast/Modal.vue b/front/src/components/audio/podcast/Modal.vue index dae3275ccc11a6527c42211ecdbe3d7a1605b570..e1de0cdb10b600b7a08bb10b932b0693721c0bd5 100644 --- a/front/src/components/audio/podcast/Modal.vue +++ b/front/src/components/audio/podcast/Modal.vue @@ -1,15 +1,14 @@ <template> <modal - @update:show="$emit('update:show', $event)" + ref="modal" :show="show" :scrolling="true" - :additionalClasses="['scrolling-track-options']" + :additional-classes="['scrolling-track-options']" + @update:show="$emit('update:show', $event)" > <div class="header"> <div class="ui large centered rounded image"> <img - alt="" - class="ui centered image" v-if=" track.album && track.album.cover && track.album.cover.urls.original " @@ -18,43 +17,50 @@ track.album.cover.urls.medium_square_crop ) " - /> - <img alt="" class="ui centered image" + > + <img v-else-if="track.cover" v-lazy=" $store.getters['instance/absoluteUrl']( track.cover.urls.medium_square_crop ) " - /> - <img alt="" class="ui centered image" + > + <img v-else-if="track.artist.cover" v-lazy=" $store.getters['instance/absoluteUrl']( track.artist.cover.urls.medium_square_crop ) " - /> - <img alt="" class="ui centered image" + > + <img v-else + alt="" + class="ui centered image" src="../../../assets/audio/default-cover.png" - /> + > </div> - <h3 class="track-modal-title">{{ track.title }}</h3> - <h4 class="track-modal-subtitle">{{ track.artist.name }}</h4> + <h3 class="track-modal-title"> + {{ track.title }} + </h3> + <h4 class="track-modal-subtitle"> + {{ track.artist.name }} + </h4> </div> - <div class="ui hidden divider"></div> + <div class="ui hidden divider" /> <div class="content"> <div class="ui one column unstackable grid"> - <div + <div + v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'" class="row" - v-if="$store.state.auth.authenticated && this.track.artist.content_category !== 'podcast'"> + > <div tabindex="0" class="column" @@ -80,11 +86,11 @@ <div class="column" role="button" + :aria-label="labels.addToQueue" @click.stop.prevent=" add(); - closeModal(); + $refs.modal.closeModal(); " - :aria-label="labels.addToQueue" > <i class="plus icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.addToQueue }}</span> @@ -94,11 +100,11 @@ <div class="column" role="button" + :aria-label="labels.playNext" @click.stop.prevent=" addNext(true); - closeModal(); + $refs.modal.closeModal(); " - :aria-label="labels.playNext" > <i class="step forward icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.playNext }}</span> @@ -108,14 +114,14 @@ <div class="column" role="button" + :aria-label="labels.startRadio" @click.stop.prevent=" $store.dispatch('radios/start', { type: 'similar', objectId: track.id, }); - closeModal(); + $refs.modal.closeModal(); " - :aria-label="labels.startRadio" > <i class="rss icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.startRadio }}</span> @@ -125,8 +131,8 @@ <div class="column" role="button" - @click.stop="$store.commit('playlists/chooseTrack', track)" :aria-label="labels.addToPlaylist" + @click.stop="$store.commit('playlists/chooseTrack', track)" > <i class="list icon track-modal list-icon" /> <span class="track-modal list-item">{{ @@ -134,8 +140,11 @@ }}</span> </div> </div> - <div class="ui divider"></div> - <div v-if="!isAlbum && track.album" class="row"> + <div class="ui divider" /> + <div + v-if="!isAlbum && track.album" + class="row" + > <div class="column" role="button" @@ -153,7 +162,10 @@ }}</span> </div> </div> - <div v-if="!isArtist" class="row"> + <div + v-if="!isArtist" + class="row" + > <div class="column" role="button" @@ -189,7 +201,7 @@ }}</span> </div> </div> - <div class="ui divider"></div> + <div class="ui divider" /> <div v-for="obj in getReportableObjs({ track, @@ -197,16 +209,15 @@ artist, })" :key="obj.target.type + obj.target.id" - class="row" :ref="`report${obj.target.type}${obj.target.id}`" + class="row" :data-ref="`report${obj.target.type}${obj.target.id}`" @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" > <div class="column"> <i class="share icon track-modal list-icon" /><span class="track-modal list-item" - >{{ obj.label }}</span - > + >{{ obj.label }}</span> </div> </div> </div> @@ -215,90 +226,83 @@ </template> <script> -import Modal from "@/components/semantic/Modal"; -import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"; +import Modal from '@/components/semantic/Modal' import ReportMixin from '@/components/mixins/Report' import PlayOptionsMixin from '@/components/mixins/PlayOptions' export default { + components: { + Modal + }, mixins: [ReportMixin, PlayOptionsMixin], props: { show: { type: Boolean, required: true, default: false }, track: { type: Object, required: true }, index: { type: Number, required: true }, isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false }, + isAlbum: { type: Boolean, required: false, default: false } }, - components: { - Modal, - TrackFavoriteIcon, - }, - data() { + data () { return { isShowing: this.show, tracks: [this.track], album: this.track.album, - artist: this.track.artist, - }; + artist: this.track.artist + } }, computed: { - isFavorite() { - return this.$store.getters["favorites/isFavorite"](this.track.id); + isFavorite () { + return this.$store.getters['favorites/isFavorite'](this.track.id) }, - favoriteButton() { + favoriteButton () { if (this.isFavorite) { return this.$pgettext( - "Content/Track/Icon.Tooltip/Verb", - "Remove from favorites" - ); + 'Content/Track/Icon.Tooltip/Verb', + 'Remove from favorites' + ) } else { - return this.$pgettext("Content/Track/*/Verb", "Add to favorites"); + return this.$pgettext('Content/Track/*/Verb', 'Add to favorites') } }, - trackDetailsButton() { + trackDetailsButton () { if (this.track.artist.content_category === 'podcast') { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "Episode details") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details') } else { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "Track details") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details') } }, - albumDetailsButton() { + albumDetailsButton () { if (this.track.artist.content_category === 'podcast') { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View series") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series') } else { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View album") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album') } }, - artistDetailsButton() { + artistDetailsButton () { if (this.track.artist.content_category === 'podcast') { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View channel") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel') } else { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View artist") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist') } }, - labels() { + labels () { return { startRadio: this.$pgettext( - "*/Queue/Dropdown/Button/Title", - "Play radio" + '*/Queue/Dropdown/Button/Title', + 'Play radio' ), - playNow: this.$pgettext("*/Queue/Dropdown/Button/Title", "Play now"), + playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), addToQueue: this.$pgettext( - "*/Queue/Dropdown/Button/Title", - "Add to queue" + '*/Queue/Dropdown/Button/Title', + 'Add to queue' ), - playNext: this.$pgettext("*/Queue/Dropdown/Button/Title", "Play next"), + playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), addToPlaylist: this.$pgettext( - "Sidebar/Player/Icon.Tooltip/Verb", - "Add to playlist…" - ), - }; - }, - }, - methods: { - closeModal() { - this.$emit("update:show", false); - }, - }, -}; + 'Sidebar/Player/Icon.Tooltip/Verb', + 'Add to playlist…' + ) + } + } + } +} </script> diff --git a/front/src/components/audio/track/Modal.vue b/front/src/components/audio/track/Modal.vue index dae3275ccc11a6527c42211ecdbe3d7a1605b570..e1de0cdb10b600b7a08bb10b932b0693721c0bd5 100644 --- a/front/src/components/audio/track/Modal.vue +++ b/front/src/components/audio/track/Modal.vue @@ -1,15 +1,14 @@ <template> <modal - @update:show="$emit('update:show', $event)" + ref="modal" :show="show" :scrolling="true" - :additionalClasses="['scrolling-track-options']" + :additional-classes="['scrolling-track-options']" + @update:show="$emit('update:show', $event)" > <div class="header"> <div class="ui large centered rounded image"> <img - alt="" - class="ui centered image" v-if=" track.album && track.album.cover && track.album.cover.urls.original " @@ -18,43 +17,50 @@ track.album.cover.urls.medium_square_crop ) " - /> - <img alt="" class="ui centered image" + > + <img v-else-if="track.cover" v-lazy=" $store.getters['instance/absoluteUrl']( track.cover.urls.medium_square_crop ) " - /> - <img alt="" class="ui centered image" + > + <img v-else-if="track.artist.cover" v-lazy=" $store.getters['instance/absoluteUrl']( track.artist.cover.urls.medium_square_crop ) " - /> - <img alt="" class="ui centered image" + > + <img v-else + alt="" + class="ui centered image" src="../../../assets/audio/default-cover.png" - /> + > </div> - <h3 class="track-modal-title">{{ track.title }}</h3> - <h4 class="track-modal-subtitle">{{ track.artist.name }}</h4> + <h3 class="track-modal-title"> + {{ track.title }} + </h3> + <h4 class="track-modal-subtitle"> + {{ track.artist.name }} + </h4> </div> - <div class="ui hidden divider"></div> + <div class="ui hidden divider" /> <div class="content"> <div class="ui one column unstackable grid"> - <div + <div + v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'" class="row" - v-if="$store.state.auth.authenticated && this.track.artist.content_category !== 'podcast'"> + > <div tabindex="0" class="column" @@ -80,11 +86,11 @@ <div class="column" role="button" + :aria-label="labels.addToQueue" @click.stop.prevent=" add(); - closeModal(); + $refs.modal.closeModal(); " - :aria-label="labels.addToQueue" > <i class="plus icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.addToQueue }}</span> @@ -94,11 +100,11 @@ <div class="column" role="button" + :aria-label="labels.playNext" @click.stop.prevent=" addNext(true); - closeModal(); + $refs.modal.closeModal(); " - :aria-label="labels.playNext" > <i class="step forward icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.playNext }}</span> @@ -108,14 +114,14 @@ <div class="column" role="button" + :aria-label="labels.startRadio" @click.stop.prevent=" $store.dispatch('radios/start', { type: 'similar', objectId: track.id, }); - closeModal(); + $refs.modal.closeModal(); " - :aria-label="labels.startRadio" > <i class="rss icon track-modal list-icon" /> <span class="track-modal list-item">{{ labels.startRadio }}</span> @@ -125,8 +131,8 @@ <div class="column" role="button" - @click.stop="$store.commit('playlists/chooseTrack', track)" :aria-label="labels.addToPlaylist" + @click.stop="$store.commit('playlists/chooseTrack', track)" > <i class="list icon track-modal list-icon" /> <span class="track-modal list-item">{{ @@ -134,8 +140,11 @@ }}</span> </div> </div> - <div class="ui divider"></div> - <div v-if="!isAlbum && track.album" class="row"> + <div class="ui divider" /> + <div + v-if="!isAlbum && track.album" + class="row" + > <div class="column" role="button" @@ -153,7 +162,10 @@ }}</span> </div> </div> - <div v-if="!isArtist" class="row"> + <div + v-if="!isArtist" + class="row" + > <div class="column" role="button" @@ -189,7 +201,7 @@ }}</span> </div> </div> - <div class="ui divider"></div> + <div class="ui divider" /> <div v-for="obj in getReportableObjs({ track, @@ -197,16 +209,15 @@ artist, })" :key="obj.target.type + obj.target.id" - class="row" :ref="`report${obj.target.type}${obj.target.id}`" + class="row" :data-ref="`report${obj.target.type}${obj.target.id}`" @click.stop.prevent="$store.dispatch('moderation/report', obj.target)" > <div class="column"> <i class="share icon track-modal list-icon" /><span class="track-modal list-item" - >{{ obj.label }}</span - > + >{{ obj.label }}</span> </div> </div> </div> @@ -215,90 +226,83 @@ </template> <script> -import Modal from "@/components/semantic/Modal"; -import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"; +import Modal from '@/components/semantic/Modal' import ReportMixin from '@/components/mixins/Report' import PlayOptionsMixin from '@/components/mixins/PlayOptions' export default { + components: { + Modal + }, mixins: [ReportMixin, PlayOptionsMixin], props: { show: { type: Boolean, required: true, default: false }, track: { type: Object, required: true }, index: { type: Number, required: true }, isArtist: { type: Boolean, required: false, default: false }, - isAlbum: { type: Boolean, required: false, default: false }, + isAlbum: { type: Boolean, required: false, default: false } }, - components: { - Modal, - TrackFavoriteIcon, - }, - data() { + data () { return { isShowing: this.show, tracks: [this.track], album: this.track.album, - artist: this.track.artist, - }; + artist: this.track.artist + } }, computed: { - isFavorite() { - return this.$store.getters["favorites/isFavorite"](this.track.id); + isFavorite () { + return this.$store.getters['favorites/isFavorite'](this.track.id) }, - favoriteButton() { + favoriteButton () { if (this.isFavorite) { return this.$pgettext( - "Content/Track/Icon.Tooltip/Verb", - "Remove from favorites" - ); + 'Content/Track/Icon.Tooltip/Verb', + 'Remove from favorites' + ) } else { - return this.$pgettext("Content/Track/*/Verb", "Add to favorites"); + return this.$pgettext('Content/Track/*/Verb', 'Add to favorites') } }, - trackDetailsButton() { + trackDetailsButton () { if (this.track.artist.content_category === 'podcast') { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "Episode details") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details') } else { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "Track details") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details') } }, - albumDetailsButton() { + albumDetailsButton () { if (this.track.artist.content_category === 'podcast') { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View series") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series') } else { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View album") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album') } }, - artistDetailsButton() { + artistDetailsButton () { if (this.track.artist.content_category === 'podcast') { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View channel") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel') } else { - return this.$pgettext("*/Queue/Dropdown/Button/Label/Short", "View artist") + return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist') } }, - labels() { + labels () { return { startRadio: this.$pgettext( - "*/Queue/Dropdown/Button/Title", - "Play radio" + '*/Queue/Dropdown/Button/Title', + 'Play radio' ), - playNow: this.$pgettext("*/Queue/Dropdown/Button/Title", "Play now"), + playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), addToQueue: this.$pgettext( - "*/Queue/Dropdown/Button/Title", - "Add to queue" + '*/Queue/Dropdown/Button/Title', + 'Add to queue' ), - playNext: this.$pgettext("*/Queue/Dropdown/Button/Title", "Play next"), + playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), addToPlaylist: this.$pgettext( - "Sidebar/Player/Icon.Tooltip/Verb", - "Add to playlist…" - ), - }; - }, - }, - methods: { - closeModal() { - this.$emit("update:show", false); - }, - }, -}; + 'Sidebar/Player/Icon.Tooltip/Verb', + 'Add to playlist…' + ) + } + } + } +} </script> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 046492fd7004e266998cd5d4e4ae2780d2896166..d614cf4b3f42467c9c14b3be7feb3c78f5c4440b 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -1,174 +1,353 @@ <template> - <main class="main pusher" v-title="labels.title"> + <main + v-title="labels.title" + class="main pusher" + > <div class="ui vertical stripe segment"> <section class="ui text container"> <h2 class="ui header"> - <translate translate-context="Content/Settings/Title">Account settings</translate> + <translate translate-context="Content/Settings/Title"> + Account settings + </translate> </h2> - <form class="ui form" @submit.prevent="submitSettings()"> - <div v-if="settings.success" class="ui positive message"> + <form + class="ui form" + @submit.prevent="submitSettings()" + > + <div + v-if="settings.success" + class="ui positive message" + > <h4 class="header"> - <translate translate-context="Content/Settings/Message">Settings updated</translate> + <translate translate-context="Content/Settings/Message"> + Settings updated + </translate> </h4> </div> - <div v-if="settings.errors.length > 0" role="alert" class="ui negative message"> - <h4 class="header"><translate translate-context="Content/Settings/Error message.Title">Your settings can't be updated</translate></h4> + <div + v-if="settings.errors.length > 0" + role="alert" + class="ui negative message" + > + <h4 class="header"> + <translate translate-context="Content/Settings/Error message.Title"> + Your settings can't be updated + </translate> + </h4> <ul class="list"> - <li v-for="error in settings.errors">{{ error }}</li> + <li + v-for="(error, key) in settings.errors" + :key="key" + > + {{ error }} + </li> </ul> </div> - <div class="field" v-for="f in orderedSettingsFields"> + <div + v-for="f in orderedSettingsFields" + :key="f.id" + class="field" + > <label :for="f.id">{{ sharedLabels.fields[f.id].label }}</label> - <p v-if="f.help">{{ sharedLabels.fields[f.id].help }}</p> - <select :id="f.id" v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value"> - <option :value="c" v-for="c in f.choices">{{ sharedLabels.fields[f.id].choices[c] }}</option> + <p v-if="f.help"> + {{ sharedLabels.fields[f.id].help }} + </p> + <select + v-if="f.type === 'dropdown'" + :id="f.id" + v-model="f.value" + class="ui dropdown" + > + <option + v-for="(c, key) in f.choices" + :key="key" + :value="c" + > + {{ sharedLabels.fields[f.id].choices[c] }} + </option> </select> - <content-form :field-id="f.id" v-if="f.type === 'content'" v-model="f.value.text"></content-form> + <content-form + v-if="f.type === 'content'" + v-model="f.value.text" + :field-id="f.id" + /> </div> - <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"> - <translate translate-context="Content/Settings/Button.Label/Verb">Update settings</translate> + <button + :class="['ui', {'loading': isLoading}, 'button']" + type="submit" + > + <translate translate-context="Content/Settings/Button.Label/Verb"> + Update settings + </translate> </button> </form> </section> <section class="ui text container"> - <div class="ui hidden divider"></div> + <div class="ui hidden divider" /> <h2 class="ui header"> - <translate translate-context="Content/Settings/Title">Avatar</translate> + <translate translate-context="Content/Settings/Title"> + Avatar + </translate> </h2> <div class="ui form"> - <div v-if="avatarErrors.length > 0" role="alert" class="ui negative message"> - <h4 class="header"><translate translate-context="Content/Settings/Error message.Title">Your avatar cannot be saved</translate></h4> + <div + v-if="avatarErrors.length > 0" + role="alert" + class="ui negative message" + > + <h4 class="header"> + <translate translate-context="Content/Settings/Error message.Title"> + Your avatar cannot be saved + </translate> + </h4> <ul class="list"> - <li v-for="error in avatarErrors">{{ error }}</li> + <li + v-for="(error, key) in avatarErrors" + :key="key" + > + {{ error }} + </li> </ul> </div> {{ }} <attachment-input :value="avatar.uuid" - @input="submitAvatar($event)" :initial-value="initialAvatar" :required="false" - @delete="avatar = {uuid: null}"> - <translate translate-context="Content/Channel/*" slot="label">Avatar</translate> - </attachment-input> + @input="submitAvatar($event)" + @delete="avatar = {uuid: null}" + > + <translate + slot="label" + translate-context="Content/Channel/*" + > + Avatar + </translate> + </attachment-input> </div> </section> <section class="ui text container"> - <div class="ui hidden divider"></div> + <div class="ui hidden divider" /> <h2 class="ui header"> - <translate translate-context="Content/Settings/Title/Verb">Change my password</translate> + <translate translate-context="Content/Settings/Title/Verb"> + Change my password + </translate> </h2> <div class="ui message"> - <translate translate-context="Content/Settings/Paragraph'">Changing your password will also change your Subsonic API password if you have requested one.</translate> <translate translate-context="Content/Settings/Paragraph">You will have to update your password on your clients that use this password.</translate> + <translate translate-context="Content/Settings/Paragraph'"> + Changing your password will also change your Subsonic API password if you have requested one. + </translate> <translate translate-context="Content/Settings/Paragraph"> + You will have to update your password on your clients that use this password. + </translate> </div> - <form class="ui form" @submit.prevent="submitPassword()"> - <div v-if="passwordError" role="alert" class="ui negative message"> + <form + class="ui form" + @submit.prevent="submitPassword()" + > + <div + v-if="passwordError" + role="alert" + class="ui negative message" + > <h4 class="header"> - <translate translate-context="Content/Settings/Error message.Title">Your password cannot be changed</translate> + <translate translate-context="Content/Settings/Error message.Title"> + Your password cannot be changed + </translate> </h4> <ul class="list"> - <li v-if="passwordError == 'invalid_credentials'"><translate translate-context="Content/Settings/Error message.List item/Call to action">Please double-check your password is correct</translate></li> + <li v-if="passwordError == 'invalid_credentials'"> + <translate translate-context="Content/Settings/Error message.List item/Call to action"> + Please double-check your password is correct + </translate> + </li> </ul> </div> <div class="field"> <label for="old-password-field"><translate translate-context="Content/Settings/Input.Label">Current password</translate></label> - <password-input field-id="old-password-field" required v-model="old_password" /> + <password-input + v-model="old_password" + field-id="old-password-field" + required + /> </div> <div class="field"> <label for="new-password-field"><translate translate-context="Content/Settings/Input.Label">New password</translate></label> - <password-input field-id="new-password-field" required v-model="new_password" /> + <password-input + v-model="new_password" + field-id="new-password-field" + required + /> </div> <dangerous-button :class="['ui', {'loading': isLoading}, {disabled: !new_password || !old_password}, 'warning', 'button']" - :action="submitPassword"> - <translate translate-context="Content/Settings/Button.Label">Change password</translate> - <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Change your password?</translate></p> + :action="submitPassword" + > + <translate translate-context="Content/Settings/Button.Label"> + Change password + </translate> + <p slot="modal-header"> + <translate translate-context="Popup/Settings/Title"> + Change your password? + </translate> + </p> <div slot="modal-content"> - <p><translate translate-context="Popup/Settings/Paragraph">Changing your password will have the following consequences:</translate></p> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + Changing your password will have the following consequences: + </translate> + </p> <ul> - <li><translate translate-context="Popup/Settings/List item">You will be logged out from this session and have to log in with the new one</translate></li> - <li><translate translate-context="Popup/Settings/List item">Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password</translate></li> + <li> + <translate translate-context="Popup/Settings/List item"> + You will be logged out from this session and have to log in with the new one + </translate> + </li> + <li> + <translate translate-context="Popup/Settings/List item"> + Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password + </translate> + </li> </ul> </div> - <div slot="modal-confirm"><translate translate-context="Popup/Settings/Button.Label">Disable access</translate></div> + <div slot="modal-confirm"> + <translate translate-context="Popup/Settings/Button.Label"> + Disable access + </translate> + </div> </dangerous-button> </form> <div class="ui hidden divider" /> <subsonic-token-form /> </section> - <section class="ui text container" id="content-filters"> - <div class="ui hidden divider"></div> + <section + id="content-filters" + class="ui text container" + > + <div class="ui hidden divider" /> <h2 class="ui header"> - <i class="eye slash outline icon"></i> + <i class="eye slash outline icon" /> <div class="content"> - <translate translate-context="Content/Settings/Title/Noun">Content filters</translate> + <translate translate-context="Content/Settings/Title/Noun"> + Content filters + </translate> </div> </h2> - <p><translate translate-context="Content/Settings/Paragraph">Content filters help you hide content you don't want to see on the service.</translate></p> + <p> + <translate translate-context="Content/Settings/Paragraph"> + Content filters help you hide content you don't want to see on the service. + </translate> + </p> <button + class="ui icon button" @click="$store.dispatch('moderation/fetchContentFilters')" - class="ui icon button"> - <i class="refresh icon"></i> - <translate translate-context="Content/*/Button.Label/Short, Verb">Refresh</translate> + > + <i class="refresh icon" /> + <translate translate-context="Content/*/Button.Label/Short, Verb"> + Refresh + </translate> </button> <h3 class="ui header"> - <translate translate-context="Content/Settings/Title">Hidden artists</translate> + <translate translate-context="Content/Settings/Title"> + Hidden artists + </translate> </h3> <table class="ui compact very basic unstackable table"> <thead> <tr> - <th><translate translate-context="*/*/*/Noun">Name</translate></th> - <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> - <th></th> + <th> + <translate translate-context="*/*/*/Noun"> + Name + </translate> + </th> + <th> + <translate translate-context="Content/*/*/Noun"> + Creation date + </translate> + </th> + <th /> </tr> </thead> <tbody> - <tr v-for="filter in $store.getters['moderation/artistFilters']()" :key='filter.uuid'> + <tr + v-for="filter in $store.getters['moderation/artistFilters']()" + :key="filter.uuid" + > <td> <router-link :to="{name: 'library.artists.detail', params: {id: filter.target.id }}"> {{ filter.target.name }} </router-link> </td> <td> - <human-date :date="filter.creation_date"></human-date> + <human-date :date="filter.creation_date" /> </td> <td> - <button @click="$store.dispatch('moderation/deleteContentFilter', filter.uuid)" class="ui basic tiny button"> - <translate translate-context="*/*/*/Verb">Delete</translate> + <button + class="ui basic tiny button" + @click="$store.dispatch('moderation/deleteContentFilter', filter.uuid)" + > + <translate translate-context="*/*/*/Verb"> + Delete + </translate> </button> </td> </tr> </tbody> </table> </section> - <section class="ui text container" id="grants"> - <div class="ui hidden divider"></div> + <section + id="grants" + class="ui text container" + > + <div class="ui hidden divider" /> <h2 class="ui header"> - <i class="open lock icon"></i> + <i class="open lock icon" /> <div class="content"> - <translate translate-context="Content/Settings/Title/Noun">Authorized apps</translate> + <translate translate-context="Content/Settings/Title/Noun"> + Authorized apps + </translate> </div> </h2> - <p><translate translate-context="Content/Settings/Paragraph">This is the list of applications that have access to your account data.</translate></p> + <p> + <translate translate-context="Content/Settings/Paragraph"> + This is the list of applications that have access to your account data. + </translate> + </p> <button + class="ui icon button" @click="fetchApps()" - class="ui icon button"> - <i class="refresh icon"></i> - <translate translate-context="Content/*/Button.Label/Short, Verb">Refresh</translate> + > + <i class="refresh icon" /> + <translate translate-context="Content/*/Button.Label/Short, Verb"> + Refresh + </translate> </button> - <table v-if="apps.length > 0" class="ui compact very basic unstackable table"> + <table + v-if="apps.length > 0" + class="ui compact very basic unstackable table" + > <thead> <tr> - <th><translate translate-context="*/*/*/Noun">Application</translate></th> - <th><translate translate-context="Content/*/*/Noun">Permissions</translate></th> - <th></th> + <th> + <translate translate-context="*/*/*/Noun"> + Application + </translate> + </th> + <th> + <translate translate-context="Content/*/*/Noun"> + Permissions + </translate> + </th> + <th /> </tr> </thead> <tbody> - <tr v-for="app in apps" :key='app.client_id'> + <tr + v-for="app in apps" + :key="app.client_id" + > <td> {{ app.name }} </td> @@ -178,18 +357,38 @@ <td> <dangerous-button class="ui tiny danger button" - @confirm="revokeApp(app.client_id)"> - <translate translate-context="*/*/*/Verb">Revoke</translate> - <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Revoke access for application "%{ application }"?</p> - <p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will prevent this application from accessing the service on your behalf.</translate></p> - <div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Revoke access</translate></div> + @confirm="revokeApp(app.client_id)" + > + <translate translate-context="*/*/*/Verb"> + Revoke + </translate> + <p + slot="modal-header" + v-translate="{application: app.name}" + translate-context="Popup/Settings/Title" + > + Revoke access for application "%{ application }"? + </p> + <p slot="modal-content"> + <translate translate-context="Popup/Settings/Paragraph"> + This will prevent this application from accessing the service on your behalf. + </translate> + </p> + <div slot="modal-confirm"> + <translate translate-context="*/Settings/Button.Label/Verb"> + Revoke access + </translate> + </div> </dangerous-button> </td> </tr> </tbody> </table> <empty-state v-else> - <translate slot="title" translate-context="Content/Applications/Paragraph"> + <translate + slot="title" + translate-context="Content/Applications/Paragraph" + > You don't have any application connected with your account. </translate> <translate translate-context="Content/Applications/Paragraph"> @@ -197,29 +396,61 @@ </translate> </empty-state> </section> - <section class="ui text container" id="apps"> - <div class="ui hidden divider"></div> + <section + id="apps" + class="ui text container" + > + <div class="ui hidden divider" /> <h2 class="ui header"> - <i class="code icon"></i> + <i class="code icon" /> <div class="content"> - <translate translate-context="Content/Settings/Title/Noun">Your applications</translate> + <translate translate-context="Content/Settings/Title/Noun"> + Your applications + </translate> </div> </h2> - <p><translate translate-context="Content/Settings/Paragraph">This is the list of applications that you have registered.</translate></p> - <router-link class="ui success button" :to="{name: 'settings.applications.new'}"> - <translate translate-context="Content/Settings/Button.Label">Register a new application</translate> + <p> + <translate translate-context="Content/Settings/Paragraph"> + This is the list of applications that you have registered. + </translate> + </p> + <router-link + class="ui success button" + :to="{name: 'settings.applications.new'}" + > + <translate translate-context="Content/Settings/Button.Label"> + Register a new application + </translate> </router-link> - <table v-if="ownedApps.length > 0" class="ui compact very basic unstackable table"> + <table + v-if="ownedApps.length > 0" + class="ui compact very basic unstackable table" + > <thead> <tr> - <th><translate translate-context="*/*/*/Noun">Application</translate></th> - <th><translate translate-context="Content/*/*/Noun">Scopes</translate></th> - <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> - <th></th> + <th> + <translate translate-context="*/*/*/Noun"> + Application + </translate> + </th> + <th> + <translate translate-context="Content/*/*/Noun"> + Scopes + </translate> + </th> + <th> + <translate translate-context="Content/*/*/Noun"> + Creation date + </translate> + </th> + <th /> </tr> </thead> <tbody> - <tr v-for="app in ownedApps" :key='app.client_id'> + <tr + v-for="app in ownedApps" + :key="app.client_id" + > <td> <router-link :to="{name: 'settings.applications.edit', params: {id: app.client_id}}"> {{ app.name }} @@ -232,23 +463,48 @@ <human-date :date="app.created" /> </td> <td> - <router-link class="ui tiny success button" :to="{name: 'settings.applications.edit', params: {id: app.client_id}}"> - <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + <router-link + class="ui tiny success button" + :to="{name: 'settings.applications.edit', params: {id: app.client_id}}" + > + <translate translate-context="Content/*/Button.Label/Verb"> + Edit + </translate> </router-link> <dangerous-button class="ui tiny danger button" - @confirm="deleteApp(app.client_id)"> - <translate translate-context="*/*/*/Verb">Remove</translate> - <p slot="modal-header" v-translate="{application: app.name}" translate-context="Popup/Settings/Title">Remove application "%{ application }"?</p> - <p slot="modal-content"><translate translate-context="Popup/Settings/Paragraph">This will permanently remove the application and all the associated tokens.</translate></p> - <div slot="modal-confirm"><translate translate-context="*/Settings/Button.Label/Verb">Remove application</translate></div> + @confirm="deleteApp(app.client_id)" + > + <translate translate-context="*/*/*/Verb"> + Remove + </translate> + <p + slot="modal-header" + v-translate="{application: app.name}" + translate-context="Popup/Settings/Title" + > + Remove application "%{ application }"? + </p> + <p slot="modal-content"> + <translate translate-context="Popup/Settings/Paragraph"> + This will permanently remove the application and all the associated tokens. + </translate> + </p> + <div slot="modal-confirm"> + <translate translate-context="*/Settings/Button.Label/Verb"> + Remove application + </translate> + </div> </dangerous-button> </td> </tr> </tbody> </table> <empty-state v-else> - <translate slot="title" translate-context="Content/Applications/Paragraph"> + <translate + slot="title" + translate-context="Content/Applications/Paragraph" + > You don't have registered any application yet. </translate> <translate translate-context="Content/Applications/Paragraph"> @@ -257,85 +513,181 @@ </empty-state> </section> - <section class="ui text container" id="plugins"> - <div class="ui hidden divider"></div> + <section + id="plugins" + class="ui text container" + > + <div class="ui hidden divider" /> <h2 class="ui header"> - <i class="code icon"></i> + <i class="code icon" /> <div class="content"> - <translate translate-context="Content/Settings/Title/Noun">Plugins</translate> + <translate translate-context="Content/Settings/Title/Noun"> + Plugins + </translate> </div> </h2> - <p><translate translate-context="Content/Settings/Paragraph">Use plugins to extend Funkwhale and get additional features.</translate></p> - <router-link class="ui success button" :to="{name: 'settings.plugins'}"> - <translate translate-context="Content/Settings/Button.Label">Manage plugins</translate> + <p> + <translate translate-context="Content/Settings/Paragraph"> + Use plugins to extend Funkwhale and get additional features. + </translate> + </p> + <router-link + class="ui success button" + :to="{name: 'settings.plugins'}" + > + <translate translate-context="Content/Settings/Button.Label"> + Manage plugins + </translate> </router-link> </section> <section class="ui text container"> - <div class="ui hidden divider"></div> + <div class="ui hidden divider" /> <h2 class="ui header"> - <i class="comment icon"></i> + <i class="comment icon" /> <div class="content"> - <translate translate-context="*/*/Button.Label">Change my e-mail address</translate> + <translate translate-context="*/*/Button.Label"> + Change my e-mail address + </translate> </div> </h2> <p> - <translate translate-context="Content/Settings/Paragraph'">Change the e-mail address associated with your account. We will send a confirmation to the new address.</translate> + <translate translate-context="Content/Settings/Paragraph'"> + Change the e-mail address associated with your account. We will send a confirmation to the new address. + </translate> </p> <p> - <translate :translate-params="{email: $store.state.auth.profile.email}" translate-context="Content/Settings/Paragraph'">Your current e-mail address is %{ email }.</translate> + <translate + :translate-params="{email: $store.state.auth.profile.email}" + translate-context="Content/Settings/Paragraph'" + > + Your current e-mail address is %{ email }. + </translate> </p> - <form class="ui form" @submit.prevent="changeEmail"> - <div v-if="changeEmailErrors.length > 0" role="alert" class="ui negative message"> - <h4 class="header"><translate translate-context="Content/Settings/Error message.Title">We cannot change your e-mail address</translate></h4> + <form + class="ui form" + @submit.prevent="changeEmail" + > + <div + v-if="changeEmailErrors.length > 0" + role="alert" + class="ui negative message" + > + <h4 class="header"> + <translate translate-context="Content/Settings/Error message.Title"> + We cannot change your e-mail address + </translate> + </h4> <ul class="list"> - <li v-for="error in changeEmailErrors">{{ error }}</li> + <li + v-for="(error, key) in changeEmailErrors" + :key="key" + > + {{ error }} + </li> </ul> </div> <div class="field"> <label for="new-email"><translate translate-context="*/*/*">New e-mail address</translate></label> - <input id="new-email" required v-model="newEmail" type="email" /> + <input + id="new-email" + v-model="newEmail" + required + type="email" + > </div> <div class="field"> <label for="current-password-field-email"><translate translate-context="*/*/*">Password</translate></label> - <password-input field-id="current-password-field-email" required v-model="emailPassword" /> + <password-input + v-model="emailPassword" + field-id="current-password-field-email" + required + /> </div> - <button type="submit" class="ui button"><translate translate-context="*/*/*">Update</translate></button> + <button + type="submit" + class="ui button" + > + <translate translate-context="*/*/*"> + Update + </translate> + </button> </form> </section> <section class="ui text container"> - <div class="ui hidden divider"></div> + <div class="ui hidden divider" /> <h2 class="ui header"> - <i class="trash icon"></i> + <i class="trash icon" /> <div class="content"> - <translate translate-context="*/*/Button.Label">Delete my account</translate> + <translate translate-context="*/*/Button.Label"> + Delete my account + </translate> </div> </h2> <p> - <translate translate-context="Content/Settings/Paragraph'">You can permanently and irreversibly delete your account and all the associated data using the form below. You will be asked for confirmation.</translate> + <translate translate-context="Content/Settings/Paragraph'"> + You can permanently and irreversibly delete your account and all the associated data using the form below. You will be asked for confirmation. + </translate> </p> - <div role="alert" class="ui warning message"> - <translate translate-context="Content/Settings/Paragraph'">Your account will be deleted from our servers within a few minutes. We will also notify other servers who may have a copy of some of your data so they can proceed to deletion. Please note that some of these servers may be offline or unwilling to comply though.</translate> + <div + role="alert" + class="ui warning message" + > + <translate translate-context="Content/Settings/Paragraph'"> + Your account will be deleted from our servers within a few minutes. We will also notify other servers who may have a copy of some of your data so they can proceed to deletion. Please note that some of these servers may be offline or unwilling to comply though. + </translate> </div> <div class="ui form"> - <div v-if="accountDeleteErrors.length > 0" role="alert" class="ui negative message"> - <h4 class="header"><translate translate-context="Content/Settings/Error message.Title">We cannot delete your account</translate></h4> + <div + v-if="accountDeleteErrors.length > 0" + role="alert" + class="ui negative message" + > + <h4 class="header"> + <translate translate-context="Content/Settings/Error message.Title"> + We cannot delete your account + </translate> + </h4> <ul class="list"> - <li v-for="error in accountDeleteErrors">{{ error }}</li> + <li + v-for="(error, key) in accountDeleteErrors" + :key="key" + > + {{ error }} + </li> </ul> </div> <div class="field"> <label for="current-password-field"><translate translate-context="*/*/*">Password</translate></label> - <password-input field-id="current-password-field" required v-model="password" /> + <password-input + v-model="password" + field-id="current-password-field" + required + /> </div> <dangerous-button :class="['ui', {'loading': isDeletingAccount}, {disabled: !password}, {danger: password}, 'button']" - :action="deleteAccount"> - <translate translate-context="*/*/Button.Label">Delete my account…</translate> - <p slot="modal-header"><translate translate-context="Popup/Settings/Title">Do you want to delete your account?</translate></p> + :action="deleteAccount" + > + <translate translate-context="*/*/Button.Label"> + Delete my account… + </translate> + <p slot="modal-header"> + <translate translate-context="Popup/Settings/Title"> + Do you want to delete your account? + </translate> + </p> <div slot="modal-content"> - <p><translate translate-context="Popup/Settings/Paragraph">This is irreversible and will permanently remove your data from our servers. You will we immediatly logged out.</translate></p> + <p> + <translate translate-context="Popup/Settings/Paragraph"> + This is irreversible and will permanently remove your data from our servers. You will we immediatly logged out. + </translate> + </p> + </div> + <div slot="modal-confirm"> + <translate translate-context="*/*/Button.Label"> + Delete my account + </translate> </div> - <div slot="modal-confirm"><translate translate-context="*/*/Button.Label">Delete my account</translate></div> </dangerous-button> </div> </section> @@ -344,30 +696,30 @@ </template> <script> -import $ from "jquery" -import axios from "axios" -import logger from "@/logging" -import PasswordInput from "@/components/forms/PasswordInput" -import SubsonicTokenForm from "@/components/auth/SubsonicTokenForm" -import TranslationsMixin from "@/components/mixins/Translations" +import $ from 'jquery' +import axios from 'axios' +import logger from '@/logging' +import PasswordInput from '@/components/forms/PasswordInput' +import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm' +import TranslationsMixin from '@/components/mixins/Translations' import AttachmentInput from '@/components/common/AttachmentInput' export default { - mixins: [TranslationsMixin], components: { PasswordInput, SubsonicTokenForm, AttachmentInput }, - data() { - let d = { + mixins: [TranslationsMixin], + data () { + const d = { // We need to initialize the component with any // properties that will be used in it - old_password: "", - new_password: "", - avatar: {...(this.$store.state.auth.profile.avatar || {uuid: null})}, - passwordError: "", - password: "", + old_password: '', + new_password: '', + avatar: { ...(this.$store.state.auth.profile.avatar || { uuid: null }) }, + passwordError: '', + password: '', isLoading: false, isLoadingAvatar: false, isDeletingAccount: false, @@ -382,16 +734,16 @@ export default { settings: { success: false, errors: [], - order: ["summary", "privacy_level"], + order: ['summary', 'privacy_level'], fields: { summary: { - type: "content", - initial: this.$store.state.auth.profile.summary || {text: '', content_type: 'text/markdown'}, + type: 'content', + initial: this.$store.state.auth.profile.summary || { text: '', content_type: 'text/markdown' } }, privacy_level: { - type: "dropdown", + type: 'dropdown', initial: this.$store.state.auth.profile.privacy_level, - choices: ["me", "instance", "everyone"] + choices: ['me', 'instance', 'everyone'] } } } @@ -403,96 +755,133 @@ export default { }) return d }, + computed: { + labels () { + return { + title: this.$pgettext('Head/Settings/Title', 'Account Settings') + } + }, + orderedSettingsFields () { + const self = this + return this.settings.order.map(id => { + return self.settings.fields[id] + }) + }, + settingsValues () { + const self = this + const s = {} + this.settings.order.forEach(setting => { + const conf = self.settings.fields[setting] + s[setting] = conf.value + if (setting === 'summary' && !conf.value.text) { + s[setting] = null + } + }) + return s + } + }, created () { this.fetchApps() this.fetchOwnedApps() }, - mounted() { - $("select.dropdown").dropdown() + mounted () { + $('select.dropdown').dropdown() }, methods: { - submitSettings() { + submitSettings () { this.settings.success = false this.settings.errors = [] - let self = this - let payload = this.settingsValues - let url = `users/${this.$store.state.auth.username}/` + const self = this + const payload = this.settingsValues + const url = `users/${this.$store.state.auth.username}/` return axios.patch(url, payload).then( response => { - logger.default.info("Updated settings successfully") + logger.default.info('Updated settings successfully') self.settings.success = true - return axios.get("users/me/").then(response => { - self.$store.dispatch("auth/updateProfile", response.data) + return axios.get('users/me/').then(response => { + self.$store.dispatch('auth/updateProfile', response.data) }) }, error => { - logger.default.error("Error while updating settings") + logger.default.error('Error while updating settings') self.isLoading = false self.settings.errors = error.backendErrors } ) }, - fetchApps() { + fetchApps () { this.apps = [] - let self = this - let url = `oauth/grants/` + const self = this + const url = 'oauth/grants/' return axios.get(url).then( response => { self.apps = response.data }, error => { + logger.default.error('Error while fetching Apps') + self.isLoading = false + self.settings.errors = error.backendErrors } ) }, - fetchOwnedApps() { + fetchOwnedApps () { this.ownedApps = [] - let self = this - let url = `oauth/apps/` + const self = this + const url = 'oauth/apps/' return axios.get(url).then( response => { self.ownedApps = response.data.results }, error => { + logger.default.error('Error while fetching owned Apps') + self.isLoading = false + self.settings.errors = error.backendErrors } ) }, revokeApp (id) { - let self = this - let url = `oauth/grants/${id}/` + const self = this + const url = `oauth/grants/${id}/` return axios.delete(url).then( response => { self.apps = self.apps.filter(a => { - return a.client_id != id + return a.client_id !== id }) }, error => { + logger.default.error('Error while revoking App') + self.isLoading = false + self.settings.errors = error.backendErrors } ) }, deleteApp (id) { - let self = this - let url = `oauth/apps/${id}/` + const self = this + const url = `oauth/apps/${id}/` return axios.delete(url).then( response => { self.ownedApps = self.ownedApps.filter(a => { - return a.client_id != id + return a.client_id !== id }) }, error => { + logger.default.error('Error while deleting App') + self.isLoading = false + self.settings.errors = error.backendErrors } ) }, - submitAvatar(uuid) { + submitAvatar (uuid) { this.isLoadingAvatar = true this.avatarErrors = [] - let self = this + const self = this axios - .patch(`users/${this.$store.state.auth.username}/`, {avatar: uuid}) + .patch(`users/${this.$store.state.auth.username}/`, { avatar: uuid }) .then( response => { this.isLoadingAvatar = false self.avatar = response.data.avatar - self.$store.commit("auth/avatar", response.data.avatar) + self.$store.commit('auth/avatar', response.data.avatar) }, error => { self.isLoadingAvatar = false @@ -500,21 +889,21 @@ export default { } ) }, - submitPassword() { - var self = this + submitPassword () { + const self = this self.isLoading = true - this.error = "" - var credentials = { + this.error = '' + const credentials = { old_password: this.old_password, new_password1: this.new_password, new_password2: this.new_password } - let url = "auth/registration/change-password/" + const url = 'auth/registration/change-password/' return axios.post(url, credentials).then( response => { - logger.default.info("Password successfully changed") + logger.default.info('Password successfully changed') self.$router.push({ - name: "profile.overview", + name: 'profile.overview', params: { username: self.$store.state.auth.username } @@ -522,27 +911,27 @@ export default { }, error => { if (error.response.status === 400) { - self.passwordError = "invalid_credentials" + self.passwordError = 'invalid_credentials' } else { - self.passwordError = "unknown_error" + self.passwordError = 'unknown_error' } self.isLoading = false } ) }, - deleteAccount() { + deleteAccount () { this.isDeletingAccount = true this.accountDeleteErrors = [] - let self = this - let payload = { + const self = this + const payload = { confirm: true, - password: this.password, + password: this.password } - axios.delete(`users/me/`, {data: payload}) + axios.delete('users/me/', { data: payload }) .then( response => { self.isDeletingAccount = false - let msg = self.$pgettext('*/Auth/Message', 'Your deletion request was submitted, your account and content will be deleted shortly') + const msg = self.$pgettext('*/Auth/Message', 'Your deletion request was submitted, your account and content will be deleted shortly') self.$store.commit('ui/addMessage', { content: msg, date: new Date() @@ -556,21 +945,21 @@ export default { ) }, - changeEmail() { + changeEmail () { this.isChangingEmail = true this.changeEmailErrors = [] - let self = this - let payload = { + const self = this + const payload = { password: this.emailPassword, - email: this.newEmail, + email: this.newEmail } - axios.post(`users/users/change-email/`, payload) + axios.post('users/users/change-email/', payload) .then( response => { self.isChangingEmail = false self.newEmail = null self.emailPassword = null - let msg = self.$pgettext('*/Auth/Message', 'Your e-mail address has been changed, please check your inbox for our confirmation message.') + const msg = self.$pgettext('*/Auth/Message', 'Your e-mail address has been changed, please check your inbox for our confirmation message.') self.$store.commit('ui/addMessage', { content: msg, date: new Date() @@ -581,31 +970,6 @@ export default { self.changeEmailErrors = error.backendErrors } ) - }, - }, - computed: { - labels() { - return { - title: this.$pgettext('Head/Settings/Title', "Account Settings") - } - }, - orderedSettingsFields() { - let self = this - return this.settings.order.map(id => { - return self.settings.fields[id] - }) - }, - settingsValues() { - let self = this - let s = {} - this.settings.order.forEach(setting => { - let conf = self.settings.fields[setting] - s[setting] = conf.value - if (setting === 'summary' && !conf.value.text) { - s[setting] = null - } - }) - return s } } } diff --git a/front/src/components/common/UserMenu.vue b/front/src/components/common/UserMenu.vue new file mode 100644 index 0000000000000000000000000000000000000000..5110bafa9c1513f52507bf542d3549a08b93ceb6 --- /dev/null +++ b/front/src/components/common/UserMenu.vue @@ -0,0 +1,204 @@ +<template> + <div class="ui menu"> + <div class="ui scrolling dropdown item"> + <i class="language icon" /> + {{ labels.language }} + <i class="dropdown icon" /> + <div + id="language-select" + class="menu" + > + <a + v-for="(language, key) in $language.available" + :key="key" + :class="[{'active': $language.current === key},'item']" + :value="key" + @click="$store.dispatch('ui/currentLanguage', key)" + >{{ language }}</a> + </div> + </div> + <div class="ui dropdown item"> + <i class="palette icon" /> + {{ labels.theme }} + <i class="dropdown icon" /> + <div + id="theme-select" + class="menu" + > + <a + v-for="theme in themes" + :key="theme.key" + :class="[{'active': $store.state.ui.theme === theme.key}, 'item']" + :value="theme.key" + @click="$store.dispatch('ui/theme', theme.key)" + > + <i :class="theme.icon" /> + {{ theme.name }} + </a> + </div> + </div> + <template v-if="$store.state.auth.authenticated"> + <div class="divider" /> + <router-link + class="item" + :to="{name: 'profile.overview', params: { username: $store.state.auth.username },}" + > + <i class="user icon" /> + {{ labels.profile }} + </router-link> + <router-link + v-if="$store.state.auth.authenticated" + class="item" + :to="{name: 'notifications'}" + > + <i class="bell icon" /> + {{ labels.notifications }} + </router-link> + <router-link + class="item" + :to="{ path: '/settings' }" + > + <i class="cog icon" /> + {{ labels.settings }} + </router-link> + </template> + <div class="divider" /> + <div class="ui dropdown item"> + <i class="life ring outline icon" /> + {{ labels.support }} + <i class="dropdown icon" /> + <div class="menu"> + <a + href="https://forum.funkwhale.audio" + class="item" + target="_blank" + > + <i class="users icon" /> + {{ labels.forum }} + </a> + <a + href="https://matrix.to/#/#funkwhale-troubleshooting:matrix.org" + class="item" + target="_blank" + > + <i class="comment icon" /> + {{ labels.chat }} + </a> + <a + href="https://dev.funkwhale.audio/funkwhale/funkwhale/issues" + class="item" + target="_blank" + > + <i class="gitlab icon" /> + {{ labels.git }} + </a> + </div> + </div> + <a + href="https://docs.funkwhale.audio" + class="item" + target="_blank" + > + <i class="book open icon" /> + {{ labels.docs }} + </a> + <a + href="" + class="item" + @click.prevent="showShortcuts" + > + <i class="keyboard icon" /> + {{ labels.shortcuts }} + </a> + <router-link + v-if="$route.path != '/about'" + class="item" + :to="{ name: 'about' }" + > + <i class="question circle outline icon" /> + {{ labels.about }} + </router-link> + <template v-if="$store.state.auth.authenticated && $route.path != '/logout'"> + <div class="divider" /> + <router-link + class="item" + style="color: var(--danger-color)!important;" + :to="{ name: 'logout' }" + > + <i class="sign out alternate icon" /> + {{ labels.logout }} + </router-link> + </template> + <template v-if="!$store.state.auth.authenticated"> + <div class="divider" /> + <router-link + class="item" + :to="{ name: 'login' }" + > + <i class="sign in alternate icon" /> + {{ labels.login }} + </router-link> + </template> + <template v-if="!$store.state.auth.authenticated && $store.state.instance.settings.users.registration_enabled.value"> + <router-link + class="item" + :to="{ name: 'signup' }" + > + <i class="user icon" /> + {{ labels.signup }} + </router-link> + </template> + </div> +</template> + +<script> + +import { mapGetters } from 'vuex' + +export default { + computed: { + labels () { + return { + profile: this.$pgettext('*/*/*/Noun', 'Profile'), + settings: this.$pgettext('*/*/*/Noun', 'Settings'), + logout: this.$pgettext('Sidebar/Login/List item.Link/Verb', 'Log out'), + about: this.$pgettext('Sidebar/About/List item.Link', 'About'), + shortcuts: this.$pgettext('*/*/*/Noun', 'Keyboard shortcuts'), + support: this.$pgettext('Sidebar/*/Listitem.Link', 'Help'), + forum: this.$pgettext('Sidebar/*/Listitem.Link', 'Forum'), + docs: this.$pgettext('Sidebar/*/Listitem.Link', 'Documentation'), + language: this.$pgettext('Footer/Settings/Dropdown.Label/Short, Verb', 'Change language'), + theme: this.$pgettext('Footer/Settings/Dropdown.Label/Short, Verb', 'Change theme'), + chat: this.$pgettext('Sidebar/*/Listitem.Link', 'Chat room'), + git: this.$pgettext('Footer/*/List item.Link', 'Issue tracker'), + login: this.$pgettext('*/*/Button.Label/Verb', 'Log in'), + signup: this.$pgettext('*/*/Button.Label/Verb', 'Sign up'), + notifications: this.$pgettext('*/Notifications/*', 'Notifications') + } + }, + themes () { + return [ + { + icon: 'sun icon', + name: this.$pgettext('Footer/Settings/Dropdown.Label/Theme name', 'Light'), + key: 'light' + }, + { + icon: 'moon icon', + name: this.$pgettext('Footer/Settings/Dropdown.Label/Theme name', 'Dark'), + key: 'dark' + } + ] + }, + ...mapGetters({ + additionalNotifications: 'ui/additionalNotifications' + }) + }, + methods: { + showShortcuts () { + this.$emit('show:shortcuts-modal') + console.log(this.$store.getters['ui/windowSize']) + } + } +} +</script> diff --git a/front/src/components/common/UserModal.vue b/front/src/components/common/UserModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..7c0b27e4b390e557aed29e8f3c3efcc60754ca06 --- /dev/null +++ b/front/src/components/common/UserModal.vue @@ -0,0 +1,239 @@ +<template> + <!-- TODO make generic and move to semantic/modal? --> + <modal + :show="show" + :scrolling="true" + :fullscreen="false" + @update:show="$emit('update:show', $event)" + > + <div + v-if="$store.state.auth.authenticated" + class="header" + > + <img + v-if="$store.state.auth.profile.avatar && $store.state.auth.profile.avatar.urls.medium_square_crop" + v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.urls.medium_square_crop)" + alt="" + class="ui centered small circular image" + > + <actor-avatar + v-else + :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username,}" + /> + <h3 class="user-modal title"> + {{ labels.header }} + </h3> + </div> + <div + v-else + class="header" + > + <h3 class="ui center aligned icon header"> + {{ labels.header }} + </h3> + </div> + <div class="content"> + <div class="ui one column unstackable grid"> + <div class="row"> + <div + class="column" + role="button" + @click="[$emit('update:show', false), $emit('showLanguageModalEvent')]" + > + <i class="language icon user-modal list-icon" /> + <span class="user-modal list-item">{{ labels.language }}:</span> + <div class="right floated"> + <span class="user-modal list-item">{{ $language.available[$language.current] }}</span> + <i class="action-hint chevron right icon" /> + </div> + </div> + </div> + <div class="row"> + <div + class="column" + role="button" + @click="[$emit('update:show', false), $emit('showThemeModalEvent')]" + > + <i class="palette icon user-modal list-icon" /> + <span class="user-modal list-item">{{ labels.theme }}:</span> + <div class="right floated"> + <span class="user-modal list-item"> {{ themes.find(x => x.key ===$store.state.ui.theme).name }}</span> + <i class="action-hint chevron right icon user-modal" /> + </div> + </div> + </div> + <div class="ui divider" /> + <template v-if="$store.state.auth.authenticated"> + <div class="row"> + <div + class="column" + role="button" + @click.prevent.exact="$router.push({name: 'profile.overview', params: { username: $store.state.auth.username }})" + > + <i class="user icon user-modal list-icon" /> + <span class="user-modal list-item">{{ labels.profile }}</span> + </div> + </div> + <div class="row"> + <router-link + v-if="$store.state.auth.authenticated" + tag="div" + class="column" + :to="{name: 'notifications'}" + role="button" + > + <i class="user-modal list-icon bell icon" /> + <span class="user-modal list-item">{{ labels.notifications }}</span> + </router-link> + </div> + <div class="row"> + <router-link + tag="div" + class="column" + :to="{ path: '/settings' }" + role="button" + > + <i class="user-modal list-icon cog icon" /> + <span class="user-modal list-item">{{ labels.settings }}</span> + </router-link> + </div> + <div class="ui divider" /> + </template> + <div class="row"> + <a + class="column" + href="https://docs.funkwhale.audio" + target="_blank" + > + <i class="user-modal list-icon book open icon" /> + <span class="user-modal list-item">{{ labels.docs }}</span> + </a> + </div> + <div class="row"> + <router-link + tag="div" + class="column" + :to="{ name: 'about' }" + role="button" + > + <i class="user-modal list-icon question circle outline icon" /> + <span class="user-modal list-item">{{ labels.about }}</span> + </router-link> + </div> + <div class="ui divider" /> + <template v-if="$store.state.auth.authenticated"> + <router-link + tag="div" + class="column" + :to="{ name: 'logout' }" + role="button" + > + <i class="user-modal list-icon sign out alternate icon" /> + <span class="user-modal list-item">{{ labels.logout }}</span> + </router-link> + </template> + <template v-if="!$store.state.auth.authenticated"> + <router-link + tag="div" + class="column" + :to="{ name: 'login' }" + role="button" + > + <i class="user-modal list-icon sign in alternate icon" /> + <span class="user-modal list-item">{{ labels.login }}</span> + </router-link> + </template> + <template + v-if="!$store.state.auth.authenticated" + && + $store.state.instance.settings.users.registration_enabled.value + > + <router-link + tag="div" + class="column" + :to="{ name: 'signup' }" + role="button" + > + <i class="user-modal list-item user icon" /> + <span class="user-modal list-item">{{ labels.signup }}</span> + </router-link> + </template> + </div> + </div> + </modal> +</template> + +<script> +import Modal from '@/components/semantic/Modal' +import { mapGetters } from 'vuex' + +export default { + components: { + Modal + }, + props: { + show: { type: Boolean, required: true } + }, + computed: { + labels () { + return { + header: this.$pgettext('Popup/Title/Noun', 'Options'), + profile: this.$pgettext('*/*/*/Noun', 'Profile'), + settings: this.$pgettext('*/*/*/Noun', 'Settings'), + logout: this.$pgettext('Sidebar/Login/List item.Link/Verb', 'Log out'), + about: this.$pgettext('Sidebar/About/List item.Link', 'About'), + shortcuts: this.$pgettext('*/*/*/Noun', 'Keyboard shortcuts'), + support: this.$pgettext('Sidebar/*/Listitem.Link', 'Help'), + forum: this.$pgettext('Sidebar/*/Listitem.Link', 'Forum'), + docs: this.$pgettext('Sidebar/*/Listitem.Link', 'Documentation'), + language: this.$pgettext( + 'Sidebar/Settings/Dropdown.Label/Short, Verb', + 'Language' + ), + theme: this.$pgettext( + 'Sidebar/Settings/Dropdown.Label/Short, Verb', + 'Theme' + ), + chat: this.$pgettext('Sidebar/*/Listitem.Link', 'Chat room'), + git: this.$pgettext('Sidebar/*/List item.Link', 'Issue tracker'), + login: this.$pgettext('*/*/Button.Label/Verb', 'Log in'), + signup: this.$pgettext('*/*/Button.Label/Verb', 'Sign up'), + notifications: this.$pgettext('*/Notifications/*', 'Notifications'), + useOtherInstance: this.$pgettext( + 'Sidebar/*/List item.Link', + 'Use another instance' + ) + } + }, + themes () { + return [ + { + icon: 'sun icon', + name: this.$pgettext( + 'Footer/Settings/Dropdown.Label/Theme name', + 'Light' + ), + key: 'light' + }, + { + icon: 'moon icon', + name: this.$pgettext( + 'Footer/Settings/Dropdown.Label/Theme name', + 'Dark' + ), + key: 'dark' + } + ] + }, + ...mapGetters({ + additionalNotifications: 'ui/additionalNotifications' + }) + } +} +</script> + +<style> +.action-hint { + margin-left: 1rem !important; +} +</style> diff --git a/front/src/components/semantic/Modal.vue b/front/src/components/semantic/Modal.vue index 223c0e730ae9816d1ee8c34d48707d2f6c1cd99f..a40511f642f06ed71dff77844994b444ad405f5d 100644 --- a/front/src/components/semantic/Modal.vue +++ b/front/src/components/semantic/Modal.vue @@ -1,9 +1,10 @@ <template> <div :class="additionalClasses.concat(['ui', {'active': show}, {'scrolling': scrolling} ,{'overlay fullscreen': fullscreen && ['phone', 'tablet'].indexOf($store.getters['ui/windowSize']) > -1},'modal'])"> - <i tabindex=0 class="close inside icon"></i> - <slot v-if="show"> - - </slot> + <i + tabindex="0" + class="close inside icon" + /> + <slot v-if="show" /> </div> </template> @@ -13,15 +14,41 @@ import createFocusTrap from 'focus-trap' export default { props: { - show: {type: Boolean, required: true}, - fullscreen: {type: Boolean, default: true}, - scrolling: {type: Boolean, required: false, default: false}, - additionalClasses: {type: Array, required: false, default: () => []} + show: { type: Boolean, required: true }, + fullscreen: { type: Boolean, default: true }, + scrolling: { type: Boolean, required: false, default: false }, + additionalClasses: { type: Array, required: false, default: () => [] } }, data () { return { control: null, - focusTrap: null, + focusTrap: null + } + }, + watch: { + show: { + handler (newValue) { + if (newValue) { + this.initModal() + this.$emit('show') + this.control.modal('show') + this.focusTrap.activate() + this.focusTrap.unpause() + document.body.classList.add('scrolling') + } else { + if (this.control) { + this.$emit('hide') + this.control.modal('hide') + this.control.remove() + this.focusTrap.deactivate() + this.focusTrap.pause() + document.body.classList.remove('scrolling') + } + } + } + }, + $route (to, from) { + this.closeModal() } }, mounted () { @@ -52,29 +79,9 @@ export default { this.focusTrap.unpause() }.bind(this) }) - } - }, - watch: { - show: { - handler (newValue) { - if (newValue) { - this.initModal() - this.$emit('show') - this.control.modal('show') - this.focusTrap.activate() - this.focusTrap.unpause() - document.body.classList.add('scrolling') - } else { - if (this.control) { - this.$emit('hide') - this.control.modal('hide') - this.control.remove() - this.focusTrap.deactivate() - this.focusTrap.pause() - document.body.classList.remove('scrolling') - } - } - } + }, + closeModal () { + this.$emit('update:show', false) } } diff --git a/front/src/router/index.js b/front/src/router/index.js index 35ce8be5d73e3c5c7e942ea131c39533bd44efdd..210f0aa43859e13fd56c2fb71eeb6db0df442d81 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -224,7 +224,7 @@ export default new Router({ ) }, { - path: 'activity', + path: '/activity', name: `profile${route.suffix}.activity`, component: () => import( @@ -318,7 +318,7 @@ export default new Router({ import(/* webpackChunkName: "admin" */ '@/views/admin/library/Base'), children: [ { - path: 'edits', + path: '/edits', name: 'manage.library.edits', component: () => import( @@ -331,7 +331,7 @@ export default new Router({ } }, { - path: 'artists', + path: '/artists', name: 'manage.library.artists', component: () => import( @@ -344,7 +344,7 @@ export default new Router({ } }, { - path: 'artists/:id', + path: '/artists/:id', name: 'manage.library.artists.detail', component: () => import( @@ -353,7 +353,7 @@ export default new Router({ props: true }, { - path: 'channels', + path: '/channels', name: 'manage.channels', component: () => import( @@ -366,7 +366,7 @@ export default new Router({ } }, { - path: 'channels/:id', + path: '/channels/:id', name: 'manage.channels.detail', component: () => import( @@ -375,7 +375,7 @@ export default new Router({ props: true }, { - path: 'albums', + path: '/albums', name: 'manage.library.albums', component: () => import( @@ -388,7 +388,7 @@ export default new Router({ } }, { - path: 'albums/:id', + path: '/albums/:id', name: 'manage.library.albums.detail', component: () => import( @@ -397,7 +397,7 @@ export default new Router({ props: true }, { - path: 'tracks', + path: '/tracks', name: 'manage.library.tracks', component: () => import( @@ -410,7 +410,7 @@ export default new Router({ } }, { - path: 'tracks/:id', + path: '/tracks/:id', name: 'manage.library.tracks.detail', component: () => import( @@ -419,7 +419,7 @@ export default new Router({ props: true }, { - path: 'libraries', + path: '/libraries', name: 'manage.library.libraries', component: () => import( @@ -432,7 +432,7 @@ export default new Router({ } }, { - path: 'libraries/:id', + path: '/libraries/:id', name: 'manage.library.libraries.detail', component: () => import( @@ -441,7 +441,7 @@ export default new Router({ props: true }, { - path: 'uploads', + path: '/uploads', name: 'manage.library.uploads', component: () => import( @@ -454,7 +454,7 @@ export default new Router({ } }, { - path: 'uploads/:id', + path: '/uploads/:id', name: 'manage.library.uploads.detail', component: () => import( @@ -463,7 +463,7 @@ export default new Router({ props: true }, { - path: 'tags', + path: '/tags', name: 'manage.library.tags', component: () => import( @@ -476,7 +476,7 @@ export default new Router({ } }, { - path: 'tags/:id', + path: '/tags/:id', name: 'manage.library.tags.detail', component: () => import( @@ -493,7 +493,7 @@ export default new Router({ import(/* webpackChunkName: "admin" */ '@/views/admin/users/Base'), children: [ { - path: 'users', + path: '/users', name: 'manage.users.users.list', component: () => import( @@ -501,7 +501,7 @@ export default new Router({ ) }, { - path: 'invitations', + path: '/invitations', name: 'manage.users.invitations.list', component: () => import( @@ -517,7 +517,7 @@ export default new Router({ import(/* webpackChunkName: "admin" */ '@/views/admin/moderation/Base'), children: [ { - path: 'domains', + path: '/domains', name: 'manage.moderation.domains.list', component: () => import( @@ -525,7 +525,7 @@ export default new Router({ ) }, { - path: 'domains/:id', + path: '/domains/:id', name: 'manage.moderation.domains.detail', component: () => import( @@ -534,7 +534,7 @@ export default new Router({ props: true }, { - path: 'accounts', + path: '/accounts', name: 'manage.moderation.accounts.list', component: () => import( @@ -547,7 +547,7 @@ export default new Router({ } }, { - path: 'accounts/:id', + path: '/accounts/:id', name: 'manage.moderation.accounts.detail', component: () => import( @@ -556,7 +556,7 @@ export default new Router({ props: true }, { - path: 'reports', + path: '/reports', name: 'manage.moderation.reports.list', component: () => import( @@ -570,7 +570,7 @@ export default new Router({ } }, { - path: 'reports/:id', + path: '/reports/:id', name: 'manage.moderation.reports.detail', component: () => import( @@ -579,7 +579,7 @@ export default new Router({ props: true }, { - path: 'requests', + path: '/requests', name: 'manage.moderation.requests.list', component: () => import( @@ -593,7 +593,7 @@ export default new Router({ } }, { - path: 'requests/:id', + path: '/requests/:id', name: 'manage.moderation.requests.detail', component: () => import( @@ -609,13 +609,13 @@ export default new Router({ import(/* webpackChunkName: "core" */ '@/components/library/Library'), children: [ { - path: '', + path: '/', component: () => import(/* webpackChunkName: "core" */ '@/components/library/Home'), name: 'library.index' }, { - path: 'me', + path: '/me', component: () => import(/* webpackChunkName: "core" */ '@/components/library/Home'), name: 'library.me', @@ -624,7 +624,7 @@ export default new Router({ }) }, { - path: 'artists/', + path: '/artists/', name: 'library.artists.browse', component: () => import( @@ -641,7 +641,7 @@ export default new Router({ }) }, { - path: 'me/artists', + path: '/me/artists', name: 'library.artists.me', component: () => import( @@ -659,7 +659,7 @@ export default new Router({ }) }, { - path: 'albums/', + path: '/albums/', name: 'library.albums.browse', component: () => import( @@ -676,7 +676,7 @@ export default new Router({ }) }, { - path: 'podcasts/', + path: '/podcasts/', name: 'library.podcasts.browse', component: () => import( @@ -693,7 +693,7 @@ export default new Router({ }) }, { - path: 'me/albums', + path: '/me/albums', name: 'library.albums.me', component: () => import( @@ -711,7 +711,7 @@ export default new Router({ }) }, { - path: 'radios/', + path: '/radios/', name: 'library.radios.browse', component: () => import( @@ -725,7 +725,7 @@ export default new Router({ }) }, { - path: 'me/radios/', + path: '/me/radios/', name: 'library.radios.me', component: () => import( @@ -740,7 +740,7 @@ export default new Router({ }) }, { - path: 'radios/build', + path: '/radios/build', name: 'library.radios.build', component: () => import( @@ -749,7 +749,7 @@ export default new Router({ props: true }, { - path: 'radios/build/:id', + path: '/radios/build/:id', name: 'library.radios.edit', component: () => import( @@ -758,14 +758,14 @@ export default new Router({ props: true }, { - path: 'radios/:id', + path: '/radios/:id', name: 'library.radios.detail', component: () => import(/* webpackChunkName: "radios" */ '@/views/radios/Detail'), props: true }, { - path: 'playlists/', + path: '/playlists/', name: 'library.playlists.browse', component: () => import(/* webpackChunkName: "playlists" */ '@/views/playlists/List'), @@ -777,7 +777,7 @@ export default new Router({ }) }, { - path: 'me/playlists/', + path: '/me/playlists/', name: 'library.playlists.me', component: () => import(/* webpackChunkName: "playlists" */ '@/views/playlists/List'), @@ -790,7 +790,7 @@ export default new Router({ }) }, { - path: 'playlists/:id', + path: '/playlists/:id', name: 'library.playlists.detail', component: () => import(/* webpackChunkName: "playlists" */ '@/views/playlists/Detail'), @@ -800,7 +800,7 @@ export default new Router({ }) }, { - path: 'tags/:id', + path: '/tags/:id', name: 'library.tags.detail', component: () => import( @@ -809,7 +809,7 @@ export default new Router({ props: true }, { - path: 'artists/:id', + path: '/artists/:id', component: () => import( /* webpackChunkName: "artists" */ '@/components/library/ArtistBase' @@ -817,7 +817,7 @@ export default new Router({ props: true, children: [ { - path: '', + path: '/', name: 'library.artists.detail', component: () => import( @@ -825,7 +825,7 @@ export default new Router({ ) }, { - path: 'edit', + path: '/edit', name: 'library.artists.edit', component: () => import( @@ -833,7 +833,7 @@ export default new Router({ ) }, { - path: 'edit/:editId', + path: '/edit/:editId', name: 'library.artists.edit.detail', component: () => import( @@ -844,7 +844,7 @@ export default new Router({ ] }, { - path: 'albums/:id', + path: '/albums/:id', component: () => import( /* webpackChunkName: "albums" */ '@/components/library/AlbumBase' @@ -852,7 +852,7 @@ export default new Router({ props: true, children: [ { - path: '', + path: '/', name: 'library.albums.detail', component: () => import( @@ -860,7 +860,7 @@ export default new Router({ ) }, { - path: 'edit', + path: '/edit', name: 'library.albums.edit', component: () => import( @@ -868,7 +868,7 @@ export default new Router({ ) }, { - path: 'edit/:editId', + path: '/edit/:editId', name: 'library.albums.edit.detail', component: () => import( @@ -879,7 +879,7 @@ export default new Router({ ] }, { - path: 'tracks/:id', + path: '/tracks/:id', component: () => import( /* webpackChunkName: "tracks" */ '@/components/library/TrackBase' @@ -887,7 +887,7 @@ export default new Router({ props: true, children: [ { - path: '', + path: '/', name: 'library.tracks.detail', component: () => import( @@ -895,7 +895,7 @@ export default new Router({ ) }, { - path: 'edit', + path: '/edit', name: 'library.tracks.edit', component: () => import( @@ -903,7 +903,7 @@ export default new Router({ ) }, { - path: 'edit/:editId', + path: '/edit/:editId', name: 'library.tracks.edit.detail', component: () => import( @@ -914,7 +914,7 @@ export default new Router({ ] }, { - path: 'uploads/:id', + path: '/uploads/:id', name: 'library.uploads.detail', props: true, component: () => @@ -924,7 +924,7 @@ export default new Router({ }, { // browse a single library via it's uuid - path: ':id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})', + path: '/:id([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})', props: true, component: () => import( @@ -932,7 +932,7 @@ export default new Router({ ), children: [ { - path: '', + path: '/', name: 'library.detail', component: () => import( @@ -940,7 +940,7 @@ export default new Router({ ) }, { - path: 'albums', + path: '/albums', name: 'library.detail.albums', component: () => import( @@ -948,7 +948,7 @@ export default new Router({ ) }, { - path: 'tracks', + path: '/tracks', name: 'library.detail.tracks', component: () => import( @@ -956,7 +956,7 @@ export default new Router({ ) }, { - path: 'edit', + path: '/edit', name: 'library.detail.edit', component: () => import( @@ -964,7 +964,7 @@ export default new Router({ ) }, { - path: 'upload', + path: '/upload', name: 'library.detail.upload', component: () => import( @@ -995,7 +995,7 @@ export default new Router({ ), children: [ { - path: '', + path: '/', name: 'channels.detail', component: () => import( @@ -1003,7 +1003,7 @@ export default new Router({ ) }, { - path: 'episodes', + path: '/episodes', name: 'channels.detail.episodes', component: () => import( diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index b867d27413df69ee0c081f1f761e832ce61ba395..217ee30cee90fb7ba87982adfd0796498b8e3011 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -48,6 +48,7 @@ $bottom-player-height: 4rem; @import "./components/_track_widget.scss"; @import "./components/_track_table.scss"; @import "./components/_user_link.scss"; +@import "./components/user_modal.scss"; @import "./components/_volume_control.scss"; @import "./components/_loaders.scss"; diff --git a/front/src/style/components/_sidebar.scss b/front/src/style/components/_sidebar.scss index 99867533ea8f54338484596a503488865bfca010..ada61d833e6b647f4711a1e6c6f512912317f03e 100644 --- a/front/src/style/components/_sidebar.scss +++ b/front/src/style/components/_sidebar.scss @@ -214,6 +214,10 @@ } } } + .ui.user-dropdown .ui.menu { + left: auto; + right: 0; + } .ui.user-dropdown>.text>.label { margin-right: 0; } @@ -234,4 +238,4 @@ } } } -} \ No newline at end of file +} diff --git a/front/src/style/components/_user_modal.scss b/front/src/style/components/_user_modal.scss new file mode 100644 index 0000000000000000000000000000000000000000..9fc87aa0ea49cc90c1b7eb1602e094424aa391ce --- /dev/null +++ b/front/src/style/components/_user_modal.scss @@ -0,0 +1,31 @@ +.ui.overlay.fullscreen.modal { + .user-modal-title, + .user-modal-subtitle { + margin: 0.1em; + } + .user-modal-subtitle { + font-weight: normal; + } + .user-modal.list-icon { + margin-right: 1em; + } + .user-modal.list-item { + font-weight: bold; + font-size: large; + } + a { + color: var(--text-color); + text-decoration: none ; + } +} + +.scrolling.dimmable.dimmed { + > .dimmer { + overflow: auto; + --webkit-overflow-scrolling: touch; + } + ::-webkit-scrollbar { + width: 0px; + background: transparent; + } +} \ No newline at end of file