Merge pull request #2748 from BookStackApp/favourite_system
Favourite System
This commit is contained in:
		
						commit
						dd6076049c
					
				| 
						 | 
				
			
			@ -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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,19 @@
 | 
			
		|||
<?php namespace BookStack\Actions;
 | 
			
		||||
 | 
			
		||||
use BookStack\Interfaces\Viewable;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Class View
 | 
			
		||||
 * Views are stored per-item per-person within the database.
 | 
			
		||||
 * They can be used to find popular items or recently viewed items
 | 
			
		||||
 * at a per-person level. They do not record every view instance as an
 | 
			
		||||
 * activity. Only the latest and original view times could be recognised.
 | 
			
		||||
 *
 | 
			
		||||
 * @property int $views
 | 
			
		||||
 * @property int $user_id
 | 
			
		||||
 */
 | 
			
		||||
class View extends Model
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -9,10 +21,37 @@ 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();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Increment the current user's view count for the given viewable model.
 | 
			
		||||
     */
 | 
			
		||||
    public static function incrementFor(Viewable $viewable): int
 | 
			
		||||
    {
 | 
			
		||||
        $user = user();
 | 
			
		||||
        if (is_null($user) || $user->isDefault()) {
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /** @var View $view */
 | 
			
		||||
        $view = $viewable->views()->firstOrNew([
 | 
			
		||||
            'user_id' => $user->id,
 | 
			
		||||
        ], ['views' => 0]);
 | 
			
		||||
 | 
			
		||||
        $view->forceFill(['views' => $view->views + 1])->save();
 | 
			
		||||
 | 
			
		||||
        return $view->views;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Clear all views from the system.
 | 
			
		||||
     */
 | 
			
		||||
    public static function clearAll()
 | 
			
		||||
    {
 | 
			
		||||
        static::query()->truncate();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,117 +0,0 @@
 | 
			
		|||
<?php namespace BookStack\Actions;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\EntityProvider;
 | 
			
		||||
use DB;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class ViewService
 | 
			
		||||
{
 | 
			
		||||
    protected $view;
 | 
			
		||||
    protected $permissionService;
 | 
			
		||||
    protected $entityProvider;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * ViewService constructor.
 | 
			
		||||
     * @param View $view
 | 
			
		||||
     * @param PermissionService $permissionService
 | 
			
		||||
     * @param EntityProvider $entityProvider
 | 
			
		||||
     */
 | 
			
		||||
    public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
 | 
			
		||||
    {
 | 
			
		||||
        $this->view = $view;
 | 
			
		||||
        $this->permissionService = $permissionService;
 | 
			
		||||
        $this->entityProvider = $entityProvider;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Add a view to the given entity.
 | 
			
		||||
     * @param \BookStack\Entities\Models\Entity $entity
 | 
			
		||||
     * @return int
 | 
			
		||||
     */
 | 
			
		||||
    public function add(Entity $entity)
 | 
			
		||||
    {
 | 
			
		||||
        $user = user();
 | 
			
		||||
        if ($user === null || $user->isDefault()) {
 | 
			
		||||
            return 0;
 | 
			
		||||
        }
 | 
			
		||||
        $view = $entity->views()->where('user_id', '=', $user->id)->first();
 | 
			
		||||
        // Add view if model exists
 | 
			
		||||
        if ($view) {
 | 
			
		||||
            $view->increment('views');
 | 
			
		||||
            return $view->views;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Otherwise create new view count
 | 
			
		||||
        $entity->views()->save($this->view->newInstance([
 | 
			
		||||
            'user_id' => $user->id,
 | 
			
		||||
            'views' => 1
 | 
			
		||||
        ]));
 | 
			
		||||
 | 
			
		||||
        return 1;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the entities with the most views.
 | 
			
		||||
     * @param int $count
 | 
			
		||||
     * @param int $page
 | 
			
		||||
     * @param string|array $filterModels
 | 
			
		||||
     * @param string $action - used for permission checking
 | 
			
		||||
     * @return Collection
 | 
			
		||||
     */
 | 
			
		||||
    public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
 | 
			
		||||
    {
 | 
			
		||||
        $skipCount = $count * $page;
 | 
			
		||||
        $query = $this->permissionService
 | 
			
		||||
            ->filterRestrictedEntityRelations($this->view->newQuery(), 'views', 'viewable_id', 'viewable_type', $action)
 | 
			
		||||
            ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
 | 
			
		||||
            ->groupBy('viewable_id', 'viewable_type')
 | 
			
		||||
            ->orderBy('view_count', 'desc');
 | 
			
		||||
 | 
			
		||||
        if ($filterModels) {
 | 
			
		||||
            $query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->with('viewable')
 | 
			
		||||
            ->skip($skipCount)
 | 
			
		||||
            ->take($count)
 | 
			
		||||
            ->get()
 | 
			
		||||
            ->pluck('viewable')
 | 
			
		||||
            ->filter();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all recently viewed entities for the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function getUserRecentlyViewed(int $count = 10, int $page = 1)
 | 
			
		||||
    {
 | 
			
		||||
        $user = user();
 | 
			
		||||
        if ($user === null || $user->isDefault()) {
 | 
			
		||||
            return collect();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $all = collect();
 | 
			
		||||
        /** @var Entity $instance */
 | 
			
		||||
        foreach ($this->entityProvider->all() as $name => $instance) {
 | 
			
		||||
            $items = $instance::visible()->withLastView()
 | 
			
		||||
                ->having('last_viewed_at', '>', 0)
 | 
			
		||||
                ->orderBy('last_viewed_at', 'desc')
 | 
			
		||||
                ->skip($count * ($page - 1))
 | 
			
		||||
                ->take($count)
 | 
			
		||||
                ->get();
 | 
			
		||||
            $all = $all->concat($items);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $all->sortByDesc('last_viewed_at')->slice(0, $count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Reset all view counts by deleting all views.
 | 
			
		||||
     */
 | 
			
		||||
    public function resetAll()
 | 
			
		||||
    {
 | 
			
		||||
        $this->view->truncate();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -580,14 +580,15 @@ class PermissionService
 | 
			
		|||
 | 
			
		||||
    /**
 | 
			
		||||
     * Filter items that have entities set as a polymorphic relation.
 | 
			
		||||
     * @param Builder|\Illuminate\Database\Query\Builder $query
 | 
			
		||||
     */
 | 
			
		||||
    public function filterRestrictedEntityRelations(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view'): Builder
 | 
			
		||||
    public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
 | 
			
		||||
    {
 | 
			
		||||
        $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
 | 
			
		||||
 | 
			
		||||
        $q = $query->where(function ($query) use ($tableDetails, $action) {
 | 
			
		||||
            $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
 | 
			
		||||
                $permissionQuery->select('id')->from('joint_permissions')
 | 
			
		||||
                $permissionQuery->select(['role_id'])->from('joint_permissions')
 | 
			
		||||
                    ->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
 | 
			
		||||
                    ->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
 | 
			
		||||
                    ->where('action', '=', $action)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Auth;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\Favourite;
 | 
			
		||||
use BookStack\Api\ApiToken;
 | 
			
		||||
use BookStack\Entities\Tools\SlugGenerator;
 | 
			
		||||
use BookStack\Interfaces\Loggable;
 | 
			
		||||
| 
						 | 
				
			
			@ -240,6 +241,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
 | 
			
		|||
        return $this->hasMany(ApiToken::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the favourite instances for this user.
 | 
			
		||||
     */
 | 
			
		||||
    public function favourites(): HasMany
 | 
			
		||||
    {
 | 
			
		||||
        return $this->hasMany(Favourite::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the last activity time for this user.
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -184,6 +184,7 @@ class UserRepo
 | 
			
		|||
    {
 | 
			
		||||
        $user->socialAccounts()->delete();
 | 
			
		||||
        $user->apiTokens()->delete();
 | 
			
		||||
        $user->favourites()->delete();
 | 
			
		||||
        $user->delete();
 | 
			
		||||
        
 | 
			
		||||
        // Delete user profile images
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -184,11 +184,9 @@ return [
 | 
			
		|||
 | 
			
		||||
        // Custom BookStack
 | 
			
		||||
        'Activity' => BookStack\Facades\Activity::class,
 | 
			
		||||
        'Views'    => BookStack\Facades\Views::class,
 | 
			
		||||
        'Images'   => BookStack\Facades\Images::class,
 | 
			
		||||
        'Permissions' => BookStack\Facades\Permissions::class,
 | 
			
		||||
        'Theme'    => BookStack\Facades\Theme::class,
 | 
			
		||||
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    // Proxy configuration
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
 | 
			
		||||
namespace BookStack\Console\Commands;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use Illuminate\Console\Command;
 | 
			
		||||
 | 
			
		||||
class ClearViews extends Command
 | 
			
		||||
| 
						 | 
				
			
			@ -36,7 +37,7 @@ class ClearViews extends Command
 | 
			
		|||
     */
 | 
			
		||||
    public function handle()
 | 
			
		||||
    {
 | 
			
		||||
        \Views::resetAll();
 | 
			
		||||
        View::clearAll();
 | 
			
		||||
        $this->comment('Views cleared');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,7 +10,9 @@ 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\Interfaces\Viewable;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
use BookStack\Traits\HasCreatorAndUpdater;
 | 
			
		||||
use BookStack\Traits\HasOwner;
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +41,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, Viewable
 | 
			
		||||
{
 | 
			
		||||
    use SoftDeletes;
 | 
			
		||||
    use HasCreatorAndUpdater;
 | 
			
		||||
| 
						 | 
				
			
			@ -297,4 +300,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();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Queries;
 | 
			
		||||
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Entities\EntityProvider;
 | 
			
		||||
 | 
			
		||||
abstract class EntityQuery
 | 
			
		||||
{
 | 
			
		||||
    protected function permissionService(): PermissionService
 | 
			
		||||
    {
 | 
			
		||||
        return app()->make(PermissionService::class);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected function entityProvider(): EntityProvider
 | 
			
		||||
    {
 | 
			
		||||
        return app()->make(EntityProvider::class);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,29 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Queries;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use Illuminate\Support\Facades\DB;
 | 
			
		||||
 | 
			
		||||
class Popular extends EntityQuery
 | 
			
		||||
{
 | 
			
		||||
    public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
 | 
			
		||||
    {
 | 
			
		||||
        $query = $this->permissionService()
 | 
			
		||||
            ->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
 | 
			
		||||
            ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
 | 
			
		||||
            ->groupBy('viewable_id', 'viewable_type')
 | 
			
		||||
            ->orderBy('view_count', 'desc');
 | 
			
		||||
 | 
			
		||||
        if ($filterModels) {
 | 
			
		||||
            $query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $query->with('viewable')
 | 
			
		||||
            ->skip($count * ($page - 1))
 | 
			
		||||
            ->take($count)
 | 
			
		||||
            ->get()
 | 
			
		||||
            ->pluck('viewable')
 | 
			
		||||
            ->filter();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,32 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Queries;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use Illuminate\Support\Collection;
 | 
			
		||||
 | 
			
		||||
class RecentlyViewed extends EntityQuery
 | 
			
		||||
{
 | 
			
		||||
    public function run(int $count, int $page): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $user = user();
 | 
			
		||||
        if ($user === null || $user->isDefault()) {
 | 
			
		||||
            return collect();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $query = $this->permissionService()->filterRestrictedEntityRelations(
 | 
			
		||||
            View::query(),
 | 
			
		||||
            'views',
 | 
			
		||||
            'viewable_id',
 | 
			
		||||
            'viewable_type',
 | 
			
		||||
            'view'
 | 
			
		||||
        )
 | 
			
		||||
            ->orderBy('views.updated_at', 'desc')
 | 
			
		||||
            ->where('user_id', '=', user()->id);
 | 
			
		||||
 | 
			
		||||
        return $query->with('viewable')
 | 
			
		||||
            ->skip(($page - 1) * $count)
 | 
			
		||||
            ->take($count)
 | 
			
		||||
            ->get()
 | 
			
		||||
            ->pluck('viewable')
 | 
			
		||||
            ->filter();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
<?php namespace BookStack\Entities\Queries;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\Favourite;
 | 
			
		||||
use Illuminate\Database\Query\JoinClause;
 | 
			
		||||
 | 
			
		||||
class TopFavourites extends EntityQuery
 | 
			
		||||
{
 | 
			
		||||
    public function run(int $count, int $skip = 0)
 | 
			
		||||
    {
 | 
			
		||||
        $user = user();
 | 
			
		||||
        if (is_null($user) || $user->isDefault()) {
 | 
			
		||||
            return collect();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $query = $this->permissionService()
 | 
			
		||||
            ->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
 | 
			
		||||
            ->select('favourites.*')
 | 
			
		||||
            ->leftJoin('views', function (JoinClause $join) {
 | 
			
		||||
                $join->on('favourites.favouritable_id', '=', 'views.viewable_id');
 | 
			
		||||
                $join->on('favourites.favouritable_type', '=', 'views.viewable_type');
 | 
			
		||||
                $join->where('views.user_id', '=', user()->id);
 | 
			
		||||
            })
 | 
			
		||||
            ->orderBy('views.views', 'desc')
 | 
			
		||||
            ->where('favourites.user_id', '=', user()->id);
 | 
			
		||||
 | 
			
		||||
        return $query->with('favouritable')
 | 
			
		||||
            ->skip($skip)
 | 
			
		||||
            ->take($count)
 | 
			
		||||
            ->get()
 | 
			
		||||
            ->pluck('favouritable')
 | 
			
		||||
            ->filter();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -317,6 +317,7 @@ class TrashCan
 | 
			
		|||
        $entity->jointPermissions()->delete();
 | 
			
		||||
        $entity->searchTerms()->delete();
 | 
			
		||||
        $entity->deletions()->delete();
 | 
			
		||||
        $entity->favourites()->delete();
 | 
			
		||||
 | 
			
		||||
        if ($entity instanceof HasCoverImage && $entity->cover) {
 | 
			
		||||
            $imageService = app()->make(ImageService::class);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,16 +0,0 @@
 | 
			
		|||
<?php namespace BookStack\Facades;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Support\Facades\Facade;
 | 
			
		||||
 | 
			
		||||
class Views extends Facade
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Get the registered name of the component.
 | 
			
		||||
     *
 | 
			
		||||
     * @return string
 | 
			
		||||
     */
 | 
			
		||||
    protected static function getFacadeAccessor()
 | 
			
		||||
    {
 | 
			
		||||
        return 'views';
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
 | 
			
		||||
use Activity;
 | 
			
		||||
use BookStack\Actions\ActivityType;
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use BookStack\Entities\Tools\BookContents;
 | 
			
		||||
use BookStack\Entities\Models\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Tools\PermissionsUpdater;
 | 
			
		||||
| 
						 | 
				
			
			@ -112,7 +113,7 @@ class BookController extends Controller
 | 
			
		|||
        $bookChildren = (new BookContents($book))->getTree(true);
 | 
			
		||||
        $bookParentShelves = $book->shelves()->visible()->get();
 | 
			
		||||
 | 
			
		||||
        Views::add($book);
 | 
			
		||||
        View::incrementFor($book);
 | 
			
		||||
        if ($request->has('shelf')) {
 | 
			
		||||
            $this->entityContextManager->setShelfContext(intval($request->get('shelf')));
 | 
			
		||||
        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use Activity;
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Tools\PermissionsUpdater;
 | 
			
		||||
use BookStack\Entities\Tools\ShelfContext;
 | 
			
		||||
| 
						 | 
				
			
			@ -109,7 +110,7 @@ class BookshelfController extends Controller
 | 
			
		|||
            ->values()
 | 
			
		||||
            ->all();
 | 
			
		||||
 | 
			
		||||
        Views::add($shelf);
 | 
			
		||||
        View::incrementFor($shelf);
 | 
			
		||||
        $this->entityContextManager->setShelfContext($shelf->id);
 | 
			
		||||
        $view = setting()->getForCurrentUser('bookshelf_view_type');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Tools\BookContents;
 | 
			
		||||
use BookStack\Entities\Repos\ChapterRepo;
 | 
			
		||||
| 
						 | 
				
			
			@ -64,7 +65,7 @@ class ChapterController extends Controller
 | 
			
		|||
 | 
			
		||||
        $sidebarTree = (new BookContents($chapter->book))->getTree();
 | 
			
		||||
        $pages = $chapter->getVisiblePages();
 | 
			
		||||
        Views::add($chapter);
 | 
			
		||||
        View::incrementFor($chapter);
 | 
			
		||||
 | 
			
		||||
        $this->setPageTitle($chapter->getShortName());
 | 
			
		||||
        return view('chapters.show', [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Entities\Models\Entity;
 | 
			
		||||
use BookStack\Entities\Queries\TopFavourites;
 | 
			
		||||
use BookStack\Interfaces\Favouritable;
 | 
			
		||||
use BookStack\Model;
 | 
			
		||||
use Illuminate\Http\Request;
 | 
			
		||||
 | 
			
		||||
class FavouriteController extends Controller
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Show a listing of all favourite items for the current user.
 | 
			
		||||
     */
 | 
			
		||||
    public function index(Request $request)
 | 
			
		||||
    {
 | 
			
		||||
        $viewCount = 20;
 | 
			
		||||
        $page = intval($request->get('page', 1));
 | 
			
		||||
        $favourites = (new TopFavourites)->run($viewCount + 1, (($page - 1) * $viewCount));
 | 
			
		||||
 | 
			
		||||
        $hasMoreLink = ($favourites->count() > $viewCount) ? url("/favourites?page=" . ($page+1)) : null;
 | 
			
		||||
 | 
			
		||||
        return view('common.detailed-listing-with-more', [
 | 
			
		||||
            'title' => trans('entities.my_favourites'),
 | 
			
		||||
            'entities' => $favourites->slice(0, $viewCount),
 | 
			
		||||
            'hasMoreLink' => $hasMoreLink,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 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;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -2,11 +2,12 @@
 | 
			
		|||
 | 
			
		||||
use Activity;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Queries\RecentlyViewed;
 | 
			
		||||
use BookStack\Entities\Queries\TopFavourites;
 | 
			
		||||
use BookStack\Entities\Tools\PageContent;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use BookStack\Entities\Repos\BookRepo;
 | 
			
		||||
use BookStack\Entities\Repos\BookshelfRepo;
 | 
			
		||||
use Illuminate\Http\Response;
 | 
			
		||||
use Views;
 | 
			
		||||
 | 
			
		||||
class HomeController extends Controller
 | 
			
		||||
| 
						 | 
				
			
			@ -32,12 +33,13 @@ class HomeController extends Controller
 | 
			
		|||
 | 
			
		||||
        $recentFactor = count($draftPages) > 0 ? 0.5 : 1;
 | 
			
		||||
        $recents = $this->isSignedIn() ?
 | 
			
		||||
              Views::getUserRecentlyViewed(12*$recentFactor, 1)
 | 
			
		||||
            (new RecentlyViewed)->run(12*$recentFactor, 1)
 | 
			
		||||
            : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
 | 
			
		||||
        $favourites = (new TopFavourites)->run(6);
 | 
			
		||||
        $recentlyUpdatedPages = Page::visible()->with('book')
 | 
			
		||||
            ->where('draft', false)
 | 
			
		||||
            ->orderBy('updated_at', 'desc')
 | 
			
		||||
            ->take(12)
 | 
			
		||||
            ->take($favourites->count() > 0 ? 6 : 12)
 | 
			
		||||
            ->get();
 | 
			
		||||
 | 
			
		||||
        $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
 | 
			
		||||
| 
						 | 
				
			
			@ -51,6 +53,7 @@ class HomeController extends Controller
 | 
			
		|||
            'recents' => $recents,
 | 
			
		||||
            'recentlyUpdatedPages' => $recentlyUpdatedPages,
 | 
			
		||||
            'draftPages' => $draftPages,
 | 
			
		||||
            'favourites' => $favourites,
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        // Add required list ordering & sorting for books & shelves views.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\View;
 | 
			
		||||
use BookStack\Entities\Tools\BookContents;
 | 
			
		||||
use BookStack\Entities\Tools\PageContent;
 | 
			
		||||
use BookStack\Entities\Tools\PageEditActivity;
 | 
			
		||||
| 
						 | 
				
			
			@ -141,7 +142,7 @@ class PageController extends Controller
 | 
			
		|||
            $page->load(['comments.createdBy']);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Views::add($page);
 | 
			
		||||
        View::incrementFor($page);
 | 
			
		||||
        $this->setPageTitle($page->getShortName());
 | 
			
		||||
        return view('pages.show', [
 | 
			
		||||
            'page' => $page,
 | 
			
		||||
| 
						 | 
				
			
			@ -337,9 +338,9 @@ class PageController extends Controller
 | 
			
		|||
            ->paginate(20)
 | 
			
		||||
            ->setPath(url('/pages/recently-updated'));
 | 
			
		||||
 | 
			
		||||
        return view('pages.detailed-listing', [
 | 
			
		||||
        return view('common.detailed-listing-paginated', [
 | 
			
		||||
            'title' => trans('entities.recently_updated_pages'),
 | 
			
		||||
            'pages' => $pages
 | 
			
		||||
            'entities' => $pages
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
<?php namespace BookStack\Http\Controllers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\ViewService;
 | 
			
		||||
use BookStack\Entities\Queries\Popular;
 | 
			
		||||
use BookStack\Entities\Tools\SearchRunner;
 | 
			
		||||
use BookStack\Entities\Tools\ShelfContext;
 | 
			
		||||
use BookStack\Entities\Tools\SearchOptions;
 | 
			
		||||
| 
						 | 
				
			
			@ -9,16 +9,13 @@ use Illuminate\Http\Request;
 | 
			
		|||
 | 
			
		||||
class SearchController extends Controller
 | 
			
		||||
{
 | 
			
		||||
    protected $viewService;
 | 
			
		||||
    protected $searchRunner;
 | 
			
		||||
    protected $entityContextManager;
 | 
			
		||||
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        ViewService $viewService,
 | 
			
		||||
        SearchRunner $searchRunner,
 | 
			
		||||
        ShelfContext $entityContextManager
 | 
			
		||||
    ) {
 | 
			
		||||
        $this->viewService = $viewService;
 | 
			
		||||
        $this->searchRunner = $searchRunner;
 | 
			
		||||
        $this->entityContextManager = $entityContextManager;
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -82,7 +79,7 @@ class SearchController extends Controller
 | 
			
		|||
            $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
 | 
			
		||||
            $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
 | 
			
		||||
        } else {
 | 
			
		||||
            $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
 | 
			
		||||
            $entities = (new Popular)->run(20, 0, $entityTypes, $permission);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return view('search.entity-ajax-list', ['entities' => $entities]);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
<?php namespace BookStack\Interfaces;
 | 
			
		||||
 | 
			
		||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
 | 
			
		||||
 | 
			
		||||
interface Viewable
 | 
			
		||||
{
 | 
			
		||||
    /**
 | 
			
		||||
     * Get all view instances for this viewable model.
 | 
			
		||||
     */
 | 
			
		||||
    public function views(): MorphMany;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,7 +3,6 @@
 | 
			
		|||
namespace BookStack\Providers;
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\ActivityService;
 | 
			
		||||
use BookStack\Actions\ViewService;
 | 
			
		||||
use BookStack\Auth\Permissions\PermissionService;
 | 
			
		||||
use BookStack\Theming\ThemeService;
 | 
			
		||||
use BookStack\Uploads\ImageService;
 | 
			
		||||
| 
						 | 
				
			
			@ -32,10 +31,6 @@ class CustomFacadeProvider extends ServiceProvider
 | 
			
		|||
            return $this->app->make(ActivityService::class);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $this->app->singleton('views', function () {
 | 
			
		||||
            return $this->app->make(ViewService::class);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $this->app->singleton('images', function () {
 | 
			
		||||
            return $this->app->make(ImageService::class);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 265 B  | 
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 270 B  | 
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,6 +40,8 @@ return [
 | 
			
		|||
    'remove' => 'Remove',
 | 
			
		||||
    'add' => 'Add',
 | 
			
		||||
    'fullscreen' => 'Fullscreen',
 | 
			
		||||
    'favourite' => 'Favourite',
 | 
			
		||||
    'unfavourite' => 'Unfavourite',
 | 
			
		||||
 | 
			
		||||
    // Sort Options
 | 
			
		||||
    'sort_options' => 'Sort Options',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,6 +27,8 @@ return [
 | 
			
		|||
    'images' => 'Images',
 | 
			
		||||
    'my_recent_drafts' => 'My Recent Drafts',
 | 
			
		||||
    'my_recently_viewed' => 'My Recently Viewed',
 | 
			
		||||
    'my_most_viewed_favourites' => 'My Most Viewed Favourites',
 | 
			
		||||
    'my_favourites' => 'My Favourites',
 | 
			
		||||
    'no_pages_viewed' => 'You have not viewed any pages',
 | 
			
		||||
    'no_pages_recently_created' => 'No pages have been recently created',
 | 
			
		||||
    'no_pages_recently_updated' => 'No pages have been recently updated',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -118,6 +118,9 @@
 | 
			
		|||
 | 
			
		||||
            <hr class="primary-background">
 | 
			
		||||
 | 
			
		||||
            @if(signedInUser())
 | 
			
		||||
                @include('partials.entity-favourite-action', ['entity' => $book])
 | 
			
		||||
            @endif
 | 
			
		||||
            @include('partials.entity-export-menu', ['entity' => $book])
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -123,6 +123,9 @@
 | 
			
		|||
 | 
			
		||||
            <hr class="primary-background"/>
 | 
			
		||||
 | 
			
		||||
            @if(signedInUser())
 | 
			
		||||
                @include('partials.entity-favourite-action', ['entity' => $chapter])
 | 
			
		||||
            @endif
 | 
			
		||||
            @include('partials.entity-export-menu', ['entity' => $chapter])
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,11 +6,11 @@
 | 
			
		|||
            <h1 class="list-heading">{{ $title }}</h1>
 | 
			
		||||
 | 
			
		||||
            <div class="book-contents">
 | 
			
		||||
                @include('partials.entity-list', ['entities' => $pages, 'style' => 'detailed'])
 | 
			
		||||
                @include('partials.entity-list', ['entities' => $entities, 'style' => 'detailed'])
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="text-center">
 | 
			
		||||
                {!! $pages->links() !!}
 | 
			
		||||
                {!! $entities->links() !!}
 | 
			
		||||
            </div>
 | 
			
		||||
        </main>
 | 
			
		||||
    </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
@extends('simple-layout')
 | 
			
		||||
 | 
			
		||||
@section('body')
 | 
			
		||||
    <div class="container small pt-xl">
 | 
			
		||||
        <main class="card content-wrap">
 | 
			
		||||
            <h1 class="list-heading">{{ $title }}</h1>
 | 
			
		||||
 | 
			
		||||
            <div class="book-contents">
 | 
			
		||||
                @include('partials.entity-list', ['entities' => $entities, 'style' => 'detailed'])
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="text-right">
 | 
			
		||||
                @if($hasMoreLink)
 | 
			
		||||
                    <a href="{{ $hasMoreLink }}" class="button outline">{{ trans('common.more') }}</a>
 | 
			
		||||
                @endif
 | 
			
		||||
            </div>
 | 
			
		||||
        </main>
 | 
			
		||||
    </div>
 | 
			
		||||
@stop
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +61,9 @@
 | 
			
		|||
                            <span class="name">{{ $currentUser->getShortName(9) }}</span> @icon('caret-down')
 | 
			
		||||
                        </span>
 | 
			
		||||
                        <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
 | 
			
		||||
                            <li>
 | 
			
		||||
                                <a href="{{ url('/favourites') }}">@icon('star'){{ trans('entities.my_favourites') }}</a>
 | 
			
		||||
                            </li>
 | 
			
		||||
                            <li>
 | 
			
		||||
                                <a href="{{ $currentUser->getProfileUrl() }}">@icon('user'){{ trans('common.view_profile') }}</a>
 | 
			
		||||
                            </li>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,20 @@
 | 
			
		|||
    </div>
 | 
			
		||||
@endif
 | 
			
		||||
 | 
			
		||||
@if(count($favourites) > 0)
 | 
			
		||||
    <div id="top-favourites" class="card mb-xl">
 | 
			
		||||
        <h3 class="card-title">
 | 
			
		||||
            <a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
 | 
			
		||||
        </h3>
 | 
			
		||||
        <div class="px-m">
 | 
			
		||||
            @include('partials.entity-list', [
 | 
			
		||||
            'entities' => $favourites,
 | 
			
		||||
            'style' => 'compact',
 | 
			
		||||
            ])
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@endif
 | 
			
		||||
 | 
			
		||||
<div class="mb-xl">
 | 
			
		||||
    <h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
 | 
			
		||||
    @include('partials.entity-list', [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,6 +42,20 @@
 | 
			
		|||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div>
 | 
			
		||||
                @if(count($favourites) > 0)
 | 
			
		||||
                    <div id="top-favourites" class="card mb-xl">
 | 
			
		||||
                        <h3 class="card-title">
 | 
			
		||||
                            <a href="{{ url('/favourites') }}" class="no-color">{{ trans('entities.my_most_viewed_favourites') }}</a>
 | 
			
		||||
                        </h3>
 | 
			
		||||
                        <div class="px-m">
 | 
			
		||||
                            @include('partials.entity-list', [
 | 
			
		||||
                            'entities' => $favourites,
 | 
			
		||||
                            'style' => 'compact',
 | 
			
		||||
                            ])
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                @endif
 | 
			
		||||
 | 
			
		||||
                <div id="recent-pages" class="card mb-xl">
 | 
			
		||||
                    <h3 class="card-title"><a class="no-color" href="{{ url("/pages/recently-updated") }}">{{ trans('entities.recently_updated_pages') }}</a></h3>
 | 
			
		||||
                    <div id="recently-updated-pages" class="px-m">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,7 +26,7 @@
 | 
			
		|||
                <div class="card mb-xl">
 | 
			
		||||
                    <h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
 | 
			
		||||
                    <div class="px-m">
 | 
			
		||||
                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact'])
 | 
			
		||||
                        @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['page']), 'style' => 'compact'])
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -34,7 +34,7 @@
 | 
			
		|||
                <div class="card mb-xl">
 | 
			
		||||
                    <h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
 | 
			
		||||
                    <div class="px-m">
 | 
			
		||||
                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact'])
 | 
			
		||||
                        @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['book']), 'style' => 'compact'])
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			@ -42,7 +42,7 @@
 | 
			
		|||
                <div class="card mb-xl">
 | 
			
		||||
                    <h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
 | 
			
		||||
                    <div class="px-m">
 | 
			
		||||
                        @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact'])
 | 
			
		||||
                        @include('partials.entity-list', ['entities' => (new \BookStack\Entities\Queries\Popular)->run(10, 0, ['chapter']), 'style' => 'compact'])
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -150,7 +150,9 @@
 | 
			
		|||
 | 
			
		||||
            <hr class="primary-background"/>
 | 
			
		||||
 | 
			
		||||
            {{--Export--}}
 | 
			
		||||
            @if(signedInUser())
 | 
			
		||||
                @include('partials.entity-favourite-action', ['entity' => $page])
 | 
			
		||||
            @endif
 | 
			
		||||
            @include('partials.entity-export-menu', ['entity' => $page])
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -133,6 +133,11 @@
 | 
			
		|||
                </a>
 | 
			
		||||
            @endif
 | 
			
		||||
 | 
			
		||||
            @if(signedInUser())
 | 
			
		||||
                <hr class="primary-background">
 | 
			
		||||
                @include('partials.entity-favourite-action', ['entity' => $shelf])
 | 
			
		||||
            @endif
 | 
			
		||||
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
@stop
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -152,9 +152,15 @@ 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::get('/favourites', 'FavouriteController@index');
 | 
			
		||||
    Route::post('/favourites/add', 'FavouriteController@add');
 | 
			
		||||
    Route::post('/favourites/remove', 'FavouriteController@remove');
 | 
			
		||||
 | 
			
		||||
    // Other Pages
 | 
			
		||||
    Route::get('/', 'HomeController@index');
 | 
			
		||||
    Route::get('/home', 'HomeController@index');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,118 @@
 | 
			
		|||
<?php
 | 
			
		||||
 | 
			
		||||
use BookStack\Actions\Favourite;
 | 
			
		||||
use BookStack\Entities\Models\Book;
 | 
			
		||||
use BookStack\Entities\Models\Bookshelf;
 | 
			
		||||
use BookStack\Entities\Models\Chapter;
 | 
			
		||||
use BookStack\Entities\Models\Page;
 | 
			
		||||
use Tests\TestCase;
 | 
			
		||||
 | 
			
		||||
class FavouriteTest extends TestCase
 | 
			
		||||
{
 | 
			
		||||
 | 
			
		||||
    public function test_page_add_favourite_flow()
 | 
			
		||||
    {
 | 
			
		||||
        $page = Page::query()->first();
 | 
			
		||||
        $editor = $this->getEditor();
 | 
			
		||||
 | 
			
		||||
        $resp = $this->actingAs($editor)->get($page->getUrl());
 | 
			
		||||
        $resp->assertElementContains('button', 'Favourite');
 | 
			
		||||
        $resp->assertElementExists('form[method="POST"][action$="/favourites/add"]');
 | 
			
		||||
 | 
			
		||||
        $resp = $this->post('/favourites/add', [
 | 
			
		||||
            'type' => get_class($page),
 | 
			
		||||
            'id' => $page->id,
 | 
			
		||||
        ]);
 | 
			
		||||
        $resp->assertRedirect($page->getUrl());
 | 
			
		||||
        $resp->assertSessionHas('success', "\"{$page->name}\" has been added to your favourites");
 | 
			
		||||
 | 
			
		||||
        $this->assertDatabaseHas('favourites', [
 | 
			
		||||
            'user_id' => $editor->id,
 | 
			
		||||
            'favouritable_type' => $page->getMorphClass(),
 | 
			
		||||
            'favouritable_id' => $page->id,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_page_remove_favourite_flow()
 | 
			
		||||
    {
 | 
			
		||||
        $page = Page::query()->first();
 | 
			
		||||
        $editor = $this->getEditor();
 | 
			
		||||
        Favourite::query()->forceCreate([
 | 
			
		||||
            'user_id' => $editor->id,
 | 
			
		||||
            'favouritable_id' => $page->id,
 | 
			
		||||
            'favouritable_type' => $page->getMorphClass(),
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        $resp = $this->actingAs($editor)->get($page->getUrl());
 | 
			
		||||
        $resp->assertElementContains('button', 'Unfavourite');
 | 
			
		||||
        $resp->assertElementExists('form[method="POST"][action$="/favourites/remove"]');
 | 
			
		||||
 | 
			
		||||
        $resp = $this->post('/favourites/remove', [
 | 
			
		||||
            'type' => get_class($page),
 | 
			
		||||
            'id' => $page->id,
 | 
			
		||||
        ]);
 | 
			
		||||
        $resp->assertRedirect($page->getUrl());
 | 
			
		||||
        $resp->assertSessionHas('success', "\"{$page->name}\" has been removed from your favourites");
 | 
			
		||||
 | 
			
		||||
        $this->assertDatabaseMissing('favourites', [
 | 
			
		||||
            'user_id' => $editor->id,
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_book_chapter_shelf_pages_contain_favourite_button()
 | 
			
		||||
    {
 | 
			
		||||
        $entities = [
 | 
			
		||||
            Bookshelf::query()->first(),
 | 
			
		||||
            Book::query()->first(),
 | 
			
		||||
            Chapter::query()->first(),
 | 
			
		||||
        ];
 | 
			
		||||
        $this->actingAs($this->getEditor());
 | 
			
		||||
 | 
			
		||||
        foreach ($entities as $entity) {
 | 
			
		||||
            $resp = $this->get($entity->getUrl());
 | 
			
		||||
            $resp->assertElementExists('form[method="POST"][action$="/favourites/add"]');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_header_contains_link_to_favourites_page_when_logged_in()
 | 
			
		||||
    {
 | 
			
		||||
        $this->setSettings(['app-public' => 'true']);
 | 
			
		||||
        $this->get('/')->assertElementNotContains('header', 'My Favourites');
 | 
			
		||||
        $this->actingAs($this->getViewer())->get('/')->assertElementContains('header a', 'My Favourites');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_favourites_shown_on_homepage()
 | 
			
		||||
    {
 | 
			
		||||
        $editor = $this->getEditor();
 | 
			
		||||
 | 
			
		||||
        $resp = $this->actingAs($editor)->get('/');
 | 
			
		||||
        $resp->assertElementNotExists('#top-favourites');
 | 
			
		||||
 | 
			
		||||
        /** @var Page $page */
 | 
			
		||||
        $page = Page::query()->first();
 | 
			
		||||
        $page->favourites()->save((new Favourite)->forceFill(['user_id' => $editor->id]));
 | 
			
		||||
 | 
			
		||||
        $resp = $this->get('/');
 | 
			
		||||
        $resp->assertElementExists('#top-favourites');
 | 
			
		||||
        $resp->assertElementContains('#top-favourites', $page->name);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function test_favourites_list_page_shows_favourites_and_has_working_pagination()
 | 
			
		||||
    {
 | 
			
		||||
        /** @var Page $page */
 | 
			
		||||
        $page = Page::query()->first();
 | 
			
		||||
        $editor = $this->getEditor();
 | 
			
		||||
 | 
			
		||||
        $resp = $this->actingAs($editor)->get('/favourites');
 | 
			
		||||
        $resp->assertDontSee($page->name);
 | 
			
		||||
 | 
			
		||||
        $page->favourites()->save((new Favourite)->forceFill(['user_id' => $editor->id]));
 | 
			
		||||
 | 
			
		||||
        $resp = $this->get('/favourites');
 | 
			
		||||
        $resp->assertSee($page->name);
 | 
			
		||||
 | 
			
		||||
        $resp = $this->get('/favourites?page=2');
 | 
			
		||||
        $resp->assertDontSee($page->name);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue