diff --git a/controller/pagecontroller.php b/controller/pagecontroller.php index 4cae46125b..186f31796e 100644 --- a/controller/pagecontroller.php +++ b/controller/pagecontroller.php @@ -82,7 +82,10 @@ public function index() { $appName = $this->appName; // Parameters sent to the template - $params = ['appName' => $appName]; + $params = [ + 'appName' => $appName, + 'uploadUrl' => $this->urlGenerator->linkTo('files', 'ajax/upload.php') + ]; // Will render the page using the template found in templates/index.php $response = new TemplateResponse($appName, 'index', $params); diff --git a/css/mobile.css b/css/mobile.css index 44e1ba9627..fbb960f0ac 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -5,4 +5,9 @@ margin-right: 0 !important; } + /* shorten elements for mobile */ + #uploadprogressbar { + width: 50px; + } + } diff --git a/css/upload.css b/css/upload.css new file mode 100644 index 0000000000..72c6b13642 --- /dev/null +++ b/css/upload.css @@ -0,0 +1,95 @@ +/* Uploading */ +.oc-dialog .fileexists .icon { + background-position: center center; + background-size: cover !important; +} + +#uploadprogressbar { + margin-top: 5px; +} + +.stop.icon-close { + margin-top: 5px; +} + +.actions { + float: left; +} + +.actions input, .actions button, .actions .button { + margin: 0; + float: left; +} + +.actions .button a { + color: #555; +} + +.actions .button a:hover, +.actions .button a:focus, +.actions .button a:active { + color: #333; +} + +.actions.creatable { + position: relative; + z-index: -30; +} + +.newFileMenu { + width: 140px; + margin-left: -56px; + margin-top: 25px; + z-index: 1001; +} + +.newFileMenu .error { + color: #e9322d; + border-color: #e9322d; + -webkit-box-shadow: 0 0 6px #f8b9b7; + -moz-box-shadow: 0 0 6px #f8b9b7; + box-shadow: 0 0 6px #f8b9b7; +} + +.newFileMenu .menuitem { + white-space: nowrap; + overflow: hidden; +} +.newFileMenu.popovermenu .menuitem .icon { + margin-bottom: -2px; +} +.newFileMenu.popovermenu a.menuitem, +.newFileMenu.popovermenu label.menuitem, +.newFileMenu.popovermenu .menuitem { + padding: 0; + margin: 0; +} + +.newFileMenu.popovermenu a.menuitem.active { + opacity: 1; +} + +.newFileMenu.bubble:after { + left: 75px; + right: auto; +} +.newFileMenu.bubble:before { + left: 75px; + right: auto; +} + +.newFileMenu .filenameform { + display: inline-block; +} + +.newFileMenu .filenameform input { + width: 100px; +} + +#fileList .popovermenu .action { + display: block; + line-height: 30px; + padding-left: 5px; + color: #000; + padding: 0; +} \ No newline at end of file diff --git a/js/gallery.js b/js/gallery.js index ff4f5af4b2..8788286c5d 100644 --- a/js/gallery.js +++ b/js/gallery.js @@ -3,6 +3,7 @@ "use strict"; var Gallery = { currentAlbum: null, + currentEtag: null, config: {}, /** Map of the whole gallery, built as we navigate through folders */ albumMap: {}, @@ -259,21 +260,6 @@ $('form.searchbox').hide(); }, - /** - * Shows an empty gallery message - */ - showEmpty: function () { - var emptyContentElement = $('#emptycontent'); - var message = ''; - message += '

' + t('gallery', - 'No pictures found') + '

'; - message += '

' + t('gallery', - 'Upload pictures in the files app to display them here') + '

'; - emptyContentElement.html(message); - emptyContentElement.removeClass('hidden'); - $('#controls').addClass('hidden'); - }, - /** * Shows an empty gallery message */ @@ -281,9 +267,9 @@ var emptyContentElement = $('#emptycontent'); var message = ''; message += '

' + t('gallery', - 'Nothing in here') + '

'; + 'No media files found') + ''; message += '

' + t('gallery', - 'No media files found in this folder') + '

'; + 'Upload new files via drag and drop or by using the [+] button above') + '

'; emptyContentElement.html(message); emptyContentElement.removeClass('hidden'); }, @@ -391,6 +377,7 @@ var mimeType = null; var mTime = null; var etag = null; + var size = null; var albumInfo = data.albuminfo; var currentLocation = albumInfo.path; // This adds a new node to the map for each parent album @@ -406,8 +393,9 @@ mimeType = files[i].mimetype; mTime = files[i].mtime; etag = files[i].etag; + size = files[i].size; - image = new GalleryImage(path, path, fileId, mimeType, mTime, etag); + image = new GalleryImage(path, path, fileId, mimeType, mTime, etag, size); // Determines the folder name for the image var dir = OC.dirname(path); diff --git a/js/galleryimage.js b/js/galleryimage.js index fa2ab7a6b8..7b7c73bf29 100644 --- a/js/galleryimage.js +++ b/js/galleryimage.js @@ -14,21 +14,23 @@ /** * Creates a new image object to store information about a media file * - * @param src - * @param path - * @param fileId - * @param mimeType - * @param mTime modification time - * @param etag + * @param {string} src + * @param {string} path + * @param {number} fileId + * @param {string} mimeType + * @param {number} mTime modification time + * @param {string} etag + * @param {number} size * @constructor */ - var GalleryImage = function (src, path, fileId, mimeType, mTime, etag) { + var GalleryImage = function (src, path, fileId, mimeType, mTime, etag, size) { this.src = src; this.path = path; this.fileId = fileId; this.mimeType = mimeType; this.mTime = mTime; this.etag = etag; + this.size = size; this.thumbnail = null; this.domDef = null; this.spinner = null; diff --git a/js/galleryview.js b/js/galleryview.js index 6ba7f7f99b..abe9cc2f3e 100644 --- a/js/galleryview.js +++ b/js/galleryview.js @@ -1,6 +1,9 @@ -/* global Gallery */ +/* global Handlebars, Gallery */ (function ($, _, OC, t, Gallery) { "use strict"; + + var TEMPLATE_ADDBUTTON = '{{addText}}'; + /** * Builds and updates the Gallery view * @@ -9,6 +12,7 @@ var View = function () { this.element = $('#gallery'); this.loadVisibleRows.loading = false; + this._setupUploader(); this.breadcrumb = new Gallery.Breadcrumb(); }; @@ -38,16 +42,12 @@ } if ($.isEmptyObject(Gallery.imageMap)) { this.clear(); - if (albumPath === '') { - Gallery.showEmpty(); - } else { - Gallery.showEmptyFolder(); - this.hideButtons(); - Gallery.currentAlbum = albumPath; - var availableWidth = $(window).width() - Gallery.buttonsWidth; - this.breadcrumb.init(albumPath, availableWidth); - Gallery.config.albumDesign = null; - } + Gallery.showEmptyFolder(); + this.hideButtons(); + Gallery.currentAlbum = albumPath; + var availableWidth = $(window).width() - Gallery.buttonsWidth; + this.breadcrumb.init(albumPath, availableWidth); + Gallery.config.albumDesign = null; } else { this.viewAlbum(albumPath); } @@ -81,9 +81,10 @@ this.clear(); - if (albumPath !== Gallery.currentAlbum) { + if (Gallery.albumMap[albumPath].etag !== Gallery.currentEtag) { this.loadVisibleRows.loading = false; Gallery.currentAlbum = albumPath; + Gallery.currentEtag = Gallery.albumMap[albumPath].etag; this._setupButtons(albumPath); } @@ -200,6 +201,29 @@ $('#sort-date-button').hide(); }, + /** + * Sets up our custom handlers for folder uploading operations + * + * We only want it to be called for that specific case as all other file uploading + * operations will call Files.highlightFiles + * + * @see OC.Upload.init/file_upload_param.done() + * + * @private + */ + _setupUploader: function () { + $('#file_upload_start').on('fileuploaddone', function (e, data) { + if (data.files[0] === data.originalFiles[data.originalFiles.length - 1] + && data.files[0].relativePath) { + + //Ask for a refresh of the photowall + Gallery.getFiles(Gallery.currentAlbum).done(function () { + Gallery.view.init(Gallery.currentAlbum); + }); + } + }); + }, + /** * Adds all the click handlers to buttons the first time they appear in the interface * @@ -215,6 +239,7 @@ $('#sort-date-button').click(Gallery.sorter); $('#save #save-button').click(Gallery.showSaveForm); $('.save-form').submit(Gallery.saveForm); + this._renderNewButton(); this.requestId = Math.random(); }, @@ -340,6 +365,60 @@ $(this).removeClass('hover'); }); } + }, + + /** + * Creates the [+] button allowing users who can't drag and drop to upload files + * + * @see core/apps/files/js/filelist.js + * @private + */ + _renderNewButton: function () { + // if no actions container exist, skip + var $actionsContainer = $('.actions'); + if (!$actionsContainer.length) { + return; + } + if (!this._addButtonTemplate) { + this._addButtonTemplate = Handlebars.compile(TEMPLATE_ADDBUTTON); + } + var $newButton = $(this._addButtonTemplate({ + addText: t('gallery', 'New'), + iconUrl: OC.imagePath('core', 'actions/add') + })); + + $actionsContainer.prepend($newButton); + $newButton.tooltip({'placement': 'bottom'}); + + $newButton.click(_.bind(this._onClickNewButton, this)); + this._newButton = $newButton; + }, + + /** + * Creates the click handler for the [+] button + * @param event + * @returns {boolean} + * + * @see core/apps/files/js/filelist.js + * @private + */ + _onClickNewButton: function (event) { + var $target = $(event.target); + if (!$target.hasClass('.button')) { + $target = $target.closest('.button'); + } + this._newButton.tooltip('hide'); + event.preventDefault(); + if ($target.hasClass('disabled')) { + return false; + } + if (!this._newFileMenu) { + this._newFileMenu = new Gallery.NewFileMenu(); + $('body').append(this._newFileMenu.$el); + } + this._newFileMenu.showAt($target); + + return false; } }; diff --git a/js/thumbnail.js b/js/thumbnail.js index f40dffdf75..03da6ff353 100644 --- a/js/thumbnail.js +++ b/js/thumbnail.js @@ -90,7 +90,7 @@ function Thumbnail (fileId, square) { */ loadBatch: function (ids, square) { var map = (square) ? Thumbnails.squareMap : Thumbnails.map; - // Purely here as a precaution + // Prevents re-loading thumbnails when resizing the window ids = ids.filter(function (id) { return !map[id]; }); diff --git a/js/upload-helper.js b/js/upload-helper.js new file mode 100644 index 0000000000..d0640c2fc1 --- /dev/null +++ b/js/upload-helper.js @@ -0,0 +1,164 @@ +/* global Gallery, Thumbnails */ +/** + * OCA.FileList methods needed for file uploading + * + * This hack makes it possible to use the Files scripts as is, without having to import and + * maintain them in Gallery + * + * Empty methods are for the "new" button, if we want to implement that one day + * + * @type {{inList: FileList.inList, lastAction: FileList.lastAction, getUniqueName: + * FileList.getUniqueName, getCurrentDirectory: FileList.getCurrentDirectory, add: + * FileList.add, checkName: FileList.checkName}} + */ +var FileList = { + /** + * Makes sure the filename does not exist + * + * Gives an early chance to the user to abort the action, before uploading everything to the + * server. + * Albums are not supported as we don't have a full list of images contained in a sub-album + * + * @param fileName + * @returns {*} + */ + findFile: function (fileName) { + "use strict"; + var path = Gallery.currentAlbum + '/' + fileName; + var galleryImage = Gallery.imageMap[path]; + if (galleryImage) { + var fileInfo = { + name: fileName, + directory: Gallery.currentAlbum, + path: path, + etag: galleryImage.etag, + mtime: galleryImage.mTime * 1000, // Javascript gives the Epoch time in milliseconds + size: galleryImage.size + }; + return fileInfo; + } else { + return null; + } + }, + + /** + * Refreshes the photowall + * + * Called at the end of the uploading process when 1 or multiple files are sent + * Never called with folders on Chrome, unless files are uploaded at the same time as folders + * + * @param fileList + */ + highlightFiles: function (fileList) { + "use strict"; + //Ask for a refresh of the photowall + Gallery.getFiles(Gallery.currentAlbum).done(function () { + var fileId, path; + // Removes the cached thumbnails of files which have been re-uploaded + _(fileList).each(function (fileName) { + path = Gallery.currentAlbum + '/' + fileName; + if (Gallery.imageMap[path]) { + fileId = Gallery.imageMap[path].fileId; + if (Thumbnails.map[fileId]) { + delete Thumbnails.map[fileId]; + } + } + }); + + Gallery.view.init(Gallery.currentAlbum); + }); + }, + + /** + * Retrieves the current album + * + * @returns {string} + */ + getCurrentDirectory: function () { + "use strict"; + + // In Files, dirs start with a / + return '/' + Gallery.currentAlbum; + }, + inList: function (filename) { + "use strict"; + + }, + lastAction: function () { + "use strict"; + + }, + getUniqueName: function (newname) { + "use strict"; + + }, + add: function (fileData, options) { + "use strict"; + + }, + checkName: function (name, newname, bool) { + "use strict"; + + } +}; + +/** + * OCA.Files methods needed for file uploading + * + * This hack makes it possible to use the Files scripts as is, without having to import and + * maintain them in Gallery + * + * @type {{isFileNameValid: Files.isFileNameValid, generatePreviewUrl: Files.generatePreviewUrl}} + */ +var Files = { + App: {fileList: {}}, + + isFileNameValid: function (name) { + "use strict"; + var trimmedName = name.trim(); + if (trimmedName === '.' || trimmedName === '..') { + throw t('files', '"{name}" is an invalid file name.', {name: name}); + } else if (trimmedName.length === 0) { + throw t('files', 'File name cannot be empty.'); + } + return true; + + }, + + /** + * Generates a preview for the conflict dialogue + * + * Since Gallery uses the fileId and Files uses the path, we have to use the preview endpoint + * of Files + */ + generatePreviewUrl: function (urlSpec) { + "use strict"; + var previewUrl; + var path = urlSpec.file; + + // In Files, root files start with // + if (path.indexOf('//') === 0) { + path = path.substring(2); + } else { + // Directories start with / + path = path.substring(1); + } + + if (Gallery.imageMap[path]) { + var fileId = Gallery.imageMap[path].fileId; + var thumbnail = Thumbnails.map[fileId]; + previewUrl = thumbnail.image.src; + } else { + var previewDimension = 96; + urlSpec.x = Math.ceil(previewDimension * window.devicePixelRatio); + urlSpec.y = Math.ceil(previewDimension * window.devicePixelRatio); + urlSpec.forceIcon = 0; + previewUrl = OC.generateUrl('/core/preview.png?') + $.param(urlSpec); + } + + return previewUrl; + } +}; + +OCA.Files = Files; +OCA.Files.App.fileList = FileList; diff --git a/js/vendor/owncloud/newfilemenu.js b/js/vendor/owncloud/newfilemenu.js new file mode 100644 index 0000000000..a3dbd748f5 --- /dev/null +++ b/js/vendor/owncloud/newfilemenu.js @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2014-2016 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING file. + * + */ + +/* global Handlebars, Gallery */ +(function ($, Gallery) { + "use strict"; + var TEMPLATE_MENU = + ''; + + /** + * Construct a new NewFileMenu instance + * @constructs NewFileMenu + * + * @memberof Gallery + */ + var NewFileMenu = OC.Backbone.View.extend({ + tagName: 'div', + className: 'newFileMenu popovermenu bubble hidden open menu', + + events: { + 'click .menuitem': '_onClickAction' + }, + + initialize: function () { + var self = this; + var $uploadEl = $('#file_upload_start'); + if ($uploadEl.length) { + $uploadEl.on('fileuploadstart', function () { + self.trigger('actionPerformed', 'upload'); + }); + } else { + console.warn('Missing upload element "file_upload_start"'); + } + }, + + template: function (data) { + if (!Gallery.NewFileMenu._TEMPLATE) { + Gallery.NewFileMenu._TEMPLATE = Handlebars.compile(TEMPLATE_MENU); + } + return Gallery.NewFileMenu._TEMPLATE(data); + }, + + /** + * Event handler whenever the upload button has been clicked within the menu + */ + _onClickAction: function () { + // note: clicking the upload label will automatically + // set the focus on the "file_upload_start" hidden field + // which itself triggers the upload dialog. + // Currently the upload logic is still in file-upload.js and filelist.js + OC.hideMenus(); + }, + + /** + * Renders the menu with the currently set items + */ + render: function () { + this.$el.html(this.template({ + uploadMaxHumanFileSize: 'TODO', + uploadLabel: t('gallery', 'Upload') + })); + }, + + /** + * Displays the menu under the given element + * + * @param {Object} $target target element + */ + showAt: function ($target) { + this.render(); + var targetOffset = $target.offset(); + this.$el.css({ + left: targetOffset.left, + top: targetOffset.top + $target.height() + }); + this.$el.removeClass('hidden'); + + OC.showMenu(null, this.$el); + } + }); + + Gallery.NewFileMenu = NewFileMenu; +})(jQuery, Gallery); diff --git a/service/searchmediaservice.php b/service/searchmediaservice.php index 974aa53f3b..338219c773 100644 --- a/service/searchmediaservice.php +++ b/service/searchmediaservice.php @@ -202,13 +202,15 @@ private function addFileToResults($file) { $mimeType = $file->getMimetype(); $mTime = $file->getMTime(); $etag = $file->getEtag(); + $size = $file->getSize(); $imageData = [ 'path' => $imagePath, 'fileid' => $imageId, 'mimetype' => $mimeType, 'mtime' => $mTime, - 'etag' => $etag + 'etag' => $etag, + 'size' => $size ]; $this->images[] = $imageData; diff --git a/templates/part.content.php b/templates/part.content.php index f77c90e8d3..727785454c 100644 --- a/templates/part.content.php +++ b/templates/part.content.php @@ -27,7 +27,24 @@ 'vendor/bigshot/bigshot-compressed', 'slideshow', 'slideshowcontrols', - 'slideshowzoomablepreview' + 'slideshowzoomablepreview', + 'upload-helper', + 'vendor/owncloud/newfilemenu' + ] +); +script( + 'files', + [ + 'upload', + 'file-upload', + 'jquery.fileupload', + 'jquery.iframe-transport' + ] +); +style( + 'files', + [ + 'upload' ] ); style( @@ -35,10 +52,11 @@ [ 'styles', 'share', - 'mobile', 'github-markdown', 'slideshow', - 'gallerybutton' + 'gallerybutton', + 'upload', + 'mobile', ] ); ?> @@ -67,6 +85,17 @@ +
+
+
+ +
+
+
@@ -100,3 +129,7 @@ +
+ +
diff --git a/tests/unit/GalleryUnitTest.php b/tests/unit/GalleryUnitTest.php index 44b759139e..625eecc7d5 100644 --- a/tests/unit/GalleryUnitTest.php +++ b/tests/unit/GalleryUnitTest.php @@ -93,7 +93,9 @@ protected function mockGetResourceFromIdWithBadFile($mockedObject, $fileId, $exc * * @return \PHPUnit_Framework_MockObject_MockObject */ - protected function mockFile($fileId, $storageId = 'home::user', $isReadable = true, $path = '' + protected function mockFile( + $fileId, $storageId = 'home::user', $isReadable = true, $path = '', + $etag = "8603c11cd6c5d739f2c156c38b8db8c4", $size = 1024 ) { $storage = $this->mockGetStorage($storageId); $file = $this->getMockBuilder('OCP\Files\File') @@ -109,12 +111,19 @@ protected function mockFile($fileId, $storageId = 'home::user', $isReadable = tr ->willReturn($isReadable); $file->method('getPath') ->willReturn($path); + $file->method('getEtag') + ->willReturn($etag); + $file->method('getSize') + ->willReturn($size); return $file; } - protected function mockJpgFile($fileId) { - $file = $this->mockFile($fileId); + protected function mockJpgFile( + $fileId, $storageId = 'home::user', $isReadable = true, $path = '', + $etag = "8603c11cd6c5d739f2c156c38b8db8c4", $size = 1024 + ) { + $file = $this->mockFile($fileId, $storageId, $isReadable, $path, $etag, $size); $this->mockJpgFileMethods($file); return $file; diff --git a/tests/unit/controller/PageControllerTest.php b/tests/unit/controller/PageControllerTest.php index 5eb0e6b0dc..d46120042c 100644 --- a/tests/unit/controller/PageControllerTest.php +++ b/tests/unit/controller/PageControllerTest.php @@ -69,7 +69,12 @@ protected function setUp() { public function testIndex() { - $params = ['appName' => $this->appName]; + $url = 'http://owncloud/ajax/upload.php'; + $this->mockUrlToUploadEndpoint($url); + $params = [ + 'appName' => $this->appName, + 'uploadUrl' => $url + ]; $template = new TemplateResponse($this->appName, 'index', $params); $response = $this->controller->index(); @@ -239,4 +244,10 @@ private function mockCookieGet($key, $value) { ->willReturn($value); } + private function mockUrlToUploadEndpoint($url) { + $this->urlGenerator->expects($this->once()) + ->method('linkTo') + ->with('files', 'ajax/upload.php') + ->willReturn($url); + } } diff --git a/tests/unit/service/SearchMediaServiceTest.php b/tests/unit/service/SearchMediaServiceTest.php index d22e3695aa..7e8b22d35d 100644 --- a/tests/unit/service/SearchMediaServiceTest.php +++ b/tests/unit/service/SearchMediaServiceTest.php @@ -205,6 +205,68 @@ public function testGetMediaFiles($topFolder, $result) { $this->assertSame($result, sizeof($response)); } + public function providesFolderWithFilesData() { + $isReadable = true; + $mounted = false; + $mount = null; + $query = '.nomedia'; + $queryResult = false; + + $file1 = [ + 'fileid' => 11111, + 'storageId' => 'home::user', + 'isReadable' => true, + 'path' => null, + 'etag' => "8603c11cd6c5d739f2c156c38b8db8c4", + 'size' => 1024, + 'mimetype' => 'image/jpeg' + ]; + + + $folder1 = $this->mockFolder( + 'home::user', 545454, [ + $this->mockJpgFile( + $file1['fileid'], $file1['storageId'], $file1['isReadable'], $file1['path'], + $file1['etag'], $file1['size'] + ) + ], $isReadable, $mounted, $mount, $query, $queryResult + ); + + return [ + [ + $folder1, [ + [ + 'path' => $file1['path'], + 'fileid' => $file1['fileid'], + 'mimetype' => $file1['mimetype'], + 'mtime' => null, + 'etag' => $file1['etag'], + 'size' => $file1['size'] + ] + ] + ] + ]; + } + + /** + * @dataProvider providesFolderWithFilesData + * + * @param array $topFolder + * @param int $result + */ + public function testPropertiesOfGetMediaFiles($topFolder, $result) { + $supportedMediaTypes = [ + 'image/png', + 'image/jpeg', + 'image/gif' + ]; + $features = []; + + $response = $this->service->getMediaFiles($topFolder, $supportedMediaTypes, $features); + + $this->assertSame($result, $response); + } + /** * @expectedException \OCA\Gallery\Service\NotFoundServiceException */