mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-02-21 10:35:40 +08:00
Merge pull request #2748 from BookStackApp/favourite_system
Favourite System
This commit is contained in:
commit
dd6076049c
17
app/Actions/Favourite.php
Normal file
17
app/Actions/Favourite.php
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
|
17
app/Entities/Queries/EntityQuery.php
Normal file
17
app/Entities/Queries/EntityQuery.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
29
app/Entities/Queries/Popular.php
Normal file
29
app/Entities/Queries/Popular.php
Normal file
@ -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();
|
||||
}
|
||||
|
||||
}
|
32
app/Entities/Queries/RecentlyViewed.php
Normal file
32
app/Entities/Queries/RecentlyViewed.php
Normal file
@ -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();
|
||||
}
|
||||
}
|
33
app/Entities/Queries/TopFavourites.php
Normal file
33
app/Entities/Queries/TopFavourites.php
Normal file
@ -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', [
|
||||
|
95
app/Http/Controllers/FavouriteController.php
Normal file
95
app/Http/Controllers/FavouriteController.php
Normal file
@ -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]);
|
||||
|
11
app/Interfaces/Favouritable.php
Normal file
11
app/Interfaces/Favouritable.php
Normal file
@ -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;
|
||||
}
|
11
app/Interfaces/Viewable.php
Normal file
11
app/Interfaces/Viewable.php
Normal file
@ -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 |
1
resources/icons/star-outline.svg
Normal file
1
resources/icons/star-outline.svg
Normal file
@ -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>
|
19
resources/views/common/detailed-listing-with-more.blade.php
Normal file
19
resources/views/common/detailed-listing-with-more.blade.php
Normal file
@ -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>
|
||||
|
||||
|
12
resources/views/partials/entity-favourite-action.blade.php
Normal file
12
resources/views/partials/entity-favourite-action.blade.php
Normal file
@ -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');
|
||||
|
118
tests/FavouriteTest.php
Normal file
118
tests/FavouriteTest.php
Normal file
@ -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…
x
Reference in New Issue
Block a user