Sidebar.vue 12.9 KB
Newer Older
1
<template>
2
<div :class="['ui', 'vertical', 'left', 'visible', 'wide', {'collapsed': isCollapsed}, 'sidebar',]">
3
  <div class="ui inverted segment header-wrapper">
4
    <search-bar @search="isCollapsed = false">
5 6 7 8
      <router-link :title="'Funkwhale'" :to="{name: 'index'}">
        <i class="logo bordered inverted orange big icon">
          <logo class="logo"></logo>
        </i>
9
      </router-link><span
10 11 12 13
        slot="after"
        @click="isCollapsed = !isCollapsed"
        :class="['ui', 'basic', 'big', {'inverted': isCollapsed}, 'orange', 'icon', 'collapse', 'button']">
          <i class="sidebar icon"></i></span>
14 15 16 17 18
    </search-bar>
  </div>

  <div class="menu-area">
    <div class="ui compact fluid two item inverted menu">
19 20
      <a class="active item" @click="selectedTab = 'library'" data-tab="library">Browse</a>
      <a class="item" @click="selectedTab = 'queue'" data-tab="queue">
Bat's avatar
Bat committed
21
        {{ $t('Queue') }}
22
         <template v-if="queue.tracks.length === 0">
Bat's avatar
Bat committed
23
           {{ $t('(empty)') }}
24 25
         </template>
         <template v-else>
Bat's avatar
Bat committed
26
           {{ $t('({%index%} of {%length%})', { index: queue.currentIndex + 1, length: queue.tracks.length }) }}
27 28 29 30 31
         </template>
      </a>
    </div>
  </div>
  <div class="tabs">
32
    <div class="ui bottom attached active tab" data-tab="library">
33
      <div class="ui inverted vertical large fluid menu">
Agate's avatar
Agate committed
34 35 36 37
        <div class="item">
          <div class="header">{{ $t('My account') }}</div>
          <div class="menu">
            <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
Agate's avatar
Agate committed
38 39
            <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i>{{ $t('Logout') }}</router-link>
            <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i>{{ $t('Login') }}</router-link>
Agate's avatar
Agate committed
40 41 42 43 44 45
          </div>
        </div>
        <div class="item">
          <div class="header">{{ $t('Music') }}</div>
          <div class="menu">
            <router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
Agate's avatar
Agate committed
46
            <router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i>{{ $t('Favorites') }}</router-link>
Agate's avatar
Agate committed
47 48 49 50
            <a
              @click="$store.commit('playlists/chooseTrack', null)"
              v-if="$store.state.auth.authenticated"
              class="item">
Agate's avatar
Agate committed
51
              <i class="list icon"></i>{{ $t('Playlists') }}
Agate's avatar
Agate committed
52 53 54
            </a>
            <router-link
              v-if="$store.state.auth.authenticated"
Agate's avatar
Agate committed
55
              class="item" :to="{path: '/activity'}"><i class="bell icon"></i>{{ $t('Activity') }}</router-link>
Agate's avatar
Agate committed
56 57
          </div>
        </div>
58
        <div class="item" v-if="showAdmin">
Agate's avatar
Agate committed
59 60 61 62
          <div class="header">{{ $t('Administration') }}</div>
          <div class="menu">
            <router-link
              class="item"
63
              v-if="$store.state.auth.availablePermissions['library']"
64
              :to="{name: 'library.requests', query: {status: 'pending' }}">
Agate's avatar
Agate committed
65
              <i class="download icon"></i>{{ $t('Import requests') }}
66 67 68 69 70
              <div
                :class="['ui', {'teal': notifications.importRequests > 0}, 'label']"
                :title="$t('Pending import requests')">
                {{ notifications.importRequests }}</div>
            </router-link>
71 72 73 74 75 76
            <router-link
              class="item"
              v-if="$store.state.auth.availablePermissions['library']"
              :to="{name: 'manage.library.files'}">
              <i class="book icon"></i>{{ $t('Library') }}
            </router-link>
77 78 79 80 81 82
            <router-link
              class="item"
              v-else-if="$store.state.auth.availablePermissions['upload']"
              to="/library/import/launch">
              <i class="download icon"></i>{{ $t('Import music') }}
            </router-link>
83 84
            <router-link
              class="item"
85
              v-if="$store.state.auth.availablePermissions['federation']"
86
              :to="{path: '/manage/federation/libraries'}">
Agate's avatar
Agate committed
87
              <i class="sitemap icon"></i>{{ $t('Federation') }}
88 89 90 91 92
              <div
                :class="['ui', {'teal': notifications.federation > 0}, 'label']"
                :title="$t('Pending follow requests')">
                {{ notifications.federation }}</div>
            </router-link>
93 94
            <router-link
              class="item"
95
              v-if="$store.state.auth.availablePermissions['settings']"
96 97 98
              :to="{path: '/manage/settings'}">
              <i class="settings icon"></i>{{ $t('Settings') }}
            </router-link>
Agate's avatar
Agate committed
99 100
          </div>
        </div>
101 102 103 104 105 106
      </div>
    </div>
    <div v-if="queue.previousQueue " class="ui black icon message">
      <i class="history icon"></i>
      <div class="content">
        <div class="header">
Bat's avatar
Bat committed
107
          {{ $t('Do you want to restore your previous queue?') }}
108
        </div>
Bat's avatar
Bat committed
109
        <p>{{ $t('{%count%} tracks', { count: queue.previousQueue.tracks.length }) }}</p>
110
        <div class="ui two buttons">
Bat's avatar
Bat committed
111 112
          <div @click="queue.restore()" class="ui basic inverted green button">{{ $t('Yes') }}</div>
          <div @click="queue.removePrevious()" class="ui basic inverted red button">{{ $t('No') }}</div>
113 114 115 116
        </div>
      </div>
    </div>
    <div class="ui bottom attached tab" data-tab="queue">
117
      <table class="ui compact inverted very basic fixed single line unstackable table">
118
        <draggable v-model="queue.tracks" element="tbody" @update="reorder">
119
          <tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
120 121 122 123 124 125 126 127 128 129
              <td class="right aligned">{{ index + 1}}</td>
              <td class="center aligned">
                  <img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
                  <img class="ui mini image" v-else src="../assets/audio/default-cover.png">
              </td>
              <td colspan="4">
                  <strong>{{ track.title }}</strong><br />
                  {{ track.artist.name }}
              </td>
              <td>
130
                <template v-if="$store.getters['favorites/isFavorite'](track.id)">
131
                  <i class="pink heart icon"></i>
132
                </template>
133 134
              </td>
              <td>
135
                  <i @click.stop="cleanTrack(index)" class="circular trash icon"></i>
136 137 138
              </td>
            </tr>
          </draggable>
139
      </table>
140
      <div v-if="$store.state.radios.running" class="ui black message">
141 142
        <div class="content">
          <div class="header">
Bat's avatar
Bat committed
143
            <i class="feed icon"></i> {{ $t('You have a radio playing') }}
144
          </div>
Bat's avatar
Bat committed
145 146
          <p>{{ $t('New tracks will be appended here automatically.') }}</p>
          <div @click="$store.dispatch('radios/stop')" class="ui basic inverted red button">{{ $t('Stop radio') }}</div>
147 148 149 150
        </div>
      </div>
    </div>
  </div>
Agate's avatar
Agate committed
151
  <player @next="scrollToCurrent" @previous="scrollToCurrent"></player>
152 153 154 155
</div>
</template>

<script>
156
import {mapState, mapActions} from 'vuex'
157
import axios from 'axios'
158

159 160 161 162
import Player from '@/components/audio/Player'
import Logo from '@/components/Logo'
import SearchBar from '@/components/audio/SearchBar'
import backend from '@/audio/backend'
163
import draggable from 'vuedraggable'
164 165 166 167 168 169 170 171

import $ from 'jquery'

export default {
  name: 'sidebar',
  components: {
    Player,
    SearchBar,
172
    Logo,
173
    draggable
174 175 176
  },
  data () {
    return {
177
      selectedTab: 'library',
178
      backend: backend,
179 180 181 182 183 184
      isCollapsed: true,
      fetchInterval: null,
      notifications: {
        federation: 0,
        importRequests: 0
      }
185 186 187 188
    }
  },
  mounted () {
    $(this.$el).find('.menu .item').tab()
189
  },
190 191 192 193 194 195 196 197 198 199
  created () {
    this.fetchNotificationsCount()
    this.fetchInterval = setInterval(
        this.fetchNotificationsCount, 1000 * 60 * 15)
  },
  destroy () {
    if (this.fetchInterval) {
      clearInterval(this.fetchInterval)
    }
  },
200 201
  computed: {
    ...mapState({
202 203
      queue: state => state.queue,
      url: state => state.route.path
204 205 206
    }),
    showAdmin () {
      let adminPermissions = [
207
        this.$store.state.auth.availablePermissions['federation'],
208 209
        this.$store.state.auth.availablePermissions['library'],
        this.$store.state.auth.availablePermissions['upload']
210 211 212 213 214
      ]
      return adminPermissions.filter(e => {
        return e
      }).length > 0
    }
215
  },
216
  methods: {
217 218 219
    ...mapActions({
      cleanTrack: 'queue/cleanTrack'
    }),
220 221 222 223 224
    fetchNotificationsCount () {
      this.fetchFederationNotificationsCount()
      this.fetchFederationImportRequestsCount()
    },
    fetchFederationNotificationsCount () {
225
      if (!this.$store.state.auth.availablePermissions['federation']) {
226 227 228 229 230 231 232 233
        return
      }
      let self = this
      axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
        self.notifications.federation = response.data.count
      })
    },
    fetchFederationImportRequestsCount () {
234
      if (!this.$store.state.auth.availablePermissions['library']) {
235 236 237 238 239 240 241
        return
      }
      let self = this
      axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
        self.notifications.importRequests = response.data.count
      })
    },
242 243 244
    reorder: function (event) {
      this.$store.commit('queue/reorder', {
        oldIndex: event.oldIndex, newIndex: event.newIndex})
245 246 247 248 249 250 251 252 253 254 255 256 257 258
    },
    scrollToCurrent () {
      let current = $(this.$el).find('[data-tab="queue"] .active')[0]
      if (!current) {
        return
      }
      let container = $(this.$el).find('.tabs')[0]
      // Position container at the top line then scroll current into view
      container.scrollTop = 0
      current.scrollIntoView(true)
      // Scroll back nothing if element is at bottom of container else do it
      // for half the height of the containers display area
      var scrollBack = (container.scrollHeight - container.scrollTop <= container.clientHeight) ? 0 : container.clientHeight / 2
      container.scrollTop = container.scrollTop - scrollBack
259
    }
260 261 262 263
  },
  watch: {
    url: function () {
      this.isCollapsed = true
264 265 266 267 268 269 270 271 272 273
    },
    selectedTab: function (newValue) {
      if (newValue === 'queue') {
        this.scrollToCurrent()
      }
    },
    '$store.state.queue.currentIndex': function () {
      if (this.selectedTab !== 'queue') {
        this.scrollToCurrent()
      }
274
    },
275
    '$store.state.auth.availablePermissions': {
276 277 278 279
      handler () {
        this.fetchNotificationsCount()
      },
      deep: true
280
    }
281 282 283 284 285 286
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
287
@import '../style/vendor/media';
288

289
$sidebar-color: #3d3e3f;
290 291

.sidebar {
292
	background: $sidebar-color;
293 294 295 296 297 298 299
  @include media(">tablet") {
    display:flex;
    flex-direction:column;
    justify-content: space-between;
  }
  @include media(">desktop") {
    .collapse.button {
300
      display: none !important;
301 302 303 304 305 306 307 308 309 310 311
    }
  }
  @include media("<desktop") {
    position: static !important;
    width: 100% !important;
    &.collapsed {
      .menu-area, .player-wrapper, .tabs {
        display: none;
      }
    }
  }
312 313 314 315 316

  > div {
    margin: 0;
    background-color: $sidebar-color;
  }
317 318
  .menu.vertical {
    background: $sidebar-color;
319 320 321 322 323
  }
}

.menu-area {
  .menu .item:not(.active):not(:hover) {
Bat's avatar
Bat committed
324
    opacity: 0.75;
325 326
  }

327 328 329 330 331 332 333
  .menu .item {
    border-radius: 0;
  }

  .menu .item.active {
    background-color: $sidebar-color;
    &:hover {
334
      background-color: rgba(255, 255, 255, 0.06);
335 336
    }
  }
337
}
Agate's avatar
Agate committed
338 339 340
.vertical.menu {
  .item .item {
    font-size: 1em;
341 342 343 344 345 346 347
    > i.icon {
      float: none;
      margin: 0 0.5em 0 0;
    }
    &:not(.active) {
      color: rgba(255, 255, 255, 0.75);
    }
Agate's avatar
Agate committed
348 349
  }
}
350
.tabs {
351 352 353
  flex: 1;
  display: flex;
  flex-direction: column;
354
  overflow-y: auto;
355
  justify-content: space-between;
356
  @include media("<desktop") {
357
    max-height: 500px;
358
  }
359
}
360 361 362
.ui.tab.active {
  display: flex;
}
363
.tab[data-tab="queue"] {
364
  flex-direction: column;
365 366 367 368
  tr {
    cursor: pointer;
  }
}
369 370 371 372 373 374 375 376 377 378 379
.tab[data-tab="library"] {
  flex-direction: column;
  flex: 1 1 auto;
  > .menu {
    flex: 1;
    flex-grow: 1;
  }
  > .player-wrapper {
    width: 100%;
  }
}
380 381 382 383 384 385 386 387 388 389 390 391
.sidebar .segment {
  margin: 0;
  border-radius: 0;
}

.ui.inverted.segment.header-wrapper {
  padding: 0;
}

.logo {
  cursor: pointer;
  display: inline-block;
Bat's avatar
Bat committed
392
  margin: 0px;
393 394 395
}

.ui.search {
Bat's avatar
Bat committed
396
  display: flex;
397 398 399 400 401 402 403

  .collapse.button, .collapse.button:hover, .collapse.button:active {
    box-shadow: none !important;
    margin: 0px;
    display: flex;
    flex-direction: column;
    justify-content: center;
404
  }
405
}
Bat's avatar
Bat committed
406

Bat's avatar
Bat committed
407 408 409
.ui.message.black {
  background: $sidebar-color;
}
Bat's avatar
Bat committed
410 411 412 413 414 415 416 417 418 419 420
</style>

<style lang="scss">
.sidebar {
  .ui.search .input {
    flex: 1;
    .prompt {
      border-radius: 0;
    }
  }
}
421
</style>