kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Merge branch '223-management-interface' into 'develop'
Resolve "Add a management interface for artists/albums/tracks" Closes #223 and #241 See merge request funkwhale/funkwhale!216merge-requests/237/head
commit
218a92547e
|
@ -38,6 +38,10 @@ v1_patterns += [
|
|||
include(
|
||||
('funkwhale_api.instance.urls', 'instance'),
|
||||
namespace='instance')),
|
||||
url(r'^manage/',
|
||||
include(
|
||||
('funkwhale_api.manage.urls', 'manage'),
|
||||
namespace='manage')),
|
||||
url(r'^federation/',
|
||||
include(
|
||||
('funkwhale_api.federation.api_urls', 'federation'),
|
||||
|
|
|
@ -97,6 +97,7 @@ THIRD_PARTY_APPS = (
|
|||
'dynamic_preferences',
|
||||
'django_filters',
|
||||
'cacheops',
|
||||
'django_cleanup',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -12,6 +12,9 @@ class ActionSerializer(serializers.Serializer):
|
|||
filters = serializers.DictField(required=False)
|
||||
actions = None
|
||||
filterset_class = None
|
||||
# those are actions identifier where we don't want to allow the "all"
|
||||
# selector because it's to dangerous. Like object deletion.
|
||||
dangerous_actions = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.queryset = kwargs.pop('queryset')
|
||||
|
@ -49,6 +52,10 @@ class ActionSerializer(serializers.Serializer):
|
|||
'list of identifiers or the string "all".'.format(value))
|
||||
|
||||
def validate(self, data):
|
||||
dangerous = data['action'] in self.dangerous_actions
|
||||
if dangerous and self.initial_data['objects'] == 'all':
|
||||
raise serializers.ValidationError(
|
||||
'This action is to dangerous to be applied to all objects')
|
||||
if self.filterset_class and 'filters' in data:
|
||||
qs_filterset = self.filterset_class(
|
||||
data['filters'], queryset=data['objects'])
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
App that includes all views/serializers and stuff for management API
|
||||
"""
|
|
@ -0,0 +1,25 @@
|
|||
from django.db.models import Count
|
||||
|
||||
from django_filters import rest_framework as filters
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
|
||||
class ManageTrackFileFilterSet(filters.FilterSet):
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'track__title',
|
||||
'track__album__title',
|
||||
'track__artist__name',
|
||||
'source',
|
||||
])
|
||||
|
||||
class Meta:
|
||||
model = music_models.TrackFile
|
||||
fields = [
|
||||
'q',
|
||||
'track__album',
|
||||
'track__artist',
|
||||
'track',
|
||||
'library_track'
|
||||
]
|
|
@ -0,0 +1,82 @@
|
|||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from funkwhale_api.common import serializers as common_serializers
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
from . import filters
|
||||
|
||||
|
||||
class ManageTrackFileArtistSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = music_models.Artist
|
||||
fields = [
|
||||
'id',
|
||||
'mbid',
|
||||
'creation_date',
|
||||
'name',
|
||||
]
|
||||
|
||||
|
||||
class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
|
||||
artist = ManageTrackFileArtistSerializer()
|
||||
|
||||
class Meta:
|
||||
model = music_models.Album
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'artist',
|
||||
'release_date',
|
||||
'cover',
|
||||
'creation_date',
|
||||
)
|
||||
|
||||
|
||||
class ManageTrackFileTrackSerializer(serializers.ModelSerializer):
|
||||
artist = ManageTrackFileArtistSerializer()
|
||||
album = ManageTrackFileAlbumSerializer()
|
||||
|
||||
class Meta:
|
||||
model = music_models.Track
|
||||
fields = (
|
||||
'id',
|
||||
'mbid',
|
||||
'title',
|
||||
'album',
|
||||
'artist',
|
||||
'creation_date',
|
||||
'position',
|
||||
)
|
||||
|
||||
|
||||
class ManageTrackFileSerializer(serializers.ModelSerializer):
|
||||
track = ManageTrackFileTrackSerializer()
|
||||
|
||||
class Meta:
|
||||
model = music_models.TrackFile
|
||||
fields = (
|
||||
'id',
|
||||
'path',
|
||||
'source',
|
||||
'filename',
|
||||
'mimetype',
|
||||
'track',
|
||||
'duration',
|
||||
'mimetype',
|
||||
'bitrate',
|
||||
'size',
|
||||
'path',
|
||||
'library_track',
|
||||
)
|
||||
|
||||
|
||||
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
||||
actions = ['delete']
|
||||
dangerous_actions = ['delete']
|
||||
filterset_class = filters.ManageTrackFileFilterSet
|
||||
|
||||
@transaction.atomic
|
||||
def handle_delete(self, objects):
|
||||
return objects.delete()
|
|
@ -0,0 +1,11 @@
|
|||
from django.conf.urls import include, url
|
||||
from . import views
|
||||
|
||||
from rest_framework import routers
|
||||
library_router = routers.SimpleRouter()
|
||||
library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files')
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^library/',
|
||||
include((library_router.urls, 'instance'), namespace='library')),
|
||||
]
|
|
@ -0,0 +1,49 @@
|
|||
from rest_framework import mixins
|
||||
from rest_framework import response
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import list_route
|
||||
|
||||
from funkwhale_api.music import models as music_models
|
||||
from funkwhale_api.users.permissions import HasUserPermission
|
||||
|
||||
from . import filters
|
||||
from . import serializers
|
||||
|
||||
|
||||
class ManageTrackFileViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
queryset = (
|
||||
music_models.TrackFile.objects.all()
|
||||
.select_related(
|
||||
'track__artist',
|
||||
'track__album__artist',
|
||||
'library_track')
|
||||
.order_by('-id')
|
||||
)
|
||||
serializer_class = serializers.ManageTrackFileSerializer
|
||||
filter_class = filters.ManageTrackFileFilterSet
|
||||
permission_classes = (HasUserPermission,)
|
||||
required_permissions = ['library']
|
||||
ordering_fields = [
|
||||
'accessed_date',
|
||||
'modification_date',
|
||||
'creation_date',
|
||||
'track__artist__name',
|
||||
'bitrate',
|
||||
'size',
|
||||
'duration',
|
||||
]
|
||||
|
||||
@list_route(methods=['post'])
|
||||
def action(self, request, *args, **kwargs):
|
||||
queryset = self.get_queryset()
|
||||
serializer = serializers.ManageTrackFileActionSerializer(
|
||||
request.data,
|
||||
queryset=queryset,
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = serializer.save()
|
||||
return response.Response(result, status=200)
|
|
@ -117,6 +117,11 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
|
|||
status='finished',
|
||||
audio_file=None,
|
||||
)
|
||||
with_audio_file = factory.Trait(
|
||||
status='finished',
|
||||
audio_file=factory.django.FileField(
|
||||
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')),
|
||||
)
|
||||
|
||||
|
||||
@registry.register(name='music.FileImportJob')
|
||||
|
|
|
@ -27,7 +27,7 @@ PERMISSIONS_CONFIGURATION = {
|
|||
},
|
||||
'library': {
|
||||
'label': 'Manage library',
|
||||
'help_text': 'Manage library',
|
||||
'help_text': 'Manage library, delete files, tracks, artists, albums...',
|
||||
},
|
||||
'settings': {
|
||||
'label': 'Manage instance-level settings',
|
||||
|
|
|
@ -65,3 +65,4 @@ cryptography>=2,<3
|
|||
# requests-http-signature==0.0.3
|
||||
# clone until the branch is merged and released upstream
|
||||
git+https://github.com/EliotBerriot/requests-http-signature.git@signature-header-support
|
||||
django-cleanup==2.1.0
|
||||
|
|
|
@ -18,6 +18,17 @@ class TestSerializer(serializers.ActionSerializer):
|
|||
return {'hello': 'world'}
|
||||
|
||||
|
||||
class TestDangerousSerializer(serializers.ActionSerializer):
|
||||
actions = ['test', 'test_dangerous']
|
||||
dangerous_actions = ['test_dangerous']
|
||||
|
||||
def handle_test(self, objects):
|
||||
pass
|
||||
|
||||
def handle_test_dangerous(self, objects):
|
||||
pass
|
||||
|
||||
|
||||
def test_action_serializer_validates_action():
|
||||
data = {'objects': 'all', 'action': 'nope'}
|
||||
serializer = TestSerializer(data, queryset=models.User.objects.none())
|
||||
|
@ -98,3 +109,28 @@ def test_action_serializers_validates_at_least_one_object():
|
|||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'non_field_errors' in serializer.errors
|
||||
|
||||
|
||||
def test_dangerous_actions_refuses_all(factories):
|
||||
factories['users.User']()
|
||||
data = {
|
||||
'objects': 'all',
|
||||
'action': 'test_dangerous',
|
||||
}
|
||||
serializer = TestDangerousSerializer(
|
||||
data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'non_field_errors' in serializer.errors
|
||||
|
||||
|
||||
def test_dangerous_actions_refuses_not_listed(factories):
|
||||
factories['users.User']()
|
||||
data = {
|
||||
'objects': 'all',
|
||||
'action': 'test',
|
||||
}
|
||||
serializer = TestDangerousSerializer(
|
||||
data, queryset=models.User.objects.all())
|
||||
|
||||
assert serializer.is_valid() is True
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
from funkwhale_api.manage import serializers
|
||||
|
||||
|
||||
def test_manage_track_file_action_delete(factories):
|
||||
tfs = factories['music.TrackFile'](size=5)
|
||||
s = serializers.ManageTrackFileActionSerializer(queryset=None)
|
||||
|
||||
s.handle_delete(tfs.__class__.objects.all())
|
||||
|
||||
assert tfs.__class__.objects.count() == 0
|
|
@ -0,0 +1,26 @@
|
|||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.manage import serializers
|
||||
from funkwhale_api.manage import views
|
||||
|
||||
|
||||
@pytest.mark.parametrize('view,permissions,operator', [
|
||||
(views.ManageTrackFileViewSet, ['library'], 'and'),
|
||||
])
|
||||
def test_permissions(assert_user_permission, view, permissions, operator):
|
||||
assert_user_permission(view, permissions, operator)
|
||||
|
||||
|
||||
def test_track_file_view(factories, superuser_api_client):
|
||||
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||
qs = tfs[0].__class__.objects.order_by('-creation_date')
|
||||
url = reverse('api:v1:manage:library:track-files-list')
|
||||
|
||||
response = superuser_api_client.get(url, {'sort': '-creation_date'})
|
||||
expected = serializers.ManageTrackFileSerializer(
|
||||
qs, many=True, context={'request': response.wsgi_request}).data
|
||||
|
||||
assert response.data['count'] == len(tfs)
|
||||
assert response.data['results'] == expected
|
|
@ -0,0 +1,10 @@
|
|||
Files management interface for users with "library" permission (#223)
|
||||
|
||||
Files management interface
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This is the first bit of an ongoing work that will span several releases, to
|
||||
bring more powerful library management features to Funkwhale. This iteration
|
||||
includes a basic file management interface where users with the "library"
|
||||
permission can list and search available files, order them using
|
||||
various criterias (size, bitrate, duration...) and delete them.
|
|
@ -0,0 +1 @@
|
|||
Autoremove media files on model instance deletion (#241)
|
|
@ -68,6 +68,12 @@
|
|||
:title="$t('Pending import requests')">
|
||||
{{ notifications.importRequests }}</div>
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['library']"
|
||||
:to="{name: 'manage.library.files'}">
|
||||
<i class="book icon"></i>{{ $t('Library') }}
|
||||
</router-link>
|
||||
<router-link
|
||||
class="item"
|
||||
v-else-if="$store.state.auth.availablePermissions['upload']"
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
:class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']">
|
||||
{{ $t('Go') }}</div>
|
||||
<dangerous-button
|
||||
v-else :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
|
||||
v-else-if="!currentAction.isDangerous" :class="['ui', {disabled: checked.length === 0}, {'loading': actionLoading}, 'button']"
|
||||
confirm-color="green"
|
||||
color=""
|
||||
@confirm="launchAction">
|
||||
|
@ -36,7 +36,7 @@
|
|||
<div class="count field">
|
||||
<span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
|
||||
<span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
|
||||
<template v-if="checkable.length === checked.length">
|
||||
<template v-if="!currentAction.isDangerous && checkable.length === checked.length">
|
||||
<a @click="selectAll = true" v-if="!selectAll">
|
||||
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
|
||||
</a>
|
||||
|
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui inline form">
|
||||
<div class="fields">
|
||||
<div class="ui field">
|
||||
<label>{{ $t('Search') }}</label>
|
||||
<input type="text" v-model="search" placeholder="Search by title, artist, domain..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Ordering"/>
|
||||
<select class="ui dropdown" v-model="ordering">
|
||||
<option v-for="option in orderingOptions" :value="option[0]">
|
||||
{{ option[1] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Ordering direction"/>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="+">Ascending</option>
|
||||
<option value="-">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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/library/track-files/action/'"
|
||||
:filters="actionFilters">
|
||||
<template slot="header-cells">
|
||||
<th>{{ $t('Title') }}</th>
|
||||
<th>{{ $t('Artist') }}</th>
|
||||
<th>{{ $t('Album') }}</th>
|
||||
<th>{{ $t('Import date') }}</th>
|
||||
<th>{{ $t('Type') }}</th>
|
||||
<th>{{ $t('Bitrate') }}</th>
|
||||
<th>{{ $t('Duration') }}</th>
|
||||
<th>{{ $t('Size') }}</th>
|
||||
</template>
|
||||
<template slot="row-cells" slot-scope="scope">
|
||||
<td>
|
||||
<span :title="scope.obj.track.title">{{ scope.obj.track.title|truncate(30) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="scope.obj.track.artist.name">{{ scope.obj.track.artist.name|truncate(30) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="scope.obj.track.album.title">{{ scope.obj.track.album.title|truncate(20) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="scope.obj.creation_date"></human-date>
|
||||
</td>
|
||||
<td v-if="scope.obj.audio_mimetype">
|
||||
{{ scope.obj.audio_mimetype }}
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
<td v-if="scope.obj.bitrate">
|
||||
{{ scope.obj.bitrate | humanSize }}/s
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
<td v-if="scope.obj.duration">
|
||||
{{ time.parse(scope.obj.duration) }}
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
<td v-if="scope.obj.size">
|
||||
{{ scope.obj.size | humanSize }}
|
||||
</td>
|
||||
<td v-else>
|
||||
{{ $t('N/A') }}
|
||||
</td>
|
||||
</template>
|
||||
</action-table>
|
||||
</div>
|
||||
<div>
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
|
||||
<span v-if="result && result.results.length > 0">
|
||||
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import time from '@/utils/time'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import ActionTable from '@/components/common/ActionTable'
|
||||
import OrderingMixin from '@/components/mixins/Ordering'
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin],
|
||||
props: {
|
||||
filters: {type: Object, required: false}
|
||||
},
|
||||
components: {
|
||||
Pagination,
|
||||
ActionTable
|
||||
},
|
||||
data () {
|
||||
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
|
||||
return {
|
||||
time,
|
||||
isLoading: false,
|
||||
result: null,
|
||||
page: 1,
|
||||
paginateBy: 25,
|
||||
search: '',
|
||||
orderingDirection: defaultOrdering.direction || '+',
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
['accessed_date', 'Accessed date'],
|
||||
['modification_date', 'Modification date'],
|
||||
['size', 'Size'],
|
||||
['bitrate', 'Bitrate'],
|
||||
['duration', 'Duration']
|
||||
]
|
||||
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let params = _.merge({
|
||||
'page': this.page,
|
||||
'page_size': this.paginateBy,
|
||||
'q': this.search,
|
||||
'ordering': this.getOrderingAsString()
|
||||
}, this.filters)
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.checked = []
|
||||
axios.get('/manage/library/track-files/', {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: {
|
||||
actionFilters () {
|
||||
var currentFilters = {
|
||||
q: this.search
|
||||
}
|
||||
if (this.filters) {
|
||||
return _.merge(currentFilters, this.filters)
|
||||
} else {
|
||||
return currentFilters
|
||||
}
|
||||
},
|
||||
actions () {
|
||||
return [
|
||||
{
|
||||
name: 'delete',
|
||||
label: this.$t('Delete'),
|
||||
isDangerous: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search (newValue) {
|
||||
this.page = 1
|
||||
this.fetchData()
|
||||
},
|
||||
page () {
|
||||
this.fetchData()
|
||||
},
|
||||
ordering () {
|
||||
this.fetchData()
|
||||
},
|
||||
orderingDirection () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -29,6 +29,8 @@ import PlaylistDetail from '@/views/playlists/Detail'
|
|||
import PlaylistList from '@/views/playlists/List'
|
||||
import Favorites from '@/components/favorites/List'
|
||||
import AdminSettings from '@/views/admin/Settings'
|
||||
import AdminLibraryBase from '@/views/admin/library/Base'
|
||||
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
|
||||
import FederationBase from '@/views/federation/Base'
|
||||
import FederationScan from '@/views/federation/Scan'
|
||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||
|
@ -167,6 +169,17 @@ export default new Router({
|
|||
{ path: 'libraries/:id', name: 'federation.libraries.detail', component: FederationLibraryDetail, props: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/manage/library',
|
||||
component: AdminLibraryBase,
|
||||
children: [
|
||||
{
|
||||
path: 'files',
|
||||
name: 'manage.library.files',
|
||||
component: AdminLibraryFilesList
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
component: Library,
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="'Manage library'">
|
||||
<div class="ui secondary pointing menu">
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link>
|
||||
</div>
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../style/vendor/media';
|
||||
|
||||
.main.pusher > .ui.secondary.menu {
|
||||
@include media(">tablet") {
|
||||
margin: 0 2.5rem;
|
||||
}
|
||||
.item {
|
||||
padding-top: 1.5em;
|
||||
padding-bottom: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div v-title="'Files'">
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">{{ $t('Library files') }}</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
<library-files-table :show-library="true"></library-files-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LibraryFilesTable from '@/components/manage/library/FilesTable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LibraryFilesTable
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
Ładowanie…
Reference in New Issue