Merge branch 'master' into release
This commit is contained in:
commit
7113807f12
12
.travis.yml
12
.travis.yml
|
@ -1,3 +1,5 @@
|
|||
dist: trusty
|
||||
sudo: required
|
||||
language: php
|
||||
php:
|
||||
- 7.0
|
||||
|
@ -5,15 +7,21 @@ php:
|
|||
cache:
|
||||
directories:
|
||||
- vendor
|
||||
- node_modules
|
||||
- $HOME/.composer/cache
|
||||
|
||||
addons:
|
||||
mariadb: '10.0'
|
||||
apt:
|
||||
packages:
|
||||
- mysql-server-5.6
|
||||
- mysql-client-core-5.6
|
||||
- mysql-client-5.6
|
||||
|
||||
before_install:
|
||||
- npm install -g npm@latest
|
||||
|
||||
before_script:
|
||||
- mysql -e 'create database `bookstack-test`;'
|
||||
- mysql -u root -e 'create database `bookstack-test`;'
|
||||
- composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
|
||||
- phpenv config-rm xdebug.ini
|
||||
- composer self-update
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Dan Brown
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -44,7 +44,7 @@ class Activity extends Model
|
|||
* @return bool
|
||||
*/
|
||||
public function isSimilarTo($activityB) {
|
||||
return [$this->key, $this->entitiy_type, $this->entitiy_id] === [$activityB->key, $activityB->entitiy_type, $activityB->entitiy_id];
|
||||
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@ class ChapterController extends Controller
|
|||
|
||||
$input = $request->all();
|
||||
$input['priority'] = $this->bookRepo->getNewPriority($book);
|
||||
$chapter = $this->chapterRepo->createFromInput($request->all(), $book);
|
||||
$chapter = $this->chapterRepo->createFromInput($input, $book);
|
||||
Activity::add($chapter, 'chapter_create', $book->id);
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
@ -154,6 +154,63 @@ class ChapterController extends Controller
|
|||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page for moving a chapter.
|
||||
* @param $bookSlug
|
||||
* @param $chapterSlug
|
||||
* @return mixed
|
||||
* @throws \BookStack\Exceptions\NotFoundException
|
||||
*/
|
||||
public function showMove($bookSlug, $chapterSlug) {
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
return view('chapters/move', [
|
||||
'chapter' => $chapter,
|
||||
'book' => $book
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the move action for a chapter.
|
||||
* @param $bookSlug
|
||||
* @param $chapterSlug
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
* @throws \BookStack\Exceptions\NotFoundException
|
||||
*/
|
||||
public function move($bookSlug, $chapterSlug, Request $request) {
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
$stringExploded = explode(':', $entitySelection);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
$parent = false;
|
||||
|
||||
if ($entityType == 'book') {
|
||||
$parent = $this->bookRepo->getById($entityId);
|
||||
}
|
||||
|
||||
if ($parent === false || $parent === null) {
|
||||
session()->flash('The selected Book was not found');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->chapterRepo->changeBook($parent->id, $chapter);
|
||||
Activity::add($chapter, 'chapter_move', $chapter->book->id);
|
||||
session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view.
|
||||
* @param $bookSlug
|
||||
|
|
|
@ -53,7 +53,7 @@ class ImageController extends Controller
|
|||
]);
|
||||
|
||||
$searchTerm = $request->get('term');
|
||||
$imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm);
|
||||
$imgData = $this->imageRepo->searchPaginatedByType($type, $page, 24, $searchTerm);
|
||||
return response()->json($imgData);
|
||||
}
|
||||
|
||||
|
@ -99,7 +99,7 @@ class ImageController extends Controller
|
|||
{
|
||||
$this->checkPermission('image-create-all');
|
||||
$this->validate($request, [
|
||||
'file' => 'image|mimes:jpeg,gif,png'
|
||||
'file' => 'is_image'
|
||||
]);
|
||||
|
||||
$imageUpload = $request->file('file');
|
||||
|
|
|
@ -92,7 +92,7 @@ class PageController extends Controller
|
|||
|
||||
$draftPage = $this->pageRepo->getById($pageId, true);
|
||||
|
||||
$chapterId = $draftPage->chapter_id;
|
||||
$chapterId = intval($draftPage->chapter_id);
|
||||
$parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
|
||||
$this->checkOwnablePermission('page-create', $parent);
|
||||
|
||||
|
@ -221,8 +221,8 @@ class PageController extends Controller
|
|||
$updateTime = $draft->updated_at->timestamp;
|
||||
$utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
|
||||
return response()->json([
|
||||
'status' => 'success',
|
||||
'message' => 'Draft saved at ',
|
||||
'status' => 'success',
|
||||
'message' => 'Draft saved at ',
|
||||
'timestamp' => $utcUpdateTimestamp
|
||||
]);
|
||||
}
|
||||
|
@ -450,6 +450,67 @@ class PageController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to choose a new parent to move a page into.
|
||||
* @param $bookSlug
|
||||
* @param $pageSlug
|
||||
* @return mixed
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showMove($bookSlug, $pageSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
return view('pages/move', [
|
||||
'book' => $book,
|
||||
'page' => $page
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the action of moving the location of a page
|
||||
* @param $bookSlug
|
||||
* @param $pageSlug
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function move($bookSlug, $pageSlug, Request $request)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$page = $this->pageRepo->getBySlug($pageSlug, $book->id);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
|
||||
$stringExploded = explode(':', $entitySelection);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
$parent = false;
|
||||
|
||||
if ($entityType == 'chapter') {
|
||||
$parent = $this->chapterRepo->getById($entityId);
|
||||
} else if ($entityType == 'book') {
|
||||
$parent = $this->bookRepo->getById($entityId);
|
||||
}
|
||||
|
||||
if ($parent === false || $parent === null) {
|
||||
session()->flash('The selected Book or Chapter was not found');
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->pageRepo->changePageParent($page, $parent);
|
||||
Activity::add($page, 'page_move', $page->book->id);
|
||||
session()->flash('success', sprintf('Page moved to "%s"', $parent->name));
|
||||
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for this page.
|
||||
* @param $bookSlug
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Services\ViewService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
use BookStack\Http\Requests;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Repos\BookRepo;
|
||||
use BookStack\Repos\ChapterRepo;
|
||||
use BookStack\Repos\PageRepo;
|
||||
|
@ -15,18 +15,21 @@ class SearchController extends Controller
|
|||
protected $pageRepo;
|
||||
protected $bookRepo;
|
||||
protected $chapterRepo;
|
||||
protected $viewService;
|
||||
|
||||
/**
|
||||
* SearchController constructor.
|
||||
* @param $pageRepo
|
||||
* @param $bookRepo
|
||||
* @param $chapterRepo
|
||||
* @param PageRepo $pageRepo
|
||||
* @param BookRepo $bookRepo
|
||||
* @param ChapterRepo $chapterRepo
|
||||
* @param ViewService $viewService
|
||||
*/
|
||||
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
|
||||
public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->viewService = $viewService;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
|
@ -48,9 +51,9 @@ class SearchController extends Controller
|
|||
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends);
|
||||
$this->setPageTitle('Search For ' . $searchTerm);
|
||||
return view('search/all', [
|
||||
'pages' => $pages,
|
||||
'books' => $books,
|
||||
'chapters' => $chapters,
|
||||
'pages' => $pages,
|
||||
'books' => $books,
|
||||
'chapters' => $chapters,
|
||||
'searchTerm' => $searchTerm
|
||||
]);
|
||||
}
|
||||
|
@ -69,8 +72,8 @@ class SearchController extends Controller
|
|||
$pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
|
||||
$this->setPageTitle('Page Search For ' . $searchTerm);
|
||||
return view('search/entity-search-list', [
|
||||
'entities' => $pages,
|
||||
'title' => 'Page Search Results',
|
||||
'entities' => $pages,
|
||||
'title' => 'Page Search Results',
|
||||
'searchTerm' => $searchTerm
|
||||
]);
|
||||
}
|
||||
|
@ -89,8 +92,8 @@ class SearchController extends Controller
|
|||
$chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
|
||||
$this->setPageTitle('Chapter Search For ' . $searchTerm);
|
||||
return view('search/entity-search-list', [
|
||||
'entities' => $chapters,
|
||||
'title' => 'Chapter Search Results',
|
||||
'entities' => $chapters,
|
||||
'title' => 'Chapter Search Results',
|
||||
'searchTerm' => $searchTerm
|
||||
]);
|
||||
}
|
||||
|
@ -109,8 +112,8 @@ class SearchController extends Controller
|
|||
$books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends);
|
||||
$this->setPageTitle('Book Search For ' . $searchTerm);
|
||||
return view('search/entity-search-list', [
|
||||
'entities' => $books,
|
||||
'title' => 'Book Search Results',
|
||||
'entities' => $books,
|
||||
'title' => 'Book Search Results',
|
||||
'searchTerm' => $searchTerm
|
||||
]);
|
||||
}
|
||||
|
@ -134,4 +137,35 @@ class SearchController extends Controller
|
|||
return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Search for a list of entities and return a partial HTML response of matching entities.
|
||||
* Returns the most popular entities if no search is provided.
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function searchEntitiesAjax(Request $request)
|
||||
{
|
||||
$entities = collect();
|
||||
$entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']);
|
||||
$searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false;
|
||||
|
||||
// Search for entities otherwise show most popular
|
||||
if ($searchTerm !== false) {
|
||||
if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items());
|
||||
if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items());
|
||||
if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items());
|
||||
$entities = $entities->sortByDesc('title_relevance');
|
||||
} else {
|
||||
$entityNames = $entityTypes->map(function ($type) {
|
||||
return 'BookStack\\' . ucfirst($type);
|
||||
})->toArray();
|
||||
$entities = $this->viewService->getPopular(20, 0, $entityNames);
|
||||
}
|
||||
|
||||
return view('search/entity-ajax-list', ['entities' => $entities]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -55,7 +55,7 @@ class TagController extends Controller
|
|||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search');
|
||||
$searchTerm = $request->has('search') ? $request->get('search') : false;
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
@ -66,8 +66,9 @@ class TagController extends Controller
|
|||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('search');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm);
|
||||
$searchTerm = $request->has('search') ? $request->get('search') : false;
|
||||
$tagName = $request->has('name') ? $request->get('name') : false;
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
return response()->json($suggestions);
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,8 @@ Route::group(['middleware' => 'auth'], function () {
|
|||
Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
|
||||
Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
|
||||
Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
|
||||
Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict');
|
||||
|
@ -53,6 +55,8 @@ Route::group(['middleware' => 'auth'], function () {
|
|||
Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
|
||||
Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
|
||||
Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update');
|
||||
Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove');
|
||||
Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move');
|
||||
Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit');
|
||||
Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict');
|
||||
Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
|
||||
|
@ -93,6 +97,8 @@ Route::group(['middleware' => 'auth'], function () {
|
|||
Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
|
||||
});
|
||||
|
||||
Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
|
||||
|
||||
// Links
|
||||
Route::get('/link/{id}', 'PageController@redirectFromLink');
|
||||
|
||||
|
|
|
@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot()
|
||||
{
|
||||
//
|
||||
// Custom validation methods
|
||||
\Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
|
||||
$imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp'];
|
||||
return in_array($value->getMimeType(), $imageMimes);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -251,7 +251,10 @@ class BookRepo extends EntityRepo
|
|||
}]);
|
||||
$chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
|
||||
$chapters = $chapterQuery->get();
|
||||
$children = $pages->merge($chapters);
|
||||
$children = $pages->values();
|
||||
foreach ($chapters as $chapter) {
|
||||
$children->push($chapter);
|
||||
}
|
||||
$bookSlug = $book->slug;
|
||||
|
||||
$children->each(function ($child) use ($bookSlug) {
|
||||
|
|
|
@ -9,6 +9,18 @@ use BookStack\Chapter;
|
|||
|
||||
class ChapterRepo extends EntityRepo
|
||||
{
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* ChapterRepo constructor.
|
||||
* @param $pageRepo
|
||||
*/
|
||||
public function __construct(PageRepo $pageRepo)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Base query for getting chapters, Takes permissions into account.
|
||||
* @return mixed
|
||||
|
@ -189,12 +201,21 @@ class ChapterRepo extends EntityRepo
|
|||
public function changeBook($bookId, Chapter $chapter)
|
||||
{
|
||||
$chapter->book_id = $bookId;
|
||||
// Update related activity
|
||||
foreach ($chapter->activity as $activity) {
|
||||
$activity->book_id = $bookId;
|
||||
$activity->save();
|
||||
}
|
||||
$chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
|
||||
$chapter->save();
|
||||
// Update all child pages
|
||||
foreach ($chapter->pages as $page) {
|
||||
$this->pageRepo->changeBook($bookId, $page);
|
||||
}
|
||||
// Update permissions
|
||||
$chapter->load('book');
|
||||
$this->permissionService->buildJointPermissionsForEntity($chapter->book);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
use Activity;
|
||||
use BookStack\Book;
|
||||
use BookStack\Chapter;
|
||||
use BookStack\Entity;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Carbon\Carbon;
|
||||
use DOMDocument;
|
||||
|
@ -572,6 +573,22 @@ class PageRepo extends EntityRepo
|
|||
return $page;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Change the page's parent to the given entity.
|
||||
* @param Page $page
|
||||
* @param Entity $parent
|
||||
*/
|
||||
public function changePageParent(Page $page, Entity $parent)
|
||||
{
|
||||
$book = $parent->isA('book') ? $parent : $parent->book;
|
||||
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
|
||||
$page->save();
|
||||
$page = $this->changeBook($book->id, $page);
|
||||
$page->load('book');
|
||||
$this->permissionService->buildJointPermissionsForEntity($book);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a suitable slug for the resource
|
||||
* @param $name
|
||||
|
|
|
@ -58,29 +58,48 @@ class TagRepo
|
|||
|
||||
/**
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
* @param $searchTerm
|
||||
* @return array
|
||||
*/
|
||||
public function getNameSuggestions($searchTerm)
|
||||
public function getNameSuggestions($searchTerm = false)
|
||||
{
|
||||
if ($searchTerm === '') return [];
|
||||
$query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc');
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['name'])->pluck('name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag value suggestions from scanning existing tag values.
|
||||
* If no search is given the 50 most popular values are provided.
|
||||
* Passing a tagName will only find values for a tags with a particular name.
|
||||
* @param $searchTerm
|
||||
* @param $tagName
|
||||
* @return array
|
||||
*/
|
||||
public function getValueSuggestions($searchTerm)
|
||||
public function getValueSuggestions($searchTerm = false, $tagName = false)
|
||||
{
|
||||
if ($searchTerm === '') return [];
|
||||
$query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
|
||||
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
if ($tagName !== false) $query = $query->where('name', '=', $tagName);
|
||||
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $query->get(['value'])->pluck('value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an array of tags to an entity
|
||||
* @param Entity $entity
|
||||
|
|
|
@ -90,7 +90,7 @@ class ActivityService
|
|||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
|
||||
->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ use BookStack\Book;
|
|||
use BookStack\Chapter;
|
||||
use BookStack\Entity;
|
||||
use BookStack\JointPermission;
|
||||
use BookStack\Ownable;
|
||||
use BookStack\Page;
|
||||
use BookStack\Role;
|
||||
use BookStack\User;
|
||||
|
@ -307,16 +308,16 @@ class PermissionService
|
|||
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
* @param Entity $entity
|
||||
* @param Ownable $ownable
|
||||
* @param $permission
|
||||
* @return bool
|
||||
*/
|
||||
public function checkEntityUserAccess(Entity $entity, $permission)
|
||||
public function checkOwnableUserAccess(Ownable $ownable, $permission)
|
||||
{
|
||||
if ($this->isAdmin) return true;
|
||||
$explodedPermission = explode('-', $permission);
|
||||
|
||||
$baseQuery = $entity->where('id', '=', $entity->id);
|
||||
$baseQuery = $ownable->where('id', '=', $ownable->id);
|
||||
$action = end($explodedPermission);
|
||||
$this->currentAction = $action;
|
||||
|
||||
|
@ -327,7 +328,7 @@ class PermissionService
|
|||
$allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
|
||||
$ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
|
||||
$this->currentAction = 'view';
|
||||
$isOwner = $this->currentUser && $this->currentUser->id === $entity->created_by;
|
||||
$isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
|
||||
return ($allPermission || ($isOwner && $ownPermission));
|
||||
}
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ class ViewService
|
|||
* Get the entities with the most views.
|
||||
* @param int $count
|
||||
* @param int $page
|
||||
* @param bool|false $filterModel
|
||||
* @param bool|false|array $filterModel
|
||||
*/
|
||||
public function getPopular($count = 10, $page = 0, $filterModel = false)
|
||||
{
|
||||
|
@ -60,7 +60,11 @@ class ViewService
|
|||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel));
|
||||
if ($filterModel && is_array($filterModel)) {
|
||||
$query->whereIn('viewable_type', $filterModel);
|
||||
} else if ($filterModel) {
|
||||
$query->where('viewable_type', '=', get_class($filterModel));
|
||||
};
|
||||
|
||||
return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use BookStack\Ownable;
|
||||
|
||||
if (!function_exists('versioned_asset')) {
|
||||
/**
|
||||
* Get the path to a versioned file.
|
||||
|
@ -34,18 +36,18 @@ if (!function_exists('versioned_asset')) {
|
|||
* If an ownable element is passed in the jointPermissions are checked against
|
||||
* that particular item.
|
||||
* @param $permission
|
||||
* @param \BookStack\Ownable $ownable
|
||||
* @param Ownable $ownable
|
||||
* @return mixed
|
||||
*/
|
||||
function userCan($permission, \BookStack\Ownable $ownable = null)
|
||||
function userCan($permission, Ownable $ownable = null)
|
||||
{
|
||||
if ($ownable === null) {
|
||||
return auth()->user() && auth()->user()->can($permission);
|
||||
}
|
||||
|
||||
// Check permission on ownable item
|
||||
$permissionService = app('BookStack\Services\PermissionService');
|
||||
return $permissionService->checkEntityUserAccess($ownable, $permission);
|
||||
$permissionService = app(\BookStack\Services\PermissionService::class);
|
||||
return $permissionService->checkOwnableUserAccess($ownable, $permission);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
*/
|
||||
return [
|
||||
|
||||
'app-editor' => 'wysiwyg'
|
||||
'app-editor' => 'wysiwyg',
|
||||
'app-color' => '#0288D1',
|
||||
'app-color-light' => 'rgba(21, 101, 192, 0.15)'
|
||||
|
||||
];
|
|
@ -12,7 +12,13 @@ class CreateBooksTable extends Migration
|
|||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('books', function (Blueprint $table) {
|
||||
$pdo = \DB::connection()->getPdo();
|
||||
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
|
||||
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
|
||||
|
||||
Schema::create('books', function (Blueprint $table) use ($requiresISAM) {
|
||||
if($requiresISAM) $table->engine = 'MyISAM';
|
||||
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('slug')->indexed();
|
||||
|
|
|
@ -12,7 +12,13 @@ class CreatePagesTable extends Migration
|
|||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('pages', function (Blueprint $table) {
|
||||
$pdo = \DB::connection()->getPdo();
|
||||
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
|
||||
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
|
||||
|
||||
Schema::create('pages', function (Blueprint $table) use ($requiresISAM) {
|
||||
if($requiresISAM) $table->engine = 'MyISAM';
|
||||
|
||||
$table->increments('id');
|
||||
$table->integer('book_id');
|
||||
$table->integer('chapter_id');
|
||||
|
|
|
@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration
|
|||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::create('chapters', function (Blueprint $table) {
|
||||
$pdo = \DB::connection()->getPdo();
|
||||
$mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
|
||||
$requiresISAM = strpos($mysqlVersion, '5.5') === 0;
|
||||
|
||||
Schema::create('chapters', function (Blueprint $table) use ($requiresISAM) {
|
||||
if($requiresISAM) $table->engine = 'MyISAM';
|
||||
$table->increments('id');
|
||||
$table->integer('book_id');
|
||||
$table->string('slug')->indexed();
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
# BookStack
|
||||
|
||||
[](https://github.com/ssddanbrown/BookStack/releases/latest)
|
||||
[](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
|
||||
[](https://travis-ci.org/ssddanbrown/BookStack)
|
||||
|
||||
A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
|
||||
|
|
|
@ -379,6 +379,15 @@ module.exports = function (ngApp, events) {
|
|||
saveDraft();
|
||||
};
|
||||
|
||||
// Listen to shortcuts coming via events
|
||||
$scope.$on('editor-keydown', (event, data) => {
|
||||
// Save shortcut (ctrl+s)
|
||||
if (data.keyCode == 83 && (navigator.platform.match("Mac") ? data.metaKey : data.ctrlKey)) {
|
||||
data.preventDefault();
|
||||
saveDraft();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Discard the current draft and grab the current page
|
||||
* content from the system via an AJAX request.
|
||||
|
|
|
@ -149,7 +149,10 @@ module.exports = function (ngApp, events) {
|
|||
};
|
||||
}]);
|
||||
|
||||
|
||||
/**
|
||||
* Dropdown
|
||||
* Provides some simple logic to create small dropdown menus
|
||||
*/
|
||||
ngApp.directive('dropdown', [function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
|
@ -166,7 +169,11 @@ module.exports = function (ngApp, events) {
|
|||
};
|
||||
}]);
|
||||
|
||||
ngApp.directive('tinymce', ['$timeout', function($timeout) {
|
||||
/**
|
||||
* TinyMCE
|
||||
* An angular wrapper around the tinyMCE editor.
|
||||
*/
|
||||
ngApp.directive('tinymce', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
|
@ -185,6 +192,10 @@ module.exports = function (ngApp, events) {
|
|||
scope.mceChange(content);
|
||||
});
|
||||
|
||||
editor.on('keydown', (event) => {
|
||||
scope.$emit('editor-keydown', event);
|
||||
});
|
||||
|
||||
editor.on('init', (e) => {
|
||||
scope.mceModel = editor.getContent();
|
||||
});
|
||||
|
@ -200,8 +211,8 @@ module.exports = function (ngApp, events) {
|
|||
scope.tinymce.extraSetups.push(tinyMceSetup);
|
||||
|
||||
// Custom tinyMCE plugins
|
||||
tinymce.PluginManager.add('customhr', function(editor) {
|
||||
editor.addCommand('InsertHorizontalRule', function() {
|
||||
tinymce.PluginManager.add('customhr', function (editor) {
|
||||
editor.addCommand('InsertHorizontalRule', function () {
|
||||
var hrElem = document.createElement('hr');
|
||||
var cNode = editor.selection.getNode();
|
||||
var parentNode = cNode.parentNode;
|
||||
|
@ -227,7 +238,11 @@ module.exports = function (ngApp, events) {
|
|||
}
|
||||
}]);
|
||||
|
||||
ngApp.directive('markdownInput', ['$timeout', function($timeout) {
|
||||
/**
|
||||
* Markdown input
|
||||
* Handles the logic for just the editor input field.
|
||||
*/
|
||||
ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
|
@ -251,7 +266,7 @@ module.exports = function (ngApp, events) {
|
|||
|
||||
scope.$on('markdown-update', (event, value) => {
|
||||
element.val(value);
|
||||
scope.mdModel= value;
|
||||
scope.mdModel = value;
|
||||
scope.mdChange(markdown(value));
|
||||
});
|
||||
|
||||
|
@ -259,23 +274,59 @@ module.exports = function (ngApp, events) {
|
|||
}
|
||||
}]);
|
||||
|
||||
ngApp.directive('markdownEditor', ['$timeout', function($timeout) {
|
||||
/**
|
||||
* Markdown Editor
|
||||
* Handles all functionality of the markdown editor.
|
||||
*/
|
||||
ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope, element, attrs) {
|
||||
|
||||
// Elements
|
||||
var input = element.find('textarea[markdown-input]');
|
||||
var insertImage = element.find('button[data-action="insertImage"]');
|
||||
const input = element.find('textarea[markdown-input]');
|
||||
const display = element.find('.markdown-display').first();
|
||||
const insertImage = element.find('button[data-action="insertImage"]');
|
||||
|
||||
var currentCaretPos = 0;
|
||||
let currentCaretPos = 0;
|
||||
|
||||
input.blur((event) => {
|
||||
input.blur(event => {
|
||||
currentCaretPos = input[0].selectionStart;
|
||||
});
|
||||
|
||||
// Insert image shortcut
|
||||
input.keydown((event) => {
|
||||
// Scroll sync
|
||||
let inputScrollHeight,
|
||||
inputHeight,
|
||||
displayScrollHeight,
|
||||
displayHeight;
|
||||
|
||||
function setScrollHeights() {
|
||||
inputScrollHeight = input[0].scrollHeight;
|
||||
inputHeight = input.height();
|
||||
displayScrollHeight = display[0].scrollHeight;
|
||||
displayHeight = display.height();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setScrollHeights();
|
||||
}, 200);
|
||||
window.addEventListener('resize', setScrollHeights);
|
||||
let scrollDebounceTime = 800;
|
||||
let lastScroll = 0;
|
||||
input.on('scroll', event => {
|
||||
let now = Date.now();
|
||||
if (now - lastScroll > scrollDebounceTime) {
|
||||
setScrollHeights()
|
||||
}
|
||||
let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
|
||||
let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
|
||||
display.scrollTop(displayScrollY);
|
||||
lastScroll = now;
|
||||
});
|
||||
|
||||
// Editor key-presses
|
||||
input.keydown(event => {
|
||||
// Insert image shortcut
|
||||
if (event.which === 73 && event.ctrlKey && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
var caretPos = input[0].selectionStart;
|
||||
|
@ -285,12 +336,15 @@ module.exports = function (ngApp, events) {
|
|||
input.focus();
|
||||
input[0].selectionStart = caretPos + (";
|
||||
input[0].selectionEnd = caretPos + (';
|
||||
return;
|
||||
}
|
||||
// Pass key presses to controller via event
|
||||
scope.$emit('editor-keydown', event);
|
||||
});
|
||||
|
||||
// Insert image from image manager
|
||||
insertImage.click((event) => {
|
||||
window.ImageManager.showExternal((image) => {
|
||||
insertImage.click(event => {
|
||||
window.ImageManager.showExternal(image => {
|
||||
var caretPos = currentCaretPos;
|
||||
var currentContent = input.val();
|
||||
var mdImageText = "";
|
||||
|
@ -303,10 +357,15 @@ module.exports = function (ngApp, events) {
|
|||
}
|
||||
}]);
|
||||
|
||||
ngApp.directive('toolbox', [function() {
|
||||
/**
|
||||
* Page Editor Toolbox
|
||||
* Controls all functionality for the sliding toolbox
|
||||
* on the page edit view.
|
||||
*/
|
||||
ngApp.directive('toolbox', [function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem, attrs) {
|
||||
link: function (scope, elem, attrs) {
|
||||
|
||||
// Get common elements
|
||||
const $buttons = elem.find('[tab-button]');
|
||||
|
@ -331,7 +390,7 @@ module.exports = function (ngApp, events) {
|
|||
setActive($content.first().attr('tab-content'), false);
|
||||
|
||||
// Handle tab button click
|
||||
$buttons.click(function(e) {
|
||||
$buttons.click(function (e) {
|
||||
let name = $(this).attr('tab-button');
|
||||
setActive(name, true);
|
||||
});
|
||||
|
@ -339,10 +398,15 @@ module.exports = function (ngApp, events) {
|
|||
}
|
||||
}]);
|
||||
|
||||
ngApp.directive('autosuggestions', ['$http', function($http) {
|
||||
/**
|
||||
* Tag Autosuggestions
|
||||
* Listens to child inputs and provides autosuggestions depending on field type
|
||||
* and input. Suggestions provided by server.
|
||||
*/
|
||||
ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, elem, attrs) {
|
||||
link: function (scope, elem, attrs) {
|
||||
|
||||
// Local storage for quick caching.
|
||||
const localCache = {};
|
||||
|
@ -360,38 +424,49 @@ module.exports = function (ngApp, events) {
|
|||
let active = 0;
|
||||
|
||||
// Listen to input events on autosuggest fields
|
||||
elem.on('input', '[autosuggest]', function(event) {
|
||||
elem.on('input focus', '[autosuggest]', function (event) {
|
||||
let $input = $(this);
|
||||
let val = $input.val();
|
||||
let url = $input.attr('autosuggest');
|
||||
// No suggestions until at least 3 chars
|
||||
if (val.length < 3) {
|
||||
if (isShowing) {
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
let type = $input.attr('autosuggest-type');
|
||||
|
||||
// Add name param to request if for a value
|
||||
if (type.toLowerCase() === 'value') {
|
||||
let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
|
||||
let nameVal = $nameInput.val();
|
||||
if (nameVal !== '') {
|
||||
url += '?name=' + encodeURIComponent(nameVal);
|
||||
}
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
let suggestionPromise = getSuggestions(val.slice(0, 3), url);
|
||||
suggestionPromise.then((suggestions) => {
|
||||
if (val.length > 2) {
|
||||
suggestions = suggestions.filter((item) => {
|
||||
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
|
||||
}).slice(0, 4);
|
||||
displaySuggestions($input, suggestions);
|
||||
}
|
||||
suggestionPromise.then(suggestions => {
|
||||
if (val.length === 0) {
|
||||
displaySuggestions($input, suggestions.slice(0, 6));
|
||||
} else {
|
||||
suggestions = suggestions.filter(item => {
|
||||
return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
|
||||
}).slice(0, 4);
|
||||
displaySuggestions($input, suggestions);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Hide autosuggestions when input loses focus.
|
||||
// Slight delay to allow clicks.
|
||||
elem.on('blur', '[autosuggest]', function(event) {
|
||||
let lastFocusTime = 0;
|
||||
elem.on('blur', '[autosuggest]', function (event) {
|
||||
let startTime = Date.now();
|
||||
setTimeout(() => {
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
if (lastFocusTime < startTime) {
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
}
|
||||
}, 200)
|
||||
});
|
||||
elem.on('focus', '[autosuggest]', function (event) {
|
||||
lastFocusTime = Date.now();
|
||||
});
|
||||
|
||||
elem.on('keydown', '[autosuggest]', function (event) {
|
||||
if (!isShowing) return;
|
||||
|
@ -401,23 +476,25 @@ module.exports = function (ngApp, events) {
|
|||
|
||||
// Down arrow
|
||||
if (event.keyCode === 40) {
|
||||
let newActive = (active === suggestCount-1) ? 0 : active + 1;
|
||||
let newActive = (active === suggestCount - 1) ? 0 : active + 1;
|
||||
changeActiveTo(newActive, suggestionElems);
|
||||
}
|
||||
// Up arrow
|
||||
else if (event.keyCode === 38) {
|
||||
let newActive = (active === 0) ? suggestCount-1 : active - 1;
|
||||
let newActive = (active === 0) ? suggestCount - 1 : active - 1;
|
||||
changeActiveTo(newActive, suggestionElems);
|
||||
}
|
||||
// Enter key
|
||||
else if (event.keyCode === 13) {
|
||||
// Enter or tab key
|
||||
else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
|
||||
let text = suggestionElems[active].textContent;
|
||||
currentInput[0].value = text;
|
||||
currentInput.focus();
|
||||
$suggestionBox.hide();
|
||||
isShowing = false;
|
||||
event.preventDefault();
|
||||
return false;
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -430,6 +507,7 @@ module.exports = function (ngApp, events) {
|
|||
|
||||
// Display suggestions on a field
|
||||
let prevSuggestions = [];
|
||||
|
||||
function displaySuggestions($input, suggestions) {
|
||||
|
||||
// Hide if no suggestions
|
||||
|
@ -466,7 +544,8 @@ module.exports = function (ngApp, events) {
|
|||
if (i === 0) {
|
||||
suggestion.className = 'active'
|
||||
active = 0;
|
||||
};
|
||||
}
|
||||
;
|
||||
$suggestionBox[0].appendChild(suggestion);
|
||||
}
|
||||
|
||||
|
@ -484,17 +563,18 @@ module.exports = function (ngApp, events) {
|
|||
|
||||
// Get suggestions & cache
|
||||
function getSuggestions(input, url) {
|
||||
let searchUrl = url + '?search=' + encodeURIComponent(input);
|
||||
let hasQuery = url.indexOf('?') !== -1;
|
||||
let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
|
||||
|
||||
// Get from local cache if exists
|
||||
if (localCache[searchUrl]) {
|
||||
if (typeof localCache[searchUrl] !== 'undefined') {
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(localCache[input]);
|
||||
resolve(localCache[searchUrl]);
|
||||
});
|
||||
}
|
||||
|
||||
return $http.get(searchUrl).then((response) => {
|
||||
localCache[input] = response.data;
|
||||
return $http.get(searchUrl).then(response => {
|
||||
localCache[searchUrl] = response.data;
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
@ -502,6 +582,67 @@ module.exports = function (ngApp, events) {
|
|||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
|
||||
ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: true,
|
||||
link: function (scope, element, attrs) {
|
||||
scope.loading = true;
|
||||
scope.entityResults = false;
|
||||
scope.search = '';
|
||||
|
||||
// Add input for forms
|
||||
const input = element.find('[entity-selector-input]').first();
|
||||
|
||||
// Listen to entity item clicks
|
||||
element.on('click', '.entity-list a', function(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
let item = $(this).closest('[data-entity-type]');
|
||||
itemSelect(item);
|
||||
});
|
||||
element.on('click', '[data-entity-type]', function(event) {
|
||||
itemSelect($(this));
|
||||
});
|
||||
|
||||
// Select entity action
|
||||
function itemSelect(item) {
|
||||
let entityType = item.attr('data-entity-type');
|
||||
let entityId = item.attr('data-entity-id');
|
||||
let isSelected = !item.hasClass('selected');
|
||||
element.find('.selected').removeClass('selected').removeClass('primary-background');
|
||||
if (isSelected) item.addClass('selected').addClass('primary-background');
|
||||
let newVal = isSelected ? `${entityType}:${entityId}` : '';
|
||||
input.val(newVal);
|
||||
}
|
||||
|
||||
// Get search url with correct types
|
||||
function getSearchUrl() {
|
||||
let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
|
||||
return `/ajax/search/entities?types=${types}`;
|
||||
}
|
||||
|
||||
// Get initial contents
|
||||
$http.get(getSearchUrl()).then(resp => {
|
||||
scope.entityResults = $sce.trustAsHtml(resp.data);
|
||||
scope.loading = false;
|
||||
});
|
||||
|
||||
// Search when typing
|
||||
scope.searchEntities = function() {
|
||||
scope.loading = true;
|
||||
input.val('');
|
||||
let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
|
||||
$http.get(url).then(resp => {
|
||||
scope.entityResults = $sce.trustAsHtml(resp.data);
|
||||
scope.loading = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
}]);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -112,16 +112,11 @@ $(function () {
|
|||
|
||||
// Common jQuery actions
|
||||
$('[data-action="expand-entity-list-details"]').click(function() {
|
||||
$('.entity-list.compact').find('p').slideToggle(240);
|
||||
$('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
function elemExists(selector) {
|
||||
return document.querySelector(selector) !== null;
|
||||
}
|
||||
|
||||
// Page specific items
|
||||
require('./pages/page-show');
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
var mceOptions = module.exports = {
|
||||
selector: '#html-editor',
|
||||
content_css: [
|
||||
'/css/styles.css'
|
||||
'/css/styles.css',
|
||||
'/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
|
||||
],
|
||||
body_class: 'page-content',
|
||||
relative_urls: false,
|
||||
|
@ -19,11 +20,18 @@ var mceOptions = module.exports = {
|
|||
{title: "Header 1", format: "h1"},
|
||||
{title: "Header 2", format: "h2"},
|
||||
{title: "Header 3", format: "h3"},
|
||||
{title: "Paragraph", format: "p"},
|
||||
{title: "Paragraph", format: "p", exact: true, classes: ''},
|
||||
{title: "Blockquote", format: "blockquote"},
|
||||
{title: "Code Block", icon: "code", format: "pre"},
|
||||
{title: "Inline Code", icon: "code", inline: "code"}
|
||||
{title: "Inline Code", icon: "code", inline: "code"},
|
||||
{title: "Callouts", items: [
|
||||
{title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
|
||||
{title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
|
||||
{title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
|
||||
{title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
|
||||
]}
|
||||
],
|
||||
style_formats_merge: false,
|
||||
formats: {
|
||||
alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
|
||||
aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
|
||||
|
|
|
@ -74,15 +74,15 @@ window.setupPageShow = module.exports = function (pageId) {
|
|||
// Make the book-tree sidebar stick in view on scroll
|
||||
var $window = $(window);
|
||||
var $bookTree = $(".book-tree");
|
||||
var $bookTreeParent = $bookTree.parent();
|
||||
// Check the page is scrollable and the content is taller than the tree
|
||||
var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
|
||||
// Get current tree's width and header height
|
||||
var headerHeight = $("#header").height() + $(".toolbar").height();
|
||||
var isFixed = $window.scrollTop() > headerHeight;
|
||||
var bookTreeWidth = $bookTree.width();
|
||||
// Function to fix the tree as a sidebar
|
||||
function stickTree() {
|
||||
$bookTree.width(bookTreeWidth + 48 + 15);
|
||||
$bookTree.width($bookTreeParent.width() + 15);
|
||||
$bookTree.addClass("fixed");
|
||||
isFixed = true;
|
||||
}
|
||||
|
@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) {
|
|||
unstickTree();
|
||||
}
|
||||
}
|
||||
// The event ran when the window scrolls
|
||||
function windowScrollEvent() {
|
||||
checkTreeStickiness(false);
|
||||
}
|
||||
|
||||
// If the page is scrollable and the window is wide enough listen to scroll events
|
||||
// and evaluate tree stickiness.
|
||||
if (pageScrollable && $window.width() > 1000) {
|
||||
$window.scroll(function() {
|
||||
checkTreeStickiness(false);
|
||||
});
|
||||
$window.on('scroll', windowScrollEvent);
|
||||
checkTreeStickiness(true);
|
||||
}
|
||||
|
||||
// Handle window resizing and switch between desktop/mobile views
|
||||
$window.on('resize', event => {
|
||||
if (pageScrollable && $window.width() > 1000) {
|
||||
$window.on('scroll', windowScrollEvent);
|
||||
checkTreeStickiness(true);
|
||||
} else {
|
||||
$window.off('scroll', windowScrollEvent);
|
||||
unstickTree();
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
|
|
@ -125,3 +125,51 @@
|
|||
margin-right: $-xl;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Callouts
|
||||
*/
|
||||
|
||||
.callout {
|
||||
border-left: 3px solid #BBB;
|
||||
background-color: #EEE;
|
||||
padding: $-s;
|
||||
&:before {
|
||||
font-family: 'Material-Design-Iconic-Font';
|
||||
padding-right: $-s;
|
||||
display: inline-block;
|
||||
}
|
||||
&.success {
|
||||
border-left-color: $positive;
|
||||
background-color: lighten($positive, 45%);
|
||||
color: darken($positive, 16%);
|
||||
}
|
||||
&.success:before {
|
||||
content: '\f269';
|
||||
}
|
||||
&.danger {
|
||||
border-left-color: $negative;
|
||||
background-color: lighten($negative, 34%);
|
||||
color: darken($negative, 20%);
|
||||
}
|
||||
&.danger:before {
|
||||
content: '\f1f2';
|
||||
}
|
||||
&.info {
|
||||
border-left-color: $info;
|
||||
background-color: lighten($info, 50%);
|
||||
color: darken($info, 16%);
|
||||
}
|
||||
&.info:before {
|
||||
content: '\f1f8';
|
||||
}
|
||||
&.warning {
|
||||
border-left-color: $warning;
|
||||
background-color: lighten($warning, 36%);
|
||||
color: darken($warning, 16%);
|
||||
}
|
||||
&.warning:before {
|
||||
content: '\f1f1';
|
||||
}
|
||||
}
|
|
@ -20,6 +20,9 @@
|
|||
&.disabled, &[disabled] {
|
||||
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
|
||||
}
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#html-editor {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
.page-list {
|
||||
h3 {
|
||||
margin: $-l 0 $-m 0;
|
||||
margin: $-l 0 $-xs 0;
|
||||
font-size: 1.666em;
|
||||
}
|
||||
a.chapter {
|
||||
color: $color-chapter;
|
||||
|
@ -8,7 +9,6 @@
|
|||
.inset-list {
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
// padding-left: $-m;
|
||||
margin-bottom: $-l;
|
||||
}
|
||||
h4 {
|
||||
|
@ -338,6 +338,10 @@ ul.pagination {
|
|||
padding-top: $-xs;
|
||||
margin: 0;
|
||||
}
|
||||
> p.empty-text {
|
||||
display: block;
|
||||
font-size: $fs-m;
|
||||
}
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
max-width: 100%;
|
||||
height:auto;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
h1, h2, h3, h4, h5, h6, pre {
|
||||
clear: left;
|
||||
}
|
||||
hr {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
h1 {
|
||||
font-size: 3.625em;
|
||||
font-size: 3.425em;
|
||||
line-height: 1.22222222em;
|
||||
margin-top: 0.48888889em;
|
||||
margin-bottom: 0.48888889em;
|
||||
|
@ -33,10 +33,10 @@ h1, h2, h3, h4 {
|
|||
display: block;
|
||||
color: #555;
|
||||
.subheader {
|
||||
display: block;
|
||||
//display: block;
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
color: lighten($text-dark, 16%);
|
||||
color: lighten($text-dark, 32%);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -225,6 +225,15 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
|
|||
color: $color-chapter;
|
||||
}
|
||||
}
|
||||
.faded .text-book:hover {
|
||||
color: $color-book !important;
|
||||
}
|
||||
.faded .text-chapter:hover {
|
||||
color: $color-chapter !important;
|
||||
}
|
||||
.faded .text-page:hover {
|
||||
color: $color-page !important;
|
||||
}
|
||||
|
||||
span.highlight {
|
||||
//background-color: rgba($primary, 0.2);
|
||||
|
|
|
@ -38,6 +38,7 @@ $primary-dark: #0288D1;
|
|||
$secondary: #e27b41;
|
||||
$positive: #52A256;
|
||||
$negative: #E84F4F;
|
||||
$info: $primary;
|
||||
$warning: $secondary;
|
||||
$primary-faded: rgba(21, 101, 192, 0.15);
|
||||
|
||||
|
|
|
@ -207,3 +207,59 @@ $btt-size: 40px;
|
|||
color: #EEE;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-selector {
|
||||
border: 1px solid #DDD;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
font-size: 0.8em;
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #DDD;
|
||||
font-size: 16px;
|
||||
padding: $-s $-m;
|
||||
}
|
||||
.entity-list {
|
||||
overflow-y: scroll;
|
||||
height: 400px;
|
||||
background-color: #EEEEEE;
|
||||
}
|
||||
.loading {
|
||||
height: 400px;
|
||||
padding-top: $-l;
|
||||
}
|
||||
.entity-list > p {
|
||||
text-align: center;
|
||||
padding-top: $-l;
|
||||
font-size: 1.333em;
|
||||
}
|
||||
.entity-list > div {
|
||||
padding-left: $-m;
|
||||
padding-right: $-m;
|
||||
background-color: #FFF;
|
||||
transition: all ease-in-out 120ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.entity-list-item.selected {
|
||||
h3, i, p ,a, span {
|
||||
color: #EEE;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ return [
|
|||
|
||||
/**
|
||||
* Activity text strings.
|
||||
* Is used for all the text within activity logs.
|
||||
* Is used for all the text within activity logs & notifications.
|
||||
*/
|
||||
|
||||
// Pages
|
||||
|
@ -16,6 +16,7 @@ return [
|
|||
'page_delete_notification' => 'Page Successfully Deleted',
|
||||
'page_restore' => 'restored page',
|
||||
'page_restore_notification' => 'Page Successfully Restored',
|
||||
'page_move' => 'moved page',
|
||||
|
||||
// Chapters
|
||||
'chapter_create' => 'created chapter',
|
||||
|
@ -24,6 +25,7 @@ return [
|
|||
'chapter_update_notification' => 'Chapter Successfully Updated',
|
||||
'chapter_delete' => 'deleted chapter',
|
||||
'chapter_delete_notification' => 'Chapter Successfully Deleted',
|
||||
'chapter_move' => 'moved chapter',
|
||||
|
||||
// Books
|
||||
'book_create' => 'created book',
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="book">
|
||||
<div class="book entity-list-item" data-entity-type="book" data-entity-id="{{$book->id}}">
|
||||
<h3 class="text-book"><a class="text-book" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i>{{$book->name}}</a></h3>
|
||||
@if(isset($book->searchSnippet))
|
||||
<p class="text-muted">{!! $book->searchSnippet !!}</p>
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
<div class="chapter">
|
||||
<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
|
||||
<h3>
|
||||
@if (isset($showPath) && $showPath)
|
||||
<a href="{{ $chapter->book->getUrl() }}" class="text-book">
|
||||
<i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
|
||||
</a>
|
||||
<span class="text-muted"> » </span>
|
||||
@endif
|
||||
<a href="{{ $chapter->getUrl() }}" class="text-chapter">
|
||||
<i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
|
||||
</a>
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
@extends('base')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="faded-small toolbar">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm-12 faded">
|
||||
<div class="breadcrumbs">
|
||||
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
|
||||
<span class="sep">»</span>
|
||||
<a href="{{$chapter->getUrl()}}" class="text-chapter text-button"><i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->getShortName() }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>Move Chapter <small class="subheader">{{$chapter->name}}</small></h1>
|
||||
|
||||
<form action="{{ $chapter->getUrl() }}/move" method="POST">
|
||||
{!! csrf_field() !!}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book'])
|
||||
|
||||
<a href="{{ $chapter->getUrl() }}" class="button muted">Cancel</a>
|
||||
<button type="submit" class="button pos">Move Chapter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -2,15 +2,15 @@
|
|||
|
||||
@section('content')
|
||||
|
||||
<div class="faded-small toolbar" ng-non-bindable>
|
||||
<div class="faded-small toolbar">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-4 faded">
|
||||
<div class="col-sm-8 faded" ng-non-bindable>
|
||||
<div class="breadcrumbs">
|
||||
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8 faded">
|
||||
<div class="col-sm-4 faded">
|
||||
<div class="action-buttons">
|
||||
@if(userCan('page-create', $chapter))
|
||||
<a href="{{$chapter->getUrl() . '/create-page'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>New Page</a>
|
||||
|
@ -18,11 +18,21 @@
|
|||
@if(userCan('chapter-update', $chapter))
|
||||
<a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
|
||||
@endif
|
||||
@if(userCan('restrictions-manage', $chapter))
|
||||
<a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
|
||||
@endif
|
||||
@if(userCan('chapter-delete', $chapter))
|
||||
<a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
|
||||
@if(userCan('chapter-update', $chapter) || userCan('restrictions-manage', $chapter) || userCan('chapter-delete', $chapter))
|
||||
<div dropdown class="dropdown-container">
|
||||
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
|
||||
<ul>
|
||||
@if(userCan('chapter-update', $chapter))
|
||||
<li><a href="{{$chapter->getUrl() . '/move'}}" class="text-primary"><i class="zmdi zmdi-folder"></i>Move</a></li>
|
||||
@endif
|
||||
@if(userCan('restrictions-manage', $chapter))
|
||||
<li><a href="{{$chapter->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
|
||||
@endif
|
||||
@if(userCan('chapter-delete', $chapter))
|
||||
<li><a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -34,18 +34,30 @@
|
|||
@else
|
||||
<h3>Recent Books</h3>
|
||||
@endif
|
||||
@include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
|
||||
@include('partials/entity-list', [
|
||||
'entities' => $recents,
|
||||
'style' => 'compact',
|
||||
'emptyText' => $signedIn ? 'You have not viewed any pages' : 'No books have been created'
|
||||
])
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
|
||||
<div id="recently-created-pages">
|
||||
@include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
|
||||
@include('partials/entity-list', [
|
||||
'entities' => $recentlyCreatedPages,
|
||||
'style' => 'compact',
|
||||
'emptyText' => 'No pages have been recently created'
|
||||
])
|
||||
</div>
|
||||
|
||||
<h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
|
||||
<div id="recently-updated-pages">
|
||||
@include('partials/entity-list', ['entities' => $recentlyUpdatedPages, 'style' => 'compact'])
|
||||
@include('partials/entity-list', [
|
||||
'entities' => $recentlyUpdatedPages,
|
||||
'style' => 'compact',
|
||||
'emptyText' => 'No pages have been recently updated'
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -10,12 +10,12 @@
|
|||
<h4>Page Tags</h4>
|
||||
<div class="padded tags">
|
||||
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
|
||||
<table class="no-style" autosuggestions style="width: 100%;">
|
||||
<table class="no-style" tag-autosuggestions style="width: 100%;">
|
||||
<tbody ui-sortable="sortOptions" ng-model="tags" >
|
||||
<tr ng-repeat="tag in tags track by $index">
|
||||
<td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
|
||||
<td><input autosuggest="/ajax/tags/suggest/names" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
|
||||
<td><input autosuggest="/ajax/tags/suggest/values" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
|
||||
<td><input autosuggest="/ajax/tags/suggest/names" autosuggest-type="name" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
|
||||
<td><input autosuggest="/ajax/tags/suggest/values" autosuggest-type="value" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
|
||||
<td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
<button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea markdown-input md-change="editorChange" md-model="editContent" name="markdown" rows="5"
|
||||
<textarea markdown-input md-change="editorChange" id="markdown-editor-input" md-model="editContent" name="markdown" rows="5"
|
||||
@if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<div class="page {{$page->draft ? 'draft' : ''}}">
|
||||
<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
|
||||
<h3>
|
||||
<a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
|
||||
</h3>
|
||||
|
@ -11,11 +11,11 @@
|
|||
|
||||
@if(isset($style) && $style === 'detailed')
|
||||
<div class="row meta text-muted text-small">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-6">
|
||||
Created {{$page->created_at->diffForHumans()}} @if($page->createdBy)by {{$page->createdBy->name}}@endif <br>
|
||||
Last updated {{ $page->updated_at->diffForHumans() }} @if($page->updatedBy)by {{$page->updatedBy->name}} @endif
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="col-md-6">
|
||||
<a class="text-book" href="{{ $page->book->getUrl() }}"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName(30) }}</a>
|
||||
<br>
|
||||
@if($page->chapter)
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
@extends('base')
|
||||
|
||||
@section('content')
|
||||
|
||||
<div class="faded-small toolbar">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-sm-12 faded">
|
||||
<div class="breadcrumbs">
|
||||
<a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
|
||||
@if($page->hasChapter())
|
||||
<span class="sep">»</span>
|
||||
<a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
|
||||
<i class="zmdi zmdi-collection-bookmark"></i>
|
||||
{{$page->chapter->getShortName()}}
|
||||
</a>
|
||||
@endif
|
||||
<span class="sep">»</span>
|
||||
<a href="{{$page->getUrl()}}" class="text-page text-button"><i class="zmdi zmdi-file-text"></i>{{ $page->getShortName() }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h1>Move Page <small class="subheader">{{$page->name}}</small></h1>
|
||||
|
||||
<form action="{{ $page->getUrl() }}/move" method="POST">
|
||||
{!! csrf_field() !!}
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
@include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter'])
|
||||
|
||||
<a href="{{ $page->getUrl() }}" class="button muted">Cancel</a>
|
||||
<button type="submit" class="button pos">Move Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@stop
|
|
@ -28,15 +28,26 @@
|
|||
</ul>
|
||||
</span>
|
||||
@if(userCan('page-update', $page))
|
||||
<a href="{{$page->getUrl()}}/revisions" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
|
||||
<a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
|
||||
@endif
|
||||
@if(userCan('restrictions-manage', $page))
|
||||
<a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
|
||||
@endif
|
||||
@if(userCan('page-delete', $page))
|
||||
<a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
|
||||
@if(userCan('page-update', $page) || userCan('restrictions-manage', $page) || userCan('page-delete', $page))
|
||||
<div dropdown class="dropdown-container">
|
||||
<a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
|
||||
<ul>
|
||||
@if(userCan('page-update', $page))
|
||||
<li><a href="{{$page->getUrl()}}/move" class="text-primary" ><i class="zmdi zmdi-folder"></i>Move</a></li>
|
||||
<li><a href="{{$page->getUrl()}}/revisions" class="text-primary"><i class="zmdi zmdi-replay"></i>Revisions</a></li>
|
||||
@endif
|
||||
@if(userCan('restrictions-manage', $page))
|
||||
<li><a href="{{$page->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
|
||||
@endif
|
||||
@if(userCan('page-delete', $page))
|
||||
<li><a href="{{$page->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
|
||||
@endif
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
|
||||
{{--Requires an entity to be passed with the name $entity--}}
|
||||
|
||||
@if(count($activity) > 0)
|
||||
<div class="activity-list">
|
||||
@foreach($activity as $activityItem)
|
||||
|
@ -10,5 +8,5 @@
|
|||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-muted">New activity will show up here.</p>
|
||||
<p class="text-muted">No activity to show</p>
|
||||
@endif
|
|
@ -1,22 +1,20 @@
|
|||
@if(Setting::get('app-color'))
|
||||
<style>
|
||||
header, #back-to-top, .primary-background {
|
||||
background-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
.faded-small, .primary-background-light {
|
||||
background-color: {{ Setting::get('app-color-light') }};
|
||||
}
|
||||
.button-base, .button, input[type="button"], input[type="submit"] {
|
||||
background-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
.button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
|
||||
background-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
|
||||
border-bottom-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
|
||||
color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
<style>
|
||||
header, #back-to-top, .primary-background {
|
||||
background-color: {{ Setting::get('app-color') }} !important;
|
||||
}
|
||||
.faded-small, .primary-background-light {
|
||||
background-color: {{ Setting::get('app-color-light') }};
|
||||
}
|
||||
.button-base, .button, input[type="button"], input[type="submit"] {
|
||||
background-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
.button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
|
||||
background-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
|
||||
border-bottom-color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
|
||||
color: {{ Setting::get('app-color') }};
|
||||
}
|
||||
</style>
|
|
@ -16,8 +16,8 @@
|
|||
|
||||
@endforeach
|
||||
@else
|
||||
<p class="text-muted">
|
||||
No items available
|
||||
<p class="text-muted empty-text">
|
||||
{{ $emptyText or 'No items available' }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
|
@ -0,0 +1,8 @@
|
|||
<div class="form-group">
|
||||
<div entity-selector class="entity-selector {{$selectorSize or ''}}" entity-types="{{ $entityTypes or 'book,chapter,page' }}">
|
||||
<input type="hidden" entity-selector-input name="{{$name}}" value="">
|
||||
<input type="text" placeholder="Search" ng-model="search" ng-model-options="{debounce: 200}" ng-change="searchEntities()">
|
||||
<div class="text-center loading" ng-show="loading">@include('partials/loading-icon')</div>
|
||||
<div ng-show="!loading" ng-bind-html="entityResults"></div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,12 +1,12 @@
|
|||
|
||||
<div class="notification anim pos" @if(!Session::has('success')) style="display:none;" @endif>
|
||||
<i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(Session::get('success'))) !!}</span>
|
||||
<div class="notification anim pos" @if(!session()->has('success')) style="display:none;" @endif>
|
||||
<i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
|
||||
</div>
|
||||
|
||||
<div class="notification anim warning stopped" @if(!Session::has('warning')) style="display:none;" @endif>
|
||||
<i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(Session::get('warning'))) !!}</span>
|
||||
<div class="notification anim warning stopped" @if(!session()->has('warning')) style="display:none;" @endif>
|
||||
<i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
|
||||
</div>
|
||||
|
||||
<div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif>
|
||||
<i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(Session::get('error'))) !!}</span>
|
||||
<div class="notification anim neg stopped" @if(!session()->has('error')) style="display:none;" @endif>
|
||||
<i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<div class="entity-list @if(isset($style)){{ $style }}@endif" ng-non-bindable>
|
||||
@if(count($entities) > 0)
|
||||
@foreach($entities as $index => $entity)
|
||||
@if($entity->isA('page'))
|
||||
@include('pages/list-item', ['page' => $entity])
|
||||
@elseif($entity->isA('book'))
|
||||
@include('books/list-item', ['book' => $entity])
|
||||
@elseif($entity->isA('chapter'))
|
||||
@include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true, 'showPath' => true])
|
||||
@endif
|
||||
|
||||
@if($index !== count($entities) - 1)
|
||||
<hr>
|
||||
@endif
|
||||
|
||||
@endforeach
|
||||
@else
|
||||
<p class="text-muted">
|
||||
No items available
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
|
@ -82,4 +82,14 @@ class EntitySearchTest extends TestCase
|
|||
$this->asAdmin()->visit('/search/books?term=' . $book->name)
|
||||
->see('Book Search Results')->see('.entity-list', $book->name);
|
||||
}
|
||||
|
||||
public function test_ajax_entity_search()
|
||||
{
|
||||
$page = \BookStack\Page::all()->last();
|
||||
$notVisitedPage = \BookStack\Page::first();
|
||||
$this->visit($page->getUrl());
|
||||
$this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name);
|
||||
$this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name);
|
||||
$this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,4 +22,47 @@ class SortTest extends TestCase
|
|||
->dontSee($draft->name);
|
||||
}
|
||||
|
||||
public function test_page_move()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$currentBook = $page->book;
|
||||
$newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
|
||||
$this->asAdmin()->visit($page->getUrl() . '/move')
|
||||
->see('Move Page')->see($page->name)
|
||||
->type('book:' . $newBook->id, 'entity_selection')->press('Move Page');
|
||||
|
||||
$page = \BookStack\Page::find($page->id);
|
||||
$this->seePageIs($page->getUrl());
|
||||
$this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
|
||||
|
||||
$this->visit($newBook->getUrl())
|
||||
->seeInNthElement('.activity-list-item', 0, 'moved page')
|
||||
->seeInNthElement('.activity-list-item', 0, $page->name);
|
||||
}
|
||||
|
||||
public function test_chapter_move()
|
||||
{
|
||||
$chapter = \BookStack\Chapter::first();
|
||||
$currentBook = $chapter->book;
|
||||
$pageToCheck = $chapter->pages->first();
|
||||
$newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
|
||||
|
||||
$this->asAdmin()->visit($chapter->getUrl() . '/move')
|
||||
->see('Move Chapter')->see($chapter->name)
|
||||
->type('book:' . $newBook->id, 'entity_selection')->press('Move Chapter');
|
||||
|
||||
$chapter = \BookStack\Chapter::find($chapter->id);
|
||||
$this->seePageIs($chapter->getUrl());
|
||||
$this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
|
||||
|
||||
$this->visit($newBook->getUrl())
|
||||
->seeInNthElement('.activity-list-item', 0, 'moved chapter')
|
||||
->seeInNthElement('.activity-list-item', 0, $chapter->name);
|
||||
|
||||
$pageToCheck = \BookStack\Page::find($pageToCheck->id);
|
||||
$this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
|
||||
$this->visit($pageToCheck->getUrl())
|
||||
->see($newBook->name);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
class ImageTest extends TestCase
|
||||
{
|
||||
|
||||
/**
|
||||
* Get a test image that can be uploaded
|
||||
* @param $fileName
|
||||
* @return \Illuminate\Http\UploadedFile
|
||||
*/
|
||||
protected function getTestImage($fileName)
|
||||
{
|
||||
return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for a test image.
|
||||
* @param $type
|
||||
* @param $fileName
|
||||
* @return string
|
||||
*/
|
||||
protected function getTestImagePath($type, $fileName)
|
||||
{
|
||||
return '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/' . $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image with the given name.
|
||||
* @param $name
|
||||
* @param int $uploadedTo
|
||||
* @return string
|
||||
*/
|
||||
protected function uploadImage($name, $uploadedTo = 0)
|
||||
{
|
||||
$file = $this->getTestImage($name);
|
||||
$this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
|
||||
return $this->getTestImagePath('gallery', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an uploaded image.
|
||||
* @param $relPath
|
||||
*/
|
||||
protected function deleteImage($relPath)
|
||||
{
|
||||
unlink(public_path($relPath));
|
||||
}
|
||||
|
||||
|
||||
public function test_image_upload()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
$admin = $this->getAdmin();
|
||||
$imageName = 'first-image.jpg';
|
||||
|
||||
$relPath = $this->uploadImage($imageName, $page->id);
|
||||
$this->assertResponseOk();
|
||||
|
||||
$this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image exists');
|
||||
|
||||
$this->seeInDatabase('images', [
|
||||
'url' => $relPath,
|
||||
'type' => 'gallery',
|
||||
'uploaded_to' => $page->id,
|
||||
'path' => $relPath,
|
||||
'created_by' => $admin->id,
|
||||
'updated_by' => $admin->id,
|
||||
'name' => $imageName
|
||||
]);
|
||||
|
||||
$this->deleteImage($relPath);
|
||||
}
|
||||
|
||||
public function test_image_delete()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
$imageName = 'first-image.jpg';
|
||||
|
||||
$relPath = $this->uploadImage($imageName, $page->id);
|
||||
$image = \BookStack\Image::first();
|
||||
|
||||
$this->call('DELETE', '/images/' . $image->id);
|
||||
$this->assertResponseOk();
|
||||
|
||||
$this->dontSeeInDatabase('images', [
|
||||
'url' => $relPath,
|
||||
'type' => 'gallery'
|
||||
]);
|
||||
|
||||
$this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has been deleted');
|
||||
}
|
||||
|
||||
}
|
|
@ -39,11 +39,19 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
|
|||
*/
|
||||
public function asAdmin()
|
||||
{
|
||||
return $this->actingAs($this->getAdmin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current admin user.
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAdmin() {
|
||||
if($this->admin === null) {
|
||||
$adminRole = \BookStack\Role::getRole('admin');
|
||||
$this->admin = $adminRole->users->first();
|
||||
}
|
||||
return $this->actingAs($this->admin);
|
||||
return $this->admin;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 5.1 KiB |
Loading…
Reference in New Issue