Profile menu redesign

This commit is contained in:
Ciarán Ainsworth 2021-11-26 11:01:58 +00:00
parent 274bdd1d3e
commit 58df0d4529
13 changed files with 2173 additions and 909 deletions

View File

@ -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

View File

@ -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>

View File

@ -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 }}
</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 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>
</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>
</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>
<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>
<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>
<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>
</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 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
},
data() {
props: {
width: { type: Number, required: true }
},
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>

View File

@ -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>

View File

@ -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>

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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(

View File

@ -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";

View File

@ -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 @@
}
}
}
}
}

View File

@ -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;
}
}