Commit 9e447ab5 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

See #170: UI for albums / series

parent 2f0c01df
......@@ -210,6 +210,7 @@ def serialize_album_track(track):
class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer):
# XXX: remove in 1.0, it's expensive and can work with a filter/api call
tracks = serializers.SerializerMethodField()
artist = serializers.SerializerMethodField()
cover = cover_field
......
<template>
<div class="album-entries">
<div :class="[{active: currentTrack && isPlaying && track.id === currentTrack.id}, 'album-entry']" v-for="track in tracks" :key="track.id">
<div class="actions">
<play-button class="basic circular icon" :button-classes="['circular inverted orange icon button']" :discrete="true" :icon-only="true" :track="track"></play-button>
</div>
<div class="position">{{ prettyPosition(track.position) }}</div>
<div class="content ellipsis">
<router-link :to="{name: 'library.tracks.detail', params: {id: track.id}}" class="discrete link">
<strong>{{ track.title }}</strong><br>
</router-link>
</div>
<div class="meta">
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](track.id)">
<track-favorite-icon class="tiny" :track="track"></track-favorite-icon>
</template>
<human-duration v-if="track.uploads[0] && track.uploads[0].duration" :duration="track.uploads[0].duration"></human-duration>
</div>
</div>
</div>
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
import PlayButton from '@/components/audio/PlayButton'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import { mapGetters } from "vuex"
export default {
props: {
tracks: Array,
},
components: {
PlayButton,
TrackFavoriteIcon
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
}),
isPlaying () {
return this.$store.state.player.playing
},
},
methods: {
prettyPosition (position, size) {
var s = String(position);
while (s.length < (size || 2)) {s = "0" + s;}
return s;
}
}
}
</script>
<template>
<router-link class="artist-label ui image label" :to="route">
<img :class="[{circular: artist.content_category != 'podcast'}]" v-if="artist.cover && artist.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.small_square_crop)" />
<i :class="[artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']" v-else />
{{ artist.name }}
</router-link>
</template>
<script>
import {momentFormat} from '@/filters'
export default {
props: {
artist: Object,
},
computed: {
route () {
if (this.artist.channel) {
return {name: 'channels.detail', params: {id: this.artist.channel.uuid}}
}
return {name: 'library.artists.detail', params: {id: this.artist.id}}
}
}
}
</script>
......@@ -15,7 +15,7 @@
<template v-if="!isLoading && objects.length === 0">
<div class="ui placeholder segment">
<div class="ui icon header">
<i class="compact disc icon"></i>
<i class="music icon"></i>
No results matching your query
</div>
</div>
......@@ -31,7 +31,7 @@ import ChannelEntryCard from '@/components/audio/ChannelEntryCard'
export default {
props: {
filters: {type: Object, required: true},
limit: {type: Number, default: 5},
limit: {type: Number, default: 10},
},
components: {
ChannelEntryCard
......
<template>
<div class="channel-entry-card">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png">
<div :class="[{active: currentTrack && isPlaying && entry.id === currentTrack.id}, 'channel-entry-card']">
<div class="controls">
<play-button class="basic circular icon" :discrete="true" :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'inverted orange', 'icon', 'button']" :track="entry"></play-button>
</div>
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
class="channel-image image"
v-if="cover && cover.original"
v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<span
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
class="channel-image image"
v-else-if="entry.artist.content_category === 'podcast'">#{{ entry.position }}</span>
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
class="channel-image image"
v-else-if="entry.album && entry.album.cover && entry.album.cover.original"
v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.square_crop)">
<img
@click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})"
class="channel-image image"
v-else
src="../../assets/audio/default-cover.png">
<div class="ellipsis content">
<strong>
<router-link class="discrete link" :title="entry.title" :to="{name: 'library.tracks.detail', params: {id: entry.id}}">
{{ entry.title }}
</router-link>
</strong>
<div class="description">
<human-date :date="entry.creation_date"></human-date><template v-if="duration"> ·
<human-duration :duration="duration"></human-duration></template>
</div>
<br>
<human-date class="really discrete" :date="entry.creation_date"></human-date>
</div>
<div class="controls">
<play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :track="entry"></play-button>
<div class="meta">
<template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)">
<track-favorite-icon class="tiny" :track="entry"></track-favorite-icon>
</template>
<human-duration v-if="duration" :duration="duration"></human-duration>
</div>
</div>
</template>
<script>
import PlayButton from '@/components/audio/PlayButton'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import { mapGetters } from "vuex"
export default {
props: ['entry'],
components: {
PlayButton,
TrackFavoriteIcon,
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
}),
isPlaying () {
return this.$store.state.player.playing
},
imageUrl () {
let url = '../../assets/audio/default-cover.png'
let cover = this.cover
......@@ -42,9 +76,6 @@ export default {
if (this.entry.cover) {
return this.entry.cover
}
if (this.entry.album && this.entry.album.cover) {
return this.entry.album.cover
}
},
duration () {
let uploads = this.entry.uploads.filter((e) => {
......@@ -60,7 +91,4 @@ export default {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.default-cover {
background-image: url("../../assets/audio/default-cover.png") !important;
}
</style>
<template>
<div class="channel-serie-card">
<div class="two-images">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)">
<img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png">
</div>
<div class="content">
<strong>
......
......@@ -5,7 +5,12 @@
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
<template v-if="isPodcast">
<channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" />
</template>
<div v-else class="ui app-cards cards">
<album-card v-for="album in objects" :album="album" :key="album.id" />
</div>
<template v-if="nextPage">
<div class="ui hidden divider"></div>
<button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']">
......@@ -27,14 +32,18 @@
import _ from '@/lodash'
import axios from 'axios'
import ChannelSerieCard from '@/components/audio/ChannelSerieCard'
import AlbumCard from '@/components/audio/album/Card'
export default {
props: {
filters: {type: Object, required: true},
isPodcast: {type: Boolean, default: true},
limit: {type: Number, default: 5},
},
components: {
ChannelSerieCard
ChannelSerieCard,
AlbumCard,
},
data () {
return {
......
......@@ -26,7 +26,7 @@
</router-link>
</td>
<td colspan="4" v-if="track.uploads && track.uploads.length > 0">
{{ time.parse(track.uploads[0].duration) }}
<human-duration :duration="track.uploads[0].duration"></human-duration>
</td>
<td colspan="4" v-else>
<translate translate-context="*/*/*">N/A</translate>
......@@ -49,7 +49,6 @@
<script>
import { mapGetters } from "vuex"
import time from '@/utils/time'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
import PlayButton from '@/components/audio/PlayButton'
......@@ -67,11 +66,6 @@ export default {
TrackPlaylistIcon,
PlayButton
},
data () {
return {
time
}
},
computed: {
...mapGetters({
currentTrack: "queue/currentTrack",
......
<template>
<time :datetime="`${duration}s`">
<template v-if="durationObj.hours">{{ durationObj.hours|padDuration }}:</template>{{ durationObj.minutes|padDuration }}:{{ durationObj.seconds|padDuration }}
{{ duration | duration}}
</time>
</template>
<script>
import {secondsToObject} from '@/filters'
export default {
props: {
duration: {required: true},
},
computed: {
durationObj () {
return secondsToObject(this.duration)
}
}
}
</script>
......@@ -4,131 +4,127 @@
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<section :class="['ui', 'head', {'with-background': object.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.title">
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted sound yellow icon"></i>
<div class="content">
{{ object.title }}
<div v-html="subtitle"></div>
</div>
</h2>
<tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list>
<div class="ui hidden divider"></div>
<div class="header-buttons">
<div class="ui buttons">
<play-button class="orange" :tracks="object.tracks">
<translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate>
</play-button>
</div>
<modal v-if="publicLibraries.length > 0" :show.sync="showEmbedModal">
<div class="header">
<translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate>
</div>
<div class="content">
<div class="description">
<embed-wizard type="album" :id="object.id" />
<section class="ui vertical stripe segment channel-serie">
<div class="ui stackable grid container">
<div class="ui seven wide column">
<div v-if="isSerie" class="padded basic segment">
<div class="ui two column grid" v-if="isSerie">
<div class="column">
<div class="large two-images">
<img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)">
<img class="channel-image" v-else src="../../assets/audio/default-cover.png">
<img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)">
<img class="channel-image" v-else src="../../assets/audio/default-cover.png">
</div>
</div>
</div>
<div class="actions">
<div class="ui basic deny button">
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
<div class="ui column right aligned">
<tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list>
<div class="ui small hidden divider"></div>
<human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration>
<template v-if="totalTracks > 0">
<div class="ui hidden very small divider"></div>
<translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="totalTracks"
:translate-params="{count: totalTracks}">
%{ count } episode
</translate>
<translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate>
</template>
<div class="ui small hidden divider"></div>
<play-button class="orange" :tracks="object.tracks"></play-button>
<div class="ui hidden horizontal divider"></div>
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
:is-loading="isLoading"
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist="artist"></album-dropdown>
</div>
</div>
</modal>
<div class="ui buttons">
<button class="ui button" @click="$refs.dropdown.click()">
<translate translate-context="*/*/Button.Label/Noun">More…</translate>
</button>
<div class="ui floating dropdown icon button" ref="dropdown" v-dropdown>
<i class="dropdown icon"></i>
<div class="menu">
<div
role="button"
v-if="publicLibraries.length > 0"
@click="showEmbedModal = !showEmbedModal"
class="basic item">
<i class="code icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Embed</translate>
</div>
<a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="wikipedia w icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate>
</a>
<a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate>
</a>
<a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item">
<i class="external icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate>
</a>
<router-link
v-if="object.is_local"
:to="{name: 'library.albums.edit', params: {id: object.id }}"
class="basic item">
<i class="edit icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Edit</translate>
</router-link>
<dangerous-button
:class="['ui', {loading: isLoading}, 'item']"
v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername"
@confirm="remove()">
<i class="ui trash icon"></i>
<translate translate-context="*/*/*/Verb">Delete…</translate>
<p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p>
<div slot="modal-content">
<p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p>
</div>
<p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p>
</dangerous-button>
<div class="divider"></div>
<div
role="button"
class="basic item"
v-for="obj in getReportableObjs({album: object})"
:key="obj.target.type + obj.target.id"
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)">
<i class="share icon" /> {{ obj.label }}
</div>
<div class="divider"></div>
<router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
</router-link>
<a
v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
class="basic item"
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
target="_blank" rel="noopener noreferrer">
<i class="wrench icon"></i>
<translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate>&nbsp;
</a>
</div>
<div class="ui small hidden divider"></div>
<header>
<h2 class="ui header" :title="object.title">
{{ object.title }}
</h2>
<artist-label :artist="artist"></artist-label>
</header>
</div>
<div v-else class="ui center aligned text padded basic segment">
<img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)">
<img class="channel-image" v-else src="../../assets/audio/default-cover.png">
<div class="ui hidden divider"></div>
<header>
<h2 class="ui header" :title="object.title">
{{ object.title }}
</h2>
<artist-label class="rounded" :artist="artist"></artist-label>
</header>
<div class="ui small hidden divider"></div>
<template v-if="totalTracks > 0">
<div class="ui hidden very small divider"></div>
<translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph"
translate-plural="%{ count } episodes"
:translate-n="totalTracks"
:translate-params="{count: totalTracks}">
%{ count } episode
</translate>
<translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate> ·
</template>
<human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration>
<div class="ui small hidden divider"></div>
<play-button class="orange" :tracks="object.tracks"></play-button>
<div class="ui horizontal hidden divider"></div>
<album-dropdown
:object="object"
:public-libraries="publicLibraries"
:is-loading="isLoading"
:is-album="isAlbum"
:is-serie="isSerie"
:is-channel="isChannel"
:artist="artist"></album-dropdown>
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
<div class="ui small hidden divider"></div>
<div class="ui divider"></div>
<div class="ui small hidden divider"></div>
<template v-if="object.tags && object.tags.length > 0" >
<tags-list :tags="object.tags"></tags-list>
<div class="ui small hidden divider"></div>
</template>
<rendered-description
v-if="object.description"
:content="object.description"
:can-update="false"></rendered-description>
<router-link v-else-if="$store.state.auth.authenticated && object.is_local" :to="{name: 'library.albums.edit', params: {id: object.id }}">
<i class="pencil icon"></i>
<translate translate-context="Content/*/Button.Label/Verb">Add a description…</translate>
</router-link>
</div>
</div>
</div>
<rendered-description
v-if="isSerie"
:content="object.description"
:can-update="false"></rendered-description>
<div class="nine wide column">
<router-view v-if="object" :is-serie="isSerie" :artist="artist" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view>
</div>
</div>
</section>
<router-view v-if="object" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view>
</template>
</main>
</template>
<script>
import axios from "axios"
import logger from "@/logging"
import lodash from "@/lodash"
import backend from "@/audio/backend"
import PlayButton from "@/components/audio/PlayButton"
import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal'
import TagsList from "@/components/tags/List"
import ReportMixin from '@/components/mixins/Report'
const FETCH_URL = "albums/"
import ArtistLabel from '@/components/audio/ArtistLabel'
import AlbumDropdown from './AlbumDropdown'
function groupByDisc(acc, track) {
......@@ -143,13 +139,12 @@ function groupByDisc(acc, track) {
}
export default {
mixins: [ReportMixin],
props: ["id"],
components: {
PlayButton,
EmbedWizard,
Modal,
TagsList,
ArtistLabel,
AlbumDropdown,
},
data() {
return {
......@@ -158,26 +153,24 @@ export default {
artist: null,
discs: [],
libraries: [],
showEmbedModal: false
}
},
created() {
this.fetchData()
async created() {
await this.fetchData()
},
methods: {
fetchData() {
var self = this
async fetchData() {
this.isLoading = true
let url = FETCH_URL + this.id + "/"
logger.default.debug('Fetching album "' + this.id + '"')
axios.get(url, {params: {refresh: 'true'}}).then(response => {
self.object = backend.Album.clean(response.data)
self.discs = self.object.tracks.reduce(groupByDisc, [])
axios.get(`artists/${response.data.artist.id}/`).then(response => {
self.artist = response.data
})
self.isLoading = false
})
let albumResponse = await axios.get(`albums/${this.id}/`, {params: {refresh: 'true'}})
let artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`)
this.artist = artistResponse.data
if (this.artist.channel) {
this.artist.channel.artist = this.artist
}