diff --git a/package.json b/package.json index 68f8bf6..bfbb6fb 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "angular": "^1.5.0", "angular-animate": "^1.5.0", "angular-aria": "^1.5.0", - "angular-breadcrumb": "^0.4.1", "angular-material": "^1.0.5", "angular-messages": "^1.5.0", "angular-translate": "^2.9.2", @@ -49,6 +48,7 @@ "javascript-natural-sort": "^0.7.1", "lodash": "^4.5.1", "material-design-icons": "^2.2.0", + "ng-file-upload": "^12.0.4", "satellizer": "^0.14.0" }, "devDependencies": { diff --git a/src/components/bucket/bucket.controller.js b/src/components/bucket/bucket.controller.js index 766be3c..c3fb2c7 100644 --- a/src/components/bucket/bucket.controller.js +++ b/src/components/bucket/bucket.controller.js @@ -1,8 +1,8 @@ export default class BucketController { /** @ngInject */ - constructor($scope, $bucket) { + constructor($scope, $bucket, $state, $breadcrumb) { Object.assign(this, { - $scope, $bucket, + $scope, $bucket, $state, }); this.$scope.$watch( @@ -10,10 +10,15 @@ export default class BucketController { newVal => Object.assign(this, newVal) , true); + $breadcrumb.initPaths(); this.$bucket.getBuckets(); } createBucket($event) { this.$bucket.createDialog($event); } + + selectBucket(bucket) { + this.$state.go('file', { path: bucket }); + } } diff --git a/src/components/bucket/bucket.html b/src/components/bucket/bucket.html index fd50d39..0044eeb 100644 --- a/src/components/bucket/bucket.html +++ b/src/components/bucket/bucket.html @@ -12,14 +12,13 @@ - + info_outline - - + diff --git a/src/components/bucket/bucket.js b/src/components/bucket/bucket.js index 1c31f17..1721a52 100644 --- a/src/components/bucket/bucket.js +++ b/src/components/bucket/bucket.js @@ -14,9 +14,6 @@ const route = $stateProvider => { controllerAs: 'bucket', template: BucketTemplate, onEnter: $nav => $nav.setTypeToBucket(), - ncyBreadcrumb: { - label: 'All Buckets ( {{ bucket.data.length }} )', - }, }); }; diff --git a/src/components/bucket/bucket.service.js b/src/components/bucket/bucket.service.js index 6fee2d9..2720db6 100644 --- a/src/components/bucket/bucket.service.js +++ b/src/components/bucket/bucket.service.js @@ -1,13 +1,13 @@ import { element } from 'angular'; -import natural from 'javascript-natural-sort'; +import { sortByName } from '../../utils/sort'; import BucketCreateController from './create/create.controller'; import BucketCreateTemplate from './create/create.html'; export default class BucketService { /** @ngInject */ - constructor($fetch, $toast, $mdDialog) { + constructor($fetch, $toast, $mdDialog, $breadcrumb) { Object.assign(this, { - $fetch, $toast, $mdDialog, + $fetch, $toast, $mdDialog, $breadcrumb, }); this.initState(); @@ -73,20 +73,6 @@ export default class BucketService { this.resetCheckBucketState(); } - /** - * Natural sort for the specified object key. - * - * @param {Object} a - * @param {Object} b - * @return {Integer} - */ - sortByName(a, b) { - const x = a.Name; - const y = b.Name; - - return natural(x, y); - } - /** * Call the bucket list API and modify the state of service. * @@ -99,13 +85,14 @@ export default class BucketService { this.$fetch.post('/v1/bucket/list') .then(({ data }) => { this.state.lists.error = false; - this.state.lists.data = data.Buckets.sort(this.sortByName); + this.state.lists.data = data.Buckets.sort(sortByName); }) .catch(() => { this.state.lists.error = true; }) .finally(() => { this.state.lists.requesting = false; + this.$breadcrumb.updateBucketPath(this.state.lists.data.length); }); } @@ -140,7 +127,7 @@ export default class BucketService { createBucket(bucket) { this.$fetch.post('/v1/bucket/create', { bucket }) .then(({ data }) => { - this.state.lists.data = data.Buckets.sort(this.sortByName); + this.state.lists.data = data.Buckets.sort(sortByName); this.$toast.show(`Bucket ${bucket} has created!`); }) .catch(() => { diff --git a/src/components/file/file.controller.js b/src/components/file/file.controller.js new file mode 100644 index 0000000..7a68940 --- /dev/null +++ b/src/components/file/file.controller.js @@ -0,0 +1,47 @@ +export default class FileController { + /** @ngInject */ + constructor($scope, $location, $stateParams, $file, $bucket, $breadcrumb, $upload, $folder) { + Object.assign(this, { + $location, $file, $upload, $bucket, $breadcrumb, $folder, + }); + + $scope.$watch( + () => $file.state.lists, + newVal => Object.assign(this, newVal) + , true); + + const paths = $stateParams.path.split('/'); + const [bucket, ...folders] = paths; + const prefix = (folders.length) ? `${folders.join('/')}/` : ''; + + this.$file.setPaths(bucket, prefix); + this.$breadcrumb.updateFilePath(paths); + + this.$bucket.getBuckets(); + this.$file.getFiles(); + } + + createFolder($event) { + this.$folder.createDialog($event); + } + + clickFile({ isFolder, display }) { + if (isFolder) { + const currentPath = this.$file.getFullPaths(); + const path = `/bucket/${currentPath}${display}`; + this.$location.path(path); + } + } + + selectFile(name) { + this.$file.selectFile(name); + } + + upload($event) { + this.$upload.createDialog($event); + } + + refresh() { + this.$file.getFiles(); + } +} diff --git a/src/components/file/file.css b/src/components/file/file.css new file mode 100644 index 0000000..551d8f5 --- /dev/null +++ b/src/components/file/file.css @@ -0,0 +1,24 @@ + +/** + * @author Jamie jamie.h@inwinstack.com + */ + +.checkbox-icon-width { + width: 80px; +} + +.storage-class-width { + width: 140px; +} + +.size-width { + width: 84px; +} + +.time-width { + width: 270px; +} + +.time-title-width { + width: 286px; +} \ No newline at end of file diff --git a/src/components/file/file.html b/src/components/file/file.html new file mode 100644 index 0000000..067ce97 --- /dev/null +++ b/src/components/file/file.html @@ -0,0 +1,104 @@ + +
+ + + + + + + + + + +
NameStorage ClassSizeLast Modified
+ + + + +

+ +

+

+ +

+

+ +

+

+ +

+
+
+
+ +
+ +
Loading...
+
+ +
+
This bucket is empty
+
You can do the following actions
+ +
+ + file_upload + Upload File + + + or + + + create_new_folder + Create Folder + +
+
+ +
+
Oops, your connection seems off...
+
Don't worry. You can refresh to try again.
+ + + refresh + +
+
diff --git a/src/components/file/file.js b/src/components/file/file.js new file mode 100644 index 0000000..edad693 --- /dev/null +++ b/src/components/file/file.js @@ -0,0 +1,31 @@ +import { module } from 'angular'; +import router from 'angular-ui-router'; + +import FileController from './file.controller'; +import FileService from './file.service'; +import FileTemplate from './file.html'; +import UploadService from './upload/upload.servce'; +import FolderService from './folder/folder.service'; +import './file.css'; + +/** @ngInject */ +const route = $stateProvider => { + $stateProvider.state('file', { + url: '/bucket/*path', + parent: 'root', + controller: FileController, + controllerAs: 'file', + template: FileTemplate, + onEnter: $nav => $nav.setTypeToFile(), + }); +}; + +const File = module('file', [ + router, +]) +.service('$file', FileService) +.service('$upload', UploadService) +.service('$folder', FolderService) +.config(route); + +export default File.name; diff --git a/src/components/file/file.service.js b/src/components/file/file.service.js new file mode 100644 index 0000000..069a183 --- /dev/null +++ b/src/components/file/file.service.js @@ -0,0 +1,137 @@ +import getIconString from '../../utils/icon'; +import { sortFiles } from '../../utils/sort'; + +export default class FileService { + /** @ngInject */ + constructor($mdDialog, $fetch, $bucket, $toast, Config) { + Object.assign(this, { + $mdDialog, $fetch, $bucket, $toast, Config, + }); + + this.initState(); + } + + initState() { + this.state = { + paths: { + bucket: '', + prefix: '', + }, + lists: { + data: [], + downloadName: null, + requesting: false, + error: false, + }, + }; + } + + getFullPaths() { + const { bucket, prefix } = this.state.paths; + return `${bucket}/${prefix}`; + } + + setPaths(bucket, prefix) { + this.state.paths = { bucket, prefix }; + } + + getFiles() { + const { bucket, prefix } = this.state.paths; + const endpoint = `/v1/file/list/${bucket}?prefix=${prefix}`; + + this.state.lists.requesting = true; + this.state.lists.data = []; + + this.$fetch + .get(endpoint) + .then(({ data }) => { + this.state.lists.error = false; + this.state.lists.data = sortFiles(this.formatFilesData(data.files)); + }) + .catch(() => { + this.state.lists.error = true; + }) + .finally(() => { + this.state.lists.requesting = false; + }); + } + + getIcon(name) { + return getIconString(name); + } + + formatFileType(name) { + const isFolder = (name.endsWith('/')) + const removeSlash = isFolder ? name.slice(0, -1) : name; + const display = removeSlash.replace(this.state.paths.prefix, ''); + return { isFolder, display }; + } + + formatFilesData(files) { + const { prefix } = this.state.paths; + const baseLen = prefix.split('/').length; + + return (! files) ? [] : + files.filter(({ Key }) => { + const { length } = Key.split('/'); + return ( + length === baseLen + || length === baseLen + 1 + && Key.endsWith('/') + ) && Key !== prefix; + }).map(file => ({ + ...file, + ...this.formatFileType(file.Key), + icon: this.getIcon(file.Key), + checked: false, + })); + } + + selectFile(name) { + let count = 0; + let downloadName = null; + + this.state.lists.data = this.state.lists.data.map(file => { + let checked = file.checked; + + if (file.Key === name) checked = ! checked; + if (checked) count ++; + + return { ...file, checked }; + }); + + if (count === 1) { + const index = this.state.lists.data.findIndex(file => file.checked); + const { isFolder, display } = this.state.lists.data[index]; + if (! isFolder) { + downloadName = display; + } + } + + this.state.lists.downloadName = downloadName; + } + + downloadFile(uri, fileName) { + const a = document.createElement('a'); + a.download = fileName; + a.href = `${this.Config.BASE_URL}${uri}`; + console.log(a.href) + a.click(); + } + + download() { + const { bucket, prefix } = this.state.paths; + const { downloadName } = this.state.lists; + const endpoint = `/v1/file/get/${bucket}/${prefix}${downloadName}`; + + this.$fetch.get(endpoint) + .then(({ data }) => { + const { uri } = data; + this.downloadFile(uri, downloadName); + }) + .catch(({ data }) => { + this.$toast.show(`The ${downloadName} doesn't exist, please try again!`); + this.getFiles(); + }); + } +} diff --git a/src/components/file/folder/folder.controller.js b/src/components/file/folder/folder.controller.js new file mode 100644 index 0000000..cf9c1d8 --- /dev/null +++ b/src/components/file/folder/folder.controller.js @@ -0,0 +1,14 @@ +export default class FolderCreateController { + /** @ngInject */ + constructor($folder) { + this.$folder = $folder; + } + + create() { + this.$folder.createFolder(this.folder); + } + + cancel() { + this.$folder.closeDialog(); + } +} diff --git a/src/components/file/folder/folder.html b/src/components/file/folder/folder.html new file mode 100644 index 0000000..2e951f3 --- /dev/null +++ b/src/components/file/folder/folder.html @@ -0,0 +1,54 @@ + +
+ +
+

Create Folder

+ + + + clear + +
+
+ +
+ + + + + That folder already exists! Please select a different name and try again. + + +
+
+
+
+
+
+ + + + Cancel + + + + Create + + + +
+
diff --git a/src/components/file/folder/folder.service.js b/src/components/file/folder/folder.service.js new file mode 100644 index 0000000..a7bef94 --- /dev/null +++ b/src/components/file/folder/folder.service.js @@ -0,0 +1,49 @@ +import { element } from 'angular'; +import FolderCreateController from './folder.controller'; +import FolderCreateTemplate from './folder.html'; + +export default class FolderCreateService { + /** @ngInject */ + constructor($mdDialog, $fetch, $file, $toast) { + Object.assign(this, { + $mdDialog, $fetch, $file, $toast, + }); + } + + initState() { + this.state = { + duplicated: false, + }; + } + + createFolder(folder) { + const { bucket, prefix } = this.$file.state.paths; + const finalPrefix = `${prefix}${folder}/`; + + this.$fetch.post('/v1/file/create/folder', { + bucket, prefix: finalPrefix, + }) + .then(res => { + this.$file.getFiles(); + this.$toast.show(`Bucket ${folder} has created!`); + this.closeDialog() + }) + .catch(() => this.state.duplicated = true); + } + + createDialog($event) { + this.$mdDialog.show({ + controller: FolderCreateController, + controllerAs: 'create', + template: FolderCreateTemplate, + parent: element(document.body), + targetEvent: $event, + clickOutsideToClose: true, + }); + } + + closeDialog() { + this.$mdDialog.cancel(); + this.initState(); + } +} diff --git a/src/components/file/folder/folder.spec.js b/src/components/file/folder/folder.spec.js new file mode 100644 index 0000000..ef2201e --- /dev/null +++ b/src/components/file/folder/folder.spec.js @@ -0,0 +1,178 @@ +import app from '../../../index.js'; +import folderCtrl from './folder.controller.js'; +import folderServ from './folder.service.js'; +import folderHtml from './folder.html'; + + +describe('Create Folder Unit Test', () => { + let $rootScope; + let makeService; + let makeDeferred; + let makeController; + let makeTemplate; + let $mdDialog; + let $fetch; + let $file; + let $toast; + let $compile; + beforeEach(angular.mock.module('app')); + + beforeEach(inject(($q, _$rootScope_, _$mdDialog_, _$compile_, _$fetch_, _$file_, _$toast_) => { + $rootScope = _$rootScope_; + $mdDialog = _$mdDialog_; + $fetch = _$fetch_; + $file = _$file_; + $toast = _$toast_; + $compile = _$compile_; + + makeTemplate = angular.element(folderHtml); + + $compile(makeTemplate)($rootScope); + + makeDeferred = () => { + return $q.defer(); + }; + + makeController = (folderServ) => { + return new folderCtrl(folderServ); + }; + + makeService = () => { + return new folderServ($mdDialog, $fetch, $file, $toast); + }; + })); + describe('when init controller', () => { + let controller; + beforeEach(() => { + controller = makeController(makeService()); + }); + it('should declare controller.$folder', () => { + expect(controller.$folder).not.to.be.null; + }); + }); + describe('when trigger create() in controller', () => { + let mockCreate; + let controller; + let service; + let form; + beforeEach(() => { + service = makeService(); + service.createFolder = () => {}; + controller = makeController(service); + mockCreate = sinon.spy(service, 'createFolder'); + form = $rootScope.create.form; + form.folder.$setViewValue('FolderName'); + controller.create(); + }); + it('should invoke createFolder and call by folder name', () => { + expect(mockCreate.called).to.eq(true); + expect(mockCreate).to.have.been.calledWith(form.folder.$ViewValue); + }); + }); + describe('when trigger cancel() in controller', () => { + let mockClose; + let controller; + let service; + beforeEach(() => { + service = makeService(); + service.closeDialog = () => {}; + controller = makeController(service); + mockClose = sinon.spy(service, 'closeDialog'); + controller.cancel(); + }); + it('should invoke closeDialog in service', () => { + expect(mockClose.called).to.eq(true); + }); + }); + describe('when trigger initState() in service', () => { + let service; + beforeEach(() => { + service = makeService(); + service.initState(); + }); + it('should declare state.duplicated', () => { + expect(service.state.duplicated).to.eq(false); + }); + }); + describe('when trigger createFolder in service and success', () => { + let deferred; + let service; + let mockGetFiles; + let mockToast; + let mockClose; + let mockFetch; + let message; + beforeEach(() => { + $file.getFiles = () => {}; + $toast.show = () => {}; + mockFetch = sinon.mock($fetch); + service = makeService(); + service.closeDialog = () => {}; + deferred = makeDeferred(); + mockFetch.expects('post').returns(deferred.promise); + deferred.resolve(); + mockGetFiles = sinon.spy($file, 'getFiles'); + mockToast = sinon.spy($toast, 'show'); + mockClose = sinon.spy(service, 'closeDialog'); + message = "Bucket FolderName has created!"; + service.createFolder('FolderName'); + $rootScope.$digest(); + }); + it('should invoke getFiles in fileService', () => { + expect(mockGetFiles.called).to.eq(true); + }); + it('should invoke $toast.show and call by message', () => { + expect(mockToast).to.have.been.calledWith(message); + }); + it('should invoke closeDialog()', () => { + expect(mockClose.called).to.eq(true); + }); + }); + describe('when trigger createFolder in service and fail', () => { + let deferred; + let service; + let mockFetch; + beforeEach(() => { + mockFetch = sinon.mock($fetch); + service = makeService(); + service.state = {}; + deferred = makeDeferred(); + mockFetch.expects('post').returns(deferred.promise); + deferred.reject(); + service.createFolder('FolderName'); + $rootScope.$digest(); + }); + it('should invoke getFiles in fileService', () => { + expect(service.state.duplicated).to.eq(true); + }); + }); + describe('when trigger createDialog in service', () => { + let service; + let mockDialog; + beforeEach(() => { + mockDialog = sinon.spy($mdDialog, 'show'); + service = makeService(); + service.createDialog(); + }); + it('should invoke mdDialog.show', () => { + expect(mockDialog.called).to.eq(true); + }); + }); + describe('when trigger closeDialog in service', () => { + let service; + let mockDialog; + let mockInit; + beforeEach(() => { + service = makeService(); + mockDialog = sinon.spy($mdDialog, 'cancel'); + mockInit = sinon.spy(service, 'initState'); + service.closeDialog(); + }); + it('should invoke mdDialog.cancel', () => { + expect(mockDialog.called).to.eq(true); + }); + it('should invoke initState', () => { + expect(mockInit.called).to.eq(true); + }) + }); +}); \ No newline at end of file diff --git a/src/components/file/upload/upload.controller.js b/src/components/file/upload/upload.controller.js new file mode 100644 index 0000000..a9f246a --- /dev/null +++ b/src/components/file/upload/upload.controller.js @@ -0,0 +1,29 @@ +export default class FileUploadController { + /** @ngInject */ + constructor($file, $upload, $scope) { + Object.assign(this, { + $file, $upload, $scope, + }); + + $scope.$watch( + () => $upload.state, + newVal => Object.assign(this, newVal) + , true); + } + + upload() { + this.$upload.upload(); + } + + select(files) { + this.$upload.select(files); + } + + delete(name) { + this.$upload.delete(name); + } + + cancel() { + this.$upload.closeDialog(); + } +} diff --git a/src/components/file/upload/upload.html b/src/components/file/upload/upload.html new file mode 100644 index 0000000..3ed64e6 --- /dev/null +++ b/src/components/file/upload/upload.html @@ -0,0 +1,85 @@ + +
+ +
+

Upload Files

+ + + + + clear + +
+
+ +
+
+

+ To upload files to S3 Portal, click Add Files. To remove files already selected, click the ✖ to the far right of the file name. +

+
+ + + +
+

Add Files

+ add +
+
+ + + + + photo + insert_drive_file + +

+

+ + clear +
+ + + +
+
+
+ + + + Cancel + + + + Upload + + + +
+
diff --git a/src/components/file/upload/upload.servce.js b/src/components/file/upload/upload.servce.js new file mode 100644 index 0000000..4ed10bb --- /dev/null +++ b/src/components/file/upload/upload.servce.js @@ -0,0 +1,92 @@ +import { element } from 'angular'; +import totalSize from '../../../utils/totalSize'; +import FileUploadController from './upload.controller'; +import FileUploadTemplate from './upload.html'; + +export default class FileUploadService { + /** @ngInject */ + constructor(Config, Upload, $mdDialog, $file, $transfer) { + Object.assign(this, { + Config, Upload, $mdDialog, $file, $transfer, + }); + + this.initState(); + } + + initState() { + this.state = { + files: [], + size: 0, + }; + } + + select(selectedFiles) { + const additionalFiles = selectedFiles.filter(selectedFile => + this.state.files.every(({ detail }) => detail.name !== selectedFile.name) + ).map(detail => ({ + id: Symbol('unique id'), detail, + })); + + const files = [...this.state.files, ...additionalFiles]; + + const size = totalSize(files); + this.state = { ...this.state, files, size }; + } + + delete(id) { + const files = this.state.files.filter(file => file.id !== id); + const size = totalSize(files); + + this.state = { ...this.state, files, size }; + } + + upload() { + const { bucket, prefix } = this.$file.state.paths; + const url = `${this.Config.API_URL}/v1/file/create`; + + this.state.uploading = true; + this.$transfer.put(this.state.files.map(({ + id, detail, + }) => ({ + id, + bucket, + name: detail.name, + type: 'UPLOAD', + status: 'UPLOADING', + upload: this.uploadFile(id, { + bucket, prefix, file: detail, + }, url), + }))); + + this.closeDialog(); + } + + uploadFile(id, data, url) { + const upload = this.Upload.upload({ url, data }); + + upload.then( + res => this.$transfer.handleSuccess(id, res), + err => this.$transfer.handleFailure(id, err), + evt => this.$transfer.handleEvent(id, evt) + ); + + return upload; + } + + createDialog($event) { + this.$mdDialog.show({ + controller: FileUploadController, + controllerAs: 'upload', + template: FileUploadTemplate, + parent: element(document.body), + targetEvent: $event, + clickOutsideToClose: true, + }); + } + + closeDialog() { + this.$mdDialog.cancel(); + this.state.size = 0; + this.state.files = []; + } +} diff --git a/src/components/index.js b/src/components/index.js index 8c1c994..d209813 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -3,12 +3,14 @@ import Layout from './layout/layout'; import NotFound from './not-found/not-found'; import Auth from './auth/auth'; import Bucket from './bucket/bucket'; +import File from './file/file'; const Components = module('app.components', [ Layout, NotFound, Auth, Bucket, + File, ]); export default Components.name; diff --git a/src/components/layout/action-navbar/action-navbar.controller.js b/src/components/layout/action-navbar/action-navbar.controller.js index 34da8c9..cee0aee 100644 --- a/src/components/layout/action-navbar/action-navbar.controller.js +++ b/src/components/layout/action-navbar/action-navbar.controller.js @@ -1,14 +1,24 @@ export default class ActionNavbarController { /** @ngInject */ - constructor($scope, $bucket, $nav) { + constructor($scope, $bucket, $nav, $file, $upload, $layout, $folder) { Object.assign(this, { - $scope, $bucket, + $bucket, $file, $upload, $layout, $folder }); - this.$scope.$watch( + $scope.$watch( () => $nav.type, newVal => (this.type = newVal) ); + + $scope.$watch( + () => $layout.state, + newVal => Object.assign(this, newVal) + ); + + $scope.$watch( + () => $file.state.lists.downloadName, + newVal => (this.downloadButton = newVal === null) + ); } /** @@ -25,27 +35,23 @@ export default class ActionNavbarController { } download() { - // - } - - upload() { - // + this.$file.download(); } delete() { // } - none() { - // + closeSidePanels() { + this.$layout.closeSidePanels(); } - properties() { - // + openProperties() { + this.$layout.openProperties(); } - transfers() { - // + openTransfers() { + this.$layout.openTransfers(); } /** @@ -56,12 +62,16 @@ export default class ActionNavbarController { */ create($event) { if (this.isFile()) { - // create file dialog + this.$upload.createDialog($event); } else { this.$bucket.createDialog($event); } } + createFolder($event) { + this.$folder.createDialog($event); + } + /** * Refresh the list by `this.type` * @@ -69,7 +79,7 @@ export default class ActionNavbarController { */ refresh() { if (this.isFile()) { - // get the files + this.$file.getFiles(); } else { this.$bucket.getBuckets(); } diff --git a/src/components/layout/action-navbar/action-navbar.html b/src/components/layout/action-navbar/action-navbar.html index 8cbd1cb..6544946 100644 --- a/src/components/layout/action-navbar/action-navbar.html +++ b/src/components/layout/action-navbar/action-navbar.html @@ -2,22 +2,21 @@
- file_upload - Upload + add + file_upload + Create Bucket + Upload - add - create_new_folder - Create Bucket - Create Folder + create_new_folder + Create Folder @@ -44,28 +43,28 @@ Download - + - Upload + Create Bucket + Upload - + - Create Bucket - Create Folder + Create Folder @@ -95,7 +94,7 @@ Properties @@ -107,14 +106,15 @@ None info_outline Properties @@ -122,7 +122,7 @@ Transfers diff --git a/src/components/layout/breadcrumb/breadcrumb.controller.js b/src/components/layout/breadcrumb/breadcrumb.controller.js new file mode 100644 index 0000000..9eb320a --- /dev/null +++ b/src/components/layout/breadcrumb/breadcrumb.controller.js @@ -0,0 +1,13 @@ +export default class BreadcrumbController { + /** @ngInject */ + constructor($scope, $bucket, $breadcrumb) { + Object.assign(this, { + $scope, $bucket, $breadcrumb, + }); + + this.$scope.$watch( + () => $breadcrumb.paths, + newVal => (this.paths = newVal) + , true); + } +} diff --git a/src/components/layout/breadcrumb/breadcrumb.html b/src/components/layout/breadcrumb/breadcrumb.html new file mode 100644 index 0000000..8f44a1f --- /dev/null +++ b/src/components/layout/breadcrumb/breadcrumb.html @@ -0,0 +1,8 @@ + diff --git a/src/components/layout/breadcrumb/breadcrumb.service.js b/src/components/layout/breadcrumb/breadcrumb.service.js new file mode 100644 index 0000000..bc1f5d1 --- /dev/null +++ b/src/components/layout/breadcrumb/breadcrumb.service.js @@ -0,0 +1,49 @@ +export default class BreadcrumbService { + /** @ngInject */ + constructor() { + this.initPaths(); + } + + /** + * Initial the paths state. + * + * @return {void} + */ + initPaths() { + const len = (typeof this.paths === 'undefined') ? 0 : this.paths[0].len; + + this.paths = [{ + link: '/bucket', + text: 'All Bucket', + isBucket: true, + len, + }]; + } + + /** + * Update the files length of bucket. + * + * @param {integer} len + * + * @return {void} + */ + updateBucketPath(len) { + this.paths[0].len = len; + } + + /** + * Update paths in breadcrumb bar. + * + * @param {Array} paths + * + * @return {void} + */ + updateFilePath(paths) { + this.initPaths(); + paths.reduce((previous, current) => { + const link = `${previous}/${current}`; + this.paths.push({ link, text: current }); + return link; + }, '/bucket'); + } +} diff --git a/src/components/layout/layout.controller.js b/src/components/layout/layout.controller.js new file mode 100644 index 0000000..854e082 --- /dev/null +++ b/src/components/layout/layout.controller.js @@ -0,0 +1,12 @@ +export default class LayoutController { + /** @ngInject */ + constructor($layout) { + Object.assign(this, { + $layout, + }); + } + + toggleTransfer() { + this.$layout.toggleTransfer(); + } +} diff --git a/src/components/layout/layout.html b/src/components/layout/layout.html index 363d63c..ca51d50 100644 --- a/src/components/layout/layout.html +++ b/src/components/layout/layout.html @@ -3,11 +3,15 @@
- +
- +
diff --git a/src/components/layout/layout.js b/src/components/layout/layout.js index 09bbabd..a5c8c11 100644 --- a/src/components/layout/layout.js +++ b/src/components/layout/layout.js @@ -1,14 +1,23 @@ import { module } from 'angular'; import router from 'angular-ui-router'; +import LayoutController from './layout.controller'; +import LayoutService from './layout.service'; import LayoutTemplate from './layout.html'; import TopNavbarController from './top-navbar/top-navbar.controller'; import TopNavbarTemplate from './top-navbar/top-navbar.html'; +import BreadcrumbController from './breadcrumb/breadcrumb.controller'; +import BreadcrumbTemplate from './breadcrumb/breadcrumb.html'; +import BreadcrumbService from './breadcrumb/breadcrumb.service'; import ActionNavbarController from './action-navbar/action-navbar.controller'; import ActionNavbarTemplate from './action-navbar/action-navbar.html'; import ActionNavbarService from './action-navbar/action-navbar.service'; +import TransferController from './transfer/transfer.controller'; +import TransferTemplate from './transfer/transfer.html'; +import TransferService from './transfer/transfer.service'; import './layout.css'; +import './transfer/transfer.css'; /** @ngInject */ const route = $stateProvider => { @@ -18,6 +27,8 @@ const route = $stateProvider => { views: { '': { template: LayoutTemplate, + controller: LayoutController, + controllerAs: 'layout', }, 'top-navbar@root': { template: TopNavbarTemplate, @@ -29,6 +40,16 @@ const route = $stateProvider => { controller: ActionNavbarController, controllerAs: 'actionNav', }, + 'breadcrumb@root': { + template: BreadcrumbTemplate, + controller: BreadcrumbController, + controllerAs: 'bc', + }, + 'transfer@root': { + template: TransferTemplate, + controller: TransferController, + controllerAs: 'transfer', + }, }, }); }; @@ -36,7 +57,10 @@ const route = $stateProvider => { const Layout = module('layout', [ router, ]) +.service('$breadcrumb', BreadcrumbService) .service('$nav', ActionNavbarService) +.service('$layout', LayoutService) +.service('$transfer', TransferService) .config(route); export default Layout.name; diff --git a/src/components/layout/layout.service.js b/src/components/layout/layout.service.js new file mode 100644 index 0000000..7e43a64 --- /dev/null +++ b/src/components/layout/layout.service.js @@ -0,0 +1,33 @@ +export default class LayoutService { + constructor() { + this.initState(); + } + + initState() { + this.state = { + transfers: false, + properties: false, + }; + } + + openProperties() { + this.state = { + transfers: false, + properties: true, + }; + } + + openTransfers() { + this.state = { + transfers: true, + properties: false, + }; + } + + closeSidePanels() { + this.state = { + transfers: false, + properties: false, + }; + } +} diff --git a/src/components/layout/top-navbar/top-navbar.controller.js b/src/components/layout/top-navbar/top-navbar.controller.js index 80be162..dd8e829 100644 --- a/src/components/layout/top-navbar/top-navbar.controller.js +++ b/src/components/layout/top-navbar/top-navbar.controller.js @@ -1,8 +1,8 @@ export default class TopNavbarController { /** @ngInject */ - constructor($translate, $auth, $state, $toast, $mdDialog, AuthService) { + constructor($scope, $translate, $auth, $state, $toast, $mdDialog, $transfer, AuthService) { Object.assign(this, { - $translate, $auth, $state, $toast, $mdDialog, AuthService, + $scope, $translate, $auth, $state, $toast, $mdDialog, $transfer, AuthService, }); this.languages = [ @@ -32,7 +32,11 @@ export default class TopNavbarController { * @return {void} */ signOut($event) { - this.showConfirmMessage($event).then(this.executedSignOut); + if (this.$transfer.isProcessing()) { + this.showConfirmMessage($event).then(this.executedSignOut); + } else { + this.executedSignOut(); + } } /** @@ -61,6 +65,7 @@ or uploads and leaving now will cancel them.Still leaving?`) */ executedSignOut = () => this.AuthService.signOut() .then(() => { + this.$transfer.abort(); this.$auth.logout(); this.$state.go('auth.signin'); this.$toast.show('Sign Out Success!'); diff --git a/src/components/layout/transfer/transfer.controller.js b/src/components/layout/transfer/transfer.controller.js new file mode 100644 index 0000000..78b2590 --- /dev/null +++ b/src/components/layout/transfer/transfer.controller.js @@ -0,0 +1,48 @@ +export default class TransferController { + /** @ngInject */ + constructor($scope, $layout, $transfer) { + Object.assign(this, { + $layout, $transfer, + }); + + $scope.$watch( + () => $transfer.state, + newVal => Object.assign(this, newVal) + , true); + } + + toggleAutoClear() { + this.$transfer.toggleAutoClear(); + } + + close() { + this.$layout.closeSidePanels(); + } + + md2line(t) { + const status = ['FAILED', 'DELETED', 'PAUSED', 'COMPLETED']; + return status.indexOf(t.status) >= 0; + } + + md3line(t) { + const status = ['UPLOADING', 'RESUMING']; + return status.indexOf(t.status) >= 0; + } + + isUpload(t) { + return t.type === 'UPLOAD'; + } + + isDelete(t) { + return t.type === 'DELETE'; + } + + isUploading(t) { + return t.status === 'UPLOADING'; + } + + showInfo(t) { + const status = ['FAILED', 'PAUSED']; + return status.indexOf(t.status) < 0; + } +} diff --git a/src/components/layout/transfer/transfer.css b/src/components/layout/transfer/transfer.css new file mode 100644 index 0000000..70da214 --- /dev/null +++ b/src/components/layout/transfer/transfer.css @@ -0,0 +1,20 @@ + +/** + * @author Jamie jamie.h@inwinstack.com + */ + +.transfer-list md-list-item { + border-bottom: 1px solid #eee; +} + +.transfer-list md-list-item:last-child { + border-bottom: none; +} + +.transfer-loaded { + padding-right: 20px; +} + +.transfer-rate { + margin: 10px 20px 10px 0; +} \ No newline at end of file diff --git a/src/components/layout/transfer/transfer.html b/src/components/layout/transfer/transfer.html new file mode 100644 index 0000000..fcc6b48 --- /dev/null +++ b/src/components/layout/transfer/transfer.html @@ -0,0 +1,98 @@ + +
+ +
+

+ Transfers +

+ + + + + clear + +
+
+ + + + + +

Automatically clear finished transfers

+
+ + + file_upload + delete + +
+

+ +
+ +
+ +

+ + + + + + / + + + + + + % + +

+
+ + check_circle + + +
+ +
+
+
+
+
+
diff --git a/src/components/layout/transfer/transfer.service.js b/src/components/layout/transfer/transfer.service.js new file mode 100644 index 0000000..a8128ae --- /dev/null +++ b/src/components/layout/transfer/transfer.service.js @@ -0,0 +1,92 @@ +export default class TransferService { + /** @ngInject */ + constructor($toast, $file) { + Object.assign(this, { + $toast, $file, + }); + this.initState(); + } + + initState() { + this.state = { + autoClear: false, + processing: false, + transfers: [], + }; + } + + isProcessing() { + return this.state.processing; + } + + toggleAutoClear() { + this.state.autoClear = ! this.state.autoClear; + } + + put(transfers) { + this.state.processing = true; + this.state.transfers = [ + ...this.state.transfers, + ...transfers, + ]; + } + + abort() { + this.state.transfers.forEach(transfer => { + if (transfer.status === 'UPLOADING') { + transfer.upload.abort(); + } + }); + this.state.transfers = []; + } + + remove(id) { + this.state.transfers = this.state.transfers.filter( + (transfer, index) => index !== id + ); + } + + findTransferIndex(id) { + return this.state.transfers.findIndex(transfer => transfer.id === id); + } + + handleEvent(id, { loaded, total }) { + const i = this.findTransferIndex(id); + const precentage = (loaded / total * 100).toFixed(2); + this.state.transfers[i].process = { + loaded, total, precentage, + }; + } + + handleSuccess(id) { + const i = this.findTransferIndex(id); + this.state.transfers[i].status = 'COMPLETED'; + this.$toast.show(`${this.state.transfers[i].name} is uploaded successfully!`); + + if (this.state.autoClear) { + this.remove(i); + } + + this.updateProcessStatus(); + this.$file.getFiles(); + } + + handleFailure(id, { statusText }) { + const i = this.findTransferIndex(id); + this.state.transfers[i] = { + ...this.state.transfers[i], + status: 'FAILED', + message: statusText, + }; + this.$toast.show( + `${this.state.transfers[i].name} is uploaded failure! Error message: ${statusText}` + ); + this.updateProcessStatus(); + } + + updateProcessStatus() { + this.state.process = this.state.transfers.every( + transfer => transfer.status !== 'UPLOADING' && transfer.status !== 'RESUMING' + ); + } +} diff --git a/src/config/breadcrumb.config.js b/src/config/breadcrumb.config.js deleted file mode 100644 index c4c08e7..0000000 --- a/src/config/breadcrumb.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import template from './breadcrumb.html'; - -/** @ngInject */ -export default $breadcrumbProvider => $breadcrumbProvider.setOptions({ template }); diff --git a/src/config/breadcrumb.html b/src/config/breadcrumb.html deleted file mode 100644 index fc71562..0000000 --- a/src/config/breadcrumb.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/src/config/http.config.js b/src/config/http.config.js index 7270500..25869aa 100644 --- a/src/config/http.config.js +++ b/src/config/http.config.js @@ -1,7 +1,8 @@ const TokenInterceptor = ($q, $injector) => ({ responseError(rejection) { const { data } = rejection; - if (data.error && data.error === 'token_not_provided') { + if (data.error && data.error === 'token_not_provided' || data.error === 'token_invalid') { + $injector.get('$auth').logout(); $injector.get('$state').go('auth.signin'); $injector.get('$toast').show('Your token has expired, please sign in again!'); } diff --git a/src/config/index.js b/src/config/index.js index 87b1861..7759af0 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -5,7 +5,6 @@ import satellizer from './satellizer.config'; import material from './material.config'; import authenticateGuard from './AuthenticateGuard'; import http from './http.config'; -import breadcrumb from './breadcrumb.config'; const Config = module('app.config', []) .config(router) @@ -13,8 +12,8 @@ const Config = module('app.config', []) .config(satellizer) .config(material) .config(http) - .config(breadcrumb) .constant('Config', { + BASE_URL: process.env.SERVER_HOST, API_URL: `${process.env.SERVER_HOST}/api`, }) .run(authenticateGuard); diff --git a/src/config/material.config.js b/src/config/material.config.js index 8916f5e..81663d3 100644 --- a/src/config/material.config.js +++ b/src/config/material.config.js @@ -4,5 +4,5 @@ export default ($mdThemingProvider) => { .theme('default') .primaryPalette('blue') .warnPalette('orange') - .accentPalette('grey'); + .accentPalette('indigo'); }; diff --git a/src/filters/filesize.js b/src/filters/filesize.js new file mode 100644 index 0000000..070dfe9 --- /dev/null +++ b/src/filters/filesize.js @@ -0,0 +1,30 @@ +const units = [ + 'bytes', + 'KB', + 'MB', + 'GB', + 'TB', + 'PB', +]; + +/** + * Format file size. + * + * @return {String} + */ +export default () => bytes => { + if (isNaN(parseFloat(bytes)) || ! isFinite(bytes)) { + return '?'; + } + + let unit = 0; + + while (bytes >= 1024) { + bytes /= 1024; + unit ++; + } + + const result = (unit === 0) ? +bytes : bytes.toFixed(2); + + return `${result} ${units[unit]}`; +}; diff --git a/src/filters/index.js b/src/filters/index.js new file mode 100644 index 0000000..5e45983 --- /dev/null +++ b/src/filters/index.js @@ -0,0 +1,7 @@ +import { module } from 'angular'; +import filesize from './filesize'; + +const Filters = module('app.Filters', []) + .filter('filesize', filesize); + +export default Filters.name; diff --git a/src/index.js b/src/index.js index ccbe9dd..14364b5 100644 --- a/src/index.js +++ b/src/index.js @@ -4,14 +4,16 @@ import './index.css'; import './templates'; import Vendor from './vendor'; import Config from './config'; -import Utils from './utils'; +import Services from './services'; import Directives from './directives'; +import Filters from './filters'; import Components from './components'; module('app', [ Vendor, Config, - Utils, + Services, Directives, + Filters, Components, ]); diff --git a/src/utils/fetch/fetch.js b/src/services/fetch/fetch.js similarity index 100% rename from src/utils/fetch/fetch.js rename to src/services/fetch/fetch.js diff --git a/src/utils/fetch/fetch.service.js b/src/services/fetch/fetch.service.js similarity index 100% rename from src/utils/fetch/fetch.service.js rename to src/services/fetch/fetch.service.js diff --git a/src/utils/index.js b/src/services/index.js similarity index 64% rename from src/utils/index.js rename to src/services/index.js index 623084f..7297e7b 100644 --- a/src/utils/index.js +++ b/src/services/index.js @@ -2,9 +2,9 @@ import { module } from 'angular'; import Toast from './toast/toast'; import Fetch from './fetch/fetch'; -const Utils = module('app.utils', [ +const Services = module('app.services', [ Toast, Fetch, ]); -export default Utils.name; +export default Services.name; diff --git a/src/utils/toast/toast.js b/src/services/toast/toast.js similarity index 100% rename from src/utils/toast/toast.js rename to src/services/toast/toast.js diff --git a/src/utils/toast/toast.service.js b/src/services/toast/toast.service.js similarity index 100% rename from src/utils/toast/toast.service.js rename to src/services/toast/toast.service.js diff --git a/src/styles/base.css b/src/styles/base.css index 8caa00f..ae5f323 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -41,3 +41,35 @@ .text-center { text-align: center; } + +.break-word { + word-break: break-word; +} + +/* display */ + +.inline{ + display: inline; +} + +.block{ + display: block; +} + +.inline-block{ + display: inline-block; +} + +/* align */ + +.valign-bottom{ + vertical-align: bottom; +} + +.valign-middle{ + vertical-align: middle; +} + +.valign-top{ + vertical-align: top; +} diff --git a/src/styles/dialog.css b/src/styles/dialog.css index 1ae5712..dfdca9c 100644 --- a/src/styles/dialog.css +++ b/src/styles/dialog.css @@ -20,3 +20,18 @@ md-dialog.input-dialog { .dialog-description { margin-bottom: 30px; } + +.dialog-footer { + margin-left: 20px; + margin-top: 25px; + text-align: center; + width: 100%; +} + +.dialog-footer span { + padding-right: 10px; +} + +.list-dialog md-list-item p { + margin-right: 40px; +} \ No newline at end of file diff --git a/src/styles/s3.css b/src/styles/s3.css index 1351233..619ba5f 100644 --- a/src/styles/s3.css +++ b/src/styles/s3.css @@ -127,3 +127,40 @@ md-input-container md-progress-circular { .load-fail-state { margin-top: 10%; } + +/* list item --------------------------------------------- */ +md-list-item.checked { + background: #E8EAF6; +} + +md-list-item > .md-list-item-inner > p { + padding: 0 8px; +} + +md-list-item > p.flex-none, +md-list-item > .md-list-item-inner > p.flex-none, +md-list-item .md-list-item-inner > p.flex-none, +md-list-item .md-list-item-inner > .md-list-item-inner > p.flex-none { + flex: 0 0 auto; + -ms-flex: 0 0 auto; +} + +md-list-item > p.flex-grow, +md-list-item > .md-list-item-inner > p.flex-grow, +md-list-item .md-list-item-inner > p.flex-grow, +md-list-item .md-list-item-inner > .md-list-item-inner > p.flex-grow { + flex: 1 1 100%; + -ms-flex: 1 1 100%; +} + +md-list.md-default-theme md-list-item.md-2-line .md-list-item-text p.text-warn, +md-list md-list-item.md-2-line .md-list-item-text p.text-warn, +md-list.md-default-theme md-list-item.md-3-line .md-list-item-text p.text-warn, +md-list md-list-item.md-3-line .md-list-item-text p.text-warn { + color: #FF6D00; +} + +/* fix flexbox type layout issues in IE10 */ +span.flex { + display: block; +} diff --git a/src/styles/table.css b/src/styles/table.css index 7f43265..5e84570 100644 --- a/src/styles/table.css +++ b/src/styles/table.css @@ -27,7 +27,7 @@ th { .table > tfoot > tr > td { padding: 8px; line-height: 1.42857143; - vertical-align: top; + vertical-align: middle; border-top: 1px solid #dddddd; } diff --git a/src/translations/EN.js b/src/translations/EN.js index e0bf219..836bfda 100644 --- a/src/translations/EN.js +++ b/src/translations/EN.js @@ -2,4 +2,16 @@ export default { SETTINGS: { SIGN_OUT: 'Sign Out', }, + TRANSFER: { + TITLE: { + UPLOAD: 'Upload {{ name }} to {{ bucket }}', + DELETE: 'Delete {{ name }} from {{ bucket }}', + }, + STATUS: { + UPLOADING: 'Uploading', + COMPLETED: 'Completed', + DELETE: 'Deleted', + RESUMING: 'Resuming', + }, + }, }; diff --git a/src/utils/icon.js b/src/utils/icon.js new file mode 100644 index 0000000..1204e72 --- /dev/null +++ b/src/utils/icon.js @@ -0,0 +1,8 @@ +const icons = [ + ['/', 'folder'], +]; + +export default name => { + const index = icons.findIndex(icon => name.endsWith(icon[0])); + return (index === -1) ? 'insert_drive_file' : icons[index][1]; +}; diff --git a/src/utils/sort.js b/src/utils/sort.js new file mode 100644 index 0000000..5622208 --- /dev/null +++ b/src/utils/sort.js @@ -0,0 +1,29 @@ +import natural from 'javascript-natural-sort'; + +/** + * Return a function that will sort by given key. + * + * @param {String} x + * @param {String} y + * + * @return {Function} + */ +const sortKey = key => (x, y) => natural(x[key], y[key]); + +const sortByDisplay = sortKey('display'); + +/** + * Natural sort by Name. + * + * @return {Function} + */ +export const sortByName = sortKey('Name'); + +export const sortFiles = xs => { + const folders = xs.filter(x => x.isFolder); + const files = xs.filter(x => ! x.isFolder); + return [ + ...folders.sort(sortByDisplay), + ...files.sort(sortByDisplay), + ]; +}; diff --git a/src/utils/totalSize.js b/src/utils/totalSize.js new file mode 100644 index 0000000..61d9b6e --- /dev/null +++ b/src/utils/totalSize.js @@ -0,0 +1,9 @@ +/** + * Calculate the total size of files. + * + * @type {Array} + */ +export default files => files + .reduce((previous, current) => + previous + current.detail.size, 0 + ); diff --git a/src/vendor/index.js b/src/vendor/index.js index 7c92515..eb26892 100644 --- a/src/vendor/index.js +++ b/src/vendor/index.js @@ -3,8 +3,8 @@ import router from 'angular-ui-router'; import material from 'angular-material'; import translate from 'angular-translate'; import validationMatch from 'angular-validation-match'; +import fileUpload from 'ng-file-upload'; import satellizer from 'satellizer'; -import 'angular-breadcrumb'; const Vendor = module('app.vendor', [ router, @@ -12,7 +12,7 @@ const Vendor = module('app.vendor', [ translate, validationMatch, satellizer, - 'ncy-angular-breadcrumb', + fileUpload, ]); export default Vendor.name;