diff --git a/app/Disk.php b/app/Disk.php new file mode 100644 index 0000000..0977dc7 --- /dev/null +++ b/app/Disk.php @@ -0,0 +1,33 @@ +name)['root']; + } + + public function mediaFiles() + { + return $this->hasMany(MediaFile::class); + } +} diff --git a/app/Events/MediaCategoryDeletingEvent.php b/app/Events/MediaCategoryDeletingEvent.php new file mode 100644 index 0000000..2b7e065 --- /dev/null +++ b/app/Events/MediaCategoryDeletingEvent.php @@ -0,0 +1,39 @@ +mediaCategory = $mediaCategory; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new PrivateChannel('channel-name'); + } +} diff --git a/app/Events/MediaFileDeletedEvent.php b/app/Events/MediaFileDeletedEvent.php new file mode 100644 index 0000000..e7c74f4 --- /dev/null +++ b/app/Events/MediaFileDeletedEvent.php @@ -0,0 +1,39 @@ +model = $model; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new PrivateChannel('channel-name'); + } +} diff --git a/app/Http/Controllers/MediaCategoryController.php b/app/Http/Controllers/MediaCategoryController.php new file mode 100644 index 0000000..148be24 --- /dev/null +++ b/app/Http/Controllers/MediaCategoryController.php @@ -0,0 +1,300 @@ +mediaCategoriesRepository = $mediaCategoriesRepository; + $this->mediaFileRepository = $mediaFileRepository; + } + + public function hasAppMediaCategoryPermission() + { + return Gate::allows('permission:manage-app-media-categories'); + } + + /** + * 取得所有媒體庫分類 + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function index(Request $request) + { + $this->protectFromNoneAjaxRequest($request); + + $responseData = [ + 'mediaCategories' => [] + ]; + if(Auth::check()) { + $userId = Auth::id(); + $categories = []; + $hasAppCategoryPermission = $this->hasAppMediaCategoryPermission(); + if($hasAppCategoryPermission) { + $categories = $this->mediaCategoriesRepository->getAllAppMediaCategories(); + } else { + $categories = $this->mediaCategoriesRepository->getAllUserCategories($userId); + } + foreach ($categories as $category) { + $category->count = $this->mediaCategoriesRepository->getMediaFilesCount($category->id); + } + $categories = array_merge([ + [ + 'id' => 'all', + 'name' => trans('mediaLibrary.all'), + 'count' => $hasAppCategoryPermission + ? $this->mediaFileRepository->getAppMediasCount() + : $this->mediaFileRepository->getUserMediasCount($userId) + ], + [ + 'id' => 'uncategorized', + 'name' => trans('mediaLibrary.uncategorized'), + 'count' => $hasAppCategoryPermission + ? $this->mediaFileRepository->getAppUncategorizedMediasCount() + : $this->mediaFileRepository->getUserUncategorizedMediasCount($userId) + ] + ] , $categories->toArray()); + $responseData['mediaCategories'] = $categories; + return response()->json($responseData); + } else { + return response()->json($responseData, 401); + } + } + + /** + * 新增媒體庫分類 + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function store(Request $request) + { + $this->protectFromNoneAjaxRequest($request); + + Validator::make($request->all(), [ + 'name' => 'required|min:1' + ])->validate(); + + $status = 200; + if(Auth::check()) { + $userId = Auth::id(); + $hasAppMediaCategoryPermission = $this->hasAppMediaCategoryPermission(); + $category = $this->mediaCategoriesRepository->addCategory($request->get('name'), $userId, $hasAppMediaCategoryPermission); + if(!$category) { + $status = 500; + } + } else { + $status = 401; + } + if($status == 200) { + return response()->json([ + 'mediaCategory' => $category, + 'message' => trans('message.categoryNameHasBeenAdded', ['name' => $category->name]) + ]); + } else { + return response()->json([ + 'message' => trans('message.failToAddCategory') + ], $status); + } + } + + /** + * 更新媒體庫分類 + * + * @param Request $request + * @param $categoryId + * @return \Illuminate\Http\JsonResponse + */ + public function update(Request $request, $categoryId) + { + $this->protectFromNoneAjaxRequest($request); + + Validator::make($request->all(), [ + 'name' => 'required|min:1' + ])->validate(); + + $category = $this->mediaCategoriesRepository->getCategory($categoryId); + + $status = 200; + if(Auth::check()) { + if($category) { + $permissionCheck = true; + if($this->hasAppMediaCategoryPermission()) { + if(!$category->is_app_media_category) { + $permissionCheck = false; + } + } else { + if($category->user_id != Auth::id()) { + $permissionCheck = false; + } + } + + if($permissionCheck) { + $category->name = $request->get('name'); + $saved = $category->save(); + if(!$saved) { + $status = 500; + } + } else { + $status = 403; + } + } else { + $status = 400; + } + } else { + $status = 401; + } + + if($status == 200) { + return response()->json([ + 'id' => $categoryId, + 'name' => $request->get('name'), + 'message' => trans('message.categoryNameHasBeenUpdated', ['name' => $request->get('name')]) + ]); + } else { + return response()->json([ + 'message' => trans('message.failToUpdateCategoryName') + ], $status); + } + } + + /** + * 刪除媒體庫分類 + * + * @param Request $request + * @param $categoryId + * @return \Illuminate\Http\JsonResponse + * @throws \Exception + */ + public function destroy(Request $request, $categoryId) + { + $this->protectFromNoneAjaxRequest($request); + $status = 200; + if(Auth::check()) { + $category = $this->mediaCategoriesRepository->getCategory($categoryId); + if($category) { + $permissionCheck = true; + if($this->hasAppMediaCategoryPermission()) { + if(!$category->is_app_media_category) { + $permissionCheck = false; + } + } else { + if($category->user_id != Auth::id()) { + $permissionCheck = false; + } + } + if($permissionCheck) { + $mediaFilesCount = $category->mediaFiles->count(); + $category->delete(); + } else { + $status = 403; + } + } else { + $status = 400; + } + } else { + $status = 401; + } + + if($status == 200) { + return response()->json([ + 'id' => $category->id, + 'count' => $mediaFilesCount, + 'message' => trans('message.categoryNameHasBeenDeleted', ['name' => $category->name]) + ]); + } else { + return response()->json([ + 'message' => trans('message.failToDeleteCategory') + ]); + } + + } + + /** + * 更新分類順序 + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + public function updateOrder(Request $request) + { + $this->protectFromNoneAjaxRequest($request); + + $this->validate($request, [ + 'ids' => 'required|min:1' + ]); + + $status = 200; + + $mediaCategories = []; + $ids = explode(',', $request->get('ids')); + foreach ($ids as $id) { + $mediaCategories[] = $this->mediaCategoriesRepository->getCategory($id); + } + + if(Auth::check()) { + $existenceCheck = true; + + foreach ($mediaCategories as $mediaCategory) { + $existenceCheck = $existenceCheck && $mediaCategory; + } + + if($existenceCheck) { + $permissionCheck = true; + + if($this->hasAppMediaCategoryPermission()) { + foreach ($mediaCategories as $mediaCategory) { + $permissionCheck = $permissionCheck && $mediaCategory->is_app_media_category; + } + } else { + foreach ($mediaCategories as $mediaCategory) { + $permissionCheck = $permissionCheck && ($mediaCategory->user_id == Auth::id()); + } + } + + if($permissionCheck) { + foreach ($mediaCategories as $index => $mediaCategory) { + $mediaCategory->seq = $index; + $mediaCategory->save(); + } + } else { + $status = 403; + } + } else { + $status = 400; + } + } else { + $status = 401; + } + + if($status == 200) { + return response()->json([]); + } else { + return response()->json([ + 'message' => trans('message.failToReOrderCategory', $status) + ]); + } + } +} diff --git a/app/Http/Controllers/MediaLibraryController.php b/app/Http/Controllers/MediaLibraryController.php new file mode 100644 index 0000000..cc28321 --- /dev/null +++ b/app/Http/Controllers/MediaLibraryController.php @@ -0,0 +1,374 @@ +mediaFileRepository = $mediaFileRepository; + $this->mediaCategoriesRepository = $mediaCategoriesRepository; + } + + /** + * 建立response資料 + * + * @param MediaFile $mediaFile + * @return array + */ + private function createResponseMediaFile(MediaFile $mediaFile) + { + $mediaCategory = $mediaFile->mediaCategory; + return [ + 'id' => $mediaFile->id, + 'fileName' => $mediaFile->file_name, + 'url' => $mediaFile->url, + 'date' => $mediaFile->created_at->format('Y-m-d H:i:s'), + 'mimeType' => $mediaFile->mime_type, + 'description' => $mediaFile->description, + 'sizeInBytes' => $mediaFile->size, + 'width' => $mediaFile->width, + 'height' => $mediaFile->height, + 'category' => [ + 'id' => $mediaCategory ? $mediaCategory->id : 'uncategorized', + 'name' => $mediaCategory ? $mediaCategory->name : '' + ] + ]; + } + + private function hasAppMediaPermission() + { + return Gate::allows("permission:manage-app-medias"); + } + + public function hasAppMediaCategoryPermission() + { + return Gate::allows('permission:manage-app-media-categories'); + } + + /** + * 新增媒體檔案 + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + * @throws \Exception + */ + public function fileUpload(Request $request) + { + $this->protectFromNoneAjaxRequest($request); + + if(Auth::check()) { + /** @var \Symfony\Component\HttpFoundation\File\UploadedFile $file */ + $file = $request->files->get('media_file'); + if($file) { + $userId = Auth::id(); + if($this->hasAppMediaPermission()) { + $uploadedMediaFile = $this->uploadMediaFile($file, 1, $userId, null, true); + } else { + $uploadedMediaFile = $this->uploadMediaFile($file, 2, $userId, $userId); + } + + $success = false; + + if($uploadedMediaFile) { + $category = $request->get('category'); + if($this->mediaCategoriesRepository->hasCategory($category)) { + $uploadedMediaFile->media_category_id = $category; + $uploadedMediaFile->save(); + } + $success = true; + } + + if($success) { + return response()->json([ + 'media' => $this->createResponseMediaFile($uploadedMediaFile), + 'category' => $category + ]); + } else { + return response()->json([], 400); + } + } + } else { + return response()->json([], 401); + } + } + + /** + * 取得媒體檔案 + * + * @param Request $request + * @param null $page + * @return \Illuminate\Http\JsonResponse + * @throws \Illuminate\Validation\ValidationException + */ + public function getMedias(Request $request, $page = null) + { + $this->protectFromNoneAjaxRequest($request); + + $this->validate($request, [ + 'category_id' => 'required' + ]); + + $responseData = [ + 'medias' => [] + ]; + + $lastQueryId = $request->get('last_fetched_media_id'); + $category = $request->get('category_id'); + $limit = $request->get('limit'); + $mediaFiles = []; + if(Auth::check()) { + $userId = Auth::id(); + if($this->hasAppMediaPermission()) { + if($lastQueryId) { + $mediaFiles = $this->mediaFileRepository->getAppMediasWithLastQueryIdAndCategory($lastQueryId, $category, $limit ? $limit : 14); + } else { + $mediaFiles = $this->mediaFileRepository->getAppMediasWithCategory($category, $limit ? $limit : 35); + } + } else { + if($lastQueryId) { + $mediaFiles = $this->mediaFileRepository->getUserMediasWithLastQueryIdAndCategory($userId, $category, $lastQueryId, $limit ? $limit : 14); + } else { + $mediaFiles = $this->mediaFileRepository->getUserMediasWithCategory($userId, $category, $limit ? $limit : 35); + } + } + foreach ($mediaFiles as $mediaFile) { + $responseData['medias'][] = $this->createResponseMediaFile($mediaFile); + } + return response()->json($responseData); + } else { + return response()->json($responseData, 401); + } + } + + /** + * 更新媒體檔案 + * + * @param Request $request + * @param $mediaId + * @return \Illuminate\Http\JsonResponse + */ + public function updateMedia(Request $request, $mediaId) + { + $this->protectFromNoneAjaxRequest($request); + + $status = 200; + if(Auth::check()) { + $media = $this->mediaFileRepository->getMedia($mediaId); + if($media) { + if($this->hasAppMediaPermission()) { + if(!$media->is_app_media) { + $status = 403; + } + } else { + if(!$media->user->id == Auth::id()) { + $status = 403; + } + } + } else { + $status = 404; + } + } else { + $status = 401; + } + + if($status == 200) { + $media->description = $request->get('description'); + $mediaCategoryId = $request->get('category_id'); + $mediaCategoryId = $mediaCategoryId == 'uncategorized' ? null : $mediaCategoryId; + $media->media_category_id = $mediaCategoryId; + $saved = $media->save(); + if($saved) { + return response()->json([ + 'message' => trans('message.descriptionHasBeenUpdated') + ]); + } else { + return response()->json([ + 'message' => trans('message.failToUpdateDescription') + ], 400); + } + } else { + return response()->json([ + 'message' => trans('message.failToUpdateDescription') + ], $status); + } + } + + /** + * 刪除媒體檔案 + * + * @param Request $request + * @param $mediaId + * @return \Illuminate\Http\JsonResponse + * @throws \Exception + */ + public function deleteMedia(Request $request, $mediaId) + { + $this->protectFromNoneAjaxRequest($request); + + $status = 200; + if(Auth::check()) { + $media = $this->mediaFileRepository->getMedia($mediaId); + if($media) { + if($this->hasAppMediaPermission()) { + if(!$media->is_app_media) { + $status = 403; + } + } else { + if(!$media->user->id == Auth::id()) { + $status = 403; + } + } + } else { + $status = 404; + } + } else { + $status = 401; + } + + if($status == 200) { + $deleted = $media->delete(); + if($deleted) { + return response()->json([ + 'id' => $media->id, + 'categoryId' => $media->media_category_id ? $media->media_category_id : 'uncategorized', + 'message' => trans('message.mediaHasBeenDeleted') + ]); + } else { + return response()->json([ + 'message' => trans('message.failToDeleteMedia') + ], 400); + } + } else { + return response()->json([ + 'message' => trans('message.failToDeleteMedia') + ], $status); + } + } + + /** + * 更新媒體檔案的分類 + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + * @throws \Illuminate\Validation\ValidationException + */ + public function updateCategory(Request $request) + { + $this->protectFromNoneAjaxRequest($request); + + $this->validate($request, [ + 'category_id' => 'required', + 'media_ids' => 'required' + ]); + + $status = 200; + if(Auth::check()) { + $userId = Auth::id(); + $categoryId = $request->get('category_id'); + $mediaIds = $request->get('media_ids'); + $mediaIds = is_array($mediaIds) ? $mediaIds : explode(',', $request->get('media_ids')); + $mediaFiles = collect($mediaIds)->map(function($mediaId){ + return $this->mediaFileRepository->getMedia($mediaId); + }); + $mediaCategory = $this->mediaCategoriesRepository->getCategory($categoryId); + + $existenceCheck = true; + if($categoryId != 'uncategorized') { + $existenceCheck = $existenceCheck && $mediaCategory; + } + + foreach ($mediaFiles as $mediaFile) { + $existenceCheck = $existenceCheck && $mediaFile; + } + + if($existenceCheck) { + $permissionCheck = true; + $hasAppMediaCategoryPermission = $this->hasAppMediaCategoryPermission(); + $hasAppMediaPermisssion = $this->hasAppMediaPermission(); + foreach ($mediaFiles as $mediaFile) { + if($hasAppMediaPermisssion) { + $permissionCheck = $permissionCheck && $mediaFile->is_app_media; + } else { + $permissionCheck = $permissionCheck && ($mediaFile->user_id == $userId); + } + } + if($mediaCategory) { + if($hasAppMediaCategoryPermission) { + $permissionCheck = $permissionCheck && $mediaCategory->is_app_media_category; + } else { + $permissionCheck = $permissionCheck && ($mediaCategory->user_id == $userId); + } + } + + + if($permissionCheck) { + $categorySources = []; + $mediaCategoryId = $mediaCategory ? $mediaCategory->id : null; + foreach ($mediaFiles as $mediaFile) { + $oldCategoryId = $mediaFile->media_category_id ? $mediaFile->media_category_id : 'uncategorized'; + if(!isset($categorySources[$oldCategoryId])) { + $categorySources[$oldCategoryId] = 0; + } + $categorySources[$oldCategoryId]++; + + $mediaFile->media_category_id = $mediaCategoryId; + $mediaFile->save(); + } + } else { + $status = 403; + } + + } else { + $status = 403; + } + + } else { + $status = 401; + } + + if($status == 200) { + $message = $mediaCategory + ? trans('message.mediaHasBeenMoveToCategory', ['name' => $mediaCategory->name]) + : trans('message.mediaHasBeenSetToUncategorized'); + return response()->json([ + 'mediaIds' => $mediaIds, + 'categoryId' => $categoryId, + 'categorySources' => $categorySources, + 'message' => $message + ]); + } else { + return response()->json([ + 'message' => trans('message.failToMoveMediaToCategory') + ], $status); + } + } +} diff --git a/app/Http/Middleware/SetSiteStates.php b/app/Http/Middleware/SetSiteStates.php index 790ca46..1e0884d 100644 --- a/app/Http/Middleware/SetSiteStates.php +++ b/app/Http/Middleware/SetSiteStates.php @@ -23,8 +23,9 @@ class SetSiteStates public function handle($request, Closure $next) { $siteState = app(SiteStateService::class); + $adminRoute = config('admin.route'); //是否網站後台,判斷route prefix - if($request->is(config('admin.route') . '/*')) { + if($request->is($adminRoute . '/*', $adminRoute)) { $siteState->isAdminArea = true; } diff --git a/app/Listeners/MediaCategoryDeletingListener.php b/app/Listeners/MediaCategoryDeletingListener.php new file mode 100644 index 0000000..3f03f45 --- /dev/null +++ b/app/Listeners/MediaCategoryDeletingListener.php @@ -0,0 +1,37 @@ +mediaCategory; + + $mediaFiles = $mediaCategory->mediaFiles; + $mediaFiles->each(function($mediaFile){ + $mediaFile->media_category_id = null; + $mediaFile->save(); + }); + } +} diff --git a/app/Listeners/MediaFileDeletedListener.php b/app/Listeners/MediaFileDeletedListener.php new file mode 100644 index 0000000..211c006 --- /dev/null +++ b/app/Listeners/MediaFileDeletedListener.php @@ -0,0 +1,46 @@ +model; + if($mediaFile->disk) { + $disk = Storage::disk($mediaFile->disk->name); + $disk->delete($mediaFile->fileNameWithPath); + if($mediaFile->path) { + if(empty($disk->allFiles($mediaFile->path)) && empty($disk->allDirectories($mediaFile->path))) { + $disk->deleteDirectory($mediaFile->path); + } + } + } + } +} diff --git a/app/MediaCategory.php b/app/MediaCategory.php new file mode 100644 index 0000000..4c9ee74 --- /dev/null +++ b/app/MediaCategory.php @@ -0,0 +1,57 @@ + \App\Events\MediaCategoryDeletingEvent::class + ]; + + protected $fillable = [ + 'name', + 'is_app_media_category', + 'user_id' + ]; + + protected $hidden = [ + 'is_app_media_category', + 'user_id', + 'seq' + ]; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function mediaFiles() + { + return $this->hasMany(MediaFile::class); + } +} diff --git a/app/MediaFile.php b/app/MediaFile.php new file mode 100644 index 0000000..19463c9 --- /dev/null +++ b/app/MediaFile.php @@ -0,0 +1,174 @@ + \App\Events\MediaFileDeletedEvent::class + ]; + + protected $fillable = [ + 'path', + 'file_name', + 'ext', + 'disk_id', + 'media_source_id', + 'size', + 'width', + 'height', + 'is_app_media', + 'user_id', + 'mime_type', + 'description' + ]; + + protected $hidden = [ + 'path', + 'ext', + 'disk_id', + 'disk', + 'is_app_media', + 'user_id' + ]; + + public function getUrlAttribute() + { + return Storage::disk($this->disk->name)->url(trim($this->path, '/'). '/' . trim($this->file_name, '/')); + } + + public function getFilePathAttribute() + { + return Storage::disk($this->disk->name)->path(trim($this->path, '/'). '/' . trim($this->file_name, '/')); + } + + public function getFileNameWithPathAttribute() + { + return trim(trim($this->path, '/') . '/' . $this->file_name, '/'); + } + + public function getYoutubeEmbedUrlAttribute() + { + $mediaSource = $this->mediaSource; + if($mediaSource && 'youtube' == $mediaSource->name) { + return "https://www.youtube.com/embed/$this->file_name"; + } + return ''; + } + + /** + * @param \Illuminate\Database\Query\Builder $query + * + * @return \Illuminate\Database\Query\Builder + */ + public function scopeUncategorized($query) + { + return $query->where('media_category_id', null); + } + + /** + * @param \Illuminate\Database\Query\Builder $query + * + * @return \Illuminate\Database\Query\Builder + */ + public function scopeCategory($query, $categoryId) + { + return $query->where('media_category_id', $categoryId); + } + + /** + * @param \Illuminate\Database\Query\Builder $query + * + * @return \Illuminate\Database\Query\Builder + */ + public function scopeIsAppMedia($query) + { + return $query->where('is_app_media', true); + } + + /** + * @param \Illuminate\Database\Query\Builder $query + * + * @return \Illuminate\Database\Query\Builder + */ + public function scopeUserMedia($query, $userId) + { + return $query->where('is_app_media', false)->where('user_id', $userId); + } + + public function user() + { + return $this->belongsTo(User::class); + } + + public function disk() + { + return $this->belongsTo(Disk::class); + } + + public function mediaCategory() + { + return $this->belongsTo(MediaCategory::class); + } + + public function mediaSource() + { + return $this->belongsTo(MediaSource::class); + } +} diff --git a/app/MediaSource.php b/app/MediaSource.php new file mode 100644 index 0000000..b022e77 --- /dev/null +++ b/app/MediaSource.php @@ -0,0 +1,27 @@ +hasMany(MediaFile::class); + } +} diff --git a/app/Option.php b/app/Option.php index b7f8201..5187bb1 100644 --- a/app/Option.php +++ b/app/Option.php @@ -4,6 +4,21 @@ namespace App; use Illuminate\Database\Eloquent\Model; +/** + * App\Option + * + * @property int $id + * @property string $key 選項名稱 + * @property string $value 選項值 + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option key($key) + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option query() + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option whereId($value) + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option whereKey($value) + * @method static \Illuminate\Database\Eloquent\Builder|\App\Option whereValue($value) + * @mixin \Eloquent + */ class Option extends Model { protected $fillable = ['key', 'value']; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 723a290..50c0a15 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -18,6 +18,14 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + // MediaCategory刪除前的Event + \App\Events\MediaCategoryDeletingEvent::class => [ + \App\Listeners\MediaCategoryDeletingListener::class + ], + // MediaFile刪除後的Event + \App\Events\MediaFileDeletedEvent::class => [ + \App\Listeners\MediaFileDeletedListener::class + ], ]; /** diff --git a/app/Reposotories/MediaCategoriesRepository.php b/app/Reposotories/MediaCategoriesRepository.php new file mode 100644 index 0000000..349aa72 --- /dev/null +++ b/app/Reposotories/MediaCategoriesRepository.php @@ -0,0 +1,82 @@ +setModel($mediaCategory); + } + + public function getAllAppMediaCategories() + { + return $this->getModel() + ->where('is_app_media_category', true) + ->orderBy('seq') + ->orderBy('id', 'desc') + ->get(); + } + + public function getAllUserCategories($userId) + { + return $this->getModel() + ->where('is_app_media_category', false) + ->where('user_id', $userId) + ->orderBy('seq') + ->orderBy('id', 'desc') + ->get(); + } + + public function getMediaFilesCount($categoryId) + { + return $this->findModel($categoryId)->mediaFiles->count(); + } + + /** + * @param $name + * @return \App\MediaCategory + */ + public function addCategory($name, $userId, $isAppMediaCategory) + { + $category = $this->createModel(); + $category->fill([ + 'name' => $name, + 'user_id' => $userId, + 'is_app_media_category' => $isAppMediaCategory + ]); + $saved = $category->save(); + if($saved) { + return $category; + } else { + return false; + } + } + + /** + * @param $id + * @return \App\MediaCategory + */ + public function getCategory($id) + { + return $this->findModel($id); + } + + /** + * @param $id + * @return bool + */ + public function hasCategory($id) + { + return $this->hasModel($id); + } +} diff --git a/app/Reposotories/MediaFileRepository.php b/app/Reposotories/MediaFileRepository.php new file mode 100644 index 0000000..13425c5 --- /dev/null +++ b/app/Reposotories/MediaFileRepository.php @@ -0,0 +1,242 @@ +setModel($mediaFile); + } + + private function queryBuilderGetLimit(Builder $queryBuilder, $limit = null) + { + if($limit) { + return $queryBuilder->limit($limit)->get(); + } + return $queryBuilder->get(); + } + + private function queryBuilderWhereCategory(Builder $queryBuilder, $category = '') + { + if($category == 'uncategorized') { + $queryBuilder = $queryBuilder->uncategorized(); + } else if(is_numeric($category)) { + $queryBuilder = $queryBuilder->category($category); + } + return $queryBuilder; + } + + /** + * @param $id + * @return \App\MediaFile|null + */ + public function getMedia($id) + { + return $this->findModel($id); + } + + public function hasMedia($id) + { + return $this->hasModel($id); + } + + /** + * @param $fileName + * @param $ext + * @param $diskId + * @param $mediaSourceId + * @param $mimeType + * @param null $userId + * @param null $width + * @param null $height + * @param bool $isAppMedia + * @param $size + * @return MediaFile|\Illuminate\Database\Eloquent\Model|boolean + */ + public function addMedia($fileName, $ext, $diskId, $mediaSourceId, $size, $mimeType, $userId, $path = null, $width = null, $height = null, $isAppMedia = false) + { + $mediaFile = $this->createModel(); + $mediaFile->fill([ + 'path' => $path, + 'file_name' => $fileName, + 'ext' => $ext, + 'disk_id' => $diskId, + 'media_source_id' => $mediaSourceId, + 'size' => $size, + 'mime_type' => $mimeType, + 'width' => $width, + 'height' => $height, + 'is_app_media' => $isAppMedia, + 'user_id' => $userId, + ]); + $saved = $mediaFile->save(); + return $saved ? $mediaFile : $saved; + } + + /** + * @param $fileName + * @param $ext + * @param $size + * @param $mimeType + * @param $userId + * @param null $width + * @param null $height + * @return MediaFile|\Illuminate\Database\Eloquent\Model + */ + public function addAppMedia($fileName, $ext, $size, $mimeType, $userId, $path = null, $width = null, $height = null) + { + return $this->addMedia($fileName, $ext, 1, null, $size, $mimeType, $userId, $path, $width, $height, true); + } + + /** + * @param $fileName + * @param $ext + * @param $size + * @param $mimeType + * @param $userId + * @param null $width + * @param null $height + * @return MediaFile|\Illuminate\Database\Eloquent\Model + */ + public function addUserMedia($fileName, $ext, $size, $mimeType, $userId, $path = null, $width = null, $height = null) + { + return $this->addMedia($fileName, $ext, 2, null, $size, $mimeType, $userId, $path, $width, $height); + } + + /** + * @param null $limit + * @return MediaFile[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection + */ + public function getAppMedias($limit = null) + { + $queryBuilder = $this->getModel() + ->isAppMedia() + ->orderByDesc('id'); + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + public function getAppMediasWithCategory($category, $limit = null) + { + $queryBuilder = $this->getModel() + ->isAppMedia() + ->orderByDesc('id'); + + $queryBuilder = $this->queryBuilderWhereCategory($queryBuilder, $category); + + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + /** + * @param $lastQueryId + * @param null $limit + * @return MediaFile[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection + */ + public function getAppMediasWithLastQueryId($lastQueryId, $limit = null) + { + $queryBuilder = $this->getModel() + ->isAppMedia() + ->where('id', '<', $lastQueryId) + ->orderByDesc('id'); + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + /** + * @param $lastQueryId + * @param null $limit + * @return MediaFile[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection + */ + public function getAppMediasWithLastQueryIdAndCategory($lastQueryId, $category, $limit = null) + { + $queryBuilder = $this->getModel() + ->where('is_app_media', true) + ->where('id', '<', $lastQueryId) + ->orderByDesc('id'); + + $queryBuilder = $this->queryBuilderWhereCategory($queryBuilder, $category); + + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + /** + * @param $userId + * @param null $limit + * @return Builder[]|\Illuminate\Database\Eloquent\Collection + */ + public function getUserMedias($userId, $limit = null) + { + $queryBuilder = $this->getModel() + ->userMedia($userId) + ->orderByDesc('id'); + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + public function getUserMediasWithCategory($userId, $category, $limit = null) + { + $queryBuilder = $this->getModel() + ->userMedia($userId) + ->orderByDesc('id'); + + $queryBuilder = $this->queryBuilderWhereCategory($queryBuilder, $category); + + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + /** + * @param $userId + * @param $lastQueryId + * @param null $limit + * @return MediaFile[]|\Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Query\Builder[]|\Illuminate\Support\Collection + */ + public function getUserMediasWithLastQueryId($userId, $lastQueryId, $limit = null) + { + $queryBuilder = $this->getModel() + ->userMedia($userId) + ->where('id', '<', $lastQueryId) + ->orderByDesc('id'); + + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + public function getUserMediasWithLastQueryIdAndCategory($userId, $lastQueryId, $category, $limit = null) + { + $queryBuilder = $this->getModel() + ->userMedia($userId) + ->where('id', '<', $lastQueryId) + ->orderByDesc('id'); + + $queryBuilder = $this->queryBuilderWhereCategory($queryBuilder, $category); + + return $this->queryBuilderGetLimit($queryBuilder, $limit); + } + + public function getAppUncategorizedMediasCount() + { + return $this->getAppMediasWithCategory('uncategorized')->count(); + } + + public function getUserUncategorizedMediasCount($userId) + { + return $this->getUserMediasWithCategory($userId, 'uncategorized')->count(); + } + + public function getAppMediasCount() + { + return $this->getModel()->isAppMedia()->count(); + } + + public function getUserMediasCount($userId) + { + return $this->getModel()->userMedia($userId)->count(); + } +} diff --git a/app/Services/AppJsObjectService.php b/app/Services/AppJsObjectService.php index 60f06fa..2f63558 100644 --- a/app/Services/AppJsObjectService.php +++ b/app/Services/AppJsObjectService.php @@ -40,8 +40,25 @@ class AppJsObjectService } if($siteState->isAdminArea) { + $obj['admin']['ajax']['resource'] = [ + 'mediaFile' => [ + 'upload' => route('media.upload'), + 'get' => route('media.get'), + 'update' => route('media.update', [':id']), + 'destroy' => route('media.destroy', [':id']), + 'updateCategory' => route('media.updateCategory') + ], + 'mediaCategory' => [ + 'add' => route('media-category.store'), + 'index' => route('media-category.index'), + 'update' => route('media-category.update', [':id']), + 'destroy' => route('media-category.destroy', [':id']), + 'updateOrder' => route('media-category.updateOrder') + ], + ]; $obj['admin']['translations'] = [ 'dataTables' => trans('datatables'), + 'mediaLibrary' => trans('mediaLibrary') ]; } return $obj; diff --git a/app/Traits/PureAjaxMethodProtectable.php b/app/Traits/PureAjaxMethodProtectable.php new file mode 100644 index 0000000..a29e684 --- /dev/null +++ b/app/Traits/PureAjaxMethodProtectable.php @@ -0,0 +1,21 @@ +ajax()) abort(404); + return; + } +} diff --git a/app/Traits/UploadedFileProccessable.php b/app/Traits/UploadedFileProccessable.php new file mode 100644 index 0000000..a00a15f --- /dev/null +++ b/app/Traits/UploadedFileProccessable.php @@ -0,0 +1,94 @@ +getClientOriginalExtension(); + $baseName = str_replace(".$ext", '', $file->getClientOriginalName()); + $ext = strtolower($ext); + $alteredBaseName = Str::random(26) . time() . ".$ext"; + $imageDim = getimagesize($file); + if($imageDim) { + $width = $imageDim[0]; + $height = $imageDim[1]; + } else { + $width = null; + $height = null; + } + $fileSize = $file->getSize(); + $mimeType = $file->getMimeType(); + + return [ + 'ext' => $ext, + 'fileName' => $alteredBaseName, + 'fileSize' => $fileSize, + 'mimeType' => $mimeType, + 'width' => $width, + 'height' => $height, + ]; + } + + /** + * 上傳媒體檔案 + * + * @param \Symfony\Component\HttpFoundation\File\UploadedFile $file + * @param $diskId + * @param $userId + * @param string $path + * @param bool $isAppMedia + * @return \App\MediaFile|null + * @throws \Exception + */ + private function uploadMediaFile(\Symfony\Component\HttpFoundation\File\UploadedFile $file, $diskId, $userId, $path = null, $isAppMedia = false) + { + $fileInfo = $this->getUpadloedFileInfo($file); + + $ext = $fileInfo['ext']; + $fileName = $fileInfo['fileName']; + $fileSize = $fileInfo['fileSize']; + $mimeType = $fileInfo['mimeType']; + $width = $fileInfo['width']; + $height = $fileInfo['height']; + + $mediaFileRepo = app(MediaFileRepository::class); + + $disk = Disk::find($diskId); + if($disk) { + $mediaFile = $mediaFileRepo->addMedia($fileName, $ext, $diskId, null, $fileSize, $mimeType, $userId, $path, $width, $height, $isAppMedia); + if($mediaFile) { + $putted = Storage::disk($disk->name)->putFileAs($path, $file, $fileName); + if($putted !== false) { + return $mediaFile; + } else { + $mediaFile->delete(); + return null; + } + } else { + return null; + } + } else { + throw new \Exception('Disk not found with id: ' . $diskId); + } + } +} diff --git a/app/User.php b/app/User.php index 111ff4c..3ca4389 100644 --- a/app/User.php +++ b/app/User.php @@ -30,6 +30,12 @@ use Spatie\Permission\Traits\HasRoles; * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereRememberToken($value) * @method static \Illuminate\Database\Eloquent\Builder|\App\User whereUpdatedAt($value) * @mixin \Eloquent + * @property-read \Illuminate\Database\Eloquent\Collection|\Spatie\Permission\Models\Permission[] $permissions + * @property-read int|null $permissions_count + * @property-read \Illuminate\Database\Eloquent\Collection|\Spatie\Permission\Models\Role[] $roles + * @property-read int|null $roles_count + * @method static \Illuminate\Database\Eloquent\Builder|\App\User permission($permissions) + * @method static \Illuminate\Database\Eloquent\Builder|\App\User role($roles, $guard = null) */ class User extends Authenticatable { diff --git a/config/data-presets.php b/config/data-presets.php index ffd2feb..bc4a952 100644 --- a/config/data-presets.php +++ b/config/data-presets.php @@ -1,5 +1,14 @@ [ + 1 => 'uploadedAppMedias', + 2 => 'uploadedUserMedias', + ], + // 外連的media來源 + 'mediaSources' => [ + 1 => 'youtube' + ], 'roles' => [ ['name' => 'administrator', 'displayName' => 'administrator'], ['name' => 'editor', 'displayName' => 'editor'], @@ -43,6 +52,22 @@ return [ 'displayName' => 'adminManageSystemStatus', 'assignTo' => [] ], + [ + //管理媒體庫 + 'name' => 'manage app medias', + 'displayName' => 'manageAppMedias', + 'assignTo' => [ + 'editor' + ] + ], + [ + //管理媒體庫分類 + 'name' => 'manage app media categories', + 'displayName' => 'manageAppMediaCategories', + 'assignTo' => [ + 'editor' + ] + ], ], // 預設的設定值 'options' => [ diff --git a/config/debugbar.php b/config/debugbar.php index 0495fcf..ebc2536 100644 --- a/config/debugbar.php +++ b/config/debugbar.php @@ -66,7 +66,7 @@ return [ | Optionally, you can also send ServerTiming headers on ajax requests for the Chrome DevTools. */ - 'capture_ajax' => true, + 'capture_ajax' => false, 'add_ajax_timing' => false, /* diff --git a/config/filesystems.php b/config/filesystems.php index ec6a7ce..d68d91a 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -63,7 +63,19 @@ return [ 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), ], + 'uploadedAppMedias' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/uploads/app'), + 'url' => env('APP_URL').'/storage/uploads/app', + 'visibility' => 'public', + ], + 'uploadedUserMedias' => [ + 'driver' => 'local', + 'root' => storage_path('app/public/uploads/user'), + 'url' => env('APP_URL').'/storage/uploads/user', + 'visibility' => 'public', + ], ], ]; diff --git a/database/migrations/2020_02_22_135647_create_disks_table.php b/database/migrations/2020_02_22_135647_create_disks_table.php new file mode 100644 index 0000000..147aaca --- /dev/null +++ b/database/migrations/2020_02_22_135647_create_disks_table.php @@ -0,0 +1,33 @@ +unsignedTinyInteger('id')->primary(); + $table->string('name', 40)->unique()->comment('名稱'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + Schema::dropIfExists('disks'); + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + } +} diff --git a/database/migrations/2020_02_22_140147_create_media_categories_table.php b/database/migrations/2020_02_22_140147_create_media_categories_table.php new file mode 100644 index 0000000..6f9ef72 --- /dev/null +++ b/database/migrations/2020_02_22_140147_create_media_categories_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->string('name', 100)->comment('名稱'); + $table->boolean('is_app_media_category')->default(false)->comment('是否為網站媒體庫分類'); + $table->unsignedInteger('seq')->nullable()->default(0)->comment('排序'); + $table->unsignedBigInteger('user_id')->nullable()->comment('所屬使用者'); + + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('media_categories'); + } +} diff --git a/database/migrations/2020_02_22_140213_create_media_files_table.php b/database/migrations/2020_02_22_140213_create_media_files_table.php new file mode 100644 index 0000000..4da4c0c --- /dev/null +++ b/database/migrations/2020_02_22_140213_create_media_files_table.php @@ -0,0 +1,47 @@ +bigIncrements('id'); + $table->text('path')->nullable()->comment('子資料夾路徑'); + $table->string('file_name', 50)->unique()->comment('檔名'); + $table->string('ext', 10)->nullable()->comment('副檔名'); + $table->unsignedTinyInteger('disk_id')->nullable()->comment('disk名稱'); + $table->unsignedInteger('size')->nullable()->comment('檔案大小'); + $table->unsignedMediumInteger('width')->nullable()->default(null)->comment('圖片寬度'); + $table->unsignedMediumInteger('height')->nullable()->default(null)->comment('圖片高度'); + $table->string('mime_type', 40)->nullable()->comment('檔案的Mime Type'); + $table->boolean('is_app_media')->default(false)->comment('是否為網站的媒體檔案'); + $table->text('description')->nullable()->comment('描述'); + $table->unsignedBigInteger('media_category_id')->nullable()->default(null)->comment('媒體分類'); + $table->bigInteger('user_id')->unsigned()->nullable()->comment('所屬使用者'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users'); + $table->foreign('disk_id')->references('id')->on('disks'); + $table->foreign('media_category_id')->references('id')->on('media_categories'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('media_files'); + } +} diff --git a/database/migrations/2020_02_22_140413_create_media_sources_table.php b/database/migrations/2020_02_22_140413_create_media_sources_table.php new file mode 100644 index 0000000..57fde36 --- /dev/null +++ b/database/migrations/2020_02_22_140413_create_media_sources_table.php @@ -0,0 +1,31 @@ +unsignedTinyInteger('id')->primary(); + $table->string('name')->comment('外部來源名稱'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('media_sources'); + } +} diff --git a/database/migrations/2020_02_22_140523_add_media_source_column_to_media_files_table.php b/database/migrations/2020_02_22_140523_add_media_source_column_to_media_files_table.php new file mode 100644 index 0000000..08e0073 --- /dev/null +++ b/database/migrations/2020_02_22_140523_add_media_source_column_to_media_files_table.php @@ -0,0 +1,36 @@ +unsignedTinyInteger('media_source_id')->nullable()->default(null)->after('disk_id')->comment('外部來源id'); + $table->foreign('media_source_id', 'source')->references('id')->on('media_sources'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('media_files', function (Blueprint $table) { + DB::statement('SET FOREIGN_KEY_CHECKS = 0'); + $table->dropForeign('source'); // Drop foreign key 'user_id' from 'posts' table + $table->dropColumn('media_source_id'); + DB::statement('SET FOREIGN_KEY_CHECKS = 1'); + }); + } +} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index 25330db..b6a7186 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -13,5 +13,7 @@ class DatabaseSeeder extends Seeder { $this->call(RolesAndPermissionsSeeder::class); $this->call(OptionsSeeder::class); + $this->call(DisksSeeder::class); + $this->call(MediaSourcesSeeder::class); } } diff --git a/database/seeds/Preset/DisksSeeder.php b/database/seeds/Preset/DisksSeeder.php new file mode 100644 index 0000000..e4bb84e --- /dev/null +++ b/database/seeds/Preset/DisksSeeder.php @@ -0,0 +1,25 @@ + $disk) { + $diskModel->insert([ + 'id' => $id, + 'name' => $disk + ]); + } + } +} diff --git a/database/seeds/Preset/MediaSourcesSeeder.php b/database/seeds/Preset/MediaSourcesSeeder.php new file mode 100644 index 0000000..8eafcf9 --- /dev/null +++ b/database/seeds/Preset/MediaSourcesSeeder.php @@ -0,0 +1,25 @@ + $mediaSource) { + $mediaSourceModel->insert([ + 'id' => $id, + 'name' => $mediaSource + ]); + } + } +} diff --git a/package-lock.json b/package-lock.json index ee1b041..7b3fddc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3281,6 +3281,12 @@ "integrity": "sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU=", "dev": true }, + "dropzone": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.7.0.tgz", + "integrity": "sha512-kOltiZXH5cO/72I22JjE+w6BoT6uaVLfWdFMsi1PMKFkU6BZWpqRwjnsRm0o6ANGTBuZar5Piu7m/CbKqRPiYg==", + "dev": true + }, "duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", diff --git a/package.json b/package.json index 0ca6d35..8c47046 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "cross-env": "^7.0.0", "datatables.net-bs4": "^1.10.20", "datatables.net-responsive-bs4": "^2.2.3", + "dropzone": "^5.7.0", "flag-icon-css": "^3.4.6", "font-awesome": "^4.7.0", "jquery": "^3.2", diff --git a/resources/js/admin/app.js b/resources/js/admin/app.js index 9d01962..e207fd3 100644 --- a/resources/js/admin/app.js +++ b/resources/js/admin/app.js @@ -1,2 +1,109 @@ import './lib'; import '../app-common' +import {block, unblock} from '../utils/blockui' +import mediaLibrary from "../utils/media-library"; + +/** + * 開啟媒體庫 + * + * @param single 是否為單選模式 + * @param inputElement 自動會傳值進input元素 + * @param selectMode 是否出現選擇按鈕 + */ +app.methods.media = function(single = true, inputElement = '', selectMode = true, callback = null) { + let media = mediaLibrary({ + url: app.admin.ajax.resource.mediaFile.upload, + translations: app.admin.translations.mediaLibrary, + csrfToken: app.csrfToken, + single: single, + selectMode: selectMode, + }) + + let resizeMediaLibraryWindow = () => { + let windowHeight = $(window).height(), + $mediaLib = $('#media-library'); + let totalHeight = windowHeight * .98, + rowHeadHeight = 50, + rowFooterHeight = 40, + padding = 15 * 2, + rowBodyHeight = totalHeight - padding - rowHeadHeight - rowFooterHeight; + $mediaLib.height(totalHeight); + $mediaLib.find('.row-head').height(rowHeadHeight); + $mediaLib.find('.row-footer').height(rowFooterHeight); + $mediaLib.find('.row-body').height(rowBodyHeight); + $mediaLib.find('.tab-page, .category-list, .media-info-wrapper').height(rowBodyHeight); + $mediaLib.find('.col-media-info').css('marginTop', this.matchMediaInPx(768) ? 0 : rowHeadHeight); + media.$emit('resize', windowHeight, $(window).width()) + } + + $(window).on('resize', resizeMediaLibraryWindow); + resizeMediaLibraryWindow(); + + let closeMediaLibrary = () => { + $('#media-library').remove(); + unblock(); + $(window).off('resize', resizeMediaLibraryWindow); + } + + if(callback) { + media.$on('media_selected', function(data){ + callback(data); + }) + } + + if(inputElement) { + media.$on('media_selected', function(data){ + if(typeof inputElement == 'string') { + document.querySelectorAll(inputElement).forEach(function(element, index){ + element.value = data.idString; + }); + } else if(inputElement instanceof HTMLElement) { + inputElement.value = data.idString; + } + }); + } + + media.$on('close', closeMediaLibrary); + + block(media.$el, { + blockMsgClass: 'media-library', + onUnblock: function(){ + closeMediaLibrary() + } + }) + + let $dropzone = $(media.$children[0].$refs.uploadZone).dropzone({ + paramName: 'media_file', + url: app.admin.ajax.resource.mediaFile.upload, + headers: { + 'X-CSRF-TOKEN': app.csrfToken + }, + sending: function(file, xhr, formData){ + formData.append('category', media.d.currentCategory); + }, + success:function(response){ + let responseBody = JSON.parse(response.xhr.response); + if(responseBody.category == media.d.currentCategory) { + media.d.medias.unshift(responseBody.media) + } + if(responseBody.category == 'all') { + media.$children[0].addCategoryCount('uncategorized', 1); + } else { + media.$children[0].addCategoryCount(responseBody.category, 1); + } + } + }); + + media.$on('remove_all_uploaded_files', function(){ + $dropzone.get(0).dropzone.removeAllFiles(); + }); + + return media; +} + + +$(() => { + $('#app-header .medialibrary').on('click', function(){ + app.adminMediaLibrary = app.methods.media(false, null, false); + }); +}) diff --git a/resources/js/admin/lib.js b/resources/js/admin/lib.js index fca0803..baef3e1 100644 --- a/resources/js/admin/lib.js +++ b/resources/js/admin/lib.js @@ -25,6 +25,7 @@ import 'datatables.net-bs4'; import 'datatables.net-responsive-bs4'; import 'block-ui'; +window.Dropzone = require('dropzone'); try { window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/resources/js/apis/admin/media-category.js b/resources/js/apis/admin/media-category.js new file mode 100644 index 0000000..8e60aa5 --- /dev/null +++ b/resources/js/apis/admin/media-category.js @@ -0,0 +1,86 @@ +/** + * 取得媒體分類 + * + * 參數: + * category_id: 分類ID + * media_ids: 媒體的ID字串(使用逗號分隔) + * + * Response: + * mediaCategories: 分類資料 + */ +export const index = (data = {}) => { + return axios.get(app.admin.ajax.resource.mediaCategory.index, {params: data}); +} +/** + * 新增媒體分類 + * + * 參數: + * name: 名稱 + * + * Success Response: + * mediaCategory: 被新增的分類 + * message: 訊息 + * + * Error Response: + * message: 訊息 + */ +export const add = (name, data = {}) => { + data = Object.assign({ + name: name + }, data); + return axios.post(app.admin.ajax.resource.mediaCategory.add, data); +} +/** + * 更新媒體分類名稱 + * + * 參數: + * id: 分類ID + * name: 名稱 + * + * Success Response: + * id: 分類ID + * name: 分類名稱 + * message: 訊息 + * + * Error Response: + * message: 訊息 + */ +export const update = (id, name, data = {}) => { + data = Object.assign({ + name: name + }, data); + return axios.put(app.admin.ajax.resource.mediaCategory.update.replace(':id', id), data); +} +/** + * 更新媒體分類排序 + * + * 參數: + * ids: 分類id(以逗號分隔) + * + * + * Error Response: + * message: 訊息 + */ +export const updateOrder = (ids, data = {}) => { + data = Object.assign({ + ids: ids + }, data); + return axios.put(app.admin.ajax.resource.mediaCategory.updateOrder, data); +} +/** + * 刪除媒體分類 + * + * 參數: + * id: 分類ID + * + * Success Response: + * id: 分類ID + * count: 該媒體分類所有的媒體數量 + * message: 訊息 + * + * Error Response: + * message: 訊息 + */ +export const destroy = (id, data = {}) => { + return axios.delete(app.admin.ajax.resource.mediaCategory.destroy.replace(':id', id), data); +} diff --git a/resources/js/apis/admin/media-file.js b/resources/js/apis/admin/media-file.js new file mode 100644 index 0000000..4d85a85 --- /dev/null +++ b/resources/js/apis/admin/media-file.js @@ -0,0 +1,74 @@ +/** + * 取得媒體 + * + * 參數: + * category_id: 分類ID + * last_fetched_media_id: 最後一個取得的媒體media + * limit: 取得的數量 + * + * Response: + * medias: 媒體資訊 + */ +export const get = (categoryId, lastFetchedMediaId = null, limit = null, data = {}) => { + data = Object.assign({ + category_id: categoryId, + last_fetched_media_id: lastFetchedMediaId, + limit: limit + }, data) + return axios.get(app.admin.ajax.resource.mediaFile.get, {params: data}); +} +/** + * 更新媒體描述 + * + * 參數: + * id: 媒體ID + * description: 描述 + * + * Response: + * message: 訊息 + */ +export const update = (id, description = '', data = {}) => { + data = Object.assign({ + description: description + }, data); + return axios.put(app.admin.ajax.resource.mediaFile.update.replace(':id', id), data); +} +/** + * 刪除媒體 + * + * 參數: + * id: 媒體ID + * + * Success Response: + * id: 媒體ID + * message: 訊息 + * + * Error Response: + * message: 訊息 + */ +export const destroy = (id, data = {}) => { + return axios.delete(app.admin.ajax.resource.mediaFile.destroy.replace(':id', id), data); +} +/** + * 更新媒體的分類 + * + * 參數: + * category_id: 分類ID + * media_ids: 媒體的ID字串(使用逗號分隔) + * + * Success Response: + * mediaIds: 被移動的媒體ID + * categoryId: 被移動至的分類ID + * categorySources: 被移動的媒體的舊分類ID與數量 + * message: 訊息 + * + * Error Response: + * message: 訊息 + */ +export const updateCategory = (mediaIds, categoryId, data = {}) => { + data = Object.assign({ + media_ids: mediaIds, + category_id: categoryId + }, data) + return axios.put(app.admin.ajax.resource.mediaFile.updateCategory, data); +} diff --git a/resources/js/app-common.js b/resources/js/app-common.js index 316f5c0..46bfe5b 100644 --- a/resources/js/app-common.js +++ b/resources/js/app-common.js @@ -20,3 +20,21 @@ app.utils = { }, select2: initSelect2, } + +app.methods = { + /** + * 檢查是否符合給定的Media Query + * 預設為max-width + * */ + matchMedia: function(value, directive = 'max-width') { + return window.matchMedia('(' + directive + ': ' + value + ')').matches; + }, + + /** + * 檢查是否符合給定的Media Query,單位為px + * 預設為max-width + * */ + matchMediaInPx(value, directive = 'max-width') { + return this.matchMedia(value + 'px', directive); + }, +} diff --git a/resources/js/components/MediaLibrary.vue b/resources/js/components/MediaLibrary.vue new file mode 100644 index 0000000..8c4a958 --- /dev/null +++ b/resources/js/components/MediaLibrary.vue @@ -0,0 +1,717 @@ + + + + + diff --git a/resources/js/utils/media-library.js b/resources/js/utils/media-library.js new file mode 100644 index 0000000..f4dbf22 --- /dev/null +++ b/resources/js/utils/media-library.js @@ -0,0 +1,335 @@ +import mediaLibrary from '../components/MediaLibrary'; +import { + get as fetchMedia, + update as updateMediaDescription, + destroy as deleteMedia, + updateCategory as updateCategoryForMedia +} from '../apis/admin/media-file'; +import { + index as fetchMediaCategories, + destroy as deleteMediaCategory, + add as addMediaCategory, + update as updateMediaCategory, + updateOrder as updateMediaCategoriesOrder +} from '../apis/admin/media-category'; +export default function(param) { + // 如果有存在的媒體庫則先刪除 + let previousElement = document.getElementById('media-library'); + if (previousElement) { + previousElement.remove(); + } + // new一個新的媒體庫結構 + let mediaLibraryElement = document.createElement('media-library'); + // 設定vue的子組件屬性映射 + mediaLibraryElement.setAttribute(':_d', 'd') + // 媒體庫元件append進body + document.body.appendChild(mediaLibraryElement); + // 初始化Vue + let vue = new Vue({ + el: mediaLibraryElement, + data: { + d: { + // 文字翻譯 + text: param.translations, + // 上傳檔案的Url + formAction: param.url, + // csrf token + csrfToken: param.csrfToken, + // 是否為單選模式 + single: param.single, + // 是否顯示選擇按鈕 + selectMode: param.selectMode, + // 是否為上傳的頁面 + isUploadingPage: false, + // 媒體列表 + medias: [], + // 目前點選的媒體 + currentSelectedMedia: false, + // 選中的媒體列表 + selectedMedias: [], + // 最後一個取得的媒體ID + lastFetchedMediaId: null, + // 取得媒體請求的lock + fetchingLock: false, + // 已取的最後一個媒體 + fetchingEnd: false, + // 正在取得分類 + categoryFetching: false, + // 媒體資訊更新失敗訊息 + infoErrorMessage: '', + // 媒體資訊更新成功訊息 + infoSuccessMessage: '', + // 媒體分類 + categories: [], + // 目前正在拖曳的媒體ID + currentDraggingMediaIds: null, + // 目前所在的媒體分類 + currentCategory: 'uncategorized', + // 目前正在編輯的分類ID + currentEditingCategory: null, + // 目前正在編輯的分類輸入框文字 + currentEditingCategoryText: '', + // 目前正在新增分類 + addingNewCategory: false, + // 目前正在拖曳的媒體分類ID + currentDraggingCategoryId: null, + // 目前拖曳媒體分類的指示線 + categoryDraggingIndicator: null, + // 目前拖曳媒體分類過的媒體分類 + currentDraggingOverCategoryId: null, + // 分類更新成功訊息 + categorySuccessMessage: '', + // 分類更新失敗訊息 + categoryErrorMessage: '', + // 圖片更新成功訊息 + imageSuccessMessage: '', + // 圖片更新失敗訊息 + imageErrorMessage: '', + // 視窗高度 + windowHeight: null, + // 視窗寬度 + windowWidth: null, + // 手機板下用,只顯示分類 + onlyShowCategory: false, + // 手機板下用,只顯示媒體資訊 + onlyShowMediaInfo: false, + } + }, + computed: { + component() { + return this.$children[0]; + } + }, + // Vue instance建立完後呼叫 + created() { + this.$on('resize', (height, width) => { + this.d.windowHeight = height; + this.d.windowWidth = width; + }); + // 預設先載入媒體資料 + this.fetchMedia(35); + // 監聽fetch_medias事件,載入媒體資料 + this.$on('fetch_medias', (limit = null) => { + this.fetchMedia(limit); + }); + // 媒體庫子組件觸發的事件,更新拖曳的媒體的分類 + this.$on('update_category_for_medias', (mediaCategoryId, mediaIds, isUpdatingDraggingMedias = true) => { + // ajax更新媒體的分類 + updateCategoryForMedia(mediaIds, mediaCategoryId) + .then(response => { + let responseBody = response.data; + // 被移動的媒體數量 + let mediasMovedCount = responseBody.mediaIds.length; + // 如果不是在all的分類下 + if(this.d.currentCategory != 'all' && responseBody.categoryId != this.d.currentCategory) { + // 將media從現有顯示的列表移除 + responseBody.mediaIds.forEach((id, i) => { + this.component.removeMediaFromList(id); + }); + } else { + responseBody.mediaIds.forEach((id, i) => { + this.component.updateCategoryForMedia(id, responseBody.categoryId); + }); + } + for(let categoryId in responseBody.categorySources) { + // 從原本分類的數量減去 + this.component.substractCategoryCount(categoryId, responseBody.categorySources[categoryId], false); + } + // 加上數量至目標分類 + this.component.addCategoryCount(responseBody.categoryId, mediasMovedCount, false); + // 顯示訊息 + if(isUpdatingDraggingMedias) { + this.showImageMessage(responseBody); + } else { + this.showInfoMessage(responseBody); + } + }).catch(error => { + // 顯示訊息 + if(isUpdatingDraggingMedias) { + this.showImageErrorMessage(error); + } else { + this.showInfoErrorMessage(error) + } + }); + }) + // 媒體庫子組件觸發的click事件,更新媒體的描述 + this.$on('update_media_description', (e, media) => { + // disable按鈕 + e.target.disabled = true; + // ajax更新媒體描述 + updateMediaDescription(media.id, media.description) + .then(response => { + this.showInfoMessage(response.data); + }) + .catch(error => { + this.showInfoErrorMessage(error); + }) + .finally(() => { + // endable按鈕 + e.target.disabled = false; + }) + }); + // 媒體庫子組件觸發的click事件,刪除媒體 + this.$on('delete_media', (e, media) => { + if(confirm(this.d.text.deleteConfirmation)) { + // disable按鈕 + e.target.disabled = true; + // ajax刪除媒體 + deleteMedia(media.id) + .then(response => { + this.showInfoMessage(response.data); + // 將媒體從列表移除 + this.component.removeMediaFromList(response.data.id); + this.component.substractCategoryCount(response.data.categoryId, 1); + }) + .catch(error => { + this.showInfoErrorMessage(error); + }) + .finally(() => { + // endable按鈕 + e.target.disabled = false; + }) + } + }); + // ajax載入媒體分類 + this.d.categoryFetching = true; + fetchMediaCategories() + .then(response => { + this.d.categories = response.data.mediaCategories; + }) + .finally(() => { + this.d.categoryFetching = false; + }); + // ajax更新媒體分類名稱 + this.$on('update_category_name', (categoryId, name) => { + if(name) { + updateMediaCategory(categoryId, name) + .then(response => { + this.showCategoryMessage(response.data); + // 更新列表分類名稱 + this.component.updateListCategoryName(response.data.id, response.data.name); + this.component.cancelCategoryEditing(); + }) + .catch(error => { + this.showCategoryErrorMessage(error); + }) + } + }) + // ajax新增媒體分類 + this.$on('add_category', name => { + if(name) { + addMediaCategory(name) + .then(response => { + this.showCategoryMessage(response.data); + this.component.addCategoryToList(response.data.mediaCategory.id, response.data.mediaCategory.name); + this.component.cancelCategoryEditing(); + this.component.onCancelCategoryAdding(); + }) + .catch(error => { + this.showCategoryErrorMessage(error); + }) + } + }) + // ajax刪除媒體分類 + this.$on('delete_category', id => { + deleteMediaCategory(id) + .then(response => { + this.showCategoryMessage(response.data); + this.component.addCategoryCount('uncategorized', response.data.count, false); + this.component.deleteCategoryFromList(response.data.id); + }) + .catch(error => { + this.showCategoryErrorMessage(error) + }) + }); + // ajax更新媒體分類順序 + this.$on('update_category_order', ids => { + updateMediaCategoriesOrder(ids) + .catch(error => { + this.showCategoryErrorMessage(error); + }) + }) + }, + components: { + mediaLibrary + }, + methods: { + // 載入媒體資料 + fetchMedia(limit = null) { + // 載入動作的lock,避免重複呼叫 + this.d.fetchingLock = true; + // ajax載入媒體資料 + fetchMedia(this.d.currentCategory, this.d.lastFetchedMediaId, limit) + .then(response => { + let medias = response.data.medias; + // 加入至原有列表 + this.d.medias = this.d.medias.concat(medias); + if(medias.length) { + // 紀錄最後一筆媒體的id,供下次使用 + this.d.lastFetchedMediaId = medias[medias.length - 1].id; + } else { + // 已載入至最後的資料 + this.d.fetchingEnd = true; + } + }) + .finally(() => { + // 釋放載入動作的lock + this.d.fetchingLock = false; + }); + }, + // 根據response body顯示訊息 + showInfoMessage(responseBody, success = true) { + if(responseBody.hasOwnProperty('message')) { + this.component.clearInfoMessages(); + if(success) { + this.component.showInfoSuccessMessage(responseBody.message); + } else { + this.component.showInfoErrorMessage(responseBody.message); + } + } + }, + // 根據error顯示訊息,只顯示http error + showInfoErrorMessage(error) { + if(error.hasOwnProperty('response')) { + this.showInfoMessage(error.response.data, false); + } + }, + // 根據response body顯示分類操作訊息 + showCategoryMessage(responseBody, success = true) { + if(responseBody.hasOwnProperty('message')) { + this.component.clearCategoryMessages(); + if(success) { + this.component.showCategorySuccessMessage(responseBody.message); + } else { + this.component.showCategoryErrorMessage(responseBody.message); + } + } + }, + // 根據error顯示分類操作訊息,只顯示http error + showCategoryErrorMessage(error) { + if(error.hasOwnProperty('response')) { + this.showCategoryMessage(error.response.data, false); + } + }, + // 根據response body顯示圖片操作訊息 + showImageMessage(responseBody, success = true) { + if(responseBody.hasOwnProperty('message')) { + this.component.clearImageMessages(); + if(success) { + this.component.showImageSuccessMessage(responseBody.message); + } else { + this.component.showImageErrorMessage(responseBody.message); + } + } + }, + // 根據error顯示圖片操作訊息,只顯示http error + showImageErrorMessage(error) { + if(error.hasOwnProperty('response')) { + this.showImageMessage(error.response.data, false); + } + }, + + } + }); + return vue; +} diff --git a/resources/lang/en/mediaLibrary.php b/resources/lang/en/mediaLibrary.php new file mode 100644 index 0000000..806a92f --- /dev/null +++ b/resources/lang/en/mediaLibrary.php @@ -0,0 +1,20 @@ + 'Category', + 'tabUpload' => 'Upload', + 'tabBrowse' => 'Browse', + 'dragOrClickToUpload' => 'Drop files here or click to upload.', + 'url' => 'Url', + 'fileSize' => 'File Size', + 'date' => 'Date', + 'size' => 'Size', + 'description' => 'Description', + 'mimeType' => 'MIME type', + 'select' => 'Select', + 'update' => 'Update', + 'delete' => 'Delete', + 'deleteConfirmation' => 'Are you sure to delete this?', + 'category' => 'Category', + 'all' => 'All', + 'uncategorized' => 'Uncategorized', +]; diff --git a/resources/lang/en/message.php b/resources/lang/en/message.php index 8b0d461..07bfaa2 100644 --- a/resources/lang/en/message.php +++ b/resources/lang/en/message.php @@ -1,4 +1,19 @@ 'Category :name has been added.', + 'categoryNameHasBeenUpdated' => 'Category :name has been updated.', + 'categoryNameHasBeenDeleted' => 'Category :name has been deleted.', + 'descriptionHasBeenUpdated' => 'The description has been updated.', + 'failToUpdateDescription' => 'Fail to update description.', + 'failToUpdateCategoryName' => 'Fail to update category name.', + 'failToAddCategory' => 'Fail to add category.', + 'failToDeleteMedia' => 'Fail to delete media.', + 'failToDeleteCategory' => 'Fail to delete category.', + 'failToMoveMediaToCategory' => 'Fail to move media to category.', + 'failToReOrderCategory' => 'Fail to reorder category.', + 'givenCategoryIdDoesNotExist' => 'Given category id does not exist.', + 'mediaHasBeenDeleted' => 'The media has been deleted.', + 'mediaHasBeenMoveToCategory' => 'Media has been move to category :name.', + 'mediaHasBeenSetToUncategorized' => 'Media has been set to uncategorized.', 'success' => 'Success' ]; diff --git a/resources/lang/zh-tw/mediaLibrary.php b/resources/lang/zh-tw/mediaLibrary.php new file mode 100644 index 0000000..8d0ce7f --- /dev/null +++ b/resources/lang/zh-tw/mediaLibrary.php @@ -0,0 +1,20 @@ + '分類', + 'date' => '日期', + 'delete' => '刪除', + 'deleteConfirmation' => '確定要刪除嗎?', + 'description' => '描述', + 'dragOrClickToUpload' => '拖曳檔案至此或點擊上傳', + 'fileSize' => '檔案大小', + 'mimeType' => '檔案類型', + 'select' => '選擇', + 'size' => '尺寸', + 'tabBrowse' => '瀏覽', + 'tabCategory' => '分類', + 'tabUpload' => '上傳', + 'update' => '更新', + 'url' => '網址', + 'all' => '全部', + 'uncategorized' => '未分類', +]; diff --git a/resources/sass/admin/app.scss b/resources/sass/admin/app.scss index cb2a187..0433349 100644 --- a/resources/sass/admin/app.scss +++ b/resources/sass/admin/app.scss @@ -3,6 +3,7 @@ //元件 @import "components/datatables"; @import "components/blockui"; +@import "../components/media-library"; diff --git a/resources/sass/components/_media-library.scss b/resources/sass/components/_media-library.scss new file mode 100644 index 0000000..aa9a6df --- /dev/null +++ b/resources/sass/components/_media-library.scss @@ -0,0 +1,770 @@ +.blockUI.media-library { + z-index: 1099 !important; + width: 98% !important; + transform: translate(-50%, -50%); + top: 50% !important; + left: 50% !important; + cursor: default !important; +} + +#media-library { + [draggable="true"] { + user-select: none; + } + height: 100%; + width: 100%; + .container-fluid { + padding-top: 15px; + padding-bottom: 15px; + } + + .close-window { + font-size: 24px; + cursor: pointer; + position: absolute; + top: 0; + right: 8px; + } + ul.tabs { + .nav-link { + &.active { + color: white; + background: #343a40; + } + } + } + + .image-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .tab-page { + overflow-y: scroll; + overflow-x: hidden; + border: 1px solid #eee; + &.browse { + &.dragging { + opacity: .5; + } + .img-wrapper { + width: calc(14.285% - 40px); + margin: 20px; + float: left; + @include media-breakpoint-down(lg) { + width: calc(20% - 30px); + margin: 15px; + } + @include media-breakpoint-down(md) { + width: calc(25% - 30px); + } + @include media-breakpoint-down(sm) { + width: calc(33.3% - 20px); + margin: 10px; + } + @include media-breakpoint-down(xs) { + width: calc(50% - 30px); + margin: 15px; + } + overflow: hidden; + cursor: pointer; + box-shadow: 0 0 1px 2px lightgrey; + + &.selected { + box-shadow: 0px 0px 0 2px white, 0px 0px 0px 8px black; + } + .img { + width: 100%; + padding-top: 100%; + background-size: cover; + background-position: center center; + } + } + } + } + + .col-categories { + .controls { + text-align: left; + input { + width: auto; + display: inline-block; + } + } + .category-list-wrapper { + overflow-y: scroll; + overflow-x: hidden; + .message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 75%; + text-align: left; + .alert { + word-break: break-all; + } + } + } + .category-list { + flex-wrap: nowrap; + &.dragging { + .category { + opacity: .5; + } + } + .dragging-indicator { + width: 100%; + border-top: 2px dotted; + } + .category { + padding: 4px; + margin: 1px 0; + text-align: left; + color: black; + &.on-drag { + border: 2px solid black; + } + &.current { + background: gray; + color: white; + &.editing { + background: none; + border: 1px solid gray; + } + } + .btn { + &.edit-category, + &.remove-category { + display: none; + padding: 0; + } + } + .edit-category { + + } + &:hover { + .btn { + &.edit-category, + &.remove-category { + display: inline-block; + } + } + } + } + } + } + + .col-media-info { + .message { + .alert { + text-align: left; + } + } + } + + .media-info-wrapper { + width: 100%; + overflow: auto; + padding-left: 5px; + padding-right: 5px; + text-align: left; + .info { + &.hidden { + visibility: hidden; + } + } + section { + margin-bottom: 2px; + } + .img { + margin-top: 5px; + img { + width: 100%; + } + } + .url { + a { + word-break: break-all; + } + } + .description { + .content { + textarea { + width: 100%; + } + } + } + + } + .select-medias { + height: 40px; + margin-top: 5px; + text-align: right; + } + .upload-zone { + position: relative; + min-height: 100%; + padding: 20px 20px; + width: 100%; + border: 1px dotted #aaa; + border-radius: 2px; + box-sizing: border-box; + * { + box-sizing: border-box; + } + + .dz-preview { + position: relative; + display: inline-block; + width: 120px; + margin: 0.5em; + } + + .dz-preview .dz-progress { + display: block; + height: 15px; + border: 1px solid #aaa; + } + + .dz-preview .dz-progress .dz-upload { + display: block; + height: 100%; + width: 0; + background: green; + } + + .dz-preview .dz-error-message { + color: red; + display: none; + } + + .dz-preview.dz-error .dz-error-message, .dz-preview.dz-error .dz-error-mark { + display: block; + } + + .dz-preview.dz-success .dz-success-mark { + display: block; + } + + .dz-preview .dz-error-mark, .dz-preview .dz-success-mark { + position: absolute; + display: none; + left: 30px; + top: 30px; + width: 54px; + height: 58px; + left: 50%; + margin-left: -27px; + } + + /* + * The MIT License + * Copyright (c) 2012 Matias Meno + */ + @-webkit-keyframes passing-through { + 0% { + opacity: 0; + -webkit-transform: translateY(40px); + -moz-transform: translateY(40px); + -ms-transform: translateY(40px); + -o-transform: translateY(40px); + transform: translateY(40px); + } + 30%, 70% { + opacity: 1; + -webkit-transform: translateY(0px); + -moz-transform: translateY(0px); + -ms-transform: translateY(0px); + -o-transform: translateY(0px); + transform: translateY(0px); + } + 100% { + opacity: 0; + -webkit-transform: translateY(-40px); + -moz-transform: translateY(-40px); + -ms-transform: translateY(-40px); + -o-transform: translateY(-40px); + transform: translateY(-40px); + } + } + + @-moz-keyframes passing-through { + 0% { + opacity: 0; + -webkit-transform: translateY(40px); + -moz-transform: translateY(40px); + -ms-transform: translateY(40px); + -o-transform: translateY(40px); + transform: translateY(40px); + } + 30%, 70% { + opacity: 1; + -webkit-transform: translateY(0px); + -moz-transform: translateY(0px); + -ms-transform: translateY(0px); + -o-transform: translateY(0px); + transform: translateY(0px); + } + 100% { + opacity: 0; + -webkit-transform: translateY(-40px); + -moz-transform: translateY(-40px); + -ms-transform: translateY(-40px); + -o-transform: translateY(-40px); + transform: translateY(-40px); + } + } + + @keyframes passing-through { + 0% { + opacity: 0; + -webkit-transform: translateY(40px); + -moz-transform: translateY(40px); + -ms-transform: translateY(40px); + -o-transform: translateY(40px); + transform: translateY(40px); + } + 30%, 70% { + opacity: 1; + -webkit-transform: translateY(0px); + -moz-transform: translateY(0px); + -ms-transform: translateY(0px); + -o-transform: translateY(0px); + transform: translateY(0px); + } + 100% { + opacity: 0; + -webkit-transform: translateY(-40px); + -moz-transform: translateY(-40px); + -ms-transform: translateY(-40px); + -o-transform: translateY(-40px); + transform: translateY(-40px); + } + } + + @-webkit-keyframes slide-in { + 0% { + opacity: 0; + -webkit-transform: translateY(40px); + -moz-transform: translateY(40px); + -ms-transform: translateY(40px); + -o-transform: translateY(40px); + transform: translateY(40px); + } + 30% { + opacity: 1; + -webkit-transform: translateY(0px); + -moz-transform: translateY(0px); + -ms-transform: translateY(0px); + -o-transform: translateY(0px); + transform: translateY(0px); + } + } + + @-moz-keyframes slide-in { + 0% { + opacity: 0; + -webkit-transform: translateY(40px); + -moz-transform: translateY(40px); + -ms-transform: translateY(40px); + -o-transform: translateY(40px); + transform: translateY(40px); + } + 30% { + opacity: 1; + -webkit-transform: translateY(0px); + -moz-transform: translateY(0px); + -ms-transform: translateY(0px); + -o-transform: translateY(0px); + transform: translateY(0px); + } + } + + @keyframes slide-in { + 0% { + opacity: 0; + -webkit-transform: translateY(40px); + -moz-transform: translateY(40px); + -ms-transform: translateY(40px); + -o-transform: translateY(40px); + transform: translateY(40px); + } + 30% { + opacity: 1; + -webkit-transform: translateY(0px); + -moz-transform: translateY(0px); + -ms-transform: translateY(0px); + -o-transform: translateY(0px); + transform: translateY(0px); + } + } + + @-webkit-keyframes pulse { + 0% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + -o-transform: scale(1); + transform: scale(1); + } + 10% { + -webkit-transform: scale(1.1); + -moz-transform: scale(1.1); + -ms-transform: scale(1.1); + -o-transform: scale(1.1); + transform: scale(1.1); + } + 20% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + -o-transform: scale(1); + transform: scale(1); + } + } + + @-moz-keyframes pulse { + 0% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + -o-transform: scale(1); + transform: scale(1); + } + 10% { + -webkit-transform: scale(1.1); + -moz-transform: scale(1.1); + -ms-transform: scale(1.1); + -o-transform: scale(1.1); + transform: scale(1.1); + } + 20% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + -o-transform: scale(1); + transform: scale(1); + } + } + + @keyframes pulse { + 0% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + -o-transform: scale(1); + transform: scale(1); + } + 10% { + -webkit-transform: scale(1.1); + -moz-transform: scale(1.1); + -ms-transform: scale(1.1); + -o-transform: scale(1.1); + transform: scale(1.1); + } + 20% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + -o-transform: scale(1); + transform: scale(1); + } + } + + * { + box-sizing: border-box; + } + + .dz-clickable { + cursor: pointer; + } + + .dz-clickable * { + cursor: default; + } + + .dz-clickable .dz-message, .dz-clickable .dz-message * { + cursor: pointer; + } + + .dz-started .dz-message { + display: none; + } + + .dz-drag-hover { + border-style: solid; + } + + .dz-drag-hover .dz-message { + opacity: 0.5; + } + + .dz-message { + text-align: center; + margin: 2em 0; + } + + .dz-preview { + position: relative; + display: inline-block; + vertical-align: top; + margin: 16px; + min-height: 100px; + } + + .dz-preview:hover { + z-index: 1000; + } + + .dz-preview:hover .dz-details { + opacity: 1; + } + + .dz-preview.dz-file-preview .dz-image { + border-radius: 20px; + background: #999; + background: linear-gradient(to bottom, #eee, #ddd); + } + + .dz-preview.dz-file-preview .dz-details { + opacity: 1; + } + + .dz-preview.dz-image-preview { + background: white; + } + + .dz-preview.dz-image-preview .dz-details { + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + -ms-transition: opacity 0.2s linear; + -o-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } + + .dz-preview .dz-remove { + font-size: 14px; + text-align: center; + display: block; + cursor: pointer; + border: none; + } + + .dz-preview .dz-remove:hover { + text-decoration: underline; + } + + .dz-preview:hover .dz-details { + opacity: 1; + } + + .dz-preview .dz-details { + z-index: 20; + position: absolute; + top: 0; + left: 0; + opacity: 0; + font-size: 13px; + min-width: 100%; + max-width: 100%; + padding: 2em 1em; + text-align: center; + color: rgba(0, 0, 0, 0.9); + line-height: 150%; + } + + .dz-preview .dz-details .dz-size { + margin-bottom: 1em; + font-size: 16px; + } + + .dz-preview .dz-details .dz-filename { + white-space: nowrap; + } + + .dz-preview .dz-details .dz-filename:hover span { + border: 1px solid rgba(200, 200, 200, 0.8); + background-color: rgba(255, 255, 255, 0.8); + } + + .dz-preview .dz-details .dz-filename:not(:hover) { + overflow: hidden; + text-overflow: ellipsis; + } + + .dz-preview .dz-details .dz-filename:not(:hover) span { + border: 1px solid transparent; + } + + .dz-preview .dz-details .dz-filename span, .dz-preview .dz-details .dz-size span { + background-color: rgba(255, 255, 255, 0.4); + padding: 0 0.4em; + border-radius: 3px; + } + + .dz-preview:hover .dz-image img { + -webkit-transform: scale(1.05, 1.05); + -moz-transform: scale(1.05, 1.05); + -ms-transform: scale(1.05, 1.05); + -o-transform: scale(1.05, 1.05); + transform: scale(1.05, 1.05); + -webkit-filter: blur(8px); + filter: blur(8px); + } + + .dz-preview .dz-image { + border-radius: 20px; + overflow: hidden; + width: 120px; + height: 120px; + position: relative; + display: block; + z-index: 10; + } + + .dz-preview .dz-image img { + display: block; + } + + .dz-preview.dz-success .dz-success-mark { + -webkit-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); + -moz-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); + -ms-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); + -o-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); + animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); + } + + .dz-preview.dz-error .dz-error-mark { + opacity: 1; + -webkit-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); + -moz-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); + -ms-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); + -o-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); + animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); + } + + .dz-preview .dz-success-mark, .dz-preview .dz-error-mark { + pointer-events: none; + opacity: 0; + z-index: 500; + position: absolute; + display: block; + top: 50%; + left: 50%; + margin-left: -27px; + margin-top: -27px; + } + + .dz-preview .dz-success-mark svg, .dz-preview .dz-error-mark svg { + display: block; + width: 54px; + height: 54px; + } + + .dz-preview.dz-processing .dz-progress { + opacity: 1; + -webkit-transition: all 0.2s linear; + -moz-transition: all 0.2s linear; + -ms-transition: all 0.2s linear; + -o-transition: all 0.2s linear; + transition: all 0.2s linear; + } + + .dz-preview.dz-complete .dz-progress { + opacity: 0; + -webkit-transition: opacity 0.4s ease-in; + -moz-transition: opacity 0.4s ease-in; + -ms-transition: opacity 0.4s ease-in; + -o-transition: opacity 0.4s ease-in; + transition: opacity 0.4s ease-in; + } + + .dz-preview:not(.dz-processing) .dz-progress { + -webkit-animation: pulse 6s ease infinite; + -moz-animation: pulse 6s ease infinite; + -ms-animation: pulse 6s ease infinite; + -o-animation: pulse 6s ease infinite; + animation: pulse 6s ease infinite; + } + + .dz-preview .dz-progress { + opacity: 1; + z-index: 1000; + pointer-events: none; + position: absolute; + height: 16px; + left: 50%; + top: 50%; + margin-top: -8px; + width: 80px; + margin-left: -40px; + background: rgba(255, 255, 255, 0.9); + -webkit-transform: scale(1); + border-radius: 8px; + overflow: hidden; + } + + .dz-preview .dz-progress .dz-upload { + background: #333; + background: linear-gradient(to bottom, #666, #444); + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 0; + -webkit-transition: width 300ms ease-in-out; + -moz-transition: width 300ms ease-in-out; + -ms-transition: width 300ms ease-in-out; + -o-transition: width 300ms ease-in-out; + transition: width 300ms ease-in-out; + } + + .dz-preview.dz-error .dz-error-message { + display: block; + } + + .dz-preview.dz-error:hover .dz-error-message { + opacity: 1; + pointer-events: auto; + } + + .dz-preview .dz-error-message { + pointer-events: none; + z-index: 1000; + position: absolute; + display: block; + display: none; + opacity: 0; + -webkit-transition: opacity 0.3s ease; + -moz-transition: opacity 0.3s ease; + -ms-transition: opacity 0.3s ease; + -o-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; + border-radius: 8px; + font-size: 13px; + top: 130px; + left: -10px; + width: 140px; + background: #be2626; + background: linear-gradient(to bottom, #be2626, #a92222); + padding: 0.5em 1.2em; + color: white; + } + + .dz-preview .dz-error-message:after { + content: ''; + position: absolute; + top: -6px; + left: 64px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid #be2626; + } + + } +} diff --git a/resources/views/admin/layouts/app.blade.php b/resources/views/admin/layouts/app.blade.php index 170c0a1..33ec86b 100644 --- a/resources/views/admin/layouts/app.blade.php +++ b/resources/views/admin/layouts/app.blade.php @@ -34,6 +34,9 @@ +