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>&nbsp;
+                  </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>&nbsp;
+                      </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>&nbsp;
+                  <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>&nbsp;
+                  <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