mirror of
https://code.eliotberriot.com/funkwhale/funkwhale.git
synced 2025-10-06 08:19:55 +02:00
[EPIC] Audio metadata update - UI / API
This commit is contained in:
parent
1a1c62ab37
commit
e0c5ffcb16
59 changed files with 2793 additions and 436 deletions
209
front/src/components/library/EditCard.vue
Normal file
209
front/src/components/library/EditCard.vue
Normal file
|
@ -0,0 +1,209 @@
|
|||
<template>
|
||||
<div class="ui fluid card">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
<router-link :to="detailUrl">
|
||||
<translate :translate-context="'Content/Library/Card/Short'" :translate-params="{id: obj.uuid.substring(0, 8)}">Modification %{ id }</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<router-link
|
||||
v-if="obj.target && obj.target.type === 'track'"
|
||||
:to="{name: 'library.tracks.detail', params: {id: obj.target.id }}">
|
||||
<i class="music icon"></i>
|
||||
<translate :translate-context="'Content/Library/Card/Short'" :translate-params="{id: obj.target.id, name: obj.target.repr}">Track #%{ id } - %{ name }</translate>
|
||||
</router-link>
|
||||
<br>
|
||||
<human-date :date="obj.creation_date" :icon="true"></human-date>
|
||||
|
||||
<span class="right floated">
|
||||
<span v-if="obj.is_approved && obj.is_applied">
|
||||
<i class="green check icon"></i>
|
||||
<translate :translate-context="'Content/Library/Card/Short'">Approved and applied</translate>
|
||||
</span>
|
||||
<span v-else-if="obj.is_approved">
|
||||
<i class="green check icon"></i>
|
||||
<translate :translate-context="'Content/Library/Card/Short'">Approved</translate>
|
||||
</span>
|
||||
<span v-else-if="obj.is_approved === null">
|
||||
<i class="yellow hourglass icon"></i>
|
||||
<translate :translate-context="'Content/Library/Card/Short'">Pending review</translate>
|
||||
</span>
|
||||
<span v-else-if="obj.is_approved === false">
|
||||
<i class="red x icon"></i>
|
||||
<translate :translate-context="'Content/Library/Card/Short'">Rejected</translate>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="obj.summary" class="content">
|
||||
{{ obj.summary }}
|
||||
</div>
|
||||
<div class="content">
|
||||
<table v-if="obj.type === 'update'" class="ui celled very basic fixed stacking table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">Field</translate></th>
|
||||
<th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">Old value</translate></th>
|
||||
<th><translate :translate-context="'Content/Library/Card.Table.Header/Short'">New value</translate></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in getUpdatedFields(obj.payload, previousState)" :key="field.id">
|
||||
<td>{{ field.id }}</td>
|
||||
|
||||
<td v-if="field.diff">
|
||||
<span v-if="!part.added" v-for="part in field.diff" :class="['diff', {removed: part.removed}]">
|
||||
{{ part.value }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
|
||||
<td v-if="field.diff">
|
||||
<span v-if="!part.removed" v-for="part in field.diff" :class="['diff', {added: part.added}]">
|
||||
{{ part.value }}
|
||||
</span>
|
||||
</td>
|
||||
<td v-else>{{ field.new }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="obj.created_by" class="extra content">
|
||||
<actor-link :actor="obj.created_by" />
|
||||
</div>
|
||||
<div v-if="canDelete || canApprove" class="ui bottom attached buttons">
|
||||
<button
|
||||
v-if="canApprove && obj.is_approved !== true"
|
||||
@click="approve(true)"
|
||||
:class="['ui', {loading: isLoading}, 'green', 'basic', 'button']">
|
||||
<translate :translate-context="'Content/Library/Button.Label'">Approve</translate>
|
||||
</button>
|
||||
<button
|
||||
v-if="canApprove && obj.is_approved === null"
|
||||
@click="approve(false)"
|
||||
:class="['ui', {loading: isLoading}, 'yellow', 'basic', 'button']">
|
||||
<translate :translate-context="'Content/Library/Button.Label'">Reject</translate>
|
||||
</button>
|
||||
<dangerous-button
|
||||
v-if="canDelete"
|
||||
:class="['ui', {loading: isLoading}, 'basic button']"
|
||||
:action="remove">
|
||||
<translate :translate-context="'*/*/*/Verb'">Delete</translate>
|
||||
<p slot="modal-header"><translate :translate-context="'Popup/Library/Title'">Delete this suggestion?</translate></p>
|
||||
<div slot="modal-content">
|
||||
<p><translate :translate-context="'Popup/Library/Paragraph'">The suggestion will be completely removed, this action is irreversible.</translate></p>
|
||||
</div>
|
||||
<p slot="modal-confirm"><translate :translate-context="'Popup/Library/Button.Label'">Delete</translate></p>
|
||||
</dangerous-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import { diffWordsWithSpace } from 'diff'
|
||||
|
||||
import edits from '@/edits'
|
||||
|
||||
function castValue (value) {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
obj: {required: true},
|
||||
currentState: {required: false}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canApprove: edits.getCanApprove,
|
||||
canDelete: edits.getCanDelete,
|
||||
previousState () {
|
||||
if (this.obj.is_applied) {
|
||||
// mutation was applied, we use the previous state that is stored
|
||||
// on the mutation itself
|
||||
return this.obj.previous_state
|
||||
}
|
||||
// mutation is not applied yet, so we use the current state that was
|
||||
// passed to the component, if any
|
||||
return this.currentState
|
||||
},
|
||||
detailUrl () {
|
||||
if (!this.obj.target) {
|
||||
return ''
|
||||
}
|
||||
let namespace
|
||||
let id = this.obj.target.id
|
||||
if (this.obj.target.type === 'track') {
|
||||
namespace = 'library.tracks.edit.detail'
|
||||
}
|
||||
if (this.obj.target.type === 'album') {
|
||||
namespace = 'library.albums.edit.detail'
|
||||
}
|
||||
if (this.obj.target.type === 'artist') {
|
||||
namespace = 'library.artists.edit.detail'
|
||||
}
|
||||
return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
axios.delete(`mutations/${this.obj.uuid}/`).then((response) => {
|
||||
self.$emit('deleted')
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
approve (approved) {
|
||||
let url
|
||||
if (approved) {
|
||||
url = `mutations/${this.obj.uuid}/approve/`
|
||||
} else {
|
||||
url = `mutations/${this.obj.uuid}/reject/`
|
||||
}
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
axios.post(url).then((response) => {
|
||||
self.$emit('approved', approved)
|
||||
self.isLoading = false
|
||||
self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewEdits'})
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
getUpdatedFields (payload, previousState) {
|
||||
let fields = Object.keys(payload)
|
||||
return fields.map((f) => {
|
||||
let d = {
|
||||
id: f,
|
||||
}
|
||||
if (previousState && previousState[f]) {
|
||||
d.old = previousState[f]
|
||||
}
|
||||
d.new = payload[f]
|
||||
if (d.old) {
|
||||
// we compute the diffs between the old and new values
|
||||
|
||||
let oldValue = castValue(d.old.value)
|
||||
let newValue = castValue(d.new)
|
||||
d.diff = diffWordsWithSpace(oldValue, newValue)
|
||||
}
|
||||
return d
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
52
front/src/components/library/EditDetail.vue
Normal file
52
front/src/components/library/EditDetail.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
|
||||
<section :class="['ui', 'vertical', 'stripe', {loading: isLoading}, 'segment']">
|
||||
<div class="ui text container">
|
||||
<edit-card v-if="obj" :obj="obj" :current-state="currentState" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios"
|
||||
import edits from '@/edits'
|
||||
import EditCard from '@/components/library/EditCard'
|
||||
export default {
|
||||
props: ["object", "objectType", "editId"],
|
||||
components: {
|
||||
EditCard
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
obj: null,
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
computed: {
|
||||
configs: edits.getConfigs,
|
||||
config: edits.getConfig,
|
||||
currentState: edits.getCurrentState,
|
||||
currentState () {
|
||||
let self = this
|
||||
let s = {}
|
||||
this.config.fields.forEach(f => {
|
||||
s[f.id] = {value: f.getValue(self.object)}
|
||||
})
|
||||
return s
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
axios.get(`mutations/${this.editId}/`).then(response => {
|
||||
self.obj = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
192
front/src/components/library/EditForm.vue
Normal file
192
front/src/components/library/EditForm.vue
Normal file
|
@ -0,0 +1,192 @@
|
|||
<template>
|
||||
<div v-if="submittedMutation">
|
||||
<div class="ui positive message">
|
||||
<div class="header"><translate :translate-context="'Content/Library/Paragraph'">Your edit was successfully submitted.</translate></div>
|
||||
</div>
|
||||
<edit-card :obj="submittedMutation" :current-state="currentState" />
|
||||
<button class="ui button" @click.prevent="submittedMutation = null">
|
||||
<translate :translate-context="'Content/Library/Button.Label'">
|
||||
Submit another edit
|
||||
</translate>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
<edit-list :filters="editListFilters" :url="mutationsUrl" :obj="object" :currentState="currentState">
|
||||
<div slot="title">
|
||||
<template v-if="showPendingReview">
|
||||
<translate :translate-context="'Content/Library/Paragraph'">
|
||||
Recent edits awaiting review
|
||||
</translate>
|
||||
<button class="ui tiny basic right floated button" @click.prevent="showPendingReview = false">
|
||||
<translate :translate-context="'Content/Library/Button.Label'">
|
||||
Show all edits
|
||||
</translate>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<translate :translate-context="'Content/Library/Paragraph'">
|
||||
Recent edits
|
||||
</translate>
|
||||
<button class="ui tiny basic right floated button" @click.prevent="showPendingReview = true">
|
||||
<translate :translate-context="'Content/Library/Button.Label'">
|
||||
Retrict to unreviewed edits
|
||||
</translate>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<empty-state slot="empty-state">
|
||||
<translate :translate-context="'Content/Library/Paragraph'">
|
||||
Suggest a change using the form below.
|
||||
</translate>
|
||||
</empty-state>
|
||||
</edit-list>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<div class="ui hidden divider"></div>
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header"><translate :translate-context="'Content/Library/Error message.Title'">Error while submitting edit</translate></div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="!canEdit" class="ui message">
|
||||
<translate :translate-context="'Content/Library/Paragraph'">
|
||||
You don't have the permission to edit this object, but you can suggest changes. Once submitted, suggestions will be reviewed before approval.
|
||||
</translate>
|
||||
</div>
|
||||
<div v-if="values" v-for="fieldConfig in config.fields" :key="fieldConfig.id" class="ui field">
|
||||
<template v-if="fieldConfig.type === 'text'">
|
||||
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
|
||||
<input :type="fieldConfig.inputType || 'text'" v-model="values[fieldConfig.id]" :required="fieldConfig.required" :name="fieldConfig.id" :id="fieldConfig.id">
|
||||
</template>
|
||||
<div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]">
|
||||
<button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]">
|
||||
<i class="undo icon"></i>
|
||||
<translate :translate-context="'Content/Library/Button.Label'" :translate-params="{value: initialValues[fieldConfig.id]}">Reset to initial value: %{ value }</translate>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="summary"><translate :translate-context="'*/*/*'">Summary (optional)</translate></label>
|
||||
<textarea name="change-summary" v-model="summary" id="change-summary" rows="3" :placeholder="labels.summaryPlaceholder"></textarea>
|
||||
</div>
|
||||
<router-link
|
||||
class="ui left floated button"
|
||||
v-if="objectType === 'track'"
|
||||
:to="{name: 'library.tracks.detail', params: {id: object.id }}"
|
||||
>
|
||||
<translate :translate-context="'Content/*/Button.Label'">Cancel</translate>
|
||||
</router-link>
|
||||
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit" :disabled="isLoading || !mutationPayload">
|
||||
<translate v-if="canEdit" key="1" :translate-context="'Content/Library/Button.Label/Verb'">Submit and apply edit</translate>
|
||||
<translate v-else key="2" :translate-context="'Content/Library/Button.Label/Verb'">Submit suggestion</translate>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from '@/lodash'
|
||||
import axios from "axios"
|
||||
import EditList from '@/components/library/EditList'
|
||||
import EditCard from '@/components/library/EditCard'
|
||||
import edits from '@/edits'
|
||||
|
||||
export default {
|
||||
props: ["objectType", "object"],
|
||||
components: {
|
||||
EditList,
|
||||
EditCard
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
errors: [],
|
||||
values: {},
|
||||
initialValues: {},
|
||||
summary: '',
|
||||
submittedMutation: null,
|
||||
showPendingReview: true,
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.setValues()
|
||||
},
|
||||
computed: {
|
||||
configs: edits.getConfigs,
|
||||
config: edits.getConfig,
|
||||
currentState: edits.getCurrentState,
|
||||
canEdit: edits.getCanEdit,
|
||||
labels () {
|
||||
return {
|
||||
summaryPlaceholder: this.$pgettext('*/*/Placeholder', 'A short summary describing your changes.'),
|
||||
}
|
||||
},
|
||||
mutationsUrl () {
|
||||
if (this.objectType === 'track') {
|
||||
return `tracks/${this.object.id}/mutations/`
|
||||
}
|
||||
},
|
||||
mutationPayload () {
|
||||
let self = this
|
||||
let changedFields = this.config.fields.filter(f => {
|
||||
return self.values[f.id] != self.initialValues[f.id]
|
||||
})
|
||||
if (changedFields.length === 0) {
|
||||
return null
|
||||
}
|
||||
let payload = {
|
||||
type: 'update',
|
||||
payload: {},
|
||||
summary: this.summary,
|
||||
}
|
||||
changedFields.forEach((f) => {
|
||||
payload.payload[f.id] = self.values[f.id]
|
||||
})
|
||||
return payload
|
||||
},
|
||||
editListFilters () {
|
||||
if (this.showPendingReview) {
|
||||
return {is_approved: 'null'}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
setValues () {
|
||||
let self = this
|
||||
this.config.fields.forEach(f => {
|
||||
self.$set(self.values, f.id, f.getValue(self.object))
|
||||
self.$set(self.initialValues, f.id, self.values[f.id])
|
||||
})
|
||||
},
|
||||
submit() {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.errors = []
|
||||
let payload = _.clone(this.mutationPayload || {})
|
||||
if (this.canEdit) {
|
||||
payload.is_approved = true
|
||||
}
|
||||
return axios.post(this.mutationsUrl, payload).then(
|
||||
response => {
|
||||
self.isLoading = false
|
||||
self.submittedMutation = response.data
|
||||
},
|
||||
error => {
|
||||
self.errors = error.backendErrors
|
||||
self.isLoading = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.reset.button {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
</style>
|
74
front/src/components/library/EditList.vue
Normal file
74
front/src/components/library/EditList.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div class="wrapper">
|
||||
<h3 class="ui header">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<slot v-if="!isLoading && objects.length === 0" name="empty-state"></slot>
|
||||
<button v-if="nextPage || previousPage" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
|
||||
<button v-if="nextPage || previousPage" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div v-if="isLoading" class="ui inverted active dimmer">
|
||||
<div class="ui loader"></div>
|
||||
</div>
|
||||
<edit-card @updated="fetchData(url)" @deleted="fetchData(url)" v-for="obj in objects" :key="obj.uuid" :obj="obj" :current-state="currentState" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from '@/lodash'
|
||||
import axios from 'axios'
|
||||
|
||||
import EditCard from '@/components/library/EditCard'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
url: {type: String, required: true},
|
||||
filters: {type: Object, required: false, default: () => {return {}}},
|
||||
currentState: {required: false},
|
||||
},
|
||||
components: {
|
||||
EditCard
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
objects: [],
|
||||
limit: 5,
|
||||
isLoading: false,
|
||||
errors: null,
|
||||
previousPage: null,
|
||||
nextPage: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData(this.url)
|
||||
},
|
||||
methods: {
|
||||
fetchData (url) {
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
this.isLoading = true
|
||||
let self = this
|
||||
let params = _.clone(this.filters)
|
||||
params.page_size = this.limit
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.previousPage = response.data.previous
|
||||
self.nextPage = response.data.next
|
||||
self.isLoading = false
|
||||
self.objects = response.data.results
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
filters: {
|
||||
handler () {
|
||||
this.fetchData(this.url)
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -64,99 +64,15 @@
|
|||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
<router-link
|
||||
:to="{name: 'library.tracks.edit', params: {id: track.id }}"
|
||||
class="ui icon labeled button">
|
||||
<i class="edit icon"></i>
|
||||
<translate :translate-context="'Content/Track/Button.Label/Verb'">Edit…</translate>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
<section class="ui vertical stripe center aligned segment">
|
||||
<h2 class="ui header">
|
||||
<translate :translate-context="'Content/Track/Title/Noun'">Track information</translate>
|
||||
</h2>
|
||||
<table class="ui very basic collapsing celled center aligned table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate>
|
||||
</td>
|
||||
<td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate>
|
||||
</td>
|
||||
<td v-if="license">
|
||||
<a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a>
|
||||
</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label'">Duration</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label'">Size</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.extension">{{ upload.extension }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="ui vertical stripe center aligned segment">
|
||||
<h2>
|
||||
<translate :translate-context="'Content/Track/Title'">Lyrics</translate>
|
||||
</h2>
|
||||
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="lyrics" v-html="lyrics.content_rendered"></div>
|
||||
<template v-if="!isLoadingLyrics & !lyrics">
|
||||
<p>
|
||||
<translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate>
|
||||
</p>
|
||||
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
|
||||
<i class="search icon"></i>
|
||||
<translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate>
|
||||
</a>
|
||||
</template>
|
||||
</section>
|
||||
<section class="ui vertical stripe segment">
|
||||
<h2>
|
||||
<translate :translate-context="'Content/Track/Title'">User libraries</translate>
|
||||
</h2>
|
||||
<library-widget @loaded="libraries = $event" :url="'tracks/' + id + '/libraries/'">
|
||||
<translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate>
|
||||
</library-widget>
|
||||
</section>
|
||||
<router-view v-if="track" @libraries-loaded="libraries = $event" :track="track" :object="track" object-type="track" :key="$route.fullPath"></router-view>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
@ -169,7 +85,6 @@ import logger from "@/logging"
|
|||
import PlayButton from "@/components/audio/PlayButton"
|
||||
import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
|
||||
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
|
||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||
import Modal from '@/components/semantic/Modal'
|
||||
import EmbedWizard from "@/components/audio/EmbedWizard"
|
||||
|
||||
|
@ -181,7 +96,6 @@ export default {
|
|||
PlayButton,
|
||||
TrackPlaylistIcon,
|
||||
TrackFavoriteIcon,
|
||||
LibraryWidget,
|
||||
Modal,
|
||||
EmbedWizard
|
||||
},
|
||||
|
@ -189,17 +103,13 @@ export default {
|
|||
return {
|
||||
time,
|
||||
isLoadingTrack: true,
|
||||
isLoadingLyrics: true,
|
||||
track: null,
|
||||
lyrics: null,
|
||||
licenseData: null,
|
||||
libraries: [],
|
||||
showEmbedModal: false
|
||||
showEmbedModal: false,
|
||||
libraries: []
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchData()
|
||||
this.fetchLyrics()
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
|
@ -212,29 +122,6 @@ export default {
|
|||
self.isLoadingTrack = false
|
||||
})
|
||||
},
|
||||
fetchLicenseData(licenseId) {
|
||||
var self = this
|
||||
let url = `licenses/${licenseId}/`
|
||||
axios.get(url).then(response => {
|
||||
self.licenseData = response.data
|
||||
})
|
||||
},
|
||||
fetchLyrics() {
|
||||
var self = this
|
||||
this.isLoadingLyrics = true
|
||||
let url = FETCH_URL + this.id + "/lyrics/"
|
||||
logger.default.debug('Fetching lyrics for track "' + this.id + '"')
|
||||
axios.get(url).then(
|
||||
response => {
|
||||
self.lyrics = response.data
|
||||
self.isLoadingLyrics = false
|
||||
},
|
||||
response => {
|
||||
console.error("No lyrics available")
|
||||
self.isLoadingLyrics = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
publicLibraries () {
|
||||
|
@ -242,16 +129,16 @@ export default {
|
|||
return l.privacy_level === 'everyone'
|
||||
})
|
||||
},
|
||||
labels() {
|
||||
return {
|
||||
title: this.$pgettext('Head/Track/Title', "Track")
|
||||
}
|
||||
},
|
||||
upload() {
|
||||
if (this.track.uploads) {
|
||||
return this.track.uploads[0]
|
||||
}
|
||||
},
|
||||
labels() {
|
||||
return {
|
||||
title: this.$pgettext('Head/Track/Title', "Track")
|
||||
}
|
||||
},
|
||||
wikipediaUrl() {
|
||||
return (
|
||||
"https://en.wikipedia.org/w/index.php?search=" +
|
||||
|
@ -276,11 +163,6 @@ export default {
|
|||
}
|
||||
return u
|
||||
},
|
||||
lyricsSearchUrl() {
|
||||
let base = "http://lyrics.wikia.com/wiki/Special:Search?query="
|
||||
let query = this.track.artist.name + ":" + this.track.title
|
||||
return base + encodeURI(query)
|
||||
},
|
||||
cover() {
|
||||
return null
|
||||
},
|
||||
|
@ -302,30 +184,11 @@ export default {
|
|||
")"
|
||||
)
|
||||
},
|
||||
license() {
|
||||
if (!this.track || !this.track.license) {
|
||||
return null
|
||||
}
|
||||
return this.licenseData
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id() {
|
||||
this.fetchData()
|
||||
},
|
||||
track (v) {
|
||||
if (v && v.license) {
|
||||
this.fetchLicenseData(v.license)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.table.center.aligned {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
191
front/src/components/library/TrackDetail.vue
Normal file
191
front/src/components/library/TrackDetail.vue
Normal file
|
@ -0,0 +1,191 @@
|
|||
<template>
|
||||
|
||||
<div v-if="track">
|
||||
<section class="ui vertical stripe center aligned segment">
|
||||
<h2 class="ui header">
|
||||
<translate :translate-context="'Content/Track/Title/Noun'">Track information</translate>
|
||||
</h2>
|
||||
<table class="ui very basic collapsing celled center aligned table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label/Noun'">Copyright</translate>
|
||||
</td>
|
||||
<td v-if="track.copyright" :title="track.copyright">{{ track.copyright|truncate(50) }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'Content/Track/Table.Paragraph'">No copyright information available for this track</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label/Noun'">License</translate>
|
||||
</td>
|
||||
<td v-if="license">
|
||||
<a :href="license.url" target="_blank" rel="noopener noreferrer">{{ license.name }}</a>
|
||||
</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'Content/Track/Table.Paragraph'">No licensing information for this track</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label'">Duration</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.duration">{{ time.parse(upload.duration) }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label'">Size</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.size">{{ upload.size | humanSize }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label'">Bitrate</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.bitrate">{{ upload.bitrate | humanSize }}/s</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<translate :translate-context="'Content/Track/Table.Label/Noun'">Type</translate>
|
||||
</td>
|
||||
<td v-if="upload && upload.extension">{{ upload.extension }}</td>
|
||||
<td v-else>
|
||||
<translate :translate-context="'*/*/*'">N/A</translate>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="ui vertical stripe center aligned segment">
|
||||
<h2>
|
||||
<translate :translate-context="'Content/Track/Title'">Lyrics</translate>
|
||||
</h2>
|
||||
<div v-if="isLoadingLyrics" class="ui vertical segment">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<div v-if="lyrics" v-html="lyrics.content_rendered"></div>
|
||||
<template v-if="!isLoadingLyrics & !lyrics">
|
||||
<p>
|
||||
<translate :translate-context="'Content/Track/Paragraph'">No lyrics available for this track.</translate>
|
||||
</p>
|
||||
<a class="ui button" target="_blank" :href="lyricsSearchUrl">
|
||||
<i class="search icon"></i>
|
||||
<translate :translate-context="'Content/Track/Link/Verb'">Search on lyrics.wikia.com</translate>
|
||||
</a>
|
||||
</template>
|
||||
</section>
|
||||
<section class="ui vertical stripe segment">
|
||||
<h2>
|
||||
<translate :translate-context="'Content/Track/Title'">User libraries</translate>
|
||||
</h2>
|
||||
<library-widget @loaded="$emit('libraries-loaded', $event)" :url="'tracks/' + id + '/libraries/'">
|
||||
<translate :translate-context="'Content/Track/Paragraph'" slot="subtitle">This track is present in the following libraries:</translate>
|
||||
</library-widget>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import time from "@/utils/time"
|
||||
import axios from "axios"
|
||||
import url from "@/utils/url"
|
||||
import logger from "@/logging"
|
||||
import LibraryWidget from "@/components/federation/LibraryWidget"
|
||||
|
||||
const FETCH_URL = "tracks/"
|
||||
|
||||
export default {
|
||||
props: ["track", "libraries"],
|
||||
components: {
|
||||
LibraryWidget,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
time,
|
||||
id: this.track.id,
|
||||
isLoadingLyrics: true,
|
||||
lyrics: null,
|
||||
licenseData: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchLyrics()
|
||||
if (this.track && this.track.license) {
|
||||
this.fetchLicenseData(this.track.license)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fetchLicenseData(licenseId) {
|
||||
var self = this
|
||||
let url = `licenses/${licenseId}/`
|
||||
axios.get(url).then(response => {
|
||||
self.licenseData = response.data
|
||||
})
|
||||
},
|
||||
fetchLyrics() {
|
||||
var self = this
|
||||
this.isLoadingLyrics = true
|
||||
let url = FETCH_URL + this.id + "/lyrics/"
|
||||
logger.default.debug('Fetching lyrics for track "' + this.id + '"')
|
||||
axios.get(url).then(
|
||||
response => {
|
||||
self.lyrics = response.data
|
||||
self.isLoadingLyrics = false
|
||||
},
|
||||
response => {
|
||||
console.error("No lyrics available")
|
||||
self.isLoadingLyrics = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labels() {
|
||||
return {
|
||||
title: this.$pgettext('Head/Track/Title', "Track")
|
||||
}
|
||||
},
|
||||
upload() {
|
||||
if (this.track.uploads) {
|
||||
return this.track.uploads[0]
|
||||
}
|
||||
},
|
||||
lyricsSearchUrl() {
|
||||
let base = "http://lyrics.wikia.com/wiki/Special:Search?query="
|
||||
let query = this.track.artist.name + ":" + this.track.title
|
||||
return base + encodeURI(query)
|
||||
},
|
||||
license() {
|
||||
if (!this.track || !this.track.license) {
|
||||
return null
|
||||
}
|
||||
return this.licenseData
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
track (v) {
|
||||
if (v && v.license) {
|
||||
this.fetchLicenseData(v.license)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped lang="scss">
|
||||
.table.center.aligned {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
34
front/src/components/library/TrackEdit.vue
Normal file
34
front/src/components/library/TrackEdit.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
|
||||
<section class="ui vertical stripe segment">
|
||||
<div class="ui text container">
|
||||
<h2>
|
||||
<translate v-if="canEdit" key="1" :translate-context="'Content/*/Title'">Edit this track</translate>
|
||||
<translate v-else key="2" :translate-context="'Content/*/Title'">Suggest an edit on this track</translate>
|
||||
</h2>
|
||||
<edit-form :object-type="objectType" :object="object" :can-edit="canEdit"></edit-form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios"
|
||||
|
||||
import EditForm from '@/components/library/EditForm'
|
||||
export default {
|
||||
props: ["objectType", "object", "libraries"],
|
||||
data() {
|
||||
return {
|
||||
id: this.object.id
|
||||
}
|
||||
},
|
||||
components: {
|
||||
EditForm
|
||||
},
|
||||
computed: {
|
||||
canEdit () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
Loading…
Add table
Add a link
Reference in a new issue