From 1674c771ca6baff17bb7b8e7ee2028580934c320 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Thu, 25 Jul 2019 10:40:34 +0200 Subject: [PATCH] See #432: added tag admin UI (list, detail) --- front/src/components/library/TagDetail.vue | 5 + .../components/manage/library/TagsTable.vue | 209 +++++++ front/src/components/mixins/Translations.vue | 2 + front/src/router/index.js | 569 +++++++++++------- front/src/views/admin/library/Base.vue | 3 + front/src/views/admin/library/TagDetail.vue | 215 +++++++ front/src/views/admin/library/TagsList.vue | 29 + 7 files changed, 802 insertions(+), 230 deletions(-) create mode 100644 front/src/components/manage/library/TagsTable.vue create mode 100644 front/src/views/admin/library/TagDetail.vue create mode 100644 front/src/views/admin/library/TagsList.vue diff --git a/front/src/components/library/TagDetail.vue b/front/src/components/library/TagDetail.vue index 904bce7a..e928caa2 100644 --- a/front/src/components/library/TagDetail.vue +++ b/front/src/components/library/TagDetail.vue @@ -7,6 +7,11 @@ </span> </h2> <radio-button type="tag" :object-id="id"></radio-button> + <router-link class="ui right floated button" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.tags.detail', params: {id: id}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + <div class="ui hidden divider"></div> <div class="ui row"> <artist-widget :controls="false" :filters="{playable: true, ordering: '-creation_date', tag: id}"> diff --git a/front/src/components/manage/library/TagsTable.vue b/front/src/components/manage/library/TagsTable.vue new file mode 100644 index 00000000..b26e8685 --- /dev/null +++ b/front/src/components/manage/library/TagsTable.vue @@ -0,0 +1,209 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui six wide field"> + <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label> + <form @submit.prevent="search.query = $refs.search.value"> + <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" /> + </form> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ sharedLabels.filters[option[1]] }} + </option> + </select> + </div> + <div class="field"> + <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering direction</translate></label> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option> + <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option> + </select> + </div> + </div> + </div> + <import-status-modal :upload="detailedUpload" :show.sync="showUploadDetailModal" /> + <div class="dimmable"> + <div v-if="isLoading" class="ui active inverted dimmer"> + <div class="ui loader"></div> + </div> + <action-table + v-if="result" + @action-launched="fetchData" + :objects-data="result" + :actions="actions" + action-url="manage/tags/action/" + idField="name" + :filters="actionFilters"> + <template slot="header-cells"> + <th><translate translate-context="*/*/*">Name</translate></th> + <th><translate translate-context="*/*/*">Artists</translate></th> + <th><translate translate-context="*/*/*">Albums</translate></th> + <th><translate translate-context="*/*/*">Tracks</translate></th> + <th><translate translate-context="Content/*/*/Noun">Creation date</translate></th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.library.tags.detail', params: {id: scope.obj.name }}" :title="scope.obj.name"> + {{ scope.obj.name|truncate(30, "…", true) }} + </router-link> + </td> + <td> + {{ scope.obj.artists_count }} + </td> + <td> + {{ scope.obj.albums_count }} + </td> + <td> + {{ scope.obj.tracks_count }} + </td> + <td> + <human-date :date="scope.obj.creation_date"></human-date> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.count > paginateBy" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + <translate translate-context="Content/*/Paragraph" + :translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}"> + Showing results %{ start }-%{ end } on %{ total } + </translate> + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from '@/lodash' +import time from '@/utils/time' +import {normalizeQuery, parseTokens} from '@/search' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' +import TranslationsMixin from '@/components/mixins/Translations' +import SmartSearchMixin from '@/components/mixins/SmartSearch' +import ImportStatusModal from '@/components/library/ImportStatusModal' + + +export default { + mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin], + props: { + filters: {type: Object, required: false}, + }, + components: { + Pagination, + ActionTable, + ImportStatusModal + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date') + return { + detailedUpload: null, + showUploadDetailModal: false, + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: { + query: this.defaultQuery, + tokens: parseTokens(normalizeQuery(this.defaultQuery)) + }, + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['creation_date', 'creation_date'], + ['name', 'name'], + ['length', 'length'], + ['items_count', 'items_count'], + ] + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search.query, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/tags/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + selectPage: function (page) { + this.page = page + }, + }, + computed: { + labels () { + return { + searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by name') + } + }, + actionFilters () { + var currentFilters = { + q: this.search.query + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + let deleteLabel = this.$pgettext('*/*/*/Verb', 'Delete') + let confirmationMessage = this.$pgettext('Popup/*/Paragraph', 'The selected tag will be removed and unlinked with existing content, if any. This action is irreversible.') + return [ + { + name: 'delete', + label: deleteLabel, + confirmationMessage: confirmationMessage, + isDangerous: true, + allowAll: false, + confirmColor: 'red', + }, + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/components/mixins/Translations.vue b/front/src/components/mixins/Translations.vue index 56ea3ed1..e6e80925 100644 --- a/front/src/components/mixins/Translations.vue +++ b/front/src/components/mixins/Translations.vue @@ -52,6 +52,8 @@ export default { album_title: this.$pgettext('Content/*/Dropdown/Noun', 'Album name'), artist_name: this.$pgettext('Content/*/Dropdown/Noun', 'Artist name'), name: this.$pgettext('*/*/*/Noun', 'Name'), + length: this.$pgettext('*/*/*/Noun', 'Length'), + items_count: this.$pgettext('*/*/*/Noun', 'Items'), size: this.$pgettext('Content/Library/*/in MB', 'Size'), bitrate: this.$pgettext('Content/Track/*/Noun', 'Bitrate'), duration: this.$pgettext('Content/*/*', 'Duration'), diff --git a/front/src/router/index.js b/front/src/router/index.js index 1d499674..af21b168 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -1,430 +1,508 @@ -import Vue from 'vue' -import Router from 'vue-router' +import Vue from "vue" +import Router from "vue-router" Vue.use(Router) -console.log('PROCESS', process.env) +console.log("PROCESS", process.env) export default new Router({ - mode: 'history', - linkActiveClass: 'active', - base: process.env.VUE_APP_ROUTER_BASE_URL || '/', + mode: "history", + linkActiveClass: "active", + base: process.env.VUE_APP_ROUTER_BASE_URL || "/", routes: [ { - path: '/', - name: 'index', + path: "/", + name: "index", component: () => - import(/* webpackChunkName: "core" */ "@/components/Home"), + import(/* webpackChunkName: "core" */ "@/components/Home") }, { - path: '/front', - name: 'front', - redirect: '/' + path: "/front", + name: "front", + redirect: "/" }, { - path: '/about', - name: 'about', + path: "/about", + name: "about", component: () => - import(/* webpackChunkName: "core" */ "@/components/About"), + import(/* webpackChunkName: "core" */ "@/components/About") }, { - path: '/login', - name: 'login', + path: "/login", + name: "login", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Login"), - props: (route) => ({ next: route.query.next || '/library' }) + props: route => ({ next: route.query.next || "/library" }) }, { - path: '/notifications', - name: 'notifications', + path: "/notifications", + name: "notifications", component: () => - import(/* webpackChunkName: "core" */ "@/views/Notifications"), + import(/* webpackChunkName: "core" */ "@/views/Notifications") }, { - path: '/auth/password/reset', - name: 'auth.password-reset', + path: "/auth/password/reset", + name: "auth.password-reset", component: () => import(/* webpackChunkName: "core" */ "@/views/auth/PasswordReset"), - props: (route) => ({ + props: route => ({ defaultEmail: route.query.email }) }, { - path: '/auth/email/confirm', - name: 'auth.email-confirm', + path: "/auth/email/confirm", + name: "auth.email-confirm", component: () => import(/* webpackChunkName: "core" */ "@/views/auth/EmailConfirm"), - props: (route) => ({ + props: route => ({ defaultKey: route.query.key }) }, { - path: '/auth/password/reset/confirm', - name: 'auth.password-reset-confirm', + path: "/auth/password/reset/confirm", + name: "auth.password-reset-confirm", component: () => - import(/* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/views/auth/PasswordResetConfirm" + ), + props: route => ({ defaultUid: route.query.uid, defaultToken: route.query.token }) }, { - path: '/authorize', - name: 'authorize', + path: "/authorize", + name: "authorize", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Authorize"), - props: (route) => ({ + props: route => ({ clientId: route.query.client_id, redirectUri: route.query.redirect_uri, scope: route.query.scope, responseType: route.query.response_type, nonce: route.query.nonce, - state: route.query.state, + state: route.query.state }) }, { - path: '/signup', - name: 'signup', + path: "/signup", + name: "signup", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Signup"), - props: (route) => ({ + props: route => ({ defaultInvitation: route.query.invitation }) }, { - path: '/logout', - name: 'logout', + path: "/logout", + name: "logout", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Logout"), - + import(/* webpackChunkName: "core" */ "@/components/auth/Logout") }, { - path: '/settings', - name: 'settings', + path: "/settings", + name: "settings", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/Settings"), + import(/* webpackChunkName: "core" */ "@/components/auth/Settings") }, { - path: '/settings/applications/new', - name: 'settings.applications.new', - props: (route) => ({ + path: "/settings/applications/new", + name: "settings.applications.new", + props: route => ({ scopes: route.query.scopes, name: route.query.name, - redirect_uris: route.query.redirect_uris, + redirect_uris: route.query.redirect_uris }), component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/ApplicationNew"), + import( + /* webpackChunkName: "core" */ "@/components/auth/ApplicationNew" + ) }, { - path: '/settings/applications/:id/edit', - name: 'settings.applications.edit', + path: "/settings/applications/:id/edit", + name: "settings.applications.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit"), + import( + /* webpackChunkName: "core" */ "@/components/auth/ApplicationEdit" + ), props: true }, { - path: '/@:username', - name: 'profile', + path: "/@:username", + name: "profile", component: () => import(/* webpackChunkName: "core" */ "@/components/auth/Profile"), props: true }, { - path: '/favorites', + path: "/favorites", component: () => import(/* webpackChunkName: "core" */ "@/components/favorites/List"), - props: (route) => ({ + props: route => ({ defaultOrdering: route.query.ordering, defaultPage: route.query.page, defaultPaginateBy: route.query.paginateBy }) }, { - path: '/content', + path: "/content", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.index', + path: "", + name: "content.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/Home"), + import(/* webpackChunkName: "core" */ "@/views/content/Home") } ] }, { - path: '/content/libraries/tracks', + path: "/content/libraries/tracks", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.libraries.files', + path: "", + name: "content.libraries.files", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Files"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Files" + ), + props: route => ({ query: route.query.q }) } ] }, { - path: '/content/libraries', + path: "/content/libraries", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.libraries.index', + path: "", + name: "content.libraries.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Home"), + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Home" + ) }, { - path: ':id/upload', - name: 'content.libraries.detail.upload', + path: ":id/upload", + name: "content.libraries.detail.upload", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Upload"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Upload" + ), + props: route => ({ id: route.params.id, defaultImportReference: route.query.import }) }, { - path: ':id', - name: 'content.libraries.detail', + path: ":id", + name: "content.libraries.detail", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/libraries/Detail"), + import( + /* webpackChunkName: "core" */ "@/views/content/libraries/Detail" + ), props: true } ] }, { - path: '/content/remote', + path: "/content/remote", component: () => import(/* webpackChunkName: "core" */ "@/views/content/Base"), children: [ { - path: '', - name: 'content.remote.index', + path: "", + name: "content.remote.index", component: () => - import(/* webpackChunkName: "core" */ "@/views/content/remote/Home"), + import(/* webpackChunkName: "core" */ "@/views/content/remote/Home") } ] }, { - path: '/manage/settings', - name: 'manage.settings', + path: "/manage/settings", + name: "manage.settings", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/Settings"), + import(/* webpackChunkName: "admin" */ "@/views/admin/Settings") }, { - path: '/manage/library', + path: "/manage/library", component: () => import(/* webpackChunkName: "admin" */ "@/views/admin/library/Base"), children: [ { - path: 'edits', - name: 'manage.library.edits', + path: "edits", + name: "manage.library.edits", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/EditsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/EditsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'artists', - name: 'manage.library.artists', + path: "artists", + name: "manage.library.artists", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/ArtistsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'artists/:id', - name: 'manage.library.artists.detail', + path: "artists/:id", + name: "manage.library.artists.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/ArtistDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/ArtistDetail" + ), props: true }, { - path: 'albums', - name: 'manage.library.albums', + path: "albums", + name: "manage.library.albums", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/AlbumsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'albums/:id', - name: 'manage.library.albums.detail', + path: "albums/:id", + name: "manage.library.albums.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/AlbumDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/AlbumDetail" + ), props: true }, { - path: 'tracks', - name: 'manage.library.tracks', + path: "tracks", + name: "manage.library.tracks", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/TracksList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TracksList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'tracks/:id', - name: 'manage.library.tracks.detail', + path: "tracks/:id", + name: "manage.library.tracks.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/TrackDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TrackDetail" + ), props: true }, { - path: 'libraries', - name: 'manage.library.libraries', + path: "libraries", + name: "manage.library.libraries", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/LibrariesList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/LibrariesList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'libraries/:id', - name: 'manage.library.libraries.detail', + path: "libraries/:id", + name: "manage.library.libraries.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/LibraryDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/LibraryDetail" + ), props: true }, { - path: 'uploads', - name: 'manage.library.uploads', + path: "uploads", + name: "manage.library.uploads", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/UploadsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/UploadsList" + ), + props: route => { return { - defaultQuery: route.query.q, + defaultQuery: route.query.q } } }, { - path: 'uploads/:id', - name: 'manage.library.uploads.detail', + path: "uploads/:id", + name: "manage.library.uploads.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/library/UploadDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/UploadDetail" + ), props: true }, + { + path: "tags", + name: "manage.library.tags", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TagsList" + ), + props: route => { + return { + defaultQuery: route.query.q + } + } + }, + { + path: "tags/:id", + name: "manage.library.tags.detail", + component: () => + import( + /* webpackChunkName: "admin" */ "@/views/admin/library/TagDetail" + ), + props: true + } ] }, { - path: '/manage/users', + path: "/manage/users", component: () => import(/* webpackChunkName: "admin" */ "@/views/admin/users/Base"), children: [ { - path: 'users', - name: 'manage.users.users.list', + path: "users", + name: "manage.users.users.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/users/UsersList"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/users/UsersList" + ) }, { - path: 'invitations', - name: 'manage.users.invitations.list', + path: "invitations", + name: "manage.users.invitations.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/users/InvitationsList"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/users/InvitationsList" + ) } ] }, { - path: '/manage/moderation', + path: "/manage/moderation", component: () => import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/Base"), children: [ { - path: 'domains', - name: 'manage.moderation.domains.list', + path: "domains", + name: "manage.moderation.domains.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsList"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsList" + ) }, { - path: 'domains/:id', - name: 'manage.moderation.domains.detail', + path: "domains/:id", + name: "manage.moderation.domains.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/DomainsDetail" + ), props: true }, { - path: 'accounts', - name: 'manage.moderation.accounts.list', + path: "accounts", + name: "manage.moderation.accounts.list", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsList"), - props: (route) => { + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsList" + ), + props: route => { return { - defaultQuery: route.query.q, - + defaultQuery: route.query.q } } }, { - path: 'accounts/:id', - name: 'manage.moderation.accounts.detail', + path: "accounts/:id", + name: "manage.moderation.accounts.detail", component: () => - import(/* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail"), + import( + /* webpackChunkName: "admin" */ "@/views/admin/moderation/AccountsDetail" + ), props: true } ] }, { - path: '/library', + path: "/library", component: () => import(/* webpackChunkName: "core" */ "@/components/library/Library"), children: [ { - path: '', + path: "", component: () => import(/* webpackChunkName: "core" */ "@/components/library/Home"), - name: 'library.index' + name: "library.index" }, { - path: 'artists/', - name: 'library.artists.browse', + path: "artists/", + name: "library.artists.browse", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/Artists"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/components/library/Artists" + ), + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) ? route.query.tag : [route.query.tag], + defaultTags: Array.isArray(route.query.tag || []) + ? route.query.tag + : [route.query.tag], defaultPaginateBy: route.query.paginateBy, defaultPage: route.query.page }) }, { - path: 'albums/', - name: 'library.albums.browse', + path: "albums/", + name: "library.albums.browse", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/Albums"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/components/library/Albums" + ), + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, - defaultTags: Array.isArray(route.query.tag || []) ? route.query.tag : [route.query.tag], + defaultTags: Array.isArray(route.query.tag || []) + ? route.query.tag + : [route.query.tag], defaultPaginateBy: route.query.paginateBy, defaultPage: route.query.page }) }, { - path: 'radios/', - name: 'library.radios.browse', + path: "radios/", + name: "library.radios.browse", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/Radios"), - props: (route) => ({ + import( + /* webpackChunkName: "core" */ "@/components/library/Radios" + ), + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, defaultPaginateBy: route.query.paginateBy, @@ -432,32 +510,36 @@ export default new Router({ }) }, { - path: 'radios/build', - name: 'library.radios.build', + path: "radios/build", + name: "library.radios.build", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/radios/Builder"), + import( + /* webpackChunkName: "core" */ "@/components/library/radios/Builder" + ), props: true }, { - path: 'radios/build/:id', - name: 'library.radios.edit', + path: "radios/build/:id", + name: "library.radios.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/radios/Builder"), + import( + /* webpackChunkName: "core" */ "@/components/library/radios/Builder" + ), props: true }, { - path: 'radios/:id', - name: 'library.radios.detail', + path: "radios/:id", + name: "library.radios.detail", component: () => import(/* webpackChunkName: "core" */ "@/views/radios/Detail"), props: true }, { - path: 'playlists/', - name: 'library.playlists.browse', + path: "playlists/", + name: "library.playlists.browse", component: () => import(/* webpackChunkName: "core" */ "@/views/playlists/List"), - props: (route) => ({ + props: route => ({ defaultOrdering: route.query.ordering, defaultQuery: route.query.query, defaultPaginateBy: route.query.paginateBy, @@ -465,112 +547,139 @@ export default new Router({ }) }, { - path: 'playlists/:id', - name: 'library.playlists.detail', + path: "playlists/:id", + name: "library.playlists.detail", component: () => import(/* webpackChunkName: "core" */ "@/views/playlists/Detail"), - props: (route) => ({ + props: route => ({ id: route.params.id, - defaultEdit: route.query.mode === 'edit' }) + defaultEdit: route.query.mode === "edit" + }) }, { - path: 'tags/:id', - name: 'library.tags.detail', + path: "tags/:id", + name: "library.tags.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TagDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/TagDetail" + ), + props: true }, { - path: 'artists/:id', + path: "artists/:id", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/ArtistBase"), + import( + /* webpackChunkName: "core" */ "@/components/library/ArtistBase" + ), props: true, children: [ { - path: '', - name: 'library.artists.detail', + path: "", + name: "library.artists.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/ArtistDetail"), + import( + /* webpackChunkName: "core" */ "@/components/library/ArtistDetail" + ) }, { - path: 'edit', - name: 'library.artists.edit', + path: "edit", + name: "library.artists.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/ArtistEdit"), + import( + /* webpackChunkName: "core" */ "@/components/library/ArtistEdit" + ) }, { - path: 'edit/:editId', - name: 'library.artists.edit.detail', + path: "edit/:editId", + name: "library.artists.edit.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/EditDetail" + ), + props: true } ] }, { - path: 'albums/:id', + path: "albums/:id", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/AlbumBase"), + import( + /* webpackChunkName: "core" */ "@/components/library/AlbumBase" + ), props: true, children: [ { - path: '', - name: 'library.albums.detail', + path: "", + name: "library.albums.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/AlbumDetail"), + import( + /* webpackChunkName: "core" */ "@/components/library/AlbumDetail" + ) }, { - path: 'edit', - name: 'library.albums.edit', + path: "edit", + name: "library.albums.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/AlbumEdit"), + import( + /* webpackChunkName: "core" */ "@/components/library/AlbumEdit" + ) }, { - path: 'edit/:editId', - name: 'library.albums.edit.detail', + path: "edit/:editId", + name: "library.albums.edit.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/EditDetail" + ), + props: true } ] }, { - path: 'tracks/:id', + path: "tracks/:id", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TrackBase"), + import( + /* webpackChunkName: "core" */ "@/components/library/TrackBase" + ), props: true, children: [ { - path: '', - name: 'library.tracks.detail', + path: "", + name: "library.tracks.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TrackDetail"), + import( + /* webpackChunkName: "core" */ "@/components/library/TrackDetail" + ) }, { - path: 'edit', - name: 'library.tracks.edit', + path: "edit", + name: "library.tracks.edit", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/TrackEdit"), + import( + /* webpackChunkName: "core" */ "@/components/library/TrackEdit" + ) }, { - path: 'edit/:editId', - name: 'library.tracks.edit.detail', + path: "edit/:editId", + name: "library.tracks.edit.detail", component: () => - import(/* webpackChunkName: "core" */ "@/components/library/EditDetail"), - props: true, + import( + /* webpackChunkName: "core" */ "@/components/library/EditDetail" + ), + props: true } ] - }, + } ] }, { - path: '*/index.html', - redirect: '/' + path: "*/index.html", + redirect: "/" }, { - path: '*', + path: "*", component: () => - import(/* webpackChunkName: "core" */ "@/components/PageNotFound"), + import(/* webpackChunkName: "core" */ "@/components/PageNotFound") } ] }) diff --git a/front/src/views/admin/library/Base.vue b/front/src/views/admin/library/Base.vue index 009e1ca9..9538f9cd 100644 --- a/front/src/views/admin/library/Base.vue +++ b/front/src/views/admin/library/Base.vue @@ -19,6 +19,9 @@ <router-link class="ui item" :to="{name: 'manage.library.uploads'}"><translate translate-context="*/*/*">Uploads</translate></router-link> + <router-link + class="ui item" + :to="{name: 'manage.library.tags'}"><translate translate-context="*/*/*">Tags</translate></router-link> </nav> <router-view :key="$route.fullPath"></router-view> </div> diff --git a/front/src/views/admin/library/TagDetail.vue b/front/src/views/admin/library/TagDetail.vue new file mode 100644 index 00000000..24c45dea --- /dev/null +++ b/front/src/views/admin/library/TagDetail.vue @@ -0,0 +1,215 @@ +<template> + <main> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.name"> + <div class="ui stackable one column grid"> + <div class="ui column"> + <div class="segment-content"> + <h2 class="ui header"> + <i class="circular inverted hashtag icon"></i> + <div class="content"> + {{ object.name | truncate(100) }} + </div> + </h2> + <div class="header-buttons"> + + <div class="ui icon buttons"> + <router-link class="ui labeled icon button" :to="{name: 'library.tags.detail', params: {id: object.name }}"> + <i class="info icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">Open local profile</translate> + </router-link> + <div class="ui floating dropdown icon button" v-dropdown> + <i class="dropdown icon"></i> + <div class="menu"> + <a + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/tags/tag/${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> + </a> + </div> + </div> + </div> + <div class="ui buttons"> + <dangerous-button + :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 tag?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The tag will be removed and unlinked from any existing entity. This action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> + </div> + </div> + </div> + </div> + </div> + </section> + <div class="ui vertical stripe segment"> + <div class="ui stackable three column grid"> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="info icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Tag data</translate> + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="*/*/*/Noun">Name</translate> + </td> + <td> + {{ object.name }} + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="feed icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Activity</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <div v-if="isLoadingStats" class="ui placeholder"> + <div class="full line"></div> + <div class="short line"></div> + <div class="medium line"></div> + <div class="long line"></div> + </div> + <table v-else class="ui very basic table"> + <tbody> + <tr> + <td> + <translate translate-context="Content/Moderation/Table.Label/Short (Value is a date)">First seen</translate> + </td> + <td> + <human-date :date="object.creation_date"></human-date> + </td> + </tr> + </tbody> + </table> + </section> + </div> + <div class="column"> + <section> + <h3 class="ui header"> + <i class="music icon"></i> + <div class="content"> + <translate translate-context="Content/Moderation/Title">Audio content</translate> + <span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span> + + </div> + </h3> + <table class="ui very basic table"> + <tbody> + <tr> + <td> + <router-link :to="{name: 'manage.library.artists', query: {q: getQuery('tag', object.name) }}"> + <translate translate-context="*/*/*">Artists</translate> + </router-link> + </td> + <td> + {{ object.artists_count }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.albums', query: {q: getQuery('tag', object.name) }}"> + <translate translate-context="*/*/*">Albums</translate> + </router-link> + </td> + <td> + {{ object.albums_count }} + </td> + </tr> + <tr> + <td> + <router-link :to="{name: 'manage.library.tracks', query: {q: getQuery('tag', object.name) }}"> + <translate translate-context="*/*/*">Tracks</translate> + </router-link> + </td> + <td> + {{ object.tracks_count }} + </td> + </tr> + </tbody> + </table> + + </section> + </div> + </div> + </div> + + </template> + </main> +</template> + +<script> +import axios from "axios" +import logger from "@/logging" + +import FetchButton from "@/components/federation/FetchButton" + +export default { + props: ["id"], + components: { + FetchButton + }, + data() { + return { + isLoading: true, + isLoadingStats: false, + object: null, + stats: null, + } + }, + created() { + this.fetchData() + }, + methods: { + fetchData() { + var self = this + this.isLoading = true + let url = `manage/tags/${this.id}/` + axios.get(url).then(response => { + self.object = response.data + self.isLoading = false + }) + }, + remove () { + var self = this + this.isLoading = true + let url = `manage/tags/${this.id}/` + axios.delete(url).then(response => { + self.$router.push({name: 'manage.library.tags'}) + }) + }, + getQuery (field, value) { + return `${field}:"${value}"` + } + }, + computed: { + labels() { + return { + statsWarning: this.$pgettext('Content/Moderation/Help text', 'Statistics are computed from known activity and content on your instance, and do not reflect general activity for this object'), + } + }, + } +} +</script> diff --git a/front/src/views/admin/library/TagsList.vue b/front/src/views/admin/library/TagsList.vue new file mode 100644 index 00000000..2ac4e4e1 --- /dev/null +++ b/front/src/views/admin/library/TagsList.vue @@ -0,0 +1,29 @@ +<template> + <main v-title="labels.title"> + <section class="ui vertical stripe segment"> + <h2 class="ui header">{{ labels.title }}</h2> + <div class="ui hidden divider"></div> + <tags-table :update-url="true" :default-query="defaultQuery"></tags-table> + </section> + </main> +</template> + +<script> +import TagsTable from "@/components/manage/library/TagsTable" + +export default { + components: { + TagsTable + }, + props: { + defaultQuery: {type: String, required: false}, + }, + computed: { + labels() { + return { + title: this.$pgettext('*/*/*', 'Tags') + } + } + } +} +</script> -- GitLab