resolves #2: photos can now be moved between albums. Started improving the bulk photo update to use a KnockoutJS view model to remove some of the logic from the view itself.

This commit is contained in:
Andy Heathershaw 2016-10-05 05:02:47 +01:00
parent 45277efbb8
commit 0e569562a4
11 changed files with 336 additions and 111 deletions

View File

@ -3,7 +3,7 @@
<component name="WebServers">
<option name="servers">
<webServer id="b14a34b0-0127-4886-964a-7be75a2281ac" name="Development" url="http://blue-twilight-dev.andys.eu">
<fileTransfer host="mickey.andys.eu" port="22" rootFolder="/srv/www/blue-twilight-dev" accessType="SFTP">
<fileTransfer host="mickey.prod.pandy06269.uk0.bigv.io" port="22" rootFolder="/srv/www/blue-twilight-dev" accessType="SFTP">
<advancedOptions>
<advancedOptions dataProtectionLevel="Private" />
</advancedOptions>

View File

@ -57,9 +57,10 @@ interface IAlbumSource
/**
* Saves an uploaded file to the container and returns the filename.
* @param File $uploadedFile The photo uploaded
* @param string $overrideFilename Specific file name to use, or null to randomly generate one.
* @return File
*/
function saveUploadedPhoto(File $uploadedFile);
function saveUploadedPhoto(File $uploadedFile, $overrideFilename = null);
/**
* @param Album $album

View File

@ -68,15 +68,24 @@ class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource
$fileInfo->move(sprintf('%s/%s', $this->getPathToAlbum(), $thumbnailInfo['name']), $photo->storage_file_name);
}
public function saveUploadedPhoto(File $uploadedFile)
public function saveUploadedPhoto(File $uploadedFile, $overrideFilename = null)
{
$tempFilename = sprintf('%s/%s/%s', $this->getPathToAlbum(), $this->getOriginalsFolder(), MiscHelper::randomString(20));
$tempFilename = sprintf(
'%s/%s/%s',
$this->getPathToAlbum(),
$this->getOriginalsFolder(),
is_null($overrideFilename) ? MiscHelper::randomString(20) : basename($overrideFilename)
);
// Only add an extension if an override filename was not given, assume this is present
if (is_null($overrideFilename))
{
$extension = $uploadedFile->guessExtension();
if (!is_null($extension))
{
$tempFilename .= '.' . $extension;
}
}
$uploadedFile->move(dirname($tempFilename), basename($tempFilename));
return new File($tempFilename);

View File

@ -165,6 +165,7 @@ class AlbumController extends Controller
'flip_vertical' => trans('admin.photo_actions.flip_vertical'),
'flip_both' => trans('admin.photo_actions.flip_both'),
'--' => '-----',
'change_album' => trans('admin.photo_actions.change_album'),
'refresh_thumbnails' => trans('admin.photo_actions.refresh_thumbnails'),
'delete' => trans('admin.photo_actions.delete')
],
@ -174,7 +175,8 @@ class AlbumController extends Controller
'max_post_limit' => $postLimit,
'max_post_limit_bulk' => $fileUploadOrPostLowerLimit,
'photos' => $photos,
'success' => $request->session()->get('success')
'success' => $request->session()->get('success'),
'warning' => $request->session()->get('warning')
]);
}

View File

@ -87,7 +87,7 @@ class PhotoController extends Controller
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
public function destroy(Request $request, $id)
{
$this->authorize('admin-access');
@ -102,7 +102,7 @@ class PhotoController extends Controller
$photoService = new PhotoService($photo);
$photoService->delete();
return back();
$request->session()->flash('success', trans('admin.delete_photo_successful_message', ['name' => $photo->name]));
}
public function flip($photoId, $horizontal, $vertical)
@ -122,6 +122,37 @@ class PhotoController extends Controller
$photoService->flip($horizontal, $vertical);
}
public function move(Request $request, $photoId)
{
$this->authorize('admin-access');
$photo = Photo::where('id', intval($photoId))->first();
if (is_null($photo))
{
App::abort(404);
}
$newAlbum = Album::where('id', intval($request->get('new_album_id')))->first();
if (is_null($newAlbum))
{
App::abort(404);
}
$messageData = ['name' => $photo->name, 'album' => $newAlbum->name];
if ($newAlbum->id == $photo->album_id)
{
$request->session()->flash('warning', trans('admin.move_failed_same_album', $messageData));
}
else
{
$photoService = new PhotoService($photo);
$photoService->changeAlbum($newAlbum);
$request->session()->flash('success', trans('admin.move_successful_message', $messageData));
}
}
public function regenerateThumbnails($photoId)
{
$this->authorize('admin-access');
@ -372,6 +403,23 @@ class PhotoController extends Controller
$photoService = new PhotoService($photo);
switch (strtolower($action))
{
case 'change_album':
$newAlbumId = intval($request->get('new-album-id'));
if ($newAlbumId == $photo->album_id)
{
// Photo already belongs to this album, don't move
continue;
}
$newAlbum = Album::where('id', $newAlbumId)->first();
if (is_null($newAlbum))
{
App::abort(404);
}
$photoService->changeAlbum($newAlbum);
break;
case 'delete':
$photoService->delete();
break;

View File

@ -7,6 +7,7 @@ use App\AlbumSources\IAlbumSource;
use App\Helpers\ImageHelper;
use App\Helpers\ThemeHelper;
use App\Photo;
use Symfony\Component\HttpFoundation\File\File;
class PhotoService
{
@ -118,6 +119,32 @@ class PhotoService
$this->regenerateThumbnails($originalPhotoResource);
}
public function changeAlbum(Album $newAlbum)
{
/** @var IAlbumSource $currentSource */
$currentSource = $this->photo->album->getAlbumSource();
$newSource = $newAlbum->getAlbumSource();
// Get the current photo's path
$file = new File($currentSource->getPathToPhoto($this->photo));
// Save to the new album
$newSource->saveUploadedPhoto($file, $this->photo->storage_file_name);
// Delete the original
$this->delete();
// Update the ID and new file name
$this->photo->album_id = $newAlbum->id;
$this->photo->save();
// Switch to the new album source
$this->albumSource = $newSource;
// Regenerate the thumbnails in the new album
$this->regenerateThumbnails();
}
public function delete()
{
// Remove all thumbnails first - so if any fail, we don't delete the original

View File

@ -1,5 +1,4 @@
function AnalyseAlbumViewModel()
{
function AnalyseAlbumViewModel() {
var self = this;
self.imagesFailed = ko.observableArray();
@ -11,31 +10,26 @@ function AnalyseAlbumViewModel()
return self.imagesToAnalyse().length;
});
self.failedPercentage = ko.computed(function()
{
self.failedPercentage = ko.computed(function () {
return ((self.numberFailed() / self.numberTotal()) * 100).toFixed(2) + '%';
});
self.successfulPercentage = ko.computed(function()
{
self.successfulPercentage = ko.computed(function () {
return ((self.numberSuccessful() / self.numberTotal()) * 100).toFixed(2) + '%';
});
self.isCompleted = ko.computed(function()
{
self.isCompleted = ko.computed(function () {
return self.numberTotal() > 0 && (self.numberSuccessful() + self.numberFailed() >= self.numberTotal());
});
// When an image is added to the array, automatically issue it for analysis
self.imagesToAnalyse.subscribe(function(changes)
{
self.imagesToAnalyse.subscribe(function (changes) {
// changes[0].value is an instance of AnalyseImageViewModel
var item = changes[0].value;
$.ajax(
item.url(),
{
dataType: 'json',
error: function (xhr, textStatus, errorThrown)
{
error: function (xhr, textStatus, errorThrown) {
self.numberFailed(self.numberFailed() + 1);
self.imagesFailed.push({
'name': item.name(),
@ -45,16 +39,13 @@ function AnalyseAlbumViewModel()
item.isPending(false);
},
method: 'POST',
success: function (data)
{
if (data.is_successful)
{
success: function (data) {
if (data.is_successful) {
self.numberSuccessful(self.numberSuccessful() + 1);
item.isSuccessful(true);
item.isPending(false);
}
else
{
else {
self.numberFailed(self.numberFailed() + 1);
self.imagesFailed.push({
'name': item.name(),
@ -69,8 +60,7 @@ function AnalyseAlbumViewModel()
}, null, 'arrayChange');
}
function AnalyseImageViewModel(image_info)
{
function AnalyseImageViewModel(image_info) {
var self = this;
self.isPending = ko.observable(true);
@ -82,8 +72,7 @@ function AnalyseImageViewModel(image_info)
self.iconClass = ko.computed(function () {
var string = 'fa fa-fw ';
if (!self.isPending())
{
if (!self.isPending()) {
string += (self.isSuccessful() ? 'check text-success' : 'times text-danger')
}
@ -91,22 +80,183 @@ function AnalyseImageViewModel(image_info)
});
}
function StorageLocationsViewModel()
/**
* This model is used by admin/show_album.blade.php to handle photo changes.
* @param album_id ID of the album the photos are in
* @param language Array containing language strings
* @param urls Array containing URLs
* @constructor
*/
function EditPhotosViewModel(album_id, language, urls) {
var self = this;
self.albums = ko.observableArray();
self.bulkModifyMethod = ko.observable();
self.photoIDs = ko.observableArray();
/* Called when the Apply button on the "bulk apply selected actions" form is clicked */
self.bulkModifySelected = function()
{
var bulk_form = $('form#bulk-modify-form');
if (self.bulkModifyMethod() == 'change_album')
{
// Prompt for the new album to move to
self.promptForNewAlbum(function(dialog) {
var album_id = $('select', dialog).val();
$('input[name="new-album-id"]', form).val(album_id);
bulk_form.submit();
});
return false;
}
else if (self.bulkModifyMethod() == 'delete')
{
// Prompt for a confirmation - are you sure?!
bootbox.dialog({
message: language.delete_bulk_confirm_message.replace(':number', self.photoIDs().length),
title: language.delete_bulk_confirm_title.replace(':number', self.photoIDs().length),
buttons: {
cancel: {
label: language.action_cancel,
className: "btn-default"
},
confirm: {
label: language.action_delete,
className: "btn-danger",
callback: function() {
bulk_form.submit();
}
}
}
});
return false;
}
// All other methods submit the form as normal
return true;
};
self.changeAlbum = function()
{
self.selectPhotoSingle(this);
self.promptForNewAlbum(function(dialog) {
var album_id = $('select', dialog).val();
$.post(urls.move_photo.replace(/\/0$/, '/' + self.photoIDs()[0]), { 'new_album_id': album_id }, function()
{
window.location.reload();
});
});
return false;
};
self.delete = function() {
self.selectPhotoSingle(this);
bootbox.dialog({
message: language.delete_confirm_message,
title: language.delete_confirm_title,
buttons: {
cancel: {
label: language.action_cancel,
className: "btn-default"
},
confirm: {
label: language.action_delete,
className: "btn-danger",
callback: function() {
var url = urls.delete_photo;
url = url.replace(/\/0$/, '/' + self.photoIDs()[0]);
$('.loading', parent).show();
$.post(url, {'_method': 'DELETE'}, function(data)
{
window.location.reload();
});
}
}
}
});
return false;
};
self.promptForNewAlbum = function(callback_on_selected)
{
var albums = self.albums();
var select = $('<select/>')
.attr('name', 'album_id')
.addClass('form-control');
for (var i = 0; i < albums.length; i++)
{
var option = $('<option/>')
.attr('value', albums[i].id)
.html(albums[i].name)
.appendTo(select);
// Pre-select the current album
if (album_id == albums[i].id)
{
option.attr('selected', 'selected');
}
}
bootbox.dialog({
message: $('<p/>').html(language.change_album_message).prop('outerHTML') + select.prop('outerHTML'),
title: language.change_album_title,
buttons:
{
cancel:
{
label: language.action_cancel,
className: 'btn-default'
},
confirm:
{
label: language.action_continue,
className: 'btn-success',
callback: function()
{
callback_on_selected(this);
}
}
}
});
};
self.selectPhotoSingle = function (link_item) {
// Get the photo ID from the clicked link
var parent = $(link_item).parents('.photo');
var photo_id = $(parent).data('photo-id');
// Save the photo ID
self.photoIDs.removeAll();
self.photoIDs.push(photo_id);
// Hide the dropdown
$(link_item).dropdown('toggle');
};
}
function StorageLocationsViewModel() {
var self = this;
self.selectedLocation = ko.observable(true);
}
/**
* This file is used by admin/show_album.blade.php to handle photo uploads.
* This model is used by admin/show_album.blade.php to handle photo uploads.
* @param album_id ID of the album the photos are being uploaded to
* @param language Array containing language strings
* @param urls Array containing URLs
* @constructor
*/
function UploadPhotosViewModel(album_id, language, urls)
{
function UploadPhotosViewModel(album_id, language, urls) {
var self = this;
self.currentStatus = ko.observable('');
@ -117,32 +267,27 @@ function UploadPhotosViewModel(album_id, language, urls)
self.isUploadInProgress = ko.observable(false);
self.statusMessages = ko.observableArray();
self.failedPercentage = ko.computed(function()
{
self.failedPercentage = ko.computed(function () {
return ((self.imagesFailed() / self.imagesTotal()) * 100).toFixed(2) + '%';
});
// This method is called when an image is uploaded - regardless if it fails or not
self.onUploadCompleted = function()
{
self.onUploadCompleted = function () {
self.currentStatus(language.upload_status
.replace(':current', (self.imagesUploaded() + self.imagesFailed()))
.replace(':total', self.imagesTotal()));
if ((self.imagesFailed() + self.imagesUploaded()) >= self.imagesTotal())
{
if ((self.imagesFailed() + self.imagesUploaded()) >= self.imagesTotal()) {
self.isUploadInProgress(false);
if (self.imagesFailed() == 0 && self.imagesUploaded() > 0)
{
if (self.imagesFailed() == 0 && self.imagesUploaded() > 0) {
window.location = urls.analyse;
}
}
};
// This method is called when an uploaded image fails
self.onUploadFailed = function(data, file_name)
{
self.onUploadFailed = function (data, file_name) {
self.imagesFailed(self.imagesFailed() + 1);
self.statusMessages.push({
'message_class': 'text-danger',
@ -152,10 +297,8 @@ function UploadPhotosViewModel(album_id, language, urls)
};
// This method is called when an uploaded image succeeds
self.onUploadSuccessful = function(data, file_name)
{
if (data.is_successful)
{
self.onUploadSuccessful = function (data, file_name) {
if (data.is_successful) {
self.imagesUploaded(self.imagesUploaded() + 1);
// Don't add to statusMessages() array so user only sees errors
/*self.statusMessages.push({
@ -164,14 +307,12 @@ function UploadPhotosViewModel(album_id, language, urls)
});*/
self.onUploadCompleted();
}
else
{
else {
self.onUploadFailed(data, file_name);
}
};
self.startUpload = function(number_images)
{
self.startUpload = function (number_images) {
self.currentStatus('');
self.statusMessages.removeAll();
self.imagesUploaded(0);
@ -180,13 +321,11 @@ function UploadPhotosViewModel(album_id, language, urls)
self.isUploadInProgress(true);
};
self.successfulPercentage = ko.computed(function()
{
self.successfulPercentage = ko.computed(function () {
return ((self.imagesUploaded() / self.imagesTotal()) * 100).toFixed(2) + '%';
});
self.uploadFile = function uploadImageFile(formObject, imageFile)
{
self.uploadFile = function uploadImageFile(formObject, imageFile) {
var formData = new FormData();
formData.append('album_id', album_id);
formData.append('photo[]', imageFile, imageFile.name);

View File

@ -14,6 +14,8 @@ return [
'bulk_photos_changed' => ':number photo was updated successfully.|:number photos were updated successfully.',
'cannot_delete_own_user_account' => 'It is not possible to delete your own user account. Please ask another administrator to delete it for you.',
'cannot_remove_own_admin' => 'You cannot remove your own administrator permissions. Please ask another administrator to remove the administrator permissions for you.',
'change_album_message' => 'Please select the album to move the photo(s) to:',
'change_album_title' => 'Move photo(s) to another album',
'create_album' => 'Create a photo album',
'create_album_intro' => 'Photo albums contain individual photographs together in the same way as a physical photo album or memory book.',
'create_album_intro2' => 'Complete the form below to create a photo album.',
@ -26,6 +28,11 @@ return [
'delete_album' => 'Delete album :name',
'delete_album_confirm' => 'Are you sure you want to permanently delete this album and all its contents?',
'delete_album_warning' => 'This is a permanent action that cannot be undone!',
'delete_bulk_photos_message' => 'Are you sure you want to delete the :number selected photos? This action cannot be undone!',
'delete_bulk_photos_title' => 'Delete :number photos',
'delete_photo_message' => 'Are you sure you want to delete this photo? This action cannot be undone!',
'delete_photo_successful_message' => 'The photo ":name" was deleted successfully.',
'delete_photo_title' => 'Delete photo',
'delete_storage' => 'Delete storage location: :name',
'delete_storage_confirm' => 'Are you sure you want to permanently remove this storage location?',
'delete_storage_existing_albums' => 'At least one album is still using the storage location. Please delete all albums before removing the storage location.',
@ -44,6 +51,8 @@ return [
'manage_widget' => [
'panel_header' => 'Manage'
],
'move_failed_same_album' => 'The photo ":name" already belongs to the ":album" album and was not moved.',
'move_successful_message' => 'The photo ":name" was moved successfully to the ":album" album.',
'no_albums_text' => 'You have no photo albums yet. Click the button below to create one.',
'no_albums_title' => 'No Photo Albums',
'no_storages_text' => 'You need a storage location to store your uploaded photographs.',
@ -51,6 +60,7 @@ return [
'no_storages_title' => 'No storage locations defined',
'open_album' => 'Open album',
'photo_actions' => [
'change_album' => 'Move to another album',
'delete' => 'Delete permanently',
'flip_both' => 'Flip both',
'flip_horizontal' => 'Flip horizontally',

View File

@ -41,16 +41,17 @@
<p style="margin-top: 30px;"><button id="upload-button" class="btn btn-lg btn-success">@lang('admin.album_no_photos_button')</button></p>
</div>
@else
{!! Form::open(['route' => ['photos.updateBulk', $album->id], 'method' => 'PUT']) !!}
{!! Form::open(['route' => ['photos.updateBulk', $album->id], 'method' => 'PUT', 'id' => 'bulk-modify-form']) !!}
@foreach ($photos as $photo)
@include (Theme::viewName('partials.single_photo_admin'))
@endforeach
<div class="pull-left">
<div class="pull-left" style="margin-bottom: 15px;">
<p>{!! Form::label('bulk-action', trans('forms.bulk_edit_photos_label'), ['class' => 'control-label']) !!}</p>
{!! Form::select('bulk-action', $bulk_actions, null, ['placeholder' => trans('forms.bulk_edit_photos_placeholder'), 'id' => 'bulk-action-apply']) !!}
<button type="submit" class="btn btn-sm btn-success" name="bulk-apply" value="clicked">@lang('forms.apply_action')</button>
{!! Form::hidden('new-album-id', $album->id) !!}
{!! Form::select('bulk-action', $bulk_actions, null, ['placeholder' => trans('forms.bulk_edit_photos_placeholder'), 'id' => 'bulk-action-apply', 'data-bind' => 'value: bulkModifyMethod, enable: photoIDs().length > 0']) !!}
<button type="submit" class="btn btn-sm btn-primary" name="bulk-apply" value="clicked" data-bind="click: bulkModifySelected, enable: photoIDs().length > 0">@lang('forms.apply_action')</button>
</div>
<div class="pull-right">
<button type="submit" class="btn btn-success">@lang('forms.save_action')</button>
@ -169,27 +170,34 @@
@push('scripts')
<script type="text/javascript">
var language = [];
language.action_cancel = '{!! addslashes(trans('forms.cancel_action')) !!}';
language.action_continue = '{!! addslashes(trans('forms.continue_action')) !!}';
language.action_delete = '{!! addslashes(trans('forms.delete_action')) !!}';
language.change_album_message = '{!! addslashes(trans('admin.change_album_message')) !!}';
language.change_album_title = '{!! addslashes(trans('admin.change_album_title')) !!}';
language.delete_bulk_confirm_message = '{!! addslashes(trans('admin.delete_bulk_photos_message')) !!}';
language.delete_bulk_confirm_title = '{!! addslashes(trans('admin.delete_bulk_photos_title')) !!}';
language.delete_confirm_message = '{!! addslashes(trans('admin.delete_photo_message')) !!}';
language.delete_confirm_title = '{!! addslashes(trans('admin.delete_photo_title')) !!}';
language.image_failed = '{!! addslashes(trans('admin.upload_file_status_failed')) !!}';
language.image_uploaded = '{!! addslashes(trans('admin.upload_file_status_success')) !!}';
language.upload_status = '{!! addslashes(trans('admin.upload_file_status_progress')) !!}';
var urls = [];
urls.analyse = '{{ route('albums.analyse', ['id' => $album->id]) }}';
urls.delete_photo = '{{ route('photos.destroy', ['id' => 0]) }}';
urls.move_photo = '{{ route('photos.move', ['photoId' => 0]) }}';
var viewModel = new UploadPhotosViewModel('{{ $album->id }}', language, urls);
var editViewModel = new EditPhotosViewModel('{{ $album->id }}', language, urls);
function deletePhoto(photo_id, parent)
{
var url = '{{ route('photos.destroy', ['id' => 0]) }}';
url = url.replace(/\/0$/, '/' + photo_id);
$('.loading', parent).show();
$.post(url, {'_method': 'DELETE'}, function(data)
{
window.location.reload();
// Populate the list of albums in the view model
@foreach ($albums as $album)
editViewModel.albums.push({
'id': '{{ $album->id }}',
'name': '{!! addslashes($album->name) !!}'
});
}
@endforeach
function flipPhoto(photo_id, horizontal, vertical, parent)
{
@ -264,32 +272,9 @@
});
{{-- Photo editing tasks - the buttons beneath the photos in partials/single_photo_admin --}}
$('a.delete-photo').click(function() {
var parent = $(this).parents('.photo');
var photo_id = $(parent).data('photo-id');
$('a.change-album').click(editViewModel.changeAlbum);
$('a.delete-photo').click(editViewModel.delete);
$(this).dropdown('toggle');
bootbox.dialog({
message: 'Are you sure you want to delete this photo? This cannot be undone!',
title: 'Delete photo',
buttons: {
cancel: {
label: "Cancel",
className: "btn-default"
},
confirm: {
label: "Delete",
className: "btn-danger",
callback: function() {
deletePhoto(photo_id, parent);
}
}
}
});
return false;
});
$('a.flip-photo-both').click(function() {
var parent = $(this).parents('.photo');
var photo_id = $(parent).data('photo-id');
@ -383,7 +368,9 @@
});
}
ko.applyBindings(viewModel);
// Bind the view models to the relevant tab
ko.applyBindings(editViewModel, document.getElementById('photos-tab'));
ko.applyBindings(viewModel, document.getElementById('upload-tab'));
})
</script>
@endpush

View File

@ -29,6 +29,7 @@
<i class="fa fa-fw fa-cog"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#" class="change-album"><i class="fa fa-fw fa-share"></i> @lang('admin.photo_actions.change_album')</a></li>
<li><a href="#" class="regenerate-thumbnails"><i class="fa fa-fw fa-picture-o"></i> @lang('admin.photo_actions.refresh_thumbnails')</a></li>
<li><a href="#" class="delete-photo"><i class="fa fa-fw fa-trash text-danger"></i> <span class="text-danger">@lang('admin.photo_actions.delete')</span></a></li>
</ul>
@ -36,7 +37,7 @@
</div>
<p style="margin-top: 10px;">
<input type="checkbox" id="select-photo-{{ $photo->id }}" name="select-photo[]" value="{{ $photo->id }}" /> <label for="select-photo-{{ $photo->id }}">@lang('forms.select')</label>
<input type="checkbox" id="select-photo-{{ $photo->id }}" name="select-photo[]" value="{{ $photo->id }}" data-bind="checked: photoIDs" /> <label for="select-photo-{{ $photo->id }}">@lang('forms.select')</label>
</p>
</div>
<div class="col-xs-12 col-sm-10">

View File

@ -28,6 +28,7 @@ Route::group(['prefix' => 'admin'], function () {
// Photo management
Route::post('photos/analyse/{id}', 'Admin\PhotoController@analyse')->name('photos.analyse');
Route::post('photos/flip/{photoId}/{horizontal}/{vertical}', 'Admin\PhotoController@flip')->name('photos.flip');
Route::post('photos/move/{photoId}', 'Admin\PhotoController@move')->name('photos.move');
Route::post('photos/regenerate-thumbnails/{id}', 'Admin\PhotoController@regenerateThumbnails')->name('photos.regenerateThumbnails');
Route::post('photos/rotate/{photoId}/{angle}', 'Admin\PhotoController@rotate')->name('photos.rotate');
Route::post('photos/store-bulk', 'Admin\PhotoController@storeBulk')->name('photos.storeBulk');