diff --git a/.env.example b/.env.example index 81a975a..a6b9fa8 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,10 @@ PUSHER_APP_SECRET= YOUDAO_APP_KEY= YOUDAO_APP_SECRET= + +GOOGLE_ANALYTICS_ID= +ENABLE_DATA_CACHE= +ENABLE_VISITOR_LOG= + +COMMENT_DRIVER= +DISQUS_SHORT_NAME= \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0956ec3..e0ab523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +## 0.3.0 - 2017-12-10 +### Added +- Visitor history and base counter. See branch `counter` and `visitor` +- Cache support. See branch `cacheable` +- Auto slug in ajax and Chinese support +- Base implement of disqus. See branch `comment` +- Hot posts widget +- Base feature image support +- Model `Setting` and base SEO support + +### Changed +- Various CSS styles changes +- BaseRepository +- Side navigation bar style + +### Fixed +- Fix Postable retrieve data bug (`890c082`) + ## 0.2.0 - 2017-06-15 ### Added - Backend diff --git a/TODO.md b/TODO.md index 695c748..1c2a6c1 100644 --- a/TODO.md +++ b/TODO.md @@ -4,9 +4,9 @@ - [x] Category - [x] Tag -- [ ] Comment -- [ ] Visitor & view count(use Middleware) -- [ ] Cache +- [x] Comment +- [x] Visitor & view count(use Middleware) +- [x] Cache - [ ] Email - [ ] Notification - [ ] Socialite @@ -15,9 +15,9 @@ - [ ] I18N - [ ] Highlight tab - [ ] Links -- [ ] Settings +- [x] Settings - [ ] File management -- [ ] Feature image +- [x] Feature image - [ ] Flash session ## Frontend @@ -33,3 +33,4 @@ - [ ] Package - [ ] Unit test - [ ] Pjax +- [ ] Upgrade to Laravel 5.5 diff --git a/app/Console/Commands/SavePostViewCount.php b/app/Console/Commands/SavePostViewCount.php new file mode 100644 index 0000000..af25cd7 --- /dev/null +++ b/app/Console/Commands/SavePostViewCount.php @@ -0,0 +1,126 @@ +postRepo = $postRepo; + + $this->cacheKeyPrefix = config('blog.counter.cache_key'); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $data = $this->savePostViewCount(); + + $this->info("Post view_count save successfully at " . Carbon::now()->toDateTimeString()); + $this->table(['Post ID', 'Increase Count'], $data); + } + + /** + * Retrieve all post view_count from cache and save into DB + * + * @return array + */ + protected function savePostViewCount() + { + $results = []; + + // Retrieve all post id + $this->postRepo + ->getModel() + ->select('id') + ->chunk(100, function ($posts) use (&$results) { + foreach ($posts as $post) { + if ($count = $this->getCacheCount($post->id)) { + $post->increment('view_count', $count); + + array_push($results, [$post->id, $count]); + + $this->flushCache($this->cacheKey($post->id)); + } + } + }); + + return $results; + } + + /** + * Get post view_count from cache + * + * @param $id + * @return mixed + */ + protected function getCacheCount($id) + { + return Cache::get($this->cacheKey($id)); + } + + /** + * Get cache key + * + * @param $id + * @return string + */ + protected function cacheKey($id) + { + return $this->cacheKeyPrefix . $id; + } + + /** + * Flush post view_count in cache by key + * + * @param $key + * @return bool + */ + protected function flushCache($key) + { + return Cache::forget($key); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 622e774..2b973bb 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -2,6 +2,7 @@ namespace App\Console; +use App\Console\Commands\SavePostViewCount; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -13,19 +14,20 @@ class Kernel extends ConsoleKernel * @var array */ protected $commands = [ - // + SavePostViewCount::class ]; /** * Define the application's command schedule. * - * @param \Illuminate\Console\Scheduling\Schedule $schedule + * @param \Illuminate\Console\Scheduling\Schedule $schedule * @return void */ protected function schedule(Schedule $schedule) { // $schedule->command('inspire') // ->hourly(); + $schedule->command('view_count:save')->hourly()->appendOutputTo(storage_path() . '/logs/cron.log'); } /** diff --git a/app/Contracts/ContentableInterface.php b/app/Contracts/ContentableInterface.php new file mode 100644 index 0000000..6623270 --- /dev/null +++ b/app/Contracts/ContentableInterface.php @@ -0,0 +1,24 @@ +postId = $postId; + } + + /** + * Get the channels the event should broadcast on. + * + * @return Channel|array + */ + public function broadcastOn() + { + return new PrivateChannel('channel-name'); + } +} diff --git a/app/Http/Controllers/Backend/DashboardController.php b/app/Http/Controllers/Backend/DashboardController.php index 499f93c..7f499ae 100644 --- a/app/Http/Controllers/Backend/DashboardController.php +++ b/app/Http/Controllers/Backend/DashboardController.php @@ -7,8 +7,24 @@ class DashboardController extends Controller { + /** + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ public function index() { return view('admin.home'); } + + /** + * @param Request $request + * @return \Illuminate\Foundation\Application|\JellyBool\Translug\Translation|mixed|string|\translug + */ + public function autoSlug(Request $request) + { + $this->validate($request, [ + 'text' => 'required', + ]); + + return str_slug_with_cn($request->input('text')); + } } diff --git a/app/Http/Controllers/Backend/PostController.php b/app/Http/Controllers/Backend/PostController.php index bcd75fe..7e1a6a8 100644 --- a/app/Http/Controllers/Backend/PostController.php +++ b/app/Http/Controllers/Backend/PostController.php @@ -78,7 +78,7 @@ public function create() */ public function store(StoreUpdatePostRequest $request) { - $this->postRepo->createPost($request->all()); + $this->postRepo->createPost($request->except('_token')); return redirect()->route('admin.posts.index')->withSuccess('Create post successfully!'); } diff --git a/app/Http/Controllers/Backend/SettingController.php b/app/Http/Controllers/Backend/SettingController.php new file mode 100644 index 0000000..90382ad --- /dev/null +++ b/app/Http/Controllers/Backend/SettingController.php @@ -0,0 +1,127 @@ +settingRepo = $settingRepo; + } + + /** + * Display a listing of the resource. + * + * @return \Illuminate\Http\Response + */ + public function index() + { + $settings = $this->settingRepo->paginate(); + + return view('admin.settings.index', compact('settings')); + } + + /** + * Show the form for creating a new resource. + * + * @return \Illuminate\Http\Response + */ + public function create() + { + return view('admin.settings.create'); + } + + /** + * Store a newly created resource in storage. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Response + */ + public function store(Request $request) + { + $this->validate($request, [ + 'key' => 'required|unique:settings,key', + 'value' => 'required', + 'tag' => 'required', + ]); + + $this->settingRepo->create($request->all()); + + return redirect()->route('admin.settings.index')->withSuccess('Create setting successfully'); + } + + /** + * Display the specified resource. + * + * @param int $id + * @return \Illuminate\Http\Response + */ + public function show($id) + { + + } + + /** + * Show the form for editing the specified resource. + * + * @param int $id + * @return \Illuminate\Http\Response + */ + public function edit($id) + { + $setting = $this->settingRepo->find($id); + + return view('admin.settings.edit', compact('setting')); + } + + /** + * Update the specified resource in storage. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * @return \Illuminate\Http\Response + */ + public function update(Request $request, $id) + { + $this->validate($request, [ + 'key' => [ + 'required', + Rule::unique('settings')->ignore($id) + ], + 'value' => 'required', + 'tag' => 'required', + ]); + + $this->settingRepo->update($request->all(), $id); + + return redirect()->route('admin.settings.index')->withSuccess('Update setting successfully'); + + } + + /** + * Remove the specified resource from storage. + * + * @param int $id + * @return \Illuminate\Http\Response + */ + public function destroy($id) + { + $this->settingRepo->delete($id); + + return redirect()->route('admin.settings.index')->withSuccess('Delete setting successfully!'); + } +} diff --git a/app/Http/Controllers/Frontend/CategoryController.php b/app/Http/Controllers/Frontend/CategoryController.php new file mode 100644 index 0000000..06aef20 --- /dev/null +++ b/app/Http/Controllers/Frontend/CategoryController.php @@ -0,0 +1,35 @@ +cateRepo = $cateRepo; + } + + /** + * @param $slug + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function show($slug) + { + list($category, $posts) = $this->cateRepo->getWithPosts($slug); + + return view('categories.show', compact('posts', 'category')); + } +} diff --git a/app/Http/Controllers/Frontend/PostController.php b/app/Http/Controllers/Frontend/PostController.php index eb3a611..8e7563c 100644 --- a/app/Http/Controllers/Frontend/PostController.php +++ b/app/Http/Controllers/Frontend/PostController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Frontend; +use App\Events\PostViewEvent; use App\Repositories\Contracts\PostRepository; use Illuminate\Http\Request; use App\Http\Controllers\Controller; @@ -29,10 +30,7 @@ public function __construct(PostRepository $postRepo) */ public function index() { - // TODO paginate page should be dynamic set in config, e.g. config('app.post.perPage', 5) - $posts = $this->postRepo - ->with(['category', 'tags', 'author']) - ->paginate(5); + $posts = $this->postRepo->lists(); return view('posts.index', compact('posts')); } @@ -40,15 +38,18 @@ public function index() /** * Display the specified resource. * - * @param int $id + * @param string $slug * @return \Illuminate\Http\Response */ - public function show($id) + public function show($slug) { - $post = $this->postRepo - ->with(['category', 'tags', 'author']) - ->find($id); + $post = $this->postRepo->getBySlug($slug); - return view('posts.show', compact('post')); + $previous = $this->postRepo->previous($post); + $next = $this->postRepo->next($post); + + event(new PostViewEvent($post->id)); + + return view('posts.show', compact('post', 'previous', 'next')); } } diff --git a/app/Http/Controllers/Frontend/TagController.php b/app/Http/Controllers/Frontend/TagController.php new file mode 100644 index 0000000..31859c3 --- /dev/null +++ b/app/Http/Controllers/Frontend/TagController.php @@ -0,0 +1,35 @@ +tagRepo = $tagRepo; + } + + /** + * @param $id + * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + */ + public function show($id) + { + list($tag, $posts) = $this->tagRepo->getWithPosts($id); + + return view('tags.show', compact('tag', 'posts')); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 28f7a27..b917606 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -60,5 +60,7 @@ class Kernel extends HttpKernel 'role' => \Zizaco\Entrust\Middleware\EntrustRole::class, 'permission' => \Zizaco\Entrust\Middleware\EntrustPermission::class, 'ability' => \Zizaco\Entrust\Middleware\EntrustAbility::class, + + 'visitor' => \App\Http\Middleware\VisitorMiddleware::class, ]; } diff --git a/app/Http/Middleware/VisitorMiddleware.php b/app/Http/Middleware/VisitorMiddleware.php new file mode 100644 index 0000000..a70f713 --- /dev/null +++ b/app/Http/Middleware/VisitorMiddleware.php @@ -0,0 +1,51 @@ +visitorRepo = $visitorRepo; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + if ($this->allowLog()) { + $this->visitorRepo->createLog(); + } + + return $next($request); + } + + /** + * @return mixed + */ + protected function allowLog() + { + return config('blog.log.visitor', false); + } +} diff --git a/app/Http/Requests/StoreUpdatePostRequest.php b/app/Http/Requests/StoreUpdatePostRequest.php index ec94d0b..d54b236 100644 --- a/app/Http/Requests/StoreUpdatePostRequest.php +++ b/app/Http/Requests/StoreUpdatePostRequest.php @@ -28,9 +28,10 @@ public function rules() 'description' => 'max:100', 'category_id' => 'required|exists:categories,id', 'slug' => 'unique:posts', - 'content' => 'required', + 'body' => 'required', 'excerpt' => 'required', - 'feature_img' => 'sometimes|required|url' + 'feature_img' => 'required_without:feature_img_file|url', + 'feature_img_file' => 'required_without:feature_img|image|max:2048' ]; switch ($this->method()) { @@ -54,8 +55,12 @@ public function rules() */ protected function prepareForValidation() { - if (!$this->has('feature_img')) { - $this->replace($this->except('feature_img')); + $fields = ['feature_img', 'feature_img_file']; + + foreach ($fields as $field) { + if (!$this->has($field)) { + $this->replace($this->except($field)); + } } } } diff --git a/app/Http/ViewComposers/CommentComposer.php b/app/Http/ViewComposers/CommentComposer.php new file mode 100644 index 0000000..c9f9854 --- /dev/null +++ b/app/Http/ViewComposers/CommentComposer.php @@ -0,0 +1,38 @@ +with('disqus', [ + 'short_name' => $shortName, + 'page_url' => request()->url(), + 'page_identifier' => request()->path(), + ]); + } + + $view->with('commentDriver', $commentDriver); + } +} \ No newline at end of file diff --git a/app/Http/ViewComposers/HotPostsComposer.php b/app/Http/ViewComposers/HotPostsComposer.php new file mode 100644 index 0000000..9331142 --- /dev/null +++ b/app/Http/ViewComposers/HotPostsComposer.php @@ -0,0 +1,37 @@ +postRepo = $postRepo; + } + + /** + * @param View $view + */ + public function compose(View $view) + { + $hotPosts = $this->postRepo->hot(); + + $view->with('hotPosts', $hotPosts); + } +} \ No newline at end of file diff --git a/app/Listeners/PostViewEventListener.php b/app/Listeners/PostViewEventListener.php new file mode 100644 index 0000000..d2e1d6d --- /dev/null +++ b/app/Listeners/PostViewEventListener.php @@ -0,0 +1,40 @@ +counter = $postViewCounter; + } + + /** + * Handle the event. + * + * @param PostViewEvent $event + * @return void + */ + public function handle(PostViewEvent $event) + { + $this->counter->run($event->postId); + } +} diff --git a/app/Models/Content.php b/app/Models/Content.php new file mode 100644 index 0000000..83ed778 --- /dev/null +++ b/app/Models/Content.php @@ -0,0 +1,21 @@ +hasOne(Post::class); + } +} diff --git a/app/Models/Post.php b/app/Models/Post.php index d93f097..d8c642f 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -2,8 +2,10 @@ namespace App\Models; +use App\Contracts\ContentableInterface; use App\Presenters\PostPresenter; use App\Scopes\PublishedScope; +use App\Services\CacheHelper; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Laracasts\Presenter\PresentableTrait; @@ -12,7 +14,7 @@ * Class Post * @package App\Models */ -class Post extends Model +class Post extends Model implements ContentableInterface { use PresentableTrait, SoftDeletes; @@ -26,6 +28,11 @@ class Post extends Model */ const IS_NOT_DRAFT = 0; + /** + * Cache key prefix of post's content + */ + const CONTENT_CACHE_KEY_PREFIX = 'contents:'; + /** * @var string */ @@ -39,7 +46,7 @@ class Post extends Model 'title', 'slug', 'description', - 'content', + 'content_id', 'published_at', 'is_draft', 'excerpt', @@ -94,4 +101,82 @@ public function getConst($name) { return constant("self::{$name}"); } + + /** + * @return string + */ + public function getContentAttribute() + { + // Always use cache + return (new CacheHelper)->cacheContent($this); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function content() + { + return $this->belongsTo(Content::class); + } + + /** + * @return mixed + */ + public function getRawContentAttribute() + { + return $this->content()->getResults()->body; + } + + /** + * Get raw content in markdown syntax. + * + * @return string + */ + public function getRawContent() + { + return $this->getRawContentAttribute(); + } + + /** + * Get primary key in 'contents' table. + * + * @return int + */ + public function getContentId() + { + return $this->content_id; + } + + /** + * @param \Illuminate\Database\Eloquent\Builder $query + * @param int $id + * @param array $columns + * @return \Illuminate\Database\Query\Builder + */ + public function scopePrevious($query, $id, $columns = ['*']) + { + return $query->select($columns)->where('id', '<', $id)->latest('published_at'); + } + + /** + * @param \Illuminate\Database\Eloquent\Builder $query + * @param int $id + * @param array $columns + * @return \Illuminate\Database\Query\Builder + */ + public function scopeNext($query, $id, $columns = ['*']) + { + return $query->select($columns)->where('id', '>', $id)->oldest('published_at'); + } + + /** + * @param \Illuminate\Database\Eloquent\Builder $query + * @param int $limit + * @param array $columns + * @return \Illuminate\Database\Query\Builder + */ + public function scopeHot($query, $limit = 5, $columns = ['*']) + { + return $query->select($columns)->orderBy('view_count', 'desc')->take($limit); + } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 0000000..93aceb1 --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,40 @@ +where('tag', $tag); + } + + /** + * @param null $tag + * @return mixed + */ + public function formatData($tag = null) + { + return $this->tag($tag)->pluck('value', 'key')->toArray(); + } +} diff --git a/app/Models/Visitor.php b/app/Models/Visitor.php new file mode 100644 index 0000000..9326a1c --- /dev/null +++ b/app/Models/Visitor.php @@ -0,0 +1,13 @@ +cacheHelper = $cacheHelper; + } +} \ No newline at end of file diff --git a/app/Observers/CategoryObserver.php b/app/Observers/CategoryObserver.php new file mode 100644 index 0000000..4d015c8 --- /dev/null +++ b/app/Observers/CategoryObserver.php @@ -0,0 +1,20 @@ +cacheHelper->flushEntity($category); + } +} \ No newline at end of file diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php new file mode 100644 index 0000000..90d67b7 --- /dev/null +++ b/app/Observers/PostObserver.php @@ -0,0 +1,67 @@ +cacheHelper->flushPagination($post); + $this->flushRelatedData(); + } + + /** + * @param Post $post + */ + public function updated(Post $post) + { + // Flush single post key + $this->flushPost($post); + $this->flushRelatedData(); + } + + /** + * @param Post $post + */ + public function deleted(Post $post) + { + $this->flushPost($post); + $this->flushRelatedData(); + $this->cacheHelper->flushPagination($post); + } + + /** + * Flush a single post's data. + * + * @param Post $post + */ + protected function flushPost(Post $post) + { + $this->cacheHelper->flushEntity($post); + $this->cacheHelper->flushContent($post); + } + + /** + * Flush category and tag data. + */ + protected function flushRelatedData() + { + // Flush category 'all' cache + $this->cacheHelper->flushList(new Category); + + // Flush tag 'all' cache + $this->cacheHelper->flushList(new Tag); + } +} \ No newline at end of file diff --git a/app/Observers/SettingObserver.php b/app/Observers/SettingObserver.php new file mode 100644 index 0000000..6c7f399 --- /dev/null +++ b/app/Observers/SettingObserver.php @@ -0,0 +1,21 @@ +cacheHelper->keySiteSettings()); + } +} \ No newline at end of file diff --git a/app/Observers/TagObserver.php b/app/Observers/TagObserver.php new file mode 100644 index 0000000..411a77a --- /dev/null +++ b/app/Observers/TagObserver.php @@ -0,0 +1,20 @@ +cacheHelper->flushEntity($tag); + } +} \ No newline at end of file diff --git a/app/Presenters/PostPresenter.php b/app/Presenters/PostPresenter.php index b345c9b..4d30fe3 100644 --- a/app/Presenters/PostPresenter.php +++ b/app/Presenters/PostPresenter.php @@ -44,9 +44,4 @@ public function publishedTime() return null; } - - public function htmlContent() - { - return app(MarkDownParser::class)->md2html($this->content); - } } \ No newline at end of file diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 55b9a4e..716ee08 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,17 @@ namespace App\Providers; +use App\Models\Category; +use App\Models\Post; +use App\Models\Setting; +use App\Models\Tag; +use App\Observers\CategoryObserver; +use App\Observers\PostObserver; +use App\Observers\SettingObserver; +use App\Observers\TagObserver; +use App\Repositories\Contracts\SettingRepository; +use App\Services\CacheHelper; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; @@ -17,6 +28,17 @@ public function boot() Relation::morphMap([ 'posts' => \App\Models\Post::class ]); + + Post::observe(PostObserver::class); + Tag::observe(TagObserver::class); + Category::observe(CategoryObserver::class); + Setting::observe(SettingObserver::class); + + $this->app->singleton('settings', function (Container $app) { + return $app['cache']->rememberForever(CacheHelper::keySiteSettings(), function () use ($app) { + return $app->make(SettingRepository::class)->siteSettings(); + }); + }); } /** diff --git a/app/Providers/ComposerServiceProvider.php b/app/Providers/ComposerServiceProvider.php index c9c83c5..c517882 100644 --- a/app/Providers/ComposerServiceProvider.php +++ b/app/Providers/ComposerServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use App\Http\ViewComposers\CategoriesComposer; +use App\Http\ViewComposers\CommentComposer; +use App\Http\ViewComposers\HotPostsComposer; use App\Http\ViewComposers\TagsComposer; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; @@ -22,6 +24,8 @@ public function boot() { View::composer(['widgets.category', 'partials.navbar'], CategoriesComposer::class); View::composer('widgets.tag', TagsComposer::class); + View::composer('widgets.hot', HotPostsComposer::class); + View::composer('partials.comment', CommentComposer::class); } /** diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index fca6152..f18e4c8 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Repositories\Listeners\RepositoryEventSubscriber; use Illuminate\Support\Facades\Event; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -13,11 +14,21 @@ class EventServiceProvider extends ServiceProvider * @var array */ protected $listen = [ - 'App\Events\Event' => [ - 'App\Listeners\EventListener', + // 'App\Events\Event' => [ + // 'App\Listeners\EventListener', + // ], + 'App\Events\PostViewEvent' => [ + 'App\Listeners\PostViewEventListener', ], ]; + /** + * @var array + */ + protected $subscribe = [ + // RepositoryEventSubscriber::class + ]; + /** * Register any events for your application. * diff --git a/app/Providers/RepositoryServiceProvider.php b/app/Providers/RepositoryServiceProvider.php index ef7a2e6..0294fd1 100644 --- a/app/Providers/RepositoryServiceProvider.php +++ b/app/Providers/RepositoryServiceProvider.php @@ -35,5 +35,9 @@ public function register() \App\Repositories\Eloquent\TagRepositoryEloquent::class); $this->app->bind(\App\Repositories\Contracts\PostRepository::class, \App\Repositories\Eloquent\PostRepositoryEloquent::class); + $this->app->bind(\App\Repositories\Contracts\VisitorRepository::class, + \App\Repositories\Eloquent\VisitorRepositoryEloquent::class); + $this->app->bind(\App\Repositories\Contracts\SettingRepository::class, + \App\Repositories\Eloquent\SettingRepositoryEloquent::class); } } diff --git a/app/Repositories/Contracts/CacheableInterface.php b/app/Repositories/Contracts/CacheableInterface.php new file mode 100644 index 0000000..12a8bf6 --- /dev/null +++ b/app/Repositories/Contracts/CacheableInterface.php @@ -0,0 +1,71 @@ +app = $app; + $this->makeModel(); + } + + /** + * @return Model|mixed + * @throws RepositoryException + */ + public function makeModel() + { + $model = $this->app->make($this->model()); + + if (!$model instanceof Model) { + throw new RepositoryException("Class {$this->model()} must be an instance of Illuminate\\Database\\Eloquent\\Model"); + } + + return $this->model = $model; + } + + /** + * Reset the model after query + */ + protected function resetModel() + { + $this->makeModel(); + } + + /** + * @return mixed + */ + abstract public function model(); + + /** + * @return Builder|Model + */ + public function getModel() + { + if ($this->model instanceof Builder) { + return $this->model->getModel(); + } + + return $this->model; + } + + /** + * @return string + */ + public function getModelTable() + { + if ($this->model instanceof Builder) { + return $this->model->getModel()->getTable(); + } else { + return $this->model->getTable(); + } + } + + /** + * The fake "booting" method of the model in calling scopes. + */ + public function scopeBoot() + { + + } + + /** + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection|static[] + */ + public function all($columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + if ($this->model instanceof Builder) { + $results = $this->model->get($columns); + } else { + $results = $this->model->all($columns); + } + + $this->resetModel(); + $this->resetScope(); + + return $results; + } + + /** + * @param int $perPage + * @param array $columns + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + public function paginate($perPage = null, $columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + $perPage = $perPage ?: $this->getDefaultPerPage(); + + $results = $this->model->paginate($perPage ?: $perPage, $columns); + + $this->resetModel(); + $this->resetScope(); + + return $results; + } + + /** + * @return int + */ + public function getDefaultPerPage() + { + return config('blog.posts.per_page', 10); + } + + /** + * @param array $attributes + * @return Model + */ + public function create(array $attributes) + { + return tap($this->model->create($attributes), function ($model) { + event(new RepositoryEntityCreated($this, $model)); + }); + } + + /** + * @param array $attributes + * @param $id + * @return \Illuminate\Database\Eloquent\Collection|Model + */ + public function update(array $attributes, $id) + { + $this->scopeBoot(); + + $this->applyScope(); + + $model = $this->model->findOrFail($id); + $model->fill($attributes); + $model->save(); + + event(new RepositoryEntityUpdated($this, $model)); + + $this->resetModel(); + $this->resetScope(); + + return $model; + } + + /** + * @param $id + * @return int + */ + public function delete($id) + { + $this->scopeBoot(); + + $this->applyScope(); + + $model = $this->find($id); + $originalModel = clone $model; + + $deleted = $model->delete(); + + event(new RepositoryEntityDeleted($this, $originalModel)); + + $this->resetModel(); + $this->resetScope(); + + return $deleted; + } + + /** + * @param $id + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection|Model + */ + public function find($id, $columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + $result = $this->model->findOrFail($id, $columns); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } + + /** + * @param $field + * @param $value + * @param array $columns + * @return mixed + */ + public function findBy($field, $value, $columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + $result = $this->model->where($field, '=', $value)->first($columns); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } + + /** + * @param $field + * @param $value + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection|static[] + */ + public function findAllBy($field, $value, $columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + $results = $this->model->where($field, '=', $value)->get($columns); + + $this->resetModel(); + $this->resetScope(); + + return $results; + } + + /** + * @param array $where + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection|static[] + */ + public function findWhere(array $where, $columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + $this->applyConditions($where); + + $results = $this->model->get($columns); + + $this->resetModel(); + $this->resetScope(); + + return $results; + } + + /** + * Applies the given where conditions to the model. + * + * @param array $where + * @return void + */ + protected function applyConditions(array $where) + { + foreach ($where as $field => $value) { + if (is_array($value)) { + list($field, $condition, $val) = $value; + $this->model = $this->model->where($field, $condition, $val); + } else { + $this->model = $this->model->where($field, '=', $value); + } + } + } + + /** + * Load relations + * + * @param array|string $relations + * + * @return $this + */ + public function with($relations) + { + $this->model = $this->model->with($relations); + + $this->relations = is_string($relations) ? func_get_args() : $relations; + + return $this; + } + + /** + * @param array $attributes + * @return Model + */ + public function firstOrCreate(array $attributes = []) + { + $this->scopeBoot(); + + $this->applyScope(); + + $result = $this->model->firstOrCreate($attributes); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } + + /** + * @param bool $only + * @return $this + */ + public function trashed($only = false) + { + if ($only) { + $this->model = $this->model->onlyTrashed(); + } else { + $this->model = $this->model->withTrashed(); + } + + return $this; + } + + /** + * @return BaseRepository + */ + public function onlyTrashed() + { + return $this->trashed(true); + } + + /** + * @return BaseRepository + */ + public function withTrashed() + { + return $this->trashed(); + } + + /** + * @param $id + * @return mixed + */ + public function restore($id) + { + $this->scopeBoot(); + + $this->applyScope(); + + $result = $this->withTrashed()->find($id)->restore(); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } + + /** + * @param $id + * @return bool|null + */ + public function forceDelete($id) + { + $this->scopeBoot(); + + $this->applyScope(); + + $result = $this->withTrashed()->find($id)->forceDelete(); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } + + /** + * @param mixed $relations + * @return $this + */ + public function withCount($relations) + { + $this->model = $this->model->withCount($relations); + + $this->relations = is_string($relations) ? func_get_args() : $relations; + + return $this; + } + + /** + * @param $relation + * @return $this + */ + public function has($relation) + { + $this->model = $this->model->has($relation); + + return $this; + } + + /** + * @param $relation + * @param Closure|null $callback + * @return $this + */ + public function whereHas($relation, Closure $callback = null) + { + $this->model = $this->model->whereHas($relation, $callback); + + return $this; + } + + /** + * @param $column + * @param string $direction + * @return $this + */ + public function orderBy($column, $direction = 'asc') + { + $this->model = $this->model->orderBy($column, $direction); + + return $this; + } + + /** + * @param Closure $callback + * @return $this + */ + public function scopeQuery(Closure $callback) + { + $this->scopeQuery = $callback; + + return $this; + } + + /** + * @return $this + */ + protected function applyScope() + { + if (!is_null($this->scopeQuery) && is_callable($this->scopeQuery)) { + $callback = $this->scopeQuery; + $this->model = $callback($this->model); + } + + return $this; + } + + /** + * @return $this + */ + public function resetScope() + { + $this->scopeQuery = null; + + return $this; + } + + /** + * @param array $columns + * @return mixed + */ + public function first($columns = ['*']) + { + $this->scopeBoot(); + + $this->applyScope(); + + $result = $this->model->first($columns); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } +} diff --git a/app/Repositories/Eloquent/CategoryRepositoryEloquent.php b/app/Repositories/Eloquent/CategoryRepositoryEloquent.php index 53cb540..f9d493c 100644 --- a/app/Repositories/Eloquent/CategoryRepositoryEloquent.php +++ b/app/Repositories/Eloquent/CategoryRepositoryEloquent.php @@ -3,17 +3,18 @@ namespace App\Repositories\Eloquent; use App\Models\Category; +use App\Repositories\Contracts\CacheableInterface; use App\Repositories\Contracts\CategoryRepository; +use App\Repositories\Eloquent\Traits\Postable; use App\Repositories\Eloquent\Traits\Slugable; -use App\Scopes\PublishedScope; /** * Class CategoryRepositoryEloquent * @package App\Repositories\Eloquent */ -class CategoryRepositoryEloquent extends Repository implements CategoryRepository +class CategoryRepositoryEloquent extends BaseRepository implements CategoryRepository, CacheableInterface { - use Slugable; + use Slugable, Postable; /** * @return string @@ -56,23 +57,4 @@ public function updateCategory(array $attributes, $id) return $this->update($attributes, $id); } - - /** - * @param array $columns - * @return mixed - */ - public function allWithPostCount($columns = ['*']) - { - return $this->withCount([ - 'posts' => function ($query) { - if (isAdmin()) { - $query->withoutGlobalScope(PublishedScope::class); - } - } - ]) - ->all() - ->reject(function ($category) { - return $category->posts_count == 0; - }); - } } diff --git a/app/Repositories/Eloquent/PermissionRepositoryEloquent.php b/app/Repositories/Eloquent/PermissionRepositoryEloquent.php index 9efee68..da14681 100644 --- a/app/Repositories/Eloquent/PermissionRepositoryEloquent.php +++ b/app/Repositories/Eloquent/PermissionRepositoryEloquent.php @@ -9,7 +9,7 @@ * Class PermissionRepositoryEloquent * @package App\Repositories\Eloquent */ -class PermissionRepositoryEloquent extends Repository implements PermissionRepository +class PermissionRepositoryEloquent extends BaseRepository implements PermissionRepository { /** * @return string diff --git a/app/Repositories/Eloquent/PostRepositoryEloquent.php b/app/Repositories/Eloquent/PostRepositoryEloquent.php index 7e8070a..d87976f 100644 --- a/app/Repositories/Eloquent/PostRepositoryEloquent.php +++ b/app/Repositories/Eloquent/PostRepositoryEloquent.php @@ -2,11 +2,15 @@ namespace App\Repositories\Eloquent; +use App\Models\Content; use App\Models\Post; +use App\Repositories\Contracts\CacheableInterface; use App\Repositories\Contracts\PostRepository; use App\Repositories\Contracts\TagRepository; +use App\Repositories\Eloquent\Traits\FieldsHandler; use App\Repositories\Eloquent\Traits\Slugable; use App\Repositories\Exceptions\RepositoryException; +use App\Repositories\Eloquent\Traits\Cacheable; use App\Scopes\PublishedScope; use Carbon\Carbon; use Illuminate\Container\Container; @@ -16,15 +20,22 @@ * Class PostRepositoryEloquent * @package App\Repositories\Eloquent */ -class PostRepositoryEloquent extends Repository implements PostRepository +class PostRepositoryEloquent extends BaseRepository implements PostRepository, CacheableInterface { use Slugable; + use Cacheable; + use FieldsHandler; /** * @var TagRepository */ protected $tagRepo; + /** + * @var mixed + */ + protected $contentModel; + /** * PostRepositoryEloquent constructor. * @param Container $app @@ -34,8 +45,20 @@ public function __construct(Container $app, TagRepository $tagRepo) { parent::__construct($app); $this->tagRepo = $tagRepo; + $this->contentModel = $this->app->make($this->contentModel()); + } + + /** + * @return string + */ + public function contentModel() + { + return Content::class; } + /** + * + */ public function scopeBoot() { parent::scopeBoot(); @@ -43,7 +66,7 @@ public function scopeBoot() // TODO to be optimized // Session middleware is called after ServiceProvider binding, so can't set method boot in constructor if (isAdmin()) { - return $this->model = $this->model->withoutGlobalScope(PublishedScope::class); + $this->model = $this->model->withoutGlobalScope(PublishedScope::class); } } @@ -64,7 +87,9 @@ public function createPost(array $attributes) $attributes = $this->preHandleData($attributes); // TODO use transaction - $this->model = request()->user()->posts()->create($attributes); + $this->model = request()->user()->posts()->create(array_merge($attributes, [ + 'content_id' => $this->contentModel->create($attributes)->id, + ])); return $this->syncTags(data_get($attributes, 'tag', [])); } @@ -77,48 +102,19 @@ protected function preHandleData(array $attributes) { $attributes = $this->autoSlug($attributes, 'title'); - $publishedAt = $this->getPublishedAt(array_get($attributes, 'published_at')); - - $isDraft = $this->getIsDraft(array_get($attributes, 'is_draft')); + foreach ($attributes as $field => $value) { + if (method_exists($this, $method = 'handle' . ucfirst(camel_case($field)))) { + array_set($attributes, $field, call_user_func_array([$this, $method], [$value])); + } + } - $attributes = array_merge($attributes, [ - 'published_at' => $publishedAt, - 'is_draft' => $isDraft, - ]); + $attributes = $this->handleImg($attributes); // TODO excerpt should be html purifier - // TODO condition while no feature_img - return $attributes; } - /** - * @param $value - * @return Carbon - */ - protected function getPublishedAt($value) - { - if (empty($value)) { - return Carbon::now(); - } - - return Carbon::createFromTimestamp(strtotime($value)); - } - - /** - * @param $value - * @return int - */ - protected function getIsDraft($value) - { - if (empty($value)) { - return $this->model->getConst('IS_NOT_DRAFT'); - } - - return $this->model->getConst('IS_DRAFT'); - } - /** * @param array $tags * @throws RepositoryException @@ -156,8 +152,89 @@ public function updatePost(array $attributes, $id) $attributes = $this->preHandleData($attributes); // TODO use transaction - $this->model = $this->update($attributes, $id); + $this->model = $this->update(array_except($attributes, 'slug'), $id); + + $this->model->content()->update($attributes); return $this->syncTags(data_get($attributes, 'tag', [])); } + + /** + * Fetch posts data of home page with pagination. + * + * Alert: It's not optimized without cache support, + * so just only use this while with cache enabled. + * + * @param null $perPage + * @return mixed + */ + public function lists($perPage = null) + { + $perPage = $perPage ?: $this->getDefaultPerPage(); + + // Second layer cache + $pagination = $this->paginate($perPage, ['slug']); + + $items = $pagination->getCollection()->map(function ($post) { + // First layer cache + return $this->getBySlug($post->slug); + }); + + return $pagination->setCollection($items); + } + + /** + * Get a single post. + * + * @param $id + * @return mixed + */ + public function retrieve($id) + { + return $this->with(['author', 'category', 'tags'])->find($id); + } + + /** + * @param $slug + * @return mixed + */ + public function getBySlug($slug) + { + return $this->with(['author', 'category', 'tags'])->findBy('slug', $slug); + } + + /** + * @param $model + * @return mixed + */ + public function previous($model) + { + return $this->scopeQuery(function ($query) use ($model) { + return $query->previous($model->id, ['title', 'slug']); + })->first(); + } + + /** + * @param $model + * @return mixed + */ + public function next($model) + { + return $this->scopeQuery(function ($query) use ($model) { + return $query->next($model->id, ['title', 'slug']); + })->first(); + } + + /** + * @param int $limit + * @return mixed + */ + public function hot($limit = 5) + { + // TODO cache support + return $this->skipCache()->scopeQuery(function ($query) use ($limit) { + return $query->hot($limit, ['slug', 'title', 'view_count']); + })->all(); + } + } diff --git a/app/Repositories/Eloquent/Repository.php b/app/Repositories/Eloquent/Repository.php deleted file mode 100644 index d5115c6..0000000 --- a/app/Repositories/Eloquent/Repository.php +++ /dev/null @@ -1,287 +0,0 @@ -app = $app; - $this->makeModel(); - } - - /** - * @return Model|mixed - * @throws RepositoryException - */ - public function makeModel() - { - $model = $this->app->make($this->model()); - - if (!$model instanceof Model) { - throw new RepositoryException("Class {$this->model()} must be an instance of Illuminate\\Database\\Eloquent\\Model"); - } - - return $this->model = $model; - } - - /** - * @return mixed - */ - abstract public function model(); - - /** - * The fake "booting" method of the model in calling scopes. - */ - public function scopeBoot() - { - - } - - /** - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection|static[] - */ - public function all($columns = ['*']) - { - $this->scopeBoot(); - - if ($this->model instanceof Builder) { - $results = $this->model->get($columns); - } else { - $results = $this->model->all($columns); - } - - return $results; - } - - /** - * @param int $perPage - * @param array $columns - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator - */ - public function paginate($perPage = 10, $columns = ['*']) - { - $this->scopeBoot(); - - return $this->model->paginate($perPage, $columns); - } - - /** - * @param array $attributes - * @return Model - */ - public function create(array $attributes) - { - return $this->model->create($attributes); - } - - /** - * @param array $attributes - * @param $id - * @return \Illuminate\Database\Eloquent\Collection|Model - */ - public function update(array $attributes, $id) - { - $this->scopeBoot(); - - $model = $this->model->findOrFail($id); - $model->fill($attributes); - $model->save(); - - return $model; - } - - /** - * @param $id - * @return int - */ - public function delete($id) - { - return $this->find($id)->delete(); - } - - /** - * @param $id - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection|Model - */ - public function find($id, $columns = ['*']) - { - $this->scopeBoot(); - - return $this->model->findOrFail($id, $columns); - } - - /** - * @param $field - * @param $value - * @param array $columns - * @return mixed - */ - public function findBy($field, $value, $columns = ['*']) - { - $this->scopeBoot(); - - return $this->model->where($field, '=', $value)->first($columns); - } - - /** - * @param $field - * @param $value - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection|static[] - */ - public function findAllBy($field, $value, $columns = ['*']) - { - $this->scopeBoot(); - - return $this->model->where($field, '=', $value)->get($columns); - } - - /** - * @param array $where - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection|static[] - */ - public function findWhere(array $where, $columns = ['*']) - { - $this->scopeBoot(); - - $this->applyConditions($where); - - return $this->model->get($columns); - } - - /** - * Applies the given where conditions to the model. - * - * @param array $where - * @return void - */ - protected function applyConditions(array $where) - { - foreach ($where as $field => $value) { - if (is_array($value)) { - list($field, $condition, $val) = $value; - $this->model = $this->model->where($field, $condition, $val); - } else { - $this->model = $this->model->where($field, '=', $value); - } - } - } - - /** - * Load relations - * - * @param array|string $relations - * - * @return $this - */ - public function with($relations) - { - $this->model = $this->model->with($relations); - return $this; - } - - /** - * @param array $attributes - * @return Model - */ - public function firstOrCreate(array $attributes = []) - { - $this->scopeBoot(); - - return $this->model->firstOrCreate($attributes); - } - - /** - * @param bool $only - * @return $this - */ - public function trashed($only = false) - { - $this->scopeBoot(); - - if ($only) { - $this->model = $this->model->onlyTrashed(); - } else { - $this->model = $this->model->withTrashed(); - } - - return $this; - } - - /** - * @return Repository - */ - public function onlyTrashed() - { - return $this->trashed(true); - } - - /** - * @return Repository - */ - public function withTrashed() - { - return $this->trashed(); - } - - /** - * @param $id - * @return mixed - */ - public function restore($id) - { - $this->scopeBoot(); - - return $this->withTrashed()->find($id)->restore(); - } - - /** - * @param $id - * @return bool|null - */ - public function forceDelete($id) - { - $this->scopeBoot(); - - return $this->withTrashed()->find($id)->forceDelete(); - } - - /** - * @param mixed $relations - * @return $this - */ - public function withCount($relations) - { - $this->model = $this->model->withCount($relations); - - return $this; - } -} \ No newline at end of file diff --git a/app/Repositories/Eloquent/RoleRepositoryEloquent.php b/app/Repositories/Eloquent/RoleRepositoryEloquent.php index 861713f..b863cf1 100644 --- a/app/Repositories/Eloquent/RoleRepositoryEloquent.php +++ b/app/Repositories/Eloquent/RoleRepositoryEloquent.php @@ -10,7 +10,7 @@ * Class RoleRepositoryEloquent * @package App\Repositories\Eloquent */ -class RoleRepositoryEloquent extends Repository implements RoleRepository +class RoleRepositoryEloquent extends BaseRepository implements RoleRepository { /** * @return string diff --git a/app/Repositories/Eloquent/SettingRepositoryEloquent.php b/app/Repositories/Eloquent/SettingRepositoryEloquent.php new file mode 100644 index 0000000..b0591f1 --- /dev/null +++ b/app/Repositories/Eloquent/SettingRepositoryEloquent.php @@ -0,0 +1,34 @@ +model, $method = 'formatData')) { + return call_user_func_array([$this->model, $method], [$tag]); + } + + return []; + } +} diff --git a/app/Repositories/Eloquent/TagRepositoryEloquent.php b/app/Repositories/Eloquent/TagRepositoryEloquent.php index 2cea2a8..2d3846d 100644 --- a/app/Repositories/Eloquent/TagRepositoryEloquent.php +++ b/app/Repositories/Eloquent/TagRepositoryEloquent.php @@ -3,17 +3,18 @@ namespace App\Repositories\Eloquent; use App\Models\Tag; +use App\Repositories\Contracts\CacheableInterface; use App\Repositories\Contracts\TagRepository; +use App\Repositories\Eloquent\Traits\Postable; use App\Repositories\Eloquent\Traits\Slugable; -use App\Scopes\PublishedScope; /** * Class TagRepositoryEloquent * @package App\Repositories\Eloquent */ -class TagRepositoryEloquent extends Repository implements TagRepository +class TagRepositoryEloquent extends BaseRepository implements TagRepository, CacheableInterface { - use Slugable; + use Slugable, Postable; /** * @return string @@ -56,23 +57,4 @@ public function updateTag(array $attributes, $id) return $this->update($attributes, $id); } - - /** - * @param array $columns - * @return mixed - */ - public function allWithPostCount($columns = ['*']) - { - return $this->withCount([ - 'posts' => function ($query) { - if (isAdmin()) { - $query->withoutGlobalScope(PublishedScope::class); - } - } - ]) - ->all() - ->reject(function ($tag) { - return $tag->posts_count == 0; - }); - } } diff --git a/app/Repositories/Eloquent/Traits/Cacheable.php b/app/Repositories/Eloquent/Traits/Cacheable.php new file mode 100644 index 0000000..1c7c4fc --- /dev/null +++ b/app/Repositories/Eloquent/Traits/Cacheable.php @@ -0,0 +1,313 @@ +cache = $cache; + + return $this; + } + + /** + * @param bool $status + * @return $this + */ + public function setForever($status = true) + { + $this->forever = $status; + + return $this; + } + + /** + * @return CacheHelper + */ + protected function getCacheHelper() + { + return new CacheHelper(); + } + + /** + * @param bool $status + * @return $this + */ + public function skipCache($status = true) + { + $this->skipCache = $status; + + return $this; + } + + /** + * @param null $perPage + * @param array $columns + * @return mixed + */ + public function paginate($perPage = null, $columns = ['*']) + { + $table = $this->getModelTable(); + + $key = $this->getCacheHelper()->keyPaginate($table); + + array_push($this->tags, $this->getCacheHelper()->tagPaginate($table)); + + return $this->getIfCacheable(__FUNCTION__, func_get_args(), $key); + } + + /** + * @param $method + * @param $args + * @param $key + * @param boolean $ignoreUriQuery + * @return mixed + */ + private function getIfCacheable($method, $args, $key = null, $ignoreUriQuery = true) + { + if (!$this->isAllowedCache()) { + return call_user_func_array([$this, 'parent::' . $method], $args); + } + + $key = $key ?: $this->getCacheKey($method, $args, $ignoreUriQuery); + + $result = $this->remember($key, + function () use ($method, $args) { + return call_user_func_array([$this, 'parent::' . $method], $args); + }); + + $this->resetModel(); + $this->resetScope(); + + return $result; + } + + /** + * @return bool + */ + public function isAllowedCache() + { + return config('blog.cache.enable', true) && !$this->isSkippedCache() && !isAdmin(); + } + + /** + * @return bool + */ + public function isSkippedCache() + { + return $this->skipCache; + } + + /** + * @param $key + * @param Closure $callback + * @param null $minutes + * @return mixed + */ + public function remember($key, Closure $callback, $minutes = null) + { + $minutes = $minutes ?: $this->getCacheMinutes(); + + $cache = $this->getCacheRepositoryWithTags(); + + if ($this->forever) { + return $cache->rememberForever($key, $callback); + } + return $cache->remember($key, $minutes, $callback); + } + + /** + * @return mixed + */ + public function getCacheMinutes() + { + return $this->cacheMinutes ?: config('blog.cache.minutes', 30); + } + + /** + * @param $minutes + * @return $this + */ + public function setCacheMinutes($minutes) + { + $this->cacheMinutes = $minutes; + + return $this; + } + + /** + * @return CacheRepository|\Illuminate\Foundation\Application|mixed + */ + public function getCacheRepositoryWithTags() + { + $cache = $this->getCacheRepository(); + + try { + $tags = array_merge($this->tags, [$this->getModelTable()]); + + return $cache->tags($tags); + } catch (\BadMethodCallException $exception) { + // Not support tags + // throw $exception; + return $cache; + } + } + + /** + * @return CacheRepository|\Illuminate\Foundation\Application|mixed + */ + public function getCacheRepository() + { + return $this->cache ?: app('cache.store'); + } + + /** + * @param $method + * @param null $args + * @param boolean $ignoreUriQuery + * @return string + */ + public function getCacheKey($method, $args = null, $ignoreUriQuery = true) + { + $relationsNameOnly = $this->parseRelations(); + + $query = $this->parseQuery($ignoreUriQuery); + + $key = sprintf('%s:%s-%s', $this->getModelTable(), $method, + md5(serialize($args) . serialize($relationsNameOnly) . $query)); + + return $key; + } + + /** + * @return array|null + */ + protected function parseRelations() + { + if (empty($this->relations)) { + return null; + } + + $names = []; + foreach ($this->relations as $name => $constraints) { + if (is_numeric($name)) { + $name = $constraints; + } + array_push($names, $name); + } + + sort($names); + + return $names; + } + + /** + * @param $ignore + * @return null|string + */ + protected function parseQuery($ignore) + { + if ($ignore) { + return ''; + } + + return request()->getQueryString(); + } + + /** + * @param $id + * @param array $columns + * @return mixed + */ + public function find($id, $columns = ['*']) + { + $id = (int)$id; + + $key = $this->getCacheHelper()->keyFind($this->getModelTable(), $id); + + return $this->setForever()->getIfCacheable(__FUNCTION__, [$id, $columns], $key); + } + + /** + * @param array $columns + * @return mixed + */ + public function all($columns = ['*']) + { + $key = $this->getCacheHelper()->keyAll($this->getModelTable()); + + return $this->getIfCacheable(__FUNCTION__, func_get_args(), $key); + } + + /** + * @param $field + * @param $value + * @param array $columns + * @return mixed + */ + public function findBy($field, $value, $columns = ['*']) + { + $key = $this->getCacheHelper()->keySlug($this->getModelTable(), $value); + + return $this->setForever()->getIfCacheable(__FUNCTION__, func_get_args(), $key); + } + // + // /** + // * @param $field + // * @param $value + // * @param array $columns + // * @return mixed + // */ + // public function findAllBy($field, $value, $columns = ['*']) + // { + // return $this->getIfCacheable(__FUNCTION__, func_get_args()); + // } + // + // /** + // * @param array $where + // * @param array $columns + // * @return mixed + // */ + // public function findWhere(array $where, $columns = ['*']) + // { + // return $this->getIfCacheable(__FUNCTION__, func_get_args()); + // } +} diff --git a/app/Repositories/Eloquent/Traits/FieldsHandler.php b/app/Repositories/Eloquent/Traits/FieldsHandler.php new file mode 100644 index 0000000..7d210f9 --- /dev/null +++ b/app/Repositories/Eloquent/Traits/FieldsHandler.php @@ -0,0 +1,69 @@ +model->getConst('IS_NOT_DRAFT'); + } + + return $this->model->getConst('IS_DRAFT'); + } + + /** + * @param UploadedFile $file + * @param string $path + * @param string $disk + * @return false|string + */ + public function handleFeatureImgFile(UploadedFile $file, $path = 'images', $disk = 'public') + { + $storePath = $file->store($path, $disk); + + if ($disk == 'public') { + return asset('storage/' . $storePath); + } + + return $storePath; + } + + /** + * @param array $attributes + * @return array + */ + public function handleImg(array $attributes) + { + if (array_has($attributes, $img = 'feature_img_file')) { + array_set($attributes, 'feature_img', array_get($attributes, $img)); + } + + return $attributes; + } +} \ No newline at end of file diff --git a/app/Repositories/Eloquent/Traits/Postable.php b/app/Repositories/Eloquent/Traits/Postable.php new file mode 100644 index 0000000..fa90ad1 --- /dev/null +++ b/app/Repositories/Eloquent/Traits/Postable.php @@ -0,0 +1,74 @@ +whereHas('posts') + ->withCount([ + 'posts' => function ($query) { + if (isAdmin()) { + $query->withoutGlobalScope(PublishedScope::class); + } + } + ]) + ->all($columns); + } + + /** + * @param $slug + * @return array + */ + public function getWithPosts($slug) + { + $model = $this->findBy('slug', $slug); + + // Call relationship $category->posts() + $relation = call_user_func([$model, 'posts']); + + if (isAdmin()) { + $relation->withoutGlobalScope(PublishedScope::class); + } + + // Relationship default set to no cache + $postsPagination = $relation->paginate($this->getDefaultPerPage(), ['slug']); + + $items = $postsPagination->getCollection()->map(function ($post) { + return $this->getPostRepo()->getBySlug($post->slug); + }); + + return [$model, $postsPagination->setCollection($items)]; + } + + /** + * @return PostRepository|\Illuminate\Foundation\Application|mixed + */ + protected function getPostRepo() + { + if (is_null($this->postRepo)) { + $this->postRepo = app(PostRepository::class); + } + + return $this->postRepo; + } +} \ No newline at end of file diff --git a/app/Repositories/Eloquent/UserRepositoryEloquent.php b/app/Repositories/Eloquent/UserRepositoryEloquent.php index 116e82f..fbffce0 100644 --- a/app/Repositories/Eloquent/UserRepositoryEloquent.php +++ b/app/Repositories/Eloquent/UserRepositoryEloquent.php @@ -10,7 +10,7 @@ * Class UserRepositoryEloquent * @package App\Repositories\Eloquent */ -class UserRepositoryEloquent extends Repository implements UserRepository +class UserRepositoryEloquent extends BaseRepository implements UserRepository { /** * @return string diff --git a/app/Repositories/Eloquent/VisitorRepositoryEloquent.php b/app/Repositories/Eloquent/VisitorRepositoryEloquent.php new file mode 100644 index 0000000..74c5c96 --- /dev/null +++ b/app/Repositories/Eloquent/VisitorRepositoryEloquent.php @@ -0,0 +1,71 @@ +request = $request; + $this->agent = $agent; + } + + /** + * @return string + */ + public function model() + { + return Visitor::class; + } + + /** + * @return \Illuminate\Database\Eloquent\Model + */ + public function createLog() + { + return $this->create($this->getLogData()); + } + + /** + * @return array + */ + protected function getLogData() + { + return [ + 'ip' => $this->request->ip(), + 'uri' =>$this->request->path(), + 'is_robot' => $this->agent->isRobot(), + 'platform' => $this->agent->platform(), + 'device' => $this->agent->device(), + 'browser' => $this->agent->browser(), + 'user_agent' => $this->request->server('HTTP_USER_AGENT'), + ]; + } +} diff --git a/app/Repositories/Events/BaseEvent.php b/app/Repositories/Events/BaseEvent.php new file mode 100644 index 0000000..6fdfc05 --- /dev/null +++ b/app/Repositories/Events/BaseEvent.php @@ -0,0 +1,63 @@ +model = $model; + $this->repository = $repository; + } + + /** + * @return Model + */ + public function getModel() + { + return $this->model; + } + + /** + * @return RepositoryInterface + */ + public function getRepository() + { + return $this->repository; + } + + /** + * @return mixed + */ + public function getAction() + { + return $this->action; + } +} diff --git a/app/Repositories/Events/RepositoryEntityCreated.php b/app/Repositories/Events/RepositoryEntityCreated.php new file mode 100644 index 0000000..ea90bc3 --- /dev/null +++ b/app/Repositories/Events/RepositoryEntityCreated.php @@ -0,0 +1,15 @@ +listen( + RepositoryEntityCreated::class, + 'App\Repositories\Listeners\RepositoryEventSubscriber@cleanCache' + ); + $events->listen( + RepositoryEntityUpdated::class, + 'App\Repositories\Listeners\RepositoryEventSubscriber@cleanCache' + ); + $events->listen( + RepositoryEntityDeleted::class, + 'App\Repositories\Listeners\RepositoryEventSubscriber@cleanCache' + ); + } + + public function cleanCache($event) + { + + } +} \ No newline at end of file diff --git a/app/Scopes/PublishedScope.php b/app/Scopes/PublishedScope.php index 0d801c2..9dd064c 100644 --- a/app/Scopes/PublishedScope.php +++ b/app/Scopes/PublishedScope.php @@ -18,7 +18,7 @@ class PublishedScope implements Scope public function apply(Builder $builder, Model $model) { return $builder - ->where('published_at', '<=', Carbon::now()) + ->where('published_at', '<=', Carbon::now()->toDateTimeString()) ->where('is_draft', '=', Post::IS_NOT_DRAFT); } } \ No newline at end of file diff --git a/app/Services/CacheHelper.php b/app/Services/CacheHelper.php new file mode 100644 index 0000000..869503c --- /dev/null +++ b/app/Services/CacheHelper.php @@ -0,0 +1,161 @@ +isAllowCache()) { + return; + } + + $tags = $this->tagPaginate($model->getTable()); + + Cache::tags($tags)->flush(); + } + + /** + * @return mixed + */ + public function isAllowCache() + { + return config('blog.cache.enable'); + } + + /** + * @param $table + * @return string + */ + public function tagPaginate($table) + { + return $table . '-paginate'; + } + + /** + * @param Model $model + */ + public function flushEntity(Model $model) + { + if (!$this->isAllowCache()) { + return; + } + + $table = $model->getTable(); + $key = $this->keySlug($table, $model->slug); + + Cache::tags($table)->forget($key); + } + + /** + * @param $table + * @param $slug + * @return string + */ + public function keySlug($table, $slug) + { + return sprintf(self::KEY_FORMAT, $table, md5($table . ':' . $slug)); + } + + /** + * @param Model $model + */ + public function flushList(Model $model) + { + if (!$this->isAllowCache()) { + return; + } + + $table = $model->getTable(); + $key = $this->keyAll($table); + + Cache::tags($table)->forget($key); + } + + /** + * @param $table + * @return string + */ + public function keyAll($table) + { + return sprintf(self::KEY_FORMAT, $table, 'all'); + } + + /** + * Set forever cache of content. + * + * @param ContentableInterface $contentable + * @return mixed + */ + public function cacheContent(ContentableInterface $contentable) + { + return Cache::rememberForever($this->getContentCacheKey($contentable), + function () use ($contentable) { + return app(MarkDownParser::class)->md2html($contentable->getRawContent()); + }); + } + + /** + * Get content cache key. + * + * @param ContentableInterface $contentable + * @return string + */ + protected function getContentCacheKey(ContentableInterface $contentable) + { + return sprintf('contents:%s', $contentable->getContentId()); + } + + /** + * Forget cache key of content. + * + * @param ContentableInterface $contentable + */ + public function flushContent(ContentableInterface $contentable) + { + Cache::forget($this->getContentCacheKey($contentable)); + } + + /** + * @param $table + * @param $id + * @return string + */ + public function keyFind($table, $id) + { + return sprintf(self::KEY_FORMAT, $table, $id); + } + + /** + * @param $table + * @return string + */ + public function keyPaginate($table) + { + return sprintf(self::KEY_FORMAT, $table, 'paginate-' . request()->input('page', 1)); + } + + /** + * @return string + */ + public static function keySiteSettings() + { + return 'site.settings'; + } +} \ No newline at end of file diff --git a/app/Services/PostViewCounter.php b/app/Services/PostViewCounter.php new file mode 100644 index 0000000..0018162 --- /dev/null +++ b/app/Services/PostViewCounter.php @@ -0,0 +1,187 @@ +request = $request; + $this->setConfig(); + } + + /** + * Prepare property + */ + protected function setConfig() + { + // TODO what if config is hack? + $config = $this->getDefaultConfig(); + + $this->timeout = $config['timeout']; + $this->key = $config['key']; + $this->cacheKey = $config['cache_key']; + $this->strict_mode = $config['strict_mode']; + } + + /** + * Get default config + * + * @return mixed + */ + protected function getDefaultConfig() + { + return config('blog.counter'); + } + + /** + * Reset timeout + * + * @param $timeout + */ + public function setTimeout($timeout) + { + $this->timeout = $timeout; + } + + /** + * Determinate if enable strict mode + */ + public function enableStrictMode() + { + $this->strict_mode = true; + } + + /** + * Determinate if disable strict mode + */ + public function disableStrictMode() + { + $this->strict_mode = false; + } + + /** + * Main + * + * @param $id + */ + public function run($id) + { + $this->id = $id; + + if ($this->strict_mode) { + if (!$this->isPostViewed() || $this->isLastViewOutdated()) { + $this->createSession(); + + $this->increaseCacheRecord(); + } + } else { + $this->increaseCacheRecord(); + } + } + + /** + * Determinate if post is have been viewed + * + * @return bool + */ + protected function isPostViewed() + { + return $this->request->session()->has($this->getPostSessionKey()); + } + + /** + * Get post's session key + * + * @return string + */ + protected function getPostSessionKey() + { + return $this->key . '.' . $this->id; + } + + /** + * Determinate if post view record is outdated + * + * @return bool + */ + protected function isLastViewOutdated() + { + $lastView = $this->request->session()->get($this->getPostSessionKey()); + + return ($lastView + $this->timeout) < $this->currentTime(); + } + + /** + * Return a unix timestamp + * + * @return int + */ + private function currentTime() + { + return time(); + } + + /** + * Create post viewed session + */ + protected function createSession() + { + $this->request->session()->put($this->getPostSessionKey(), $this->currentTime()); + } + + /** + * Save view record into cache + */ + protected function increaseCacheRecord() + { + Cache::increment($this->getPostCacheKey()); + } + + /** + * Return post's cache key + * + * @return string + */ + protected function getPostCacheKey() + { + return $this->cacheKey . $this->id; + } +} diff --git a/app/helpers.php b/app/helpers.php index 9950343..7ef72a2 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -83,3 +83,13 @@ function isAdmin() return auth()->check() && auth()->user()->isAdmin(); } } + +if (!function_exists('setting')) { + /** + * @param $key + * @return mixed + */ + function setting($key) { + return array_get(app('settings'), $key); + } +} diff --git a/composer.json b/composer.json index 38be99a..8291887 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "php": ">=5.6.4", "erusev/parsedown": "^1.6", "jellybool/translug": "^2.0", + "jenssegers/agent": "^2.5", "laracasts/presenter": "^0.2.1", "laravel/framework": "5.4.*", "laravel/tinker": "~1.0", diff --git a/composer.lock b/composer.lock index 01c7880..82d1bd1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "534ff426fdda758f65abb831aaa656d1", + "content-hash": "d256e969bbd67aeacd227f8ca867d755", "packages": [ { "name": "dnoegel/php-xdg-base-dir", @@ -413,6 +413,54 @@ ], "time": "2015-04-20T18:58:01+00:00" }, + { + "name": "jaybizzle/crawler-detect", + "version": "v1.2.45", + "source": { + "type": "git", + "url": "https://github.com/JayBizzle/Crawler-Detect.git", + "reference": "fa55dc49376dbc4b4786e49113faa8bdea8250a1" + }, + "dist": { + "type": "zip", + "url": "https://files.phpcomposer.com/files/JayBizzle/Crawler-Detect/fa55dc49376dbc4b4786e49113faa8bdea8250a1.zip", + "reference": "fa55dc49376dbc4b4786e49113faa8bdea8250a1", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "4.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jaybizzle\\CrawlerDetect\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Beech", + "email": "m@rkbee.ch", + "role": "Developer" + } + ], + "description": "CrawlerDetect is a PHP class for detecting bots/crawlers/spiders via the user agent", + "homepage": "https://github.com/JayBizzle/Crawler-Detect/", + "keywords": [ + "crawler", + "crawler detect", + "crawler detector", + "crawlerdetect", + "php crawler detect" + ], + "time": "2017-06-07T20:58:57+00:00" + }, { "name": "jellybool/translug", "version": "2.0.1", @@ -461,6 +509,65 @@ ], "time": "2017-05-26T15:45:17+00:00" }, + { + "name": "jenssegers/agent", + "version": "v2.5.1", + "source": { + "type": "git", + "url": "https://github.com/jenssegers/agent.git", + "reference": "68bef4f773933d33f9b6c8d1f2ff002f97a65b0b" + }, + "dist": { + "type": "zip", + "url": "https://files.phpcomposer.com/files/jenssegers/agent/68bef4f773933d33f9b6c8d1f2ff002f97a65b0b.zip", + "reference": "68bef4f773933d33f9b6c8d1f2ff002f97a65b0b", + "shasum": "" + }, + "require": { + "illuminate/support": "^4.0|^5.0", + "jaybizzle/crawler-detect": "^1.2", + "mobiledetect/mobiledetectlib": "^2.7.6", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0|^5.0|^6.0", + "satooshi/php-coveralls": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Jenssegers\\Agent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jens Segers", + "homepage": "https://jenssegers.com" + } + ], + "description": "Desktop/mobile user agent parser with support for Laravel, based on Mobiledetect", + "homepage": "https://github.com/jenssegers/agent", + "keywords": [ + "Agent", + "browser", + "desktop", + "laravel", + "mobile", + "platform", + "user agent", + "useragent" + ], + "time": "2017-03-12T09:58:22+00:00" + }, { "name": "laracasts/presenter", "version": "0.2.1", @@ -846,6 +953,58 @@ ], "time": "2017-03-16T00:45:59+00:00" }, + { + "name": "mobiledetect/mobiledetectlib", + "version": "2.8.25", + "source": { + "type": "git", + "url": "https://github.com/serbanghita/Mobile-Detect.git", + "reference": "f0896b5c7274d1450023b0b376240be902c3251c" + }, + "dist": { + "type": "zip", + "url": "https://files.phpcomposer.com/files/serbanghita/Mobile-Detect/f0896b5c7274d1450023b0b376240be902c3251c.zip", + "reference": "f0896b5c7274d1450023b0b376240be902c3251c", + "shasum": "" + }, + "require": { + "php": ">=5.0.0" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "classmap": [ + "Mobile_Detect.php" + ], + "psr-0": { + "Detection": "namespaced/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Serban Ghita", + "email": "serbanghita@gmail.com", + "homepage": "http://mobiledetect.net", + "role": "Developer" + } + ], + "description": "Mobile_Detect is a lightweight PHP class for detecting mobile devices. It uses the User-Agent string combined with specific HTTP headers to detect the mobile environment.", + "homepage": "https://github.com/serbanghita/Mobile-Detect", + "keywords": [ + "detect mobile devices", + "mobile", + "mobile detect", + "mobile detector", + "php mobile detect" + ], + "time": "2017-03-29T13:59:30+00:00" + }, { "name": "monolog/monolog", "version": "1.22.1", diff --git a/config/app.php b/config/app.php index 41eb5bf..336cf14 100644 --- a/config/app.php +++ b/config/app.php @@ -183,6 +183,7 @@ Thomaswelton\LaravelGravatar\LaravelGravatarServiceProvider::class, App\Providers\RepositoryServiceProvider::class, JellyBool\Translug\TranslugServiceProvider::class, + Jenssegers\Agent\AgentServiceProvider::class, ], @@ -237,6 +238,7 @@ 'Entrust' => Zizaco\Entrust\EntrustFacade::class, 'Gravatar' => Thomaswelton\LaravelGravatar\Facades\Gravatar::class, 'Translug' => \JellyBool\Translug\TranslugFacade::class, + 'Agent' => Jenssegers\Agent\Facades\Agent::class, ], ]; diff --git a/config/blog.php b/config/blog.php new file mode 100644 index 0000000..828be85 --- /dev/null +++ b/config/blog.php @@ -0,0 +1,30 @@ + [ + 'strict_mode' => env('COUNT_STRICT_MODE', false), + 'cache_key' => 'post_viewed_count:', + // require strict_mode to be set + 'timeout' => 3600, // 1h + 'key' => 'post_viewed', + ], + 'cache' => [ + 'enable' => env('ENABLE_DATA_CACHE', true), + 'minutes' => 60, + ], + 'posts' => [ + 'per_page' => 5, + ], + 'analytics' => [ + 'google_trace_id' => env('GOOGLE_ANALYTICS_ID'), + ], + 'log' => [ + 'visitor' => env('ENABLE_VISITOR_LOG', false), + ], + 'comment' => [ + 'driver' => env('COMMENT_DRIVER', 'null'), // Supported: "null", "disqus" + 'disqus' => [ + 'short_name' => env('DISQUS_SHORT_NAME'), + ] + ] +]; diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 391cf97..ca998da 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -65,6 +65,12 @@ ]; }); +$factory->define(\App\Models\Content::class, function (\Faker\Generator $faker) { + return [ + 'body' => markdownContent($faker) + ]; +}); + $factory->define(\App\Models\Post::class, function (\Faker\Generator $faker) { $title = $faker->unique()->sentence(mt_rand(3, 6)); @@ -76,7 +82,9 @@ 'slug' => str_slug($title), 'excerpt' => $faker->sentences(3, true), 'feature_img' => $faker->imageUrl(), - 'content' => markdownContent($faker), + 'content_id' => function () { + return factory(\App\Models\Content::class)->create()->id; + }, 'view_count' => mt_rand(0, 10000), 'is_draft' => $faker->boolean, 'published_at' => $faker->dateTimeThisYear('2018-12-31 23:59:59'), diff --git a/database/migrations/2017_05_29_154816_create_posts_table.php b/database/migrations/2017_05_29_154816_create_posts_table.php index ae1d6ce..bf0455a 100644 --- a/database/migrations/2017_05_29_154816_create_posts_table.php +++ b/database/migrations/2017_05_29_154816_create_posts_table.php @@ -26,7 +26,7 @@ public function up() $table->string('feature_img')->nullable(); $table->longText('content'); $table->integer('view_count')->unsigned()->default(0); - $table->timestamp('published_at'); + $table->timestamp('published_at')->nullable()->index(); $table->boolean('is_draft')->default(false); $table->timestamps(); $table->softDeletes(); diff --git a/database/migrations/2017_06_18_194514_create_visitors_table.php b/database/migrations/2017_06_18_194514_create_visitors_table.php new file mode 100644 index 0000000..d4b30ec --- /dev/null +++ b/database/migrations/2017_06_18_194514_create_visitors_table.php @@ -0,0 +1,38 @@ +increments('id'); + $table->string('ip'); + $table->string('uri'); + $table->boolean('is_robot')->nullable(); + $table->string('platform')->nullable(); + $table->string('device')->nullable(); + $table->string('browser')->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('visitors'); + } +} diff --git a/database/migrations/2017_06_23_222953_create_contents_table.php b/database/migrations/2017_06_23_222953_create_contents_table.php new file mode 100644 index 0000000..975319a --- /dev/null +++ b/database/migrations/2017_06_23_222953_create_contents_table.php @@ -0,0 +1,32 @@ +increments('id'); + $table->longText('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('contents'); + } +} diff --git a/database/migrations/2017_06_23_224806_add_content_id_to_posts_table.php b/database/migrations/2017_06_23_224806_add_content_id_to_posts_table.php new file mode 100644 index 0000000..ddf2d67 --- /dev/null +++ b/database/migrations/2017_06_23_224806_add_content_id_to_posts_table.php @@ -0,0 +1,36 @@ +dropColumn('content'); + $table->integer('content_id')->unsigned()->index()->after('feature_img'); + $table->foreign('content_id')->references('id')->on('contents'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('posts', function (Blueprint $table) { + $table->dropForeign('posts_content_id_foreign'); + $table->dropColumn('content_id'); + $table->longText('content'); + }); + } +} diff --git a/database/migrations/2017_07_13_112605_create_settings_table.php b/database/migrations/2017_07_13_112605_create_settings_table.php new file mode 100644 index 0000000..cb5d1bc --- /dev/null +++ b/database/migrations/2017_07_13_112605_create_settings_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('tag')->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('settings'); + } +} diff --git a/public/images/office.jpg b/public/images/office.jpg new file mode 100644 index 0000000..67e78fc Binary files /dev/null and b/public/images/office.jpg differ diff --git a/resources/assets/admin/js/admin.js b/resources/assets/admin/js/admin.js index c1620c1..f3bb228 100644 --- a/resources/assets/admin/js/admin.js +++ b/resources/assets/admin/js/admin.js @@ -9,6 +9,8 @@ require('./bootstrap'); window.Vue = require('vue'); +require('./main'); + /** * Next, we will create a fresh Vue application instance and attach it to * the page. Then, you may begin adding components to this application diff --git a/resources/assets/admin/js/main.js b/resources/assets/admin/js/main.js new file mode 100644 index 0000000..457f6c9 --- /dev/null +++ b/resources/assets/admin/js/main.js @@ -0,0 +1,28 @@ +jQuery(document).ready(function () { + $.ajaxSetup({ + headers: { + 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') + } + }); + + $(".to-be-slug").blur(function () { + + // TODO Leave if update + if ($("input[name='_method']").val() == 'PATCH' ) { + return; + } + + var value = $(this).val(); + if (value) { + $.post("/dashboard/auto-slug", {text: value}) + .done(function (data) { + $("#slug").val(data); + }) + .fail(function (jqXHR, textStatus) { + alert('Auto slug error.'); + // console.log(jqXHR); + // console.log(textStatus); + }) + } + }); +}); \ No newline at end of file diff --git a/resources/assets/sass/app.scss b/resources/assets/sass/app.scss index 8632223..04dc2eb 100644 --- a/resources/assets/sass/app.scss +++ b/resources/assets/sass/app.scss @@ -11,6 +11,7 @@ @import "modules/article.css"; @import "modules/search.css"; @import "modules/tag.css"; +@import "modules/sidenav.css"; body { display: flex; diff --git a/resources/assets/sass/modules/sidenav.css b/resources/assets/sass/modules/sidenav.css new file mode 100644 index 0000000..b7a4360 --- /dev/null +++ b/resources/assets/sass/modules/sidenav.css @@ -0,0 +1,63 @@ +.side-nav .user-view { + position: relative; + padding: 32px 32px 0; + margin-bottom: 8px +} + +.side-nav .user-view>a { + height: auto; + padding: 0 +} + +.side-nav .user-view>a:hover { + background-color: transparent +} + +.side-nav .user-view .background { + overflow: hidden; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: -1 +} + +.side-nav .user-view .circle,.side-nav .user-view .name,.side-nav .user-view .email { + display: block +} + +.side-nav .user-view .circle { + height: 64px; + width: 64px +} + +.side-nav .user-view .name,.side-nav .user-view .email { + font-size: 14px; + line-height: 24px +} + +.side-nav .user-view .name { + margin-top: 16px; + font-weight: 500 +} + +.side-nav .user-view .email { + padding-bottom: 16px; + font-weight: 400 +} + +@media only screen and (max-width: 992px) { + .side-nav .user-view { + padding: 16px 16px 0; + } +} + +.side-nav .collapsible .collapsible-header { + padding-left: 30px; + font-weight: 500; +} + +.side-nav .collapsible .collapsible-header .material-icons { + margin-right: 28px; +} \ No newline at end of file diff --git a/resources/views/admin/categories/_form.blade.php b/resources/views/admin/categories/_form.blade.php index 5da6aba..5da42c0 100644 --- a/resources/views/admin/categories/_form.blade.php +++ b/resources/views/admin/categories/_form.blade.php @@ -3,7 +3,7 @@
- +
@@ -13,7 +13,7 @@
- +
diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 3eee2ee..96c7cc5 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -48,6 +48,7 @@
  • Categories
  • Tags
  • Posts
  • +
  • Settings
  • diff --git a/resources/views/admin/posts/_form.blade.php b/resources/views/admin/posts/_form.blade.php index f6d38b5..5f62abb 100644 --- a/resources/views/admin/posts/_form.blade.php +++ b/resources/views/admin/posts/_form.blade.php @@ -2,32 +2,32 @@
    - + -
    - +
    +
    - + -
    +
    - + -
    - +
    + id))disabled="disabled"@endif>
    - -
    + +
    @foreach($tags as $tag) @@ -48,39 +48,42 @@
    - -
    - + +
    + +
    +
    +
    - -
    + +
    - + -
    - +
    +
    - + -
    +
    -
    Draft
    +
    Draft
    -
    +
    is_draft))checked="checked"@endif>
    diff --git a/resources/views/admin/posts/create.blade.php b/resources/views/admin/posts/create.blade.php index 26f5e9f..ab59c90 100644 --- a/resources/views/admin/posts/create.blade.php +++ b/resources/views/admin/posts/create.blade.php @@ -11,11 +11,11 @@

    Post Info

    -
    -
    - @include('admin.posts._form') -
    - + +
    + @include('admin.posts._form') +
    + @stack('box-footer')
    diff --git a/resources/views/admin/posts/edit.blade.php b/resources/views/admin/posts/edit.blade.php index d79e499..a5f4bcc 100644 --- a/resources/views/admin/posts/edit.blade.php +++ b/resources/views/admin/posts/edit.blade.php @@ -9,7 +9,7 @@

    Post Info

    -
    +
    {{ method_field('PATCH') }} @include('admin.posts._form') diff --git a/resources/views/admin/settings/_form.blade.php b/resources/views/admin/settings/_form.blade.php new file mode 100644 index 0000000..9b5afce --- /dev/null +++ b/resources/views/admin/settings/_form.blade.php @@ -0,0 +1,23 @@ +{{ csrf_field() }} + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    diff --git a/resources/views/admin/settings/_list.blade.php b/resources/views/admin/settings/_list.blade.php new file mode 100644 index 0000000..5f3d288 --- /dev/null +++ b/resources/views/admin/settings/_list.blade.php @@ -0,0 +1,33 @@ + + + + + + + + + + + + @foreach($settings as $setting) + + + + + + + + @endforeach + +
    IDKeyValueTagAction
    {{ $setting->id }}{{ $setting->key }}{{ $setting->value }}{{ $setting->tag }} + Edit + + {{ csrf_field() }} + {{ method_field('DELETE') }} + + +
    + +
    + {{ $settings->links() }} +
    \ No newline at end of file diff --git a/resources/views/admin/settings/create.blade.php b/resources/views/admin/settings/create.blade.php new file mode 100644 index 0000000..58027f1 --- /dev/null +++ b/resources/views/admin/settings/create.blade.php @@ -0,0 +1,28 @@ +@extends('admin.layouts.app') + +@inject('setting', 'App\Models\Setting') + +@section('content') +
    +
    + + +
    +
    +

    Setting Info

    +
    + +
    + +
    + + @include('admin.settings._form') + +
    +
    + +
    + +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/admin/settings/edit.blade.php b/resources/views/admin/settings/edit.blade.php new file mode 100644 index 0000000..fe3962a --- /dev/null +++ b/resources/views/admin/settings/edit.blade.php @@ -0,0 +1,27 @@ +@extends('admin.layouts.app') + +@section('content') +
    +
    + + +
    +
    +

    Setting Info

    +
    + +
    + +
    + {{ method_field('PATCH') }} + + @include('admin.settings._form') + +
    +
    + +
    + +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php new file mode 100644 index 0000000..3904888 --- /dev/null +++ b/resources/views/admin/settings/index.blade.php @@ -0,0 +1,17 @@ +@extends('admin.layouts.app') + +@section('content') + Add New + +
    +
    + +
    +
    + @include('admin.settings._list') +
    +
    +
    +
    + +@endsection \ No newline at end of file diff --git a/resources/views/admin/tags/_form.blade.php b/resources/views/admin/tags/_form.blade.php index 0bc04a3..e4baec7 100644 --- a/resources/views/admin/tags/_form.blade.php +++ b/resources/views/admin/tags/_form.blade.php @@ -3,7 +3,7 @@
    - +
    @@ -13,7 +13,7 @@
    - +
    diff --git a/resources/views/categories/show.blade.php b/resources/views/categories/show.blade.php new file mode 100644 index 0000000..ca55545 --- /dev/null +++ b/resources/views/categories/show.blade.php @@ -0,0 +1,33 @@ +@extends('layouts.app') + +@section('title') + {{ $category->name }} | @parent +@endsection + +@section('keywords'){{ $category->name }}@endsection +@section('description'){{ $category->description }}@endsection + +@section('content') + @component('components.header') +
    +
    +

    {{ $category->name }}

    +

    {{ $category->description }}

    +
    +
    + @endcomponent + +
    +
    +
    + @include('posts._list') +
    +
    + @include('partials.sidebar') +
    +
    + {{ $posts->links('pagination::materialize') }} +
    +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 5694693..f926606 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -9,9 +9,14 @@ + + @include('partials.favicon') - {{ config('app.name', 'Laravel') }} + @section('title'){{ config('app.name', 'Laravel') }}@show + + + {{----}} {{----}} @@ -25,6 +30,21 @@ {{--@yield('css')--}} @stack('css') + + @stack('js') + + @include('partials.google_analytics') diff --git a/resources/views/partials/comment.blade.php b/resources/views/partials/comment.blade.php new file mode 100644 index 0000000..4627156 --- /dev/null +++ b/resources/views/partials/comment.blade.php @@ -0,0 +1,9 @@ +@if($commentDriver == 'disqus' && App::environment('production')) + @include('widgets.disqus', [ + 'disqus_short_name' => $disqus['short_name'], + 'page_url' => $disqus['page_url'], + 'page_identifier' => $disqus['page_identifier'], + ]) +@else + +@endif \ No newline at end of file diff --git a/resources/views/partials/google_analytics.blade.php b/resources/views/partials/google_analytics.blade.php new file mode 100644 index 0000000..b695df6 --- /dev/null +++ b/resources/views/partials/google_analytics.blade.php @@ -0,0 +1,12 @@ +@if(config('blog.analytics.google_trace_id')) + +@endif \ No newline at end of file diff --git a/resources/views/partials/navbar.blade.php b/resources/views/partials/navbar.blade.php index d2f4e76..d04074c 100644 --- a/resources/views/partials/navbar.blade.php +++ b/resources/views/partials/navbar.blade.php @@ -1,8 +1,9 @@ -
      -
    • + + + + + - - - @push('js') @@ -48,6 +68,12 @@ $(function () { $(".button-collapse").sideNav(); + // TODO Can't set sideNav() at the same time?, hack + if ($(window).width() > 992) { + // Initialize collapse button + $(".btn-profile").sideNav(); + } + $(window).scroll(function () { var nav = $("#nav-bar"); var scroll = $(window).scrollTop(); diff --git a/resources/views/partials/post-meta.blade.php b/resources/views/partials/post-meta.blade.php index f2cb0e1..85ce063 100644 --- a/resources/views/partials/post-meta.blade.php +++ b/resources/views/partials/post-meta.blade.php @@ -6,7 +6,7 @@ person_pin{{ $post->author->name }}
    • - folder_open{{ $post->category->name }} + folder_open{{ $post->category->name }}
    • remove_red_eye{{ $post->view_count }} diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index ce2625a..851c07b 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -4,6 +4,12 @@
      +
      + @include('widgets.hot') +
      + +
      +
      @include('widgets.category')
      diff --git a/resources/views/posts/show.blade.php b/resources/views/posts/show.blade.php index 67d44c1..6d53dab 100644 --- a/resources/views/posts/show.blade.php +++ b/resources/views/posts/show.blade.php @@ -1,12 +1,19 @@ @extends('layouts.app') +@section('title') +{{ $post->title }} | @parent +@endsection + +@section('keywords'){{ $post->tags->implode('name', ',') }}@endsection +@section('description'){{ $post->description }}@endsection + @section('content') @component('components.header')

      {{ $post->title }}

      - @@ -26,7 +33,7 @@ {{-- TODO when use api, content should be parse first, use transformer or parse markdown before store--}}
      - {!! $post->present()->htmlContent !!} + {!! $post->content !!}
      + +
      +
      +
      +

      版权声明:{{ $post->author->name }} 创作,使用 + License: CC BY-SA 4.0 + 创作共享协议,相关说明 +

      +

      本文链接:{{ route('articles.show', $post->slug) }}

      +
      +
      +
      + +
      +
      +
      + @if($previous) + keyboard_arrow_left{{ $previous->title }} + @endif +
      +
      + @if($next) + keyboard_arrow_right{{ $next->title }} + @endif +
      +
      +
      + +
      + @include('partials.comment') +
      +
    -@endsection \ No newline at end of file +@endsection + +@push('css') + +@endpush \ No newline at end of file diff --git a/resources/views/tags/show.blade.php b/resources/views/tags/show.blade.php new file mode 100644 index 0000000..3b1fa93 --- /dev/null +++ b/resources/views/tags/show.blade.php @@ -0,0 +1,33 @@ +@extends('layouts.app') + +@section('title') + {{ $tag->name }} | @parent +@endsection + +@section('keywords'){{ $tag->name }}@endsection +@section('description'){{ $tag->description }}@endsection + +@section('content') + @component('components.header') +
    +
    +

    {{ $tag->name }}

    +

    {{ $tag->description }}

    +
    +
    + @endcomponent + +
    +
    +
    + @include('posts._list') +
    +
    + @include('partials.sidebar') +
    +
    + {{ $posts->links('pagination::materialize') }} +
    +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/widgets/category.blade.php b/resources/views/widgets/category.blade.php index b5f1279..d575992 100644 --- a/resources/views/widgets/category.blade.php +++ b/resources/views/widgets/category.blade.php @@ -1,7 +1,7 @@
    -
    Categories
    +
    Categories
    @foreach($categories as $category) - + {{ $category->posts_count }} {{ $category->name }} diff --git a/resources/views/widgets/disqus.blade.php b/resources/views/widgets/disqus.blade.php new file mode 100644 index 0000000..1ecb61c --- /dev/null +++ b/resources/views/widgets/disqus.blade.php @@ -0,0 +1,23 @@ +
    + +@push('js') + + +@endpush('js') \ No newline at end of file diff --git a/resources/views/widgets/hot.blade.php b/resources/views/widgets/hot.blade.php new file mode 100644 index 0000000..c9aec16 --- /dev/null +++ b/resources/views/widgets/hot.blade.php @@ -0,0 +1,9 @@ +
    +
    Hot
    + @foreach($hotPosts as $hotPost) + + {{ $hotPost->view_count }} + {{ $hotPost->title }} + + @endforeach +
    \ No newline at end of file diff --git a/resources/views/widgets/post-card.blade.php b/resources/views/widgets/post-card.blade.php index d0d72da..4cc2d0a 100644 --- a/resources/views/widgets/post-card.blade.php +++ b/resources/views/widgets/post-card.blade.php @@ -2,13 +2,15 @@
    - {{ $post->title }} + + {{ $post->title }} +
    \ No newline at end of file diff --git a/resources/views/widgets/tag.blade.php b/resources/views/widgets/tag.blade.php index 06fdd9e..c132ad1 100644 --- a/resources/views/widgets/tag.blade.php +++ b/resources/views/widgets/tag.blade.php @@ -1,8 +1,8 @@
    -
    Tags
    -
    +
    Tags
    +
    @foreach($tags as $tag) - + {{ $tag->name }} {{ $tag->posts_count }} diff --git a/routes/web.php b/routes/web.php index 8e902ed..8440507 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,9 +26,16 @@ Route::post('posts/{id}/restore', 'PostController@restore')->name('posts.restore'); Route::post('posts/{id}/force-delete', 'PostController@forceDelete')->name('posts.force-delete'); Route::resource('posts', 'PostController'); + + Route::post('auto-slug', 'DashboardController@autoSlug')->name('auto-slug'); + + Route::resource('settings', 'SettingController', ['except' => ['show']]); }); Route::group(['namespace' => 'Frontend'], function () { Route::get('/', 'PostController@index')->name('home'); - Route::resource('articles', 'PostController', ['only' => ['show']]); + + Route::resource('articles', 'PostController', ['only' => ['show'], 'middleware' => 'visitor']); + Route::resource('categories', 'CategoryController', ['only' => ['show']]); + Route::resource('tags', 'TagController', ['only' => ['show']]); });