Skip to content
Snippets Groups Projects
Commit 3ce2f0d7 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch '432-tags-suggestions' into 'develop'

See #432: UI for tags (first round)

See merge request funkwhale/funkwhale!832
parents e3f6baec fec1a430
No related branches found
No related tags found
No related merge requests found
Showing
with 620 additions and 68 deletions
......@@ -46,16 +46,28 @@ def create_local_accounts(factories, count, dependencies):
return actors
def create_tagged_tracks(factories, count, dependencies):
def create_taggable_items(dependency):
def inner(factories, count, dependencies):
objs = []
for track in dependencies["tracks"]:
tag = random.choice(dependencies["tags"])
objs.append(factories["tags.TaggedItem"].build(content_object=track, tag=tag))
objs = []
tagged_objects = dependencies.get(
dependency, list(CONFIG_BY_ID[dependency]["model"].objects.all().only("pk"))
)
tags = dependencies.get("tags", list(tags_models.Tag.objects.all().only("pk")))
for i in range(count):
tag = random.choice(tags)
tagged_object = random.choice(tagged_objects)
objs.append(
factories["tags.TaggedItem"].build(
content_object=tagged_object, tag=tag
)
)
return tags_models.TaggedItem.objects.bulk_create(
objs, batch_size=BATCH_SIZE, ignore_conflicts=True
)
return tags_models.TaggedItem.objects.bulk_create(
objs, batch_size=BATCH_SIZE, ignore_conflicts=True
)
return inner
CONFIG = [
......@@ -110,7 +122,10 @@ CONFIG = [
{
"id": "track_tags",
"model": tags_models.TaggedItem,
"handler": create_tagged_tracks,
"queryset": tags_models.TaggedItem.objects.filter(
content_type__app_label="music", content_type__model="track"
),
"handler": create_taggable_items("tracks"),
"depends_on": [
{
"field": "tag",
......@@ -127,6 +142,52 @@ CONFIG = [
},
],
},
{
"id": "album_tags",
"model": tags_models.TaggedItem,
"queryset": tags_models.TaggedItem.objects.filter(
content_type__app_label="music", content_type__model="album"
),
"handler": create_taggable_items("albums"),
"depends_on": [
{
"field": "tag",
"id": "tags",
"default_factor": 0.1,
"queryset": tags_models.Tag.objects.all(),
"set": False,
},
{
"field": "content_object",
"id": "albums",
"default_factor": 1,
"set": False,
},
],
},
{
"id": "artist_tags",
"model": tags_models.TaggedItem,
"queryset": tags_models.TaggedItem.objects.filter(
content_type__app_label="music", content_type__model="artist"
),
"handler": create_taggable_items("artists"),
"depends_on": [
{
"field": "tag",
"id": "tags",
"default_factor": 0.1,
"queryset": tags_models.Tag.objects.all(),
"set": False,
},
{
"field": "content_object",
"id": "artists",
"default_factor": 1,
"set": False,
},
],
},
]
CONFIG_BY_ID = {c["id"]: c for c in CONFIG}
......@@ -194,8 +255,8 @@ class Command(BaseCommand):
self.stdout.write("\nFinal state of database:\n\n")
for row in CONFIG:
model = row["model"]
total = model.objects.all().count()
qs = row.get("queryset", row["model"].objects.all())
total = qs.count()
self.stdout.write("- {} {} objects".format(total, row["id"]))
self.stdout.write("")
......
......@@ -18,4 +18,4 @@ class TagFilter(filters.FilterSet):
class Meta:
model = models.Tag
fields = ["q"]
fields = {"q": ["exact"], "name": ["exact", "startswith"]}
from django.db.models import functions
from rest_framework import viewsets
import django_filters.rest_framework
from funkwhale_api.users.oauth import permissions as oauth_permissions
from . import filters
......@@ -20,3 +22,4 @@ class TagViewSet(viewsets.ReadOnlyModelViewSet):
required_scope = "libraries"
anonymous_policy = "setting"
filterset_class = filters.TagFilter
filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
......@@ -81,11 +81,7 @@ def test_load_test_data_dry_run(factories, mocker):
),
(
{"create_dependencies": True, "track_tags": 3},
[
(tags_models.Tag.objects.all(), 1),
(tags_models.TaggedItem.objects.all(), 3),
(music_models.Track.objects.all(), 3),
],
[(tags_models.Tag.objects.all(), 1), (music_models.Track.objects.all(), 3)],
),
],
)
......
......@@ -23,18 +23,21 @@ def test_tags_list_ordering_length(factories, logged_in_api_client):
url = reverse("api:v1:tags-list")
tags = [
factories["tags.Tag"](name="iamareallylongtag"),
factories["tags.Tag"](name="reallylongtag"),
factories["tags.Tag"](name="short"),
factories["tags.Tag"](name="reallylongtag"),
factories["tags.Tag"](name="bar"),
]
expected = {
"count": 4,
"next": None,
"previous": None,
"results": [serializers.TagSerializer(tag).data for tag in tags],
"results": [
serializers.TagSerializer(tag).data
for tag in [tags[3], tags[1], tags[2], tags[0]]
],
}
response = logged_in_api_client.get(url, {"ordering": "-length"})
response = logged_in_api_client.get(url, {"ordering": "length"})
assert response.data == expected
......
......@@ -57,6 +57,7 @@ services:
- "${MUSIC_DIRECTORY_SERVE_PATH-./data/music}:/music:ro"
- "./data/plugins:/srv/funkwhale/plugins"
- "./data/staticfiles:/staticfiles"
- "./data/media:/data/media"
environment:
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
......@@ -65,6 +66,7 @@ services:
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- "STATIC_ROOT=/staticfiles"
- "MEDIA_ROOT=/data/media"
depends_on:
- postgres
......@@ -92,10 +94,12 @@ services:
- "FUNKWHALE_PROTOCOL=${FUNKWHALE_PROTOCOL-http}"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- "MEDIA_ROOT=/data/media"
volumes:
- ./api:/app
- "${MUSIC_DIRECTORY_SERVE_PATH-./data/music}:/music:ro"
- "./data/plugins:/srv/funkwhale/plugins"
- "./data/media:/data/media"
networks:
- internal
nginx:
......@@ -122,9 +126,9 @@ services:
- ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
- "${MUSIC_DIRECTORY_SERVE_PATH-./data/music}:/music:ro"
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
- "${MEDIA_ROOT-./api/funkwhale_api/media}:/protected/media:ro"
- "./front:/frontend:ro"
- "./data/staticfiles:/staticfiles:ro"
- "./data/media:/protected/media:ro"
networks:
- federation
- internal
......
......@@ -22,6 +22,7 @@
"masonry-layout": "^4.2.2",
"moment": "^2.22.2",
"fomantic-ui-css": "^2.7",
"qs": "^6.7.0",
"showdown": "^1.8.6",
"vue": "^2.5.17",
"vue-gettext": "^2.1.0",
......
......@@ -2,10 +2,11 @@
<div class="wrapper">
<h3 class="ui header">
<slot name="title"></slot>
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
</h3>
<button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
<button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
<button @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
<button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
<button v-if="controls" @click="fetchData('albums/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div class="ui hidden divider"></div>
<div class="ui five cards">
<div v-if="isLoading" class="ui inverted active dimmer">
......@@ -33,6 +34,7 @@
</div>
</div>
</div>
<div v-if="!isLoading && albums.length === 0">No results matching your query.</div>
</div>
</template>
......@@ -43,7 +45,9 @@ import PlayButton from '@/components/audio/PlayButton'
export default {
props: {
filters: {type: Object, required: true}
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
showCount: {type: Boolean, default: false},
},
components: {
PlayButton
......@@ -52,6 +56,7 @@ export default {
return {
albums: [],
limit: 12,
count: 0,
isLoading: false,
errors: null,
previousPage: null,
......@@ -76,6 +81,7 @@ export default {
self.nextPage = response.data.next
self.isLoading = false
self.albums = response.data.results
self.count = response.data.count
}, error => {
self.isLoading = false
self.errors = error.backendErrors
......
<template>
<div class="wrapper">
<h3 class="ui header">
<slot name="title"></slot>
<span class="ui tiny circular label">{{ count }}</span>
</h3>
<button v-if="controls" :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle left', 'icon']"></i></button>
<button v-if="controls" :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle right', 'icon']"></i></button>
<button v-if="controls" @click="fetchData('artists/')" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div class="ui hidden divider"></div>
<div class="ui three cards">
<div v-if="isLoading" class="ui inverted active dimmer">
<div class="ui loader"></div>
</div>
<div class="flat inline card" v-for="object in objects" :key="object.id">
<div :class="['ui', 'image', 'with-overlay', {'default-cover': !getCover(object).original}]" v-lazy:background-image="getImageUrl(object)">
<play-button class="play-overlay" :icon-only="true" :is-playable="object.is_playable" :button-classes="['ui', 'circular', 'large', 'orange', 'icon', 'button']" :artist="object"></play-button>
</div>
<div class="content">
<router-link :title="object.name" :to="{name: 'library.artists.detail', params: {id: object.id}}">
{{ object.name|truncate(30) }}
</router-link>
<div>
<i class="small sound icon"></i>
<translate translate-context="Content/Artist/Card" :translate-params="{count: object.albums.length}" :translate-n="object.albums.length" translate-plural="%{ count } albums">1 album</translate>
</div>
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.tags"></tags-list>
<play-button
class="play-button basic icon"
:dropdown-only="true"
:is-playable="object.is_playable"
:dropdown-icon-classes="['ellipsis', 'vertical', 'large', 'grey']"
:artist="object"></play-button>
</div>
</div>
</div>
<div v-if="!isLoading && objects.length === 0">No results matching your query.</div>
</div>
</template>
<script>
import _ from '@/lodash'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
export default {
props: {
filters: {type: Object, required: true},
controls: {type: Boolean, default: true},
},
components: {
PlayButton,
TagsList
},
data () {
return {
objects: [],
limit: 12,
count: 0,
isLoading: false,
errors: null,
previousPage: null,
nextPage: null
}
},
created () {
this.fetchData('artists/')
},
methods: {
fetchData (url) {
if (!url) {
return
}
this.isLoading = true
let self = this
let params = _.clone(this.filters)
params.page_size = this.limit
params.offset = this.offset
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
self.count = response.data.count
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
updateOffset (increment) {
if (increment) {
this.offset += this.limit
} else {
this.offset = Math.max(this.offset - this.limit, 0)
}
},
getImageUrl (object) {
let url = '../../../assets/audio/default-cover.png'
let cover = this.getCover(object)
if (cover.original) {
url = this.$store.getters['instance/absoluteUrl'](cover.medium_square_crop)
} else {
return null
}
return url
},
getCover (object) {
return object.albums.map((a) => {
return a.cover
}).filter((c) => {
return !!c
})[0] || {}
}
},
watch: {
offset () {
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData('objects/')
}
}
}
</script>
<style scoped lang="scss">
@import "../../../style/vendor/media";
.default-cover {
background-image: url("../../../assets/audio/default-cover.png") !important;
}
.wrapper {
width: 100%;
}
.ui.cards {
justify-content: flex-start;
}
.play-button {
position: absolute;
right: 0;
bottom: 0;
}
.ui.three.cards .card {
width: 100%;
}
@include media(">tablet") {
.ui.three.cards .card {
width: 25em;
}
}
.with-overlay {
background-size: cover !important;
background-position: center !important;
height: 8em;
width: 8em;
display: flex !important;
justify-content: center !important;
align-items: center !important;
}
.flat.card .with-overlay.image {
border-radius: 50% !important;
margin: 0 auto;
}
</style>
<style>
.ui.cards .ui.button {
margin-right: 0px;
}
</style>
......@@ -2,12 +2,13 @@
<div>
<h3 class="ui header">
<slot name="title"></slot>
<span v-if="showCount" class="ui tiny circular label">{{ count }}</span>
</h3>
<button :disabled="!previousPage" @click="fetchData(previousPage)" :class="['ui', {disabled: !previousPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle up', 'icon']"></i></button>
<button :disabled="!nextPage" @click="fetchData(nextPage)" :class="['ui', {disabled: !nextPage}, 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'angle down', 'icon']"></i></button>
<button @click="fetchData(url)" :class="['ui', 'circular', 'icon', 'basic', 'button']"><i :class="['ui', 'refresh', 'icon']"></i></button>
<div class="ui divided unstackable items">
<div class="item" v-for="object in objects" :key="object.id">
<div :class="['item', itemClasses]" v-for="object in objects" :key="object.id">
<div class="ui tiny image">
<img v-if="object.track.album.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.track.album.cover.medium_square_crop)">
<img v-else src="../../../assets/audio/default-cover.png">
......@@ -28,7 +29,9 @@
</router-link>
</span>
</div>
<div class="extra">
<tags-list label-classes="tiny" :truncate-size="20" :limit="2" :show-more="false" :tags="object.track.tags"></tags-list>
<div class="extra" v-if="isActivity">
<span class="left floated">@{{ object.user.username }}</span>
<span class="right floated"><human-date :date="object.creation_date" /></span>
</div>
......@@ -50,19 +53,25 @@
import _ from '@/lodash'
import axios from 'axios'
import PlayButton from '@/components/audio/PlayButton'
import TagsList from "@/components/tags/List"
export default {
props: {
filters: {type: Object, required: true},
url: {type: String, required: true}
url: {type: String, required: true},
isActivity: {type: Boolean, default: true},
showCount: {type: Boolean, default: false},
limit: {type: Number, default: 5},
itemClasses: {type: String, default: ''},
},
components: {
PlayButton
PlayButton,
TagsList
},
data () {
return {
objects: [],
limit: 5,
count: 0,
isLoading: false,
errors: null,
previousPage: null,
......@@ -86,7 +95,15 @@ export default {
self.previousPage = response.data.previous
self.nextPage = response.data.next
self.isLoading = false
self.objects = response.data.results
self.count = response.data.count
if (self.isActivity) {
// we have listening/favorites objects, not directly tracks
self.objects = response.data.results
} else {
self.objects = response.data.results.map((r) => {
return {track: r}
})
}
}, error => {
self.isLoading = false
self.errors = error.backendErrors
......@@ -129,4 +146,18 @@ export default {
.ui.divided.items > .item:last-child {
padding-bottom: 1em !important;
}
@include media(">tablet") {
.divided.items > .track-item.inline {
width: 25em;
float: left;
border-top: none;
&,
&:first-child {
margin-top: 0.5em !important;
margin-right: 0.5em !important;
padding: 1em 0 !important;
}
}
}
</style>
......@@ -13,6 +13,7 @@
<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">
......@@ -103,6 +104,7 @@ 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"
const FETCH_URL = "albums/"
......@@ -123,7 +125,8 @@ export default {
components: {
PlayButton,
EmbedWizard,
Modal
Modal,
TagsList,
},
data() {
return {
......
......@@ -20,6 +20,10 @@
</option>
</select>
</div>
<div class="field">
<label><translate translate-context="*/*/*/Noun">Tags</translate></label>
<tags-selector v-model="tags"></tags-selector>
</div>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label>
<select class="ui dropdown" v-model="orderingDirection">
......@@ -70,6 +74,7 @@
</template>
<script>
import qs from 'qs'
import axios from "axios"
import _ from "@/lodash"
import $ from "jquery"
......@@ -81,17 +86,20 @@ import PaginationMixin from "@/components/mixins/Pagination"
import TranslationsMixin from "@/components/mixins/Translations"
import AlbumCard from "@/components/audio/album/Card"
import Pagination from "@/components/Pagination"
import TagsSelector from '@/components/library/TagsSelector'
const FETCH_URL = "albums/"
export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: {
defaultQuery: { type: String, required: false, default: "" }
defaultQuery: { type: String, required: false, default: "" },
defaultTags: { type: Array, required: false, default: () => { return [] } },
},
components: {
AlbumCard,
Pagination
Pagination,
TagsSelector,
},
data() {
let defaultOrdering = this.getOrderingFromString(
......@@ -102,6 +110,7 @@ export default {
result: null,
page: parseInt(this.defaultPage),
query: this.defaultQuery,
tags: this.defaultTags.filter((t) => { return t.length > 0 }) || [],
paginateBy: parseInt(this.defaultPaginateBy || 25),
orderingDirection: defaultOrdering.direction || "+",
ordering: defaultOrdering.field,
......@@ -130,6 +139,7 @@ export default {
query: {
query: this.query,
page: this.page,
tag: this.tags,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}
......@@ -144,10 +154,19 @@ export default {
page_size: this.paginateBy,
q: this.query,
ordering: this.getOrderingAsString(),
playable: "true"
playable: "true",
tag: this.tags,
}
logger.default.debug("Fetching albums")
axios.get(url, { params: params }).then(response => {
axios.get(
url,
{
params: params,
paramsSerializer: function(params) {
return qs.stringify(params, { indices: false })
}
}
).then(response => {
self.result = response.data
self.isLoading = false
})
......@@ -177,6 +196,10 @@ export default {
this.updateQueryString()
this.fetchData()
},
tags() {
this.updateQueryString()
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
}
......
......@@ -21,6 +21,7 @@
</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">
......@@ -123,17 +124,20 @@ import PlayButton from "@/components/audio/PlayButton"
import EmbedWizard from "@/components/audio/EmbedWizard"
import Modal from '@/components/semantic/Modal'
import RadioButton from "@/components/radios/Button"
import TagsList from "@/components/tags/List"
const FETCH_URL = "albums/"
export default {
props: ["id"],
components: {
PlayButton,
EmbedWizard,
Modal,
RadioButton
RadioButton,
TagsList,
},
data() {
return {
......
......@@ -12,6 +12,10 @@
</label>
<input type="text" name="search" v-model="query" :placeholder="labels.searchPlaceholder"/>
</div>
<div class="field">
<label><translate translate-context="*/*/*/Noun">Tags</translate></label>
<tags-selector v-model="tags"></tags-selector>
</div>
<div class="field">
<label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
<select class="ui dropdown" v-model="ordering">
......@@ -67,6 +71,7 @@
</template>
<script>
import qs from 'qs'
import axios from "axios"
import _ from "@/lodash"
import $ from "jquery"
......@@ -78,17 +83,20 @@ import PaginationMixin from "@/components/mixins/Pagination"
import TranslationsMixin from "@/components/mixins/Translations"
import ArtistCard from "@/components/audio/artist/Card"
import Pagination from "@/components/Pagination"
import TagsSelector from '@/components/library/TagsSelector'
const FETCH_URL = "artists/"
export default {
mixins: [OrderingMixin, PaginationMixin, TranslationsMixin],
props: {
defaultQuery: { type: String, required: false, default: "" }
defaultQuery: { type: String, required: false, default: "" },
defaultTags: { type: Array, required: false, default: () => { return [] } },
},
components: {
ArtistCard,
Pagination
Pagination,
TagsSelector,
},
data() {
let defaultOrdering = this.getOrderingFromString(
......@@ -99,6 +107,7 @@ export default {
result: null,
page: parseInt(this.defaultPage),
query: this.defaultQuery,
tags: this.defaultTags.filter((t) => { return t.length > 0 }) || [],
paginateBy: parseInt(this.defaultPaginateBy || 12),
orderingDirection: defaultOrdering.direction || "+",
ordering: defaultOrdering.field,
......@@ -127,6 +136,7 @@ export default {
query: {
query: this.query,
page: this.page,
tag: this.tags,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}
......@@ -141,10 +151,19 @@ export default {
page_size: this.paginateBy,
name__icontains: this.query,
ordering: this.getOrderingAsString(),
playable: "true"
playable: "true",
tag: this.tags,
}
logger.default.debug("Fetching artists")
axios.get(url, { params: params }).then(response => {
axios.get(
url,
{
params: params,
paramsSerializer: function(params) {
return qs.stringify(params, { indices: false })
}
}
).then(response => {
self.result = response.data
self.isLoading = false
})
......@@ -174,6 +193,10 @@ export default {
this.updateQueryString()
this.fetchData()
},
tags() {
this.updateQueryString()
this.fetchData()
},
"$store.state.moderation.lastUpdate": function () {
this.fetchData()
}
......
......@@ -49,7 +49,7 @@
</tr>
</thead>
<tbody>
<tr v-for="field in getUpdatedFields(obj.payload, previousState)" :key="field.id">
<tr v-for="field in updatedFields" :key="field.id">
<td>{{ field.id }}</td>
<td v-if="field.diff">
......@@ -61,12 +61,12 @@
<translate translate-context="*/*/*">N/A</translate>
</td>
<td v-if="field.diff">
<td v-if="field.diff" :title="field.newRepr">
<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>
<td v-else :title="field.newRepr">{{ field.newRepr }}</td>
</tr>
</tbody>
</table>
......@@ -126,6 +126,7 @@ export default {
}
},
computed: {
configs: edits.getConfigs,
canApprove: edits.getCanApprove,
canDelete: edits.getCanDelete,
previousState () {
......@@ -154,6 +155,32 @@ export default {
namespace = 'library.artists.edit.detail'
}
return this.$router.resolve({name: namespace, params: {id, editId: this.obj.uuid}}).href
},
updatedFields () {
let payload = this.obj.payload
let previousState = this.previousState
let fields = Object.keys(payload)
let self = this
return fields.map((f) => {
let fieldConfig = edits.getFieldConfig(self.configs, this.obj.target.type, f)
let dummyRepr = (v) => { return v }
let getValueRepr = fieldConfig.getValueRepr || dummyRepr
let d = {
id: f,
}
if (previousState && previousState[f]) {
d.old = previousState[f]
d.oldRepr = castValue(getValueRepr(d.old.value))
}
d.new = payload[f]
d.newRepr = castValue(getValueRepr(d.new))
if (d.old) {
// we compute the diffs between the old and new values
d.diff = diffWordsWithSpace(d.oldRepr, d.newRepr)
}
return d
})
}
},
methods: {
......@@ -184,26 +211,6 @@ export default {
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>
......@@ -77,10 +77,22 @@
</button>
</template>
<template v-else-if="fieldConfig.type === 'tags'">
<label :for="fieldConfig.id">{{ fieldConfig.label }}</label>
<tags-selector
ref="tags"
v-model="values[fieldConfig.id]"
:id="fieldConfig.id"
required="fieldConfig.required"></tags-selector>
<button class="ui tiny basic left floated button" form="noop" @click.prevent="values[fieldConfig.id] = []">
<i class="x icon"></i>
<translate translate-context="Content/Library/Button.Label">Clear</translate>
</button>
</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>
<translate translate-context="Content/Library/Button.Label">Reset to initial value</translate>
</button>
</div>
</div>
......@@ -110,13 +122,17 @@ import _ from '@/lodash'
import axios from "axios"
import EditList from '@/components/library/EditList'
import EditCard from '@/components/library/EditCard'
import TagsSelector from '@/components/library/TagsSelector'
import edits from '@/edits'
import lodash from '@/lodash'
export default {
props: ["objectType", "object", "licenses"],
components: {
EditList,
EditCard
EditCard,
TagsSelector
},
data() {
return {
......@@ -159,7 +175,7 @@ export default {
mutationPayload () {
let self = this
let changedFields = this.config.fields.filter(f => {
return self.values[f.id] != self.initialValues[f.id]
return !lodash.isEqual(self.values[f.id], self.initialValues[f.id])
})
if (changedFields.length === 0) {
return null
......
<template>
<main v-title="labels.title">
<section class="ui vertical stripe segment">
<h2 class="ui header">
<span class="ui circular huge hashtag label">
{{ labels.title }}
</span>
</h2>
<div class="ui hidden divider"></div>
<div class="ui row">
<artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">
<template slot="title">
<router-link :to="{name: 'library.artists.browse', query: {tag: id}}">
<translate translate-context="*/*/*">Artists</translate>
</router-link>
</template>
</artist-widget>
<div class="ui hidden divider"></div>
<div class="ui hidden divider"></div>
<album-widget :show-count="true" :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">
<template slot="title">
<router-link :to="{name: 'library.albums.browse', query: {tag: id}}">
<translate translate-context="*/*/*">Albums</translate>
</router-link>
</template>
</album-widget>
<div class="ui hidden divider"></div>
<div class="ui hidden divider"></div>
<track-widget :show-count="true" :limit="12" item-classes="track-item inline" :url="'/tracks/'" :is-activity="false" :filters="{playable: true, ordering: '-creation_date', tag: id}">
<template slot="title">
<translate translate-context="*/*/*">Tracks</translate>
</template>
</track-widget>
<div class="ui clearing hidden divider"></div>
</div>
</section>
</main>
</template>
<script>
import TrackWidget from "@/components/audio/track/Widget"
import AlbumWidget from "@/components/audio/album/Widget"
import ArtistWidget from "@/components/audio/artist/Widget"
export default {
props: {
id: { type: String, required: true }
},
components: {
ArtistWidget,
AlbumWidget,
TrackWidget,
},
computed: {
labels() {
let title = `#${this.id}`
return {
title
}
},
isAuthenticated () {
return this.$store.state.auth.authenticated
},
hasFavorites () {
return this.$store.state.favorites.count > 0
},
},
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.ui.circular.label {
padding-left: 1em !important;
padding-right: 1em !important;
}
</style>
<template>
<div ref="dropdown" class="ui multiple search selection dropdown">
<input type="hidden">
<i class="dropdown icon"></i>
<input type="text" class="search">
<div class="default text">
<translate translate-context="*/Dropdown/Placeholder/Verb">Search for tags…</translate>
</div>
</div>
</template>
<script>
import $ from 'jquery'
import lodash from '@/lodash'
export default {
props: ['value'],
mounted () {
this.$nextTick(() => {
this.initDropdown()
})
},
methods: {
initDropdown () {
let self = this
let handleUpdate = () => {
let value = $(self.$refs.dropdown).dropdown('get value').split(',')
self.$emit('input', value)
return value
}
let settings = {
saveRemoteData: false,
filterRemoteData: true,
apiSettings: {
url: this.$store.getters['instance/absoluteUrl']('/api/v1/tags/?name__startswith={query}&ordering=length&page_size=5'),
beforeXHR: function (xhrObject) {
xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header'])
return xhrObject
},
},
fields: {
remoteValues: 'results',
value: 'name'
},
allowAdditions: true,
minCharacters: 1,
onAdd: handleUpdate,
onRemove: handleUpdate,
onLabelRemove: handleUpdate,
onChange: handleUpdate,
}
$(this.$refs.dropdown).dropdown(settings)
$(this.$refs.dropdown).dropdown('set exactly', this.value)
}
},
watch: {
value: {
handler (v) {
let current = $(this.$refs.dropdown).dropdown('get value').split(',').sort()
if (!lodash.isEqual([...v].sort(), current)) {
$(this.$refs.dropdown).dropdown('set exactly', v)
}
},
deep: true
}
}
}
</script>
......@@ -17,6 +17,8 @@
<div class="sub header" v-html="subtitle"></div>
</div>
</h2>
<tags-list v-if="track.tags && track.tags.length > 0" :tags="track.tags"></tags-list>
<div class="ui hidden divider"></div>
<div class="header-buttons">
<div class="ui buttons">
<play-button class="orange" :track="track">
......@@ -121,6 +123,7 @@ import TrackFavoriteIcon from "@/components/favorites/TrackFavoriteIcon"
import TrackPlaylistIcon from "@/components/playlists/TrackPlaylistIcon"
import Modal from '@/components/semantic/Modal'
import EmbedWizard from "@/components/audio/EmbedWizard"
import TagsList from "@/components/tags/List"
const FETCH_URL = "tracks/"
......@@ -131,7 +134,8 @@ export default {
TrackPlaylistIcon,
TrackFavoriteIcon,
Modal,
EmbedWizard
EmbedWizard,
TagsList,
},
data() {
return {
......
<template>
<div class="tag-list">
<router-link
:to="{name: 'library.tags.detail', params: {id: tag}}"
:class="['ui', 'circular', 'hashtag', 'label', labelClasses]"
v-for="tag in toDisplay"
:title="tag"
:key="tag">
#{{ tag|truncate(truncateSize) }}
</router-link>
<div role="button" @click.prevent="honorLimit = false" class="ui circular inverted teal label" v-if="showMore && toDisplay.length < tags.length">
<translate translate-context="Content/*/Button/Label/Verb" :translate-params="{count: tags.length - toDisplay.length}" :translate-n="tags.length - toDisplay.length" translate-plural="Show %{ count } more tags">Show 1 more tag</translate>
</div>
</div>
</template>
<script>
export default {
props: {
tags: {type: Array, required: true},
showMore: {type: Boolean, default: true},
truncateSize: {type: Number, default: 25},
limit: {type: Number, default: 5},
labelClasses: {type: String, default: ''},
},
data () {
return {
honorLimit: true,
}
},
computed: {
toDisplay () {
if (!this.honorLimit) {
return this.tags
}
return (this.tags || []).slice(0, this.limit)
}
}
}
</script>
<style lang="scss" scoped>
.ui.circular.label {
padding-left: 1em !important;
padding-right: 1em !important;
}
.hashtag {
margin: 0.25em;
}
</style>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment