diff --git a/app/Exceptions/FileUploadException.php b/app/Exceptions/FileUploadException.php new file mode 100644 index 000000000..af976072a --- /dev/null +++ b/app/Exceptions/FileUploadException.php @@ -0,0 +1,4 @@ +name, '.')) return $this->name; + return $this->name . '.' . $this->extension; + } + + /** + * Get the page this file was uploaded to. + * @return Page + */ + public function page() + { + return $this->belongsTo(Page::class, 'uploaded_to'); + } + + /** + * Get the url of this file. + * @return string + */ + public function getUrl() + { + return baseUrl('/files/' . $this->id); + } + +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 2dabc417b..2b6c88fe0 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -3,13 +3,11 @@ namespace BookStack\Http\Controllers; use BookStack\Ownable; -use HttpRequestException; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Http\Exception\HttpResponseException; +use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Foundation\Validation\ValidatesRequests; -use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Session; use BookStack\User; abstract class Controller extends BaseController @@ -71,8 +69,13 @@ abstract class Controller extends BaseController */ protected function showPermissionError() { - Session::flash('error', trans('errors.permission')); - $response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/'); + if (request()->wantsJson()) { + $response = response()->json(['error' => trans('errors.permissionJson')], 403); + } else { + $response = redirect('/'); + session()->flash('error', trans('errors.permission')); + } + throw new HttpResponseException($response); } @@ -83,7 +86,7 @@ abstract class Controller extends BaseController */ protected function checkPermission($permissionName) { - if (!$this->currentUser || !$this->currentUser->can($permissionName)) { + if (!user() || !user()->can($permissionName)) { $this->showPermissionError(); } return true; @@ -125,4 +128,22 @@ abstract class Controller extends BaseController return response()->json(['message' => $messageText], $statusCode); } + /** + * Create the response for when a request fails validation. + * + * @param \Illuminate\Http\Request $request + * @param array $errors + * @return \Symfony\Component\HttpFoundation\Response + */ + protected function buildFailedValidationResponse(Request $request, array $errors) + { + if ($request->expectsJson()) { + return response()->json(['validation' => $errors], 422); + } + + return redirect()->to($this->getRedirectUrl()) + ->withInput($request->input()) + ->withErrors($errors, $this->errorBag()); + } + } diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php new file mode 100644 index 000000000..668e9ec6c --- /dev/null +++ b/app/Http/Controllers/FileController.php @@ -0,0 +1,214 @@ +fileService = $fileService; + $this->file = $file; + $this->pageRepo = $pageRepo; + } + + + /** + * Endpoint at which files are uploaded to. + * @param Request $request + */ + public function upload(Request $request) + { + $this->validate($request, [ + 'uploaded_to' => 'required|integer|exists:pages,id', + 'file' => 'required|file' + ]); + + $pageId = $request->get('uploaded_to'); + $page = $this->pageRepo->getById($pageId); + + $this->checkPermission('file-create-all'); + $this->checkOwnablePermission('page-update', $page); + + $uploadedFile = $request->file('file'); + + try { + $file = $this->fileService->saveNewUpload($uploadedFile, $pageId); + } catch (FileUploadException $e) { + return response($e->getMessage(), 500); + } + + return response()->json($file); + } + + /** + * Update an uploaded file. + * @param int $fileId + * @param Request $request + * @return mixed + */ + public function uploadUpdate($fileId, Request $request) + { + $this->validate($request, [ + 'uploaded_to' => 'required|integer|exists:pages,id', + 'file' => 'required|file' + ]); + + $pageId = $request->get('uploaded_to'); + $page = $this->pageRepo->getById($pageId); + $file = $this->file->findOrFail($fileId); + + $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission('file-create', $file); + + if (intval($pageId) !== intval($file->uploaded_to)) { + return $this->jsonError('Page mismatch during attached file update'); + } + + $uploadedFile = $request->file('file'); + + try { + $file = $this->fileService->saveUpdatedUpload($uploadedFile, $file); + } catch (FileUploadException $e) { + return response($e->getMessage(), 500); + } + + return response()->json($file); + } + + /** + * Update the details of an existing file. + * @param $fileId + * @param Request $request + * @return File|mixed + */ + public function update($fileId, Request $request) + { + $this->validate($request, [ + 'uploaded_to' => 'required|integer|exists:pages,id', + 'name' => 'required|string|min:1|max:255', + 'link' => 'url|min:1|max:255' + ]); + + $pageId = $request->get('uploaded_to'); + $page = $this->pageRepo->getById($pageId); + $file = $this->file->findOrFail($fileId); + + $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission('file-create', $file); + + if (intval($pageId) !== intval($file->uploaded_to)) { + return $this->jsonError('Page mismatch during attachment update'); + } + + $file = $this->fileService->updateFile($file, $request->all()); + return $file; + } + + /** + * Attach a link to a page as a file. + * @param Request $request + * @return mixed + */ + public function attachLink(Request $request) + { + $this->validate($request, [ + 'uploaded_to' => 'required|integer|exists:pages,id', + 'name' => 'required|string|min:1|max:255', + 'link' => 'required|url|min:1|max:255' + ]); + + $pageId = $request->get('uploaded_to'); + $page = $this->pageRepo->getById($pageId); + + $this->checkPermission('file-create-all'); + $this->checkOwnablePermission('page-update', $page); + + $fileName = $request->get('name'); + $link = $request->get('link'); + $file = $this->fileService->saveNewFromLink($fileName, $link, $pageId); + + return response()->json($file); + } + + /** + * Get the files for a specific page. + * @param $pageId + * @return mixed + */ + public function listForPage($pageId) + { + $page = $this->pageRepo->getById($pageId); + $this->checkOwnablePermission('page-view', $page); + return response()->json($page->files); + } + + /** + * Update the file sorting. + * @param $pageId + * @param Request $request + * @return mixed + */ + public function sortForPage($pageId, Request $request) + { + $this->validate($request, [ + 'files' => 'required|array', + 'files.*.id' => 'required|integer', + ]); + $page = $this->pageRepo->getById($pageId); + $this->checkOwnablePermission('page-update', $page); + + $files = $request->get('files'); + $this->fileService->updateFileOrderWithinPage($files, $pageId); + return response()->json(['message' => 'Attachment order updated']); + } + + /** + * Get a file from storage. + * @param $fileId + */ + public function get($fileId) + { + $file = $this->file->findOrFail($fileId); + $page = $this->pageRepo->getById($file->uploaded_to); + $this->checkOwnablePermission('page-view', $page); + + if ($file->external) { + return redirect($file->path); + } + + $fileContents = $this->fileService->getFile($file); + return response($fileContents, 200, [ + 'Content-Type' => 'application/octet-stream', + 'Content-Disposition' => 'attachment; filename="'. $file->getFileName() .'"' + ]); + } + + /** + * Delete a specific file in the system. + * @param $fileId + * @return mixed + */ + public function delete($fileId) + { + $file = $this->file->findOrFail($fileId); + $this->checkOwnablePermission('file-delete', $file); + $this->fileService->deleteFile($file); + return response()->json(['message' => 'Attachment deleted']); + } +} diff --git a/app/Page.php b/app/Page.php index 1961a4f7f..94bcce2b5 100644 --- a/app/Page.php +++ b/app/Page.php @@ -54,6 +54,15 @@ class Page extends Entity return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc'); } + /** + * Get the files attached to this page. + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function files() + { + return $this->hasMany(File::class, 'uploaded_to')->orderBy('order', 'asc'); + } + /** * Get the url for this page. * @param string|bool $path diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 537dd9bd0..8cd5c35a9 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -5,6 +5,7 @@ use BookStack\Book; use BookStack\Chapter; use BookStack\Entity; use BookStack\Exceptions\NotFoundException; +use BookStack\Services\FileService; use Carbon\Carbon; use DOMDocument; use DOMXPath; @@ -48,7 +49,7 @@ class PageRepo extends EntityRepo * Get a page via a specific ID. * @param $id * @param bool $allowDrafts - * @return mixed + * @return Page */ public function getById($id, $allowDrafts = false) { @@ -633,6 +634,13 @@ class PageRepo extends EntityRepo $page->revisions()->delete(); $page->permissions()->delete(); $this->permissionService->deleteJointPermissionsForEntity($page); + + // Delete AttachedFiles + $fileService = app(FileService::class); + foreach ($page->files as $file) { + $fileService->deleteFile($file); + } + $page->delete(); } diff --git a/app/Services/FileService.php b/app/Services/FileService.php new file mode 100644 index 000000000..261695e1f --- /dev/null +++ b/app/Services/FileService.php @@ -0,0 +1,204 @@ +getStorageBasePath() . $file->path; + return $this->getStorage()->get($filePath); + } + + /** + * Store a new file upon user upload. + * @param UploadedFile $uploadedFile + * @param int $page_id + * @return File + * @throws FileUploadException + */ + public function saveNewUpload(UploadedFile $uploadedFile, $page_id) + { + $fileName = $uploadedFile->getClientOriginalName(); + $filePath = $this->putFileInStorage($fileName, $uploadedFile); + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order'); + + $file = File::forceCreate([ + 'name' => $fileName, + 'path' => $filePath, + 'extension' => $uploadedFile->getClientOriginalExtension(), + 'uploaded_to' => $page_id, + 'created_by' => user()->id, + 'updated_by' => user()->id, + 'order' => $largestExistingOrder + 1 + ]); + + return $file; + } + + /** + * Store a upload, saving to a file and deleting any existing uploads + * attached to that file. + * @param UploadedFile $uploadedFile + * @param File $file + * @return File + * @throws FileUploadException + */ + public function saveUpdatedUpload(UploadedFile $uploadedFile, File $file) + { + if (!$file->external) { + $this->deleteFileInStorage($file); + } + + $fileName = $uploadedFile->getClientOriginalName(); + $filePath = $this->putFileInStorage($fileName, $uploadedFile); + + $file->name = $fileName; + $file->path = $filePath; + $file->external = false; + $file->extension = $uploadedFile->getClientOriginalExtension(); + $file->save(); + return $file; + } + + /** + * Save a new File attachment from a given link and name. + * @param string $name + * @param string $link + * @param int $page_id + * @return File + */ + public function saveNewFromLink($name, $link, $page_id) + { + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order'); + return File::forceCreate([ + 'name' => $name, + 'path' => $link, + 'external' => true, + 'extension' => '', + 'uploaded_to' => $page_id, + 'created_by' => user()->id, + 'updated_by' => user()->id, + 'order' => $largestExistingOrder + 1 + ]); + } + + /** + * Get the file storage base path, amended for storage type. + * This allows us to keep a generic path in the database. + * @return string + */ + private function getStorageBasePath() + { + return $this->isLocal() ? 'storage/' : ''; + } + + /** + * Updates the file ordering for a listing of attached files. + * @param array $fileList + * @param $pageId + */ + public function updateFileOrderWithinPage($fileList, $pageId) + { + foreach ($fileList as $index => $file) { + File::where('uploaded_to', '=', $pageId)->where('id', '=', $file['id'])->update(['order' => $index]); + } + } + + + /** + * Update the details of a file. + * @param File $file + * @param $requestData + * @return File + */ + public function updateFile(File $file, $requestData) + { + $file->name = $requestData['name']; + if (isset($requestData['link']) && trim($requestData['link']) !== '') { + $file->path = $requestData['link']; + if (!$file->external) { + $this->deleteFileInStorage($file); + $file->external = true; + } + } + $file->save(); + return $file; + } + + /** + * Delete a File from the database and storage. + * @param File $file + */ + public function deleteFile(File $file) + { + if ($file->external) { + $file->delete(); + return; + } + + $this->deleteFileInStorage($file); + $file->delete(); + } + + /** + * Delete a file from the filesystem it sits on. + * Cleans any empty leftover folders. + * @param File $file + */ + protected function deleteFileInStorage(File $file) + { + $storedFilePath = $this->getStorageBasePath() . $file->path; + $storage = $this->getStorage(); + $dirPath = dirname($storedFilePath); + + $storage->delete($storedFilePath); + if (count($storage->allFiles($dirPath)) === 0) { + $storage->deleteDirectory($dirPath); + } + } + + /** + * Store a file in storage with the given filename + * @param $fileName + * @param UploadedFile $uploadedFile + * @return string + * @throws FileUploadException + */ + protected function putFileInStorage($fileName, UploadedFile $uploadedFile) + { + $fileData = file_get_contents($uploadedFile->getRealPath()); + + $storage = $this->getStorage(); + $fileBasePath = 'uploads/files/' . Date('Y-m-M') . '/'; + $storageBasePath = $this->getStorageBasePath() . $fileBasePath; + + $uploadFileName = $fileName; + while ($storage->exists($storageBasePath . $uploadFileName)) { + $uploadFileName = str_random(3) . $uploadFileName; + } + + $filePath = $fileBasePath . $uploadFileName; + $fileStoragePath = $this->getStorageBasePath() . $filePath; + + try { + $storage->put($fileStoragePath, $fileData); + } catch (Exception $e) { + throw new FileUploadException('File path ' . $fileStoragePath . ' could not be uploaded to. Ensure it is writable to the server.'); + } + return $filePath; + } + +} \ No newline at end of file diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index a56626246..dfe2cf453 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -9,20 +9,13 @@ use Intervention\Image\ImageManager; use Illuminate\Contracts\Filesystem\Factory as FileSystem; use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; use Illuminate\Contracts\Cache\Repository as Cache; -use Setting; use Symfony\Component\HttpFoundation\File\UploadedFile; -class ImageService +class ImageService extends UploadService { protected $imageTool; - protected $fileSystem; protected $cache; - - /** - * @var FileSystemInstance - */ - protected $storageInstance; protected $storageUrl; /** @@ -34,8 +27,8 @@ class ImageService public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache) { $this->imageTool = $imageTool; - $this->fileSystem = $fileSystem; $this->cache = $cache; + parent::__construct($fileSystem); } /** @@ -88,6 +81,9 @@ class ImageService if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/'; + + if ($this->isLocal()) $imagePath = '/public' . $imagePath; + while ($storage->exists($imagePath . $imageName)) { $imageName = str_random(3) . $imageName; } @@ -100,6 +96,8 @@ class ImageService throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); } + if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath); + $imageDetails = [ 'name' => $imageName, 'path' => $fullPath, @@ -119,6 +117,16 @@ class ImageService return $image; } + /** + * Get the storage path, Dependant of storage type. + * @param Image $image + * @return mixed|string + */ + protected function getPath(Image $image) + { + return ($this->isLocal()) ? ('public/' . $image->path) : $image->path; + } + /** * Get the thumbnail for an image. * If $keepRatio is true only the width will be used. @@ -135,7 +143,8 @@ class ImageService public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) { $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; - $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path); + $imagePath = $this->getPath($image); + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) { return $this->getPublicUrl($thumbFilePath); @@ -148,7 +157,7 @@ class ImageService } try { - $thumb = $this->imageTool->make($storage->get($image->path)); + $thumb = $this->imageTool->make($storage->get($imagePath)); } catch (Exception $e) { if ($e instanceof \ErrorException || $e instanceof NotSupportedException) { throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.'); @@ -183,8 +192,8 @@ class ImageService { $storage = $this->getStorage(); - $imageFolder = dirname($image->path); - $imageFileName = basename($image->path); + $imageFolder = dirname($this->getPath($image)); + $imageFileName = basename($this->getPath($image)); $allImages = collect($storage->allFiles($imageFolder)); $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { @@ -222,35 +231,9 @@ class ImageService return $image; } - /** - * Get the storage that will be used for storing images. - * @return FileSystemInstance - */ - private function getStorage() - { - if ($this->storageInstance !== null) return $this->storageInstance; - - $storageType = config('filesystems.default'); - $this->storageInstance = $this->fileSystem->disk($storageType); - - return $this->storageInstance; - } - - /** - * Check whether or not a folder is empty. - * @param $path - * @return int - */ - private function isFolderEmpty($path) - { - $files = $this->getStorage()->files($path); - $folders = $this->getStorage()->directories($path); - return count($files) === 0 && count($folders) === 0; - } - /** * Gets a public facing url for an image by checking relevant environment variables. - * @param $filePath + * @param string $filePath * @return string */ private function getPublicUrl($filePath) @@ -273,6 +256,8 @@ class ImageService $this->storageUrl = $storageUrl; } + if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath); + return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath; } diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php new file mode 100644 index 000000000..44d4bb4f7 --- /dev/null +++ b/app/Services/UploadService.php @@ -0,0 +1,64 @@ +fileSystem = $fileSystem; + } + + /** + * Get the storage that will be used for storing images. + * @return FileSystemInstance + */ + protected function getStorage() + { + if ($this->storageInstance !== null) return $this->storageInstance; + + $storageType = config('filesystems.default'); + $this->storageInstance = $this->fileSystem->disk($storageType); + + return $this->storageInstance; + } + + + /** + * Check whether or not a folder is empty. + * @param $path + * @return bool + */ + protected function isFolderEmpty($path) + { + $files = $this->getStorage()->files($path); + $folders = $this->getStorage()->directories($path); + return (count($files) === 0 && count($folders) === 0); + } + + /** + * Check if using a local filesystem. + * @return bool + */ + protected function isLocal() + { + return strtolower(config('filesystems.default')) === 'local'; + } +} \ No newline at end of file diff --git a/config/filesystems.php b/config/filesystems.php index dbcb03db1..836f68d3d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -56,7 +56,7 @@ return [ 'local' => [ 'driver' => 'local', - 'root' => public_path(), + 'root' => base_path(), ], 'ftp' => [ diff --git a/database/migrations/2016_10_09_142037_create_files_table.php b/database/migrations/2016_10_09_142037_create_files_table.php new file mode 100644 index 000000000..49433f19c --- /dev/null +++ b/database/migrations/2016_10_09_142037_create_files_table.php @@ -0,0 +1,71 @@ +increments('id'); + $table->string('name'); + $table->string('path'); + $table->string('extension', 20); + $table->integer('uploaded_to'); + + $table->boolean('external'); + $table->integer('order'); + + $table->integer('created_by'); + $table->integer('updated_by'); + + $table->index('uploaded_to'); + $table->timestamps(); + }); + + // Get roles with permissions we need to change + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; + + // Create & attach new entity permissions + $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own']; + $entity = 'File'; + foreach ($ops as $op) { + $permissionId = DB::table('role_permissions')->insertGetId([ + 'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)), + 'display_name' => $op . ' ' . $entity . 's', + 'created_at' => \Carbon\Carbon::now()->toDateTimeString(), + 'updated_at' => \Carbon\Carbon::now()->toDateTimeString() + ]); + DB::table('permission_role')->insert([ + 'role_id' => $adminRoleId, + 'permission_id' => $permissionId + ]); + } + + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('files'); + + // Create & attach new entity permissions + $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own']; + $entity = 'File'; + foreach ($ops as $op) { + $permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)); + DB::table('role_permissions')->where('name', '=', $permName)->delete(); + } + } +} diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index a64bdfa8c..99cf6af9d 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -460,7 +460,7 @@ module.exports = function (ngApp, events) { * Get all tags for the current book and add into scope. */ function getTags() { - let url = window.baseUrl('/ajax/tags/get/page/' + pageId); + let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`); $http.get(url).then((responseData) => { $scope.tags = responseData.data; addEmptyTag(); @@ -529,6 +529,205 @@ module.exports = function (ngApp, events) { }]); + + ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs', + function ($scope, $http, $attrs) { + + const pageId = $scope.uploadedTo = $attrs.pageId; + let currentOrder = ''; + $scope.files = []; + $scope.editFile = false; + $scope.file = getCleanFile(); + $scope.errors = { + link: {}, + edit: {} + }; + + function getCleanFile() { + return { + page_id: pageId + }; + } + + // Angular-UI-Sort options + $scope.sortOptions = { + handle: '.handle', + items: '> tr', + containment: "parent", + axis: "y", + stop: sortUpdate, + }; + + /** + * Event listener for sort changes. + * Updates the file ordering on the server. + * @param event + * @param ui + */ + function sortUpdate(event, ui) { + let newOrder = $scope.files.map(file => {return file.id}).join(':'); + if (newOrder === currentOrder) return; + + currentOrder = newOrder; + $http.put(`/files/sort/page/${pageId}`, {files: $scope.files}).then(resp => { + events.emit('success', resp.data.message); + }, checkError('sort')); + } + + /** + * Used by dropzone to get the endpoint to upload to. + * @returns {string} + */ + $scope.getUploadUrl = function (file) { + let suffix = (typeof file !== 'undefined') ? `/${file.id}` : ''; + return window.baseUrl(`/files/upload${suffix}`); + }; + + /** + * Get files for the current page from the server. + */ + function getFiles() { + let url = window.baseUrl(`/files/get/page/${pageId}`) + $http.get(url).then(resp => { + $scope.files = resp.data; + currentOrder = resp.data.map(file => {return file.id}).join(':'); + }, checkError('get')); + } + getFiles(); + + /** + * Runs on file upload, Adds an file to local file list + * and shows a success message to the user. + * @param file + * @param data + */ + $scope.uploadSuccess = function (file, data) { + $scope.$apply(() => { + $scope.files.push(data); + }); + events.emit('success', 'File uploaded'); + }; + + /** + * Upload and overwrite an existing file. + * @param file + * @param data + */ + $scope.uploadSuccessUpdate = function (file, data) { + $scope.$apply(() => { + let search = filesIndexOf(data); + if (search !== -1) $scope.files[search] = data; + + if ($scope.editFile) { + $scope.editFile = angular.copy(data); + data.link = ''; + } + }); + events.emit('success', 'File updated'); + }; + + /** + * Delete a file from the server and, on success, the local listing. + * @param file + */ + $scope.deleteFile = function(file) { + if (!file.deleting) { + file.deleting = true; + return; + } + $http.delete(`/files/${file.id}`).then(resp => { + events.emit('success', resp.data.message); + $scope.files.splice($scope.files.indexOf(file), 1); + }, checkError('delete')); + }; + + /** + * Attach a link to a page. + * @param fileName + * @param fileLink + */ + $scope.attachLinkSubmit = function(file) { + file.uploaded_to = pageId; + $http.post('/files/link', file).then(resp => { + $scope.files.push(resp.data); + events.emit('success', 'Link attached'); + $scope.file = getCleanFile(); + }, checkError('link')); + }; + + /** + * Start the edit mode for a file. + * @param fileId + */ + $scope.startEdit = function(file) { + console.log(file); + $scope.editFile = angular.copy(file); + $scope.editFile.link = (file.external) ? file.path : ''; + }; + + /** + * Cancel edit mode + */ + $scope.cancelEdit = function() { + $scope.editFile = false; + }; + + /** + * Update the name and link of a file. + * @param file + */ + $scope.updateFile = function(file) { + $http.put(`/files/${file.id}`, file).then(resp => { + let search = filesIndexOf(resp.data); + if (search !== -1) $scope.files[search] = resp.data; + + if ($scope.editFile && !file.external) { + $scope.editFile.link = ''; + } + $scope.editFile = false; + events.emit('success', 'Attachment details updated'); + }, checkError('edit')); + }; + + /** + * Get the url of a file. + */ + $scope.getFileUrl = function(file) { + return window.baseUrl('/files/' + file.id); + } + + /** + * Search the local files via another file object. + * Used to search via object copies. + * @param file + * @returns int + */ + function filesIndexOf(file) { + for (let i = 0; i < $scope.files.length; i++) { + if ($scope.files[i].id == file.id) return i; + } + return -1; + } + + /** + * Check for an error response in a ajax request. + * @param response + */ + function checkError(errorGroupName) { + $scope.errors[errorGroupName] = {}; + return function(response) { + if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') { + events.emit('error', response.data.error); + } + if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') { + $scope.errors[errorGroupName] = response.data.validation; + console.log($scope.errors[errorGroupName]) + } + } + } + + }]); + }; diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 933bbf5ff..fa6c2c3be 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -33,6 +33,59 @@ module.exports = function (ngApp, events) { }; }); + /** + * Common tab controls using simple jQuery functions. + */ + ngApp.directive('tabContainer', function() { + return { + restrict: 'A', + link: function (scope, element, attrs) { + const $content = element.find('[tab-content]'); + const $buttons = element.find('[tab-button]'); + + if (attrs.tabContainer) { + let initial = attrs.tabContainer; + $buttons.filter(`[tab-button="${initial}"]`).addClass('selected'); + $content.hide().filter(`[tab-content="${initial}"]`).show(); + } else { + $content.hide().first().show(); + $buttons.first().addClass('selected'); + } + + $buttons.click(function() { + let clickedTab = $(this); + $buttons.removeClass('selected'); + $content.hide(); + let name = clickedTab.addClass('selected').attr('tab-button'); + $content.filter(`[tab-content="${name}"]`).show(); + }); + } + }; + }); + + /** + * Sub form component to allow inner-form sections to act like thier own forms. + */ + ngApp.directive('subForm', function() { + return { + restrict: 'A', + link: function (scope, element, attrs) { + element.on('keypress', e => { + if (e.keyCode === 13) { + submitEvent(e); + } + }); + + element.find('button[type="submit"]').click(submitEvent); + + function submitEvent(e) { + e.preventDefault() + if (attrs.subForm) scope.$eval(attrs.subForm); + } + } + }; + }); + /** * Image Picker @@ -116,6 +169,7 @@ module.exports = function (ngApp, events) { uploadedTo: '@' }, link: function (scope, element, attrs) { + if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder; var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), { url: scope.uploadUrl, init: function () { @@ -488,8 +542,8 @@ module.exports = function (ngApp, events) { link: function (scope, elem, attrs) { // Get common elements - const $buttons = elem.find('[tab-button]'); - const $content = elem.find('[tab-content]'); + const $buttons = elem.find('[toolbox-tab-button]'); + const $content = elem.find('[toolbox-tab-content]'); const $toggle = elem.find('[toolbox-toggle]'); // Handle toolbox toggle click @@ -501,17 +555,17 @@ module.exports = function (ngApp, events) { function setActive(tabName, openToolbox) { $buttons.removeClass('active'); $content.hide(); - $buttons.filter(`[tab-button="${tabName}"]`).addClass('active'); - $content.filter(`[tab-content="${tabName}"]`).show(); + $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active'); + $content.filter(`[toolbox-tab-content="${tabName}"]`).show(); if (openToolbox) elem.addClass('open'); } // Set the first tab content active on load - setActive($content.first().attr('tab-content'), false); + setActive($content.first().attr('toolbox-tab-content'), false); // Handle tab button click $buttons.click(function (e) { - let name = $(this).attr('tab-button'); + let name = $(this).attr('toolbox-tab-button'); setActive(name, true); }); } diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss index ccb69b44e..2f9051a52 100644 --- a/resources/assets/sass/_components.scss +++ b/resources/assets/sass/_components.scss @@ -43,10 +43,6 @@ } } -//body.ie .popup-body { -// min-height: 100%; -//} - .corner-button { position: absolute; top: 0; @@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { min-height: 70vh; } -#image-manager .dropzone-container { +.dropzone-container { position: relative; border: 3px dashed #DDD; } @@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border-right: 6px solid transparent; border-bottom: 6px solid $negative; } + + +[tab-container] .nav-tabs { + text-align: left; + border-bottom: 1px solid #DDD; + margin-bottom: $-m; + .tab-item { + padding: $-s; + color: #666; + &.selected { + border-bottom-width: 3px; + } + } +} \ No newline at end of file diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index e608295a8..c7d3e0377 100755 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -150,7 +150,6 @@ background-color: #FFF; border: 1px solid #DDD; right: $-xl*2; - z-index: 99; width: 48px; overflow: hidden; align-items: stretch; @@ -201,7 +200,7 @@ color: #444; background-color: rgba(0, 0, 0, 0.1); } - div[tab-content] { + div[toolbox-tab-content] { padding-bottom: 45px; display: flex; flex: 1; @@ -209,7 +208,7 @@ min-height: 0px; overflow-y: scroll; } - div[tab-content] .padded { + div[toolbox-tab-content] .padded { flex: 1; padding-top: 0; } @@ -228,21 +227,6 @@ padding-top: $-s; position: relative; } - button.pos { - position: absolute; - bottom: 0; - display: block; - width: 100%; - padding: $-s; - height: 45px; - border: 0; - margin: 0; - box-shadow: none; - border-radius: 0; - &:hover{ - box-shadow: none; - } - } .handle { user-select: none; cursor: move; @@ -256,7 +240,7 @@ } } -[tab-content] { +[toolbox-tab-content] { display: none; } diff --git a/resources/assets/sass/_tables.scss b/resources/assets/sass/_tables.scss index 1fc8e11c2..37c61159d 100644 --- a/resources/assets/sass/_tables.scss +++ b/resources/assets/sass/_tables.scss @@ -51,4 +51,14 @@ table.list-table { vertical-align: middle; padding: $-xs; } +} + +table.file-table { + @extend .no-style; + td { + padding: $-xs; + } + .ui-sortable-helper { + display: table; + } } \ No newline at end of file diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php index a03a208b6..78e485eab 100644 --- a/resources/views/pages/form-toolbox.blade.php +++ b/resources/views/pages/form-toolbox.blade.php @@ -3,10 +3,13 @@
Upload some files or attach some link to display on your page. This are visible in the page sidebar.
+ ++ |
+
+
+ Click delete again to confirm you want to delete this attachment.
+
+ + Cancel + |
+ + | + | + |
+ No files have been uploaded. +
+You can attach a link if you'd prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.
+