From bf8e7f3393d48e6300c4d8775daeb40d55ea2017 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 16 May 2021 00:29:56 +0100
Subject: [PATCH] Started addition of favourite system

---
 app/Actions/Favourite.php                     | 17 +++++
 app/Actions/View.php                          |  4 +-
 app/Entities/Models/Entity.php                | 22 +++++-
 app/Http/Controllers/FavouriteController.php  | 76 +++++++++++++++++++
 app/Interfaces/Favouritable.php               | 11 +++
 ...1_05_15_173110_create_favourites_table.php | 36 +++++++++
 resources/icons/star-circle.svg               |  1 -
 resources/icons/star-outline.svg              |  1 +
 resources/lang/en/activities.php              |  4 +
 resources/lang/en/common.php                  |  2 +
 resources/views/pages/show.blade.php          |  3 +
 .../entity-favourite-action.blade.php         | 12 +++
 routes/web.php                                |  5 ++
 13 files changed, 190 insertions(+), 4 deletions(-)
 create mode 100644 app/Actions/Favourite.php
 create mode 100644 app/Http/Controllers/FavouriteController.php
 create mode 100644 app/Interfaces/Favouritable.php
 create mode 100644 database/migrations/2021_05_15_173110_create_favourites_table.php
 create mode 100644 resources/icons/star-outline.svg
 create mode 100644 resources/views/partials/entity-favourite-action.blade.php

diff --git a/app/Actions/Favourite.php b/app/Actions/Favourite.php
new file mode 100644
index 000000000..107a76578
--- /dev/null
+++ b/app/Actions/Favourite.php
@@ -0,0 +1,17 @@
+<?php namespace BookStack\Actions;
+
+use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+class Favourite extends Model
+{
+    protected $fillable = ['user_id'];
+
+    /**
+     * Get the related model that can be favourited.
+     */
+    public function favouritable(): MorphTo
+    {
+        return $this->morphTo();
+    }
+}
diff --git a/app/Actions/View.php b/app/Actions/View.php
index e9841293b..c5ec6a38d 100644
--- a/app/Actions/View.php
+++ b/app/Actions/View.php
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Actions;
 
 use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 class View extends Model
 {
@@ -9,9 +10,8 @@ class View extends Model
 
     /**
      * Get all owning viewable models.
-     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
      */
-    public function viewable()
+    public function viewable(): MorphTo
     {
         return $this->morphTo();
     }
diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php
index d4b477304..be63cec2b 100644
--- a/app/Entities/Models/Entity.php
+++ b/app/Entities/Models/Entity.php
@@ -2,6 +2,7 @@
 
 use BookStack\Actions\Activity;
 use BookStack\Actions\Comment;
+use BookStack\Actions\Favourite;
 use BookStack\Actions\Tag;
 use BookStack\Actions\View;
 use BookStack\Auth\Permissions\EntityPermission;
@@ -9,6 +10,7 @@ use BookStack\Auth\Permissions\JointPermission;
 use BookStack\Entities\Tools\SearchIndex;
 use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Facades\Permissions;
+use BookStack\Interfaces\Favouritable;
 use BookStack\Interfaces\Sluggable;
 use BookStack\Model;
 use BookStack\Traits\HasCreatorAndUpdater;
@@ -38,7 +40,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
  * @method static Builder withLastView()
  * @method static Builder withViewCount()
  */
-abstract class Entity extends Model implements Sluggable
+abstract class Entity extends Model implements Sluggable, Favouritable
 {
     use SoftDeletes;
     use HasCreatorAndUpdater;
@@ -297,4 +299,22 @@ abstract class Entity extends Model implements Sluggable
         $this->slug = app(SlugGenerator::class)->generate($this);
         return $this->slug;
     }
+
+    /**
+     * @inheritdoc
+     */
+    public function favourites(): MorphMany
+    {
+        return $this->morphMany(Favourite::class, 'favouritable');
+    }
+
+    /**
+     * Check if the entity is a favourite of the current user.
+     */
+    public function isFavourite(): bool
+    {
+        return $this->favourites()
+            ->where('user_id', '=', user()->id)
+            ->exists();
+    }
 }
diff --git a/app/Http/Controllers/FavouriteController.php b/app/Http/Controllers/FavouriteController.php
new file mode 100644
index 000000000..8a26eac8e
--- /dev/null
+++ b/app/Http/Controllers/FavouriteController.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Models\Entity;
+use BookStack\Interfaces\Favouritable;
+use BookStack\Model;
+use Illuminate\Http\Request;
+
+class FavouriteController extends Controller
+{
+    /**
+     * Add a new item as a favourite.
+     */
+    public function add(Request $request)
+    {
+        $favouritable = $this->getValidatedModelFromRequest($request);
+        $favouritable->favourites()->firstOrCreate([
+            'user_id' => user()->id,
+        ]);
+
+        $this->showSuccessNotification(trans('activities.favourite_add_notification', [
+            'name' => $favouritable->name,
+        ]));
+        return redirect()->back();
+    }
+
+    /**
+     * Remove an item as a favourite.
+     */
+    public function remove(Request $request)
+    {
+        $favouritable = $this->getValidatedModelFromRequest($request);
+        $favouritable->favourites()->where([
+            'user_id' => user()->id,
+        ])->delete();
+
+        $this->showSuccessNotification(trans('activities.favourite_remove_notification', [
+            'name' => $favouritable->name,
+        ]));
+        return redirect()->back();
+    }
+
+    /**
+     * @throws \Illuminate\Validation\ValidationException
+     * @throws \Exception
+     */
+    protected function getValidatedModelFromRequest(Request $request): Favouritable
+    {
+        $modelInfo = $this->validate($request, [
+            'type' => 'required|string',
+            'id' => 'required|integer',
+        ]);
+
+        if (!class_exists($modelInfo['type'])) {
+            throw new \Exception('Model not found');
+        }
+
+        /** @var Model $model */
+        $model = new $modelInfo['type'];
+        if (! $model instanceof Favouritable) {
+            throw new \Exception('Model not favouritable');
+        }
+
+        $modelInstance = $model->newQuery()
+            ->where('id', '=', $modelInfo['id'])
+            ->first(['id', 'name']);
+
+        $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
+        if (is_null($modelInstance) || $inaccessibleEntity) {
+            throw new \Exception('Model instance not found');
+        }
+
+        return $modelInstance;
+    }
+}
diff --git a/app/Interfaces/Favouritable.php b/app/Interfaces/Favouritable.php
new file mode 100644
index 000000000..dd335feed
--- /dev/null
+++ b/app/Interfaces/Favouritable.php
@@ -0,0 +1,11 @@
+<?php namespace BookStack\Interfaces;
+
+use Illuminate\Database\Eloquent\Relations\MorphMany;
+
+interface Favouritable
+{
+    /**
+     * Get the related favourite instances.
+     */
+    public function favourites(): MorphMany;
+}
\ No newline at end of file
diff --git a/database/migrations/2021_05_15_173110_create_favourites_table.php b/database/migrations/2021_05_15_173110_create_favourites_table.php
new file mode 100644
index 000000000..783bf5825
--- /dev/null
+++ b/database/migrations/2021_05_15_173110_create_favourites_table.php
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateFavouritesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('favourites', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('user_id')->index();
+            $table->integer('favouritable_id');
+            $table->string('favouritable_type', 100);
+            $table->timestamps();
+
+            $table->index(['favouritable_id', 'favouritable_type'], 'favouritable_index');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('favourites');
+    }
+}
diff --git a/resources/icons/star-circle.svg b/resources/icons/star-circle.svg
index 0e3f16879..a7667e48f 100644
--- a/resources/icons/star-circle.svg
+++ b/resources/icons/star-circle.svg
@@ -1,4 +1,3 @@
 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-    <path d="M0 0h24v24H0z" fill="none"/>
     <path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/>
 </svg>
\ No newline at end of file
diff --git a/resources/icons/star-outline.svg b/resources/icons/star-outline.svg
new file mode 100644
index 000000000..4e83ab42b
--- /dev/null
+++ b/resources/icons/star-outline.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"/></svg>
\ No newline at end of file
diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php
index fe937b061..5917de2cf 100644
--- a/resources/lang/en/activities.php
+++ b/resources/lang/en/activities.php
@@ -43,6 +43,10 @@ return [
     'bookshelf_delete'                 => 'deleted bookshelf',
     'bookshelf_delete_notification'    => 'Bookshelf Successfully Deleted',
 
+    // Favourites
+    'favourite_add_notification' => '":name" has been added to your favourites',
+    'favourite_remove_notification' => '":name" has been removed from your favourites',
+
     // Other
     'commented_on'                => 'commented on',
     'permissions_update'          => 'updated permissions',
diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php
index 855c1c807..e198878ad 100644
--- a/resources/lang/en/common.php
+++ b/resources/lang/en/common.php
@@ -40,6 +40,8 @@ return [
     'remove' => 'Remove',
     'add' => 'Add',
     'fullscreen' => 'Fullscreen',
+    'favourite' => 'Favourite',
+    'unfavourite' => 'Unfavourite',
 
     // Sort Options
     'sort_options' => 'Sort Options',
diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php
index 13125464a..73a107cf7 100644
--- a/resources/views/pages/show.blade.php
+++ b/resources/views/pages/show.blade.php
@@ -151,6 +151,9 @@
             <hr class="primary-background"/>
 
             {{--Export--}}
+            @if(signedInUser())
+                @include('partials.entity-favourite-action', ['entity' => $page, 'alreadyFavourite' => $page->isFavourite()])
+            @endif
             @include('partials.entity-export-menu', ['entity' => $page])
         </div>
 
diff --git a/resources/views/partials/entity-favourite-action.blade.php b/resources/views/partials/entity-favourite-action.blade.php
new file mode 100644
index 000000000..49ba6aa5d
--- /dev/null
+++ b/resources/views/partials/entity-favourite-action.blade.php
@@ -0,0 +1,12 @@
+@php
+ $isFavourite = $entity->isFavourite();
+@endphp
+<form action="{{ url('/favourites/' . ($isFavourite ? 'remove' : 'add')) }}" method="POST">
+    {{ csrf_field() }}
+    <input type="hidden" name="type" value="{{ get_class($entity) }}">
+    <input type="hidden" name="id" value="{{ $entity->id }}">
+    <button type="submit" class="icon-list-item text-primary">
+        <span>@icon($isFavourite ? 'star' : 'star-outline')</span>
+        <span>{{ $isFavourite ? trans('common.unfavourite') : trans('common.favourite') }}</span>
+    </button>
+</form>
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index 730c795f8..69823b4ba 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -152,9 +152,14 @@ Route::group(['middleware' => 'auth'], function () {
     // User Search
     Route::get('/search/users/select', 'UserSearchController@forSelect');
 
+    // Template System
     Route::get('/templates', 'PageTemplateController@list');
     Route::get('/templates/{templateId}', 'PageTemplateController@get');
 
+    // Favourites
+    Route::post('/favourites/add', 'FavouriteController@add');
+    Route::post('/favourites/remove', 'FavouriteController@remove');
+
     // Other Pages
     Route::get('/', 'HomeController@index');
     Route::get('/home', 'HomeController@index');