Merge pull request 'Configurable block layouts' (#6179) from sidebar_management into development

Reviewed-on: https://codeberg.org/bookstack/bookstack/pulls/6179
This commit is contained in:
Dan Brown
2026-08-25 14:11:14 +02:00
106 changed files with 2668 additions and 305 deletions
+9 -42
View File
@@ -2,11 +2,8 @@
namespace BookStack\App;
use BookStack\Activity\ActivityQueries;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\QueryRecentlyViewed;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\PageContent;
use BookStack\Http\Controller;
use BookStack\Util\SimpleListOptions;
@@ -24,49 +21,19 @@ class HomeController extends Controller
*/
public function index(
Request $request,
ActivityQueries $activities,
QueryRecentlyViewed $recentlyViewed,
QueryTopFavourites $topFavourites,
) {
$activity = $activities->latest(10);
$draftPages = [];
if ($this->isSignedIn()) {
$draftPages = $this->queries->pages->currentUserDraftsForList()
->orderBy('updated_at', 'desc')
->with('book')
->take(6)
->get();
}
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ?
$recentlyViewed->run(12 * $recentFactor, 1)
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$favourites = $topFavourites->run(6);
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
->where('draft', false)
->orderBy('updated_at', 'desc')
->take($favourites->count() > 0 ? 5 : 10)
->get();
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
$homepageOption = setting('app-homepage-type', 'default');
if (!in_array($homepageOption, $homepageOptions)) {
$homepageOption = 'default';
$homepageType = setting('app-homepage-type');
if (!in_array($homepageType, ['default', 'books', 'bookshelves', 'page'])) {
$homepageType = 'default';
}
$commonData = [
'activity' => $activity,
'recents' => $recents,
'recentlyUpdatedPages' => $recentlyUpdatedPages,
'draftPages' => $draftPages,
'favourites' => $favourites,
'homeView' => $homepageType,
];
// Add required list ordering & sorting for books & shelves views.
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
$key = $homepageOption;
if ($homepageType === 'bookshelves' || $homepageType === 'books') {
$key = $homepageType;
$view = setting()->getForCurrentUser($key . '_view_type');
$listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
'name' => trans('common.sort_name'),
@@ -80,7 +47,7 @@ class HomeController extends Controller
]);
}
if ($homepageOption === 'bookshelves') {
if ($homepageType === 'bookshelves') {
$shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
@@ -89,7 +56,7 @@ class HomeController extends Controller
return view('home.shelves', $data);
}
if ($homepageOption === 'books') {
if ($homepageType === 'books') {
$books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
@@ -98,7 +65,7 @@ class HomeController extends Controller
return view('home.books', $data);
}
if ($homepageOption === 'page') {
if ($homepageType === 'page') {
$homepageSetting = setting('app-homepage', '0:');
$id = intval(explode(':', $homepageSetting)[0]);
/** @var Page $customHomepage */
@@ -3,7 +3,11 @@
namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Util\DateFormatter;
use BookStack\View\ViewBlockManager;
use BookStack\View\ViewBlockPreferences;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
@@ -18,6 +22,10 @@ class ViewTweaksServiceProvider extends ServiceProvider
$app['config']->get('app.display_timezone'),
);
});
$this->app->singleton(ViewBlockManager::class, function ($app) {
return new ViewBlockManager(new ViewBlockPreferences());
});
}
/**
@@ -32,7 +40,10 @@ class ViewTweaksServiceProvider extends ServiceProvider
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// View Globals
$viewBlockManager = $this->app->make(ViewBlockManager::class);
View::share('dates', $this->app->make(DateFormatter::class));
View::share('viewBlocks', $viewBlockManager);
Theme::dispatch(ThemeEvents::VIEW_BLOCKS_REGISTER, $viewBlockManager);
// Custom blade view directives
Blade::directive('icon', function ($expression) {
+1
View File
@@ -32,6 +32,7 @@ return [
'page-draft-color-dark' => '#a66ce8',
'app-custom-head' => false,
'registration-enabled' => false,
'app-homepage-type' => 'default',
// User-level default settings
'user' => [
+1 -16
View File
@@ -2,10 +2,8 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityQueries;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
@@ -19,7 +17,6 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -34,7 +31,6 @@ class BookController extends Controller
protected BookQueries $queries,
protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
@@ -53,9 +49,6 @@ class BookController extends Controller
$books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
$this->shelfContext->clearShelfContext();
@@ -63,9 +56,6 @@ class BookController extends Controller
return view('books.index', [
'books' => $books,
'recents' => $recents,
'popular' => $popular,
'new' => $new,
'view' => $view,
'listOptions' => $listOptions,
]);
@@ -127,7 +117,7 @@ class BookController extends Controller
/**
* Display the specified book.
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
public function show(Request $request, string $slug)
{
try {
$book = $this->queries->findVisibleBySlugOrFail($slug);
@@ -140,7 +130,6 @@ class BookController extends Controller
}
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get();
View::incrementFor($book);
if ($request->has('shelf')) {
@@ -153,10 +142,6 @@ class BookController extends Controller
'book' => $book,
'current' => $book,
'bookChildren' => $bookChildren,
'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
]);
}
@@ -2,7 +2,6 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
@@ -13,7 +12,6 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\SimpleListOptions;
use Exception;
use Illuminate\Http\Request;
@@ -27,7 +25,6 @@ class BookshelfController extends Controller
protected EntityQueries $entityQueries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
) {
}
@@ -46,21 +43,12 @@ class BookshelfController extends Controller
$shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList()
->orderBy('created_at', 'desc')
->take(4)
->get();
$this->shelfContext->clearShelfContext();
$this->setPageTitle(trans('entities.shelves'));
return view('shelves.index', [
'shelves' => $shelves,
'recents' => $recents,
'popular' => $popular,
'new' => $new,
'view' => $view,
'listOptions' => $listOptions,
]);
@@ -105,7 +93,7 @@ class BookshelfController extends Controller
*
* @throws NotFoundException
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
public function show(Request $request, string $slug)
{
try {
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
@@ -144,9 +132,7 @@ class BookshelfController extends Controller
'shelf' => $shelf,
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
'view' => $view,
'activity' => $activities->entityActivity($shelf, 20, 1),
'listOptions' => $listOptions,
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
]);
}
@@ -3,7 +3,6 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Queries\EntityQueries;
@@ -18,7 +17,6 @@ use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -30,7 +28,6 @@ class ChapterController extends Controller
protected ChapterRepo $chapterRepo,
protected ChapterQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher,
) {
}
@@ -87,10 +84,10 @@ class ChapterController extends Controller
return redirect($chapter->getUrl());
}
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree);
$bookTree = (new BookContents($chapter->book))->getTree();
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $bookTree);
View::incrementFor($chapter);
$this->setPageTitle($chapter->getShortName());
@@ -99,12 +96,10 @@ class ChapterController extends Controller
'book' => $chapter->book,
'chapter' => $chapter,
'current' => $chapter,
'sidebarTree' => $sidebarTree,
'watchOptions' => new UserEntityWatchOptions(user(), $chapter),
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
'bookTree' => $bookTree,
]);
}
+3 -10
View File
@@ -4,7 +4,6 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
@@ -21,7 +20,6 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use Exception;
@@ -36,7 +34,6 @@ class PageController extends Controller
protected PageRepo $pageRepo,
protected PageQueries $queries,
protected EntityQueries $entityQueries,
protected ReferenceFetcher $referenceFetcher
) {
}
@@ -154,11 +151,10 @@ class PageController extends Controller
$pageContent = (new PageContent($page));
$page->html = $pageContent->render();
$pageNav = $pageContent->getNavigation($page->html);
$sidebarTree = (new BookContents($page->book))->getTree();
$bookTree = (new BookContents($page->book))->getTree();
$commentTree = (new CommentTree($page));
$nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree);
$nextPreviousLocator = new NextPreviousContentLocator($page, $bookTree);
View::incrementFor($page);
$this->setPageTitle($page->getShortName());
@@ -167,13 +163,10 @@ class PageController extends Controller
'page' => $page,
'book' => $page->book,
'current' => $page,
'sidebarTree' => $sidebarTree,
'bookTree' => $bookTree,
'commentTree' => $commentTree,
'pageNav' => $pageNav,
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
]);
}
+1
View File
@@ -36,6 +36,7 @@ class Kernel extends HttpKernel
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
\BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class,
\BookStack\Http\Middleware\ClearPerRequestCaches::class,
],
'api' => [
\BookStack\Http\Middleware\ThrottleApiRequests::class,
@@ -0,0 +1,33 @@
<?php
namespace BookStack\Http\Middleware;
use BookStack\View\ViewBlockManager;
use Closure;
use Illuminate\Http\Request;
/**
* Custom middleware to clear any local caches in the app which are created on a
* per-request basis. While this can be somewhat redundant in the normal PHP request
* lifecycle (since in memory caches are cleared on each request), this can be useful
* in testing and to prepare for future long-serving PHP runtimes.
*/
class ClearPerRequestCaches
{
public function __construct(
protected ViewBlockManager $viewBlockManager,
) {
}
public function handle(Request $request, Closure $next)
{
$this->clearCaches();
return $next($request);
}
protected function clearCaches(): void
{
$this->viewBlockManager->clearLocalCache();
}
}
+8
View File
@@ -264,6 +264,14 @@ class SettingService
}
}
/**
* Remove a user-specific setting from the database, for the current access user.
*/
public function removeForCurrentUser(string $key): void
{
$this->remove($this->userKey(user()->id, $key));
}
/**
* Delete settings for a given user id.
*/
+9
View File
@@ -180,6 +180,15 @@ class ThemeEvents
*/
const THEME_REGISTER_VIEWS = 'theme_register_views';
/**
* View blocks register event.
* Runs once a ViewBlockManager instance is available so that custom blocks can be registered
* for use within user-configurable layouts in the system.
*
* @param \BookStack\View\ViewBlockManager $manager
*/
const VIEW_BLOCKS_REGISTER = 'view_blocks_register';
/**
* Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those
@@ -10,6 +10,7 @@ use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Uploads\ImageRepo;
use BookStack\Users\UserRepo;
use BookStack\View\ViewBlockManager;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
@@ -159,6 +160,37 @@ class UserAccountController extends Controller
return redirect('/my-account/notifications');
}
/**
* Show the view for the "Interface Preferences" user account area.
*/
public function showInterface(ViewBlockManager $viewBlockManager)
{
$this->setPageTitle(trans('preferences.interface'));
return view('users.account.interface', [
'category' => 'interface',
'namedLocations' => $viewBlockManager->getNamedLocations(),
]);
}
/**
* Handle the submission of the interface preferences form.
*/
public function updateInterface(Request $request)
{
$this->preventAccessInDemoMode();
$user = user();
$validated = $this->validate($request, [
'language' => ['string', 'max:15', 'alpha_dash'],
'display_mode' => ['string', 'max:15', 'alpha_dash'],
]);
$this->userRepo->update($user, $validated, userCan(Permission::UsersManage));
return redirect('/my-account/interface');
}
/**
* Show the view for the "Access & Security" account options.
*/
+6 -1
View File
@@ -122,7 +122,7 @@ class UserRepo
/**
* Update the given user with the given data, but do not create an activity.
*
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string, display_mode: ?string} $data
*
* @throws UserUpdateException
*/
@@ -153,6 +153,11 @@ class UserRepo
setting()->putUser($user, 'language', $data['language']);
}
if (!empty($data['display_mode'])) {
$value = $data['display_mode'] === 'dark' ? 'true' : 'false';
setting()->putUser($user, 'dark-mode-enabled', $value);
}
$user->save();
return $user;
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace BookStack\View;
use BookStack\Http\Controller;
use Closure;
use Illuminate\Http\Request;
class LayoutController extends Controller
{
public function __construct(
protected ViewBlockManager $viewBlocks,
protected ViewBlockPreferences $viewBlockPreferences,
) {
$this->middleware(function (Request $request, Closure $next) {
$this->preventGuestAccess();
return $next($request);
});
}
/**
* Start editing the layout for a specific location.
*/
public function edit(string $location)
{
$namedLocations = $this->viewBlocks->getNamedLocations();
$locationName = $namedLocations[$location] ?? $location;
$blocks = $this->viewBlocks->getForLocationForCurrentUser($location);
$this->setPageTitle(trans('preferences.layout_edit'));
return view('settings.layouts.edit', [
'location' => $location,
'locationName' => $locationName,
'namedLocations' => $namedLocations,
'blocks' => $blocks,
]);
}
/**
* Update the layout for a specific location for the current user.
*/
public function update(string $location, Request $request)
{
$data = $this->validate($request, [
'layout' => ['required', 'string', 'json'],
]);
$layoutData = json_decode($data['layout'], true, 5);
if (is_array($layoutData)) {
$this->viewBlocks->updatePreferencesFromIdPositionMap($location, $layoutData);
}
$this->showSuccessNotification(trans('preferences.layout_update_success'));
return redirect("/layouts/{$location}");
}
/**
* Reset the layout for a specific location, for the current user,
* back to system defaults by removing any user-specific preferences.
*/
public function reset(string $location)
{
$this->viewBlockPreferences->clearForLocation($location);
$this->showSuccessNotification(trans('preferences.layout_reset_success'));
return redirect("/layouts/{$location}");
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace BookStack\View;
abstract class ViewBlock implements ViewBlockInterface
{
protected static string $id;
protected static string $view;
protected static string $labelTranslationKey;
/**
* @inheritDoc
*/
public static function getId(): string
{
return static::$id;
}
/**
* @inheritDoc
*/
public static function getLabel(): string
{
return trans(static::$labelTranslationKey);
}
/**
* @inheritDoc
*/
public function getView(array $viewData): string
{
return static::$view;
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace BookStack\View;
class ViewBlockDefaults
{
/**
* @var array<string, array<string, class-string<ViewBlockInterface>[]>>
*/
protected static array $defaults = [
'home-default' => [
'left' => [
ViewBlocks\HomeRecentDrafts::class,
ViewBlocks\HomeRecentlyViewedOrRecentBooks::class,
],
'center' => [
ViewBlocks\HomeTopFavourites::class,
ViewBlocks\HomeRecentlyUpdatedPages::class,
],
'right' => [
ViewBlocks\HomeRecentActivity::class,
],
],
'home-non-default' => [
'left' => [
ViewBlocks\HomeRecentDrafts::class,
ViewBlocks\HomeTopFavourites::class,
ViewBlocks\HomeRecentlyViewedOrRecentBooks::class,
ViewBlocks\HomeRecentlyUpdatedPages::class,
ViewBlocks\HomeRecentActivity::class,
],
'right' => [
ViewBlocks\HomeActions::class,
],
],
'shelves-index' => [
'left' => [
ViewBlocks\ShelvesIndexRecents::class,
ViewBlocks\ShelvesIndexPopular::class,
ViewBlocks\ShelvesIndexNew::class,
],
'right' => [
ViewBlocks\ShelvesIndexActions::class,
],
],
'shelves-show' => [
'left' => [
ViewBlocks\ShelvesShowTags::class,
ViewBlocks\ShelvesShowDetails::class,
ViewBlocks\ShelvesShowActivity::class,
],
'right' => [
ViewBlocks\ShelvesShowActions::class,
],
],
'books-index' => [
'left' => [
ViewBlocks\BooksIndexRecents::class,
ViewBlocks\BooksIndexPopular::class,
ViewBlocks\BooksIndexNew::class,
],
'right' => [
ViewBlocks\BooksIndexActions::class,
],
],
'books-show' => [
'left' => [
ViewBlocks\BooksShowSearchForm::class,
ViewBlocks\BooksShowTags::class,
ViewBlocks\BooksShowShelves::class,
ViewBlocks\BooksShowActivity::class,
],
'right' => [
ViewBlocks\BooksShowDetails::class,
ViewBlocks\BooksShowActions::class,
],
],
'chapters-show' => [
'left' => [
ViewBlocks\ChaptersShowSearchForm::class,
ViewBlocks\ChaptersShowTags::class,
ViewBlocks\ChaptersShowBookTree::class,
],
'right' => [
ViewBlocks\ChaptersShowDetails::class,
ViewBlocks\ChaptersShowActions::class,
],
],
'pages-show' => [
'left' => [
ViewBlocks\PagesShowTags::class,
ViewBlocks\PagesShowAttachments::class,
ViewBlocks\PagesShowPageNav::class,
ViewBlocks\PagesShowBookTree::class,
],
'right' => [
ViewBlocks\PagesShowDetails::class,
ViewBlocks\PagesShowActions::class,
],
],
];
/**
* Get the default view blocks for the given location.
*/
public static function getForLocation(string $location): array
{
return self::$defaults[$location] ?? [];
}
/**
* Get the locations for all default blocks.
* @return string[]
*/
public static function getLocations(): array
{
return array_keys(self::$defaults);
}
public static function getLocationLabels(): array
{
return [
'home-default' => trans('common.homepage'),
'home-non-default' => trans('common.homepage'),
'shelves-index' => trans('entities.shelves'),
'shelves-show' => trans('entities.shelf'),
'books-index' => trans('entities.books'),
'books-show' => trans('entities.book'),
'chapters-show' => trans('entities.chapter'),
'pages-show' => trans('entities.page'),
];
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace BookStack\View;
interface ViewBlockInterface
{
/**
* Get the unique ID for this block.
*/
public static function getId(): string;
/**
* Get the label for this block.
*/
public static function getLabel(): string;
/**
* Get the view to render for this block.
* Is provided with the existing parent view data.
*/
public function getView(array $viewData): string;
/**
* Specify the data to pass to the view on render.
* Is provided with the existing parent view data and the original request.
* @return array<string, mixed>
*/
public function withData(array $viewData): array;
}
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace BookStack\View;
use Illuminate\Contracts\Container\BindingResolutionException;
class ViewBlockManager
{
public function __construct(
protected ViewBlockPreferences $preferences,
) {
}
/**
* @var array<string, array<string, class-string<ViewBlockInterface>[]>>
*/
protected array $blocksByLocationAndPosition = [];
/**
* @var array<string, array<string, class-string<ViewBlockInterface>[]>>
*/
protected array $locationBlockCache = [];
/**
* Register a block type to be displayed at the given location and position.
* @param class-string<ViewBlockInterface> $blockClass
*/
public function register(string $location, string $defaultPosition, string $blockClass): void
{
if (!isset($this->blocksByLocationAndPosition[$location])) {
$this->blocksByLocationAndPosition[$location] = [];
}
if (!isset($this->blocksByLocationAndPosition[$location][$defaultPosition])) {
$this->blocksByLocationAndPosition[$location][$defaultPosition] = [];
}
// @phpstan-ignore-next-line
if (!is_a($blockClass, ViewBlockInterface::class, true)) {
throw new \InvalidArgumentException('When registering a view block, the block class must implement ViewBlockInterface');
}
$this->blocksByLocationAndPosition[$location][$defaultPosition][] = $blockClass;
}
/**
* Get all blocks registered for a given location and position, considering the
* preferences for the current user.
* @return ViewBlockInterface[]
* @throws BindingResolutionException
*/
public function getInstancesForLocationAndPositionForCurrentUser(string $location, string $position): array
{
$key = $location;
if (isset($this->locationBlockCache[$key])) {
$blocks = $this->locationBlockCache[$key][$position] ?? [];
return $this->blocksToInstances($blocks);
}
$forLocation = $this->getForLocationForCurrentUser($location);
$this->locationBlockCache[$key] = $forLocation;
$blocks = $forLocation[$position] ?? [];
return $this->blocksToInstances($blocks);
}
/**
* Create instances of the given block classes.
* @param class-string<ViewBlockInterface>[] $blocks
* @return ViewBlockInterface[]
* @throws BindingResolutionException
*/
protected function blocksToInstances(array $blocks): array
{
return array_map(fn (string $blockClass) => app()->make($blockClass), $blocks);
}
/**
* Get all blocks registered for a given location, as sets of arrays
* keyed by position.
* @return array<string, class-string<ViewBlockInterface>[]>
*/
protected function getForLocation(string $location): array
{
$defaults = ViewBlockDefaults::getForLocation($location);
$registered = $this->blocksByLocationAndPosition[$location] ?? [];
return array_merge_recursive($defaults, $registered);
}
/**
* Get all blocks registered for a given location, as sets of arrays
* keyed by position, for the current user.
* Same as above but with user-specific preferences applied.
* @return array<string, list<class-string<ViewBlockInterface>>>
* @throws BindingResolutionException
*/
public function getForLocationForCurrentUser(string $location): array
{
$forLocation = $this->getForLocation($location);
$userBlocksByPosition = $this->preferences->getIdByPositionMap($location);
if (empty($userBlocksByPosition)) {
return $forLocation;
}
$results = [];
$blocksById = $this->blocksByPositionToIdMap($forLocation);
$idPositionMap = $this->blocksByPositionToIdPositionMap($forLocation);
$locations = array_keys($forLocation);
$locations[] = 'unused';
// Add based on user preferences
foreach ($locations as $position) {
$userBlockIds = $userBlocksByPosition[$position] ?? [];
$results[$position] = [];
foreach ($userBlockIds as $blockId) {
$block = $blocksById[$blockId] ?? null;
if ($block) {
$results[$position][] = $block;
unset($blocksById[$blockId]);
}
}
}
// Add remaining blocks based on their default locations
foreach ($blocksById as $block) {
$position = $idPositionMap[$block::getId()] ?? 'unused';
$results[$position][] = $block;
}
return $results;
}
/**
* Get the names of all locations where blocks are registered.
* Returns an array where the keys are location strings, and the
* values are translated labels for that location.
* @return array<string, string>
*/
public function getNamedLocations(): array
{
$labels = ViewBlockDefaults::getLocationLabels();
$defaults = ViewBlockDefaults::getLocations();
$registered = array_keys($this->blocksByLocationAndPosition);
$merged = array_unique(array_merge($defaults, $registered));
$results = [];
foreach ($merged as $location) {
$results[$location] = $labels[$location] ?? $location;
}
$usingDefaultHome = setting('app-homepage-type') === 'default';
$toIgnore = $usingDefaultHome ? 'home-non-default' : 'home-default';
unset($results[$toIgnore]);
return $results;
}
/**
* Update user preferences for a given location to match the given layout data map.
* @param array<string, string[]> $layoutData
* @throws BindingResolutionException
*/
public function updatePreferencesFromIdPositionMap(string $location, array $layoutData): void
{
$this->preferences->storeByIdPositionMap(
$location,
$layoutData,
$this->getForLocation($location),
);
}
/**
* Clear the local user-specific cache of blocks.
* The cache only needs to exist for the current request time since its purpose is to
* avoid duplicate loading across views.
*/
public function clearLocalCache(): void
{
$this->locationBlockCache = [];
}
/**
* Convert a blocksByPosition array into a map of block IDs to blocks.
* @param array<string, class-string<ViewBlockInterface>[]> $blocksByPosition
* @return array<string, class-string<ViewBlockInterface>>
*/
protected function blocksByPositionToIdMap(array $blocksByPosition): array
{
$map = [];
foreach ($blocksByPosition as $position => $blocks) {
foreach ($blocks as $block) {
$map[$block::getId()] = $block;
}
}
return $map;
}
/**
* Convert a blocksByPosition array into a map of block IDs to their positions.
* @param array<string, class-string<ViewBlockInterface>[]> $blocksByPosition
* @return array<string, string>
*/
protected function blocksByPositionToIdPositionMap(array $blocksByPosition): array
{
$map = [];
foreach ($blocksByPosition as $position => $blocks) {
foreach ($blocks as $block) {
$map[$block::getId()] = $position;
}
}
return $map;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace BookStack\View;
use Illuminate\Contracts\Container\BindingResolutionException;
class ViewBlockPreferences
{
/**
* From the request data of the layout editor, validate the request data to ensure the blocks
* and locations are valid and then store the layout data.
* The request data is expected to be in the format of:
* [
* 'position-1' => ['block-id-1', 'block-id-2'],
* 'position-2' => ['block-id-3'],
* ]
* @param array<string, string[]> $layoutData
* @param array<string, class-string<ViewBlockInterface>[]> $validBlocksByPosition
* @throws BindingResolutionException
*/
public function storeByIdPositionMap(
string $location,
array $layoutData,
array $validBlocksByPosition,
): void {
$validIds = $this->extractValidBlockIds($validBlocksByPosition);
$validPositions = array_keys($validBlocksByPosition);
$validPositions[] = 'unused';
// Ignore updates for invalid/unknown locations
if (empty($validBlocksByPosition)) {
return;
}
/** @var array<string, string[]> $validatedLayoutData */
$validatedLayoutData = [];
foreach ($layoutData as $position => $blockIds) {
// @phpstan-ignore-next-line
if (!in_array($position, $validPositions) || !is_array($blockIds)) {
continue;
}
$validatedLayoutData[$position] = array_filter($blockIds, function ($id) use ($validIds) {
return is_string($id) && in_array($id, $validIds);
});
}
$settingKey = $this->getSettingKey($location);
setting()->putForCurrentUser($settingKey, json_encode($validatedLayoutData));
}
/**
* Clear the view block preferences for a given location for the current user.
*/
public function clearForLocation(string $location): void
{
$settingKey = $this->getSettingKey($location);
setting()->removeForCurrentUser($settingKey);
}
/**
* Get the layout data for a given location.
* Provides arrays of block ids keyed by position.
* @return array<string, string[]>
*/
public function getIdByPositionMap(string $location): array
{
$settingKey = $this->getSettingKey($location);
$layoutData = setting()->getForCurrentUser($settingKey, '{}');
return json_decode($layoutData, true) ?? [];
}
protected function getSettingKey(string $location): string
{
return 'view-layout#' . $location;
}
/**
* @param array<string, class-string<ViewBlockInterface>[]> $blocksByPosition
* @return string[]
*/
protected function extractValidBlockIds(array $blocksByPosition): array
{
$ids = [];
foreach ($blocksByPosition as $blocks) {
foreach ($blocks as $block) {
$ids[] = $block::getId();
}
}
return array_unique($ids);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksIndexActions extends ViewBlock
{
protected static string $id = 'builtin_books-index-actions';
protected static string $view = 'books.parts.index-sidebar-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
return [
'view' => $viewData['view'],
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\BookQueries;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksIndexNew extends ViewBlock
{
protected static string $id = 'builtin_books-index-new';
protected static string $view = 'books.parts.index-sidebar-section-new';
protected static string $labelTranslationKey = 'entities.books_new';
public function __construct(
protected BookQueries $queries,
) {
}
public function withData(array $viewData): array
{
$new = $this->queries->visibleForList()
->orderBy('created_at', 'desc')
->take(4)
->get();
return [
'new' => $new,
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\BookQueries;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksIndexPopular extends ViewBlock
{
protected static string $id = 'builtin_books-index-popular';
protected static string $view = 'books.parts.index-sidebar-section-popular';
protected static string $labelTranslationKey = 'entities.books_popular';
public function __construct(
protected BookQueries $queries,
) {
}
public function withData(array $viewData): array
{
return [
'popular' => $this->queries->popularForList()->take(4)->get(),
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\BookQueries;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksIndexRecents extends ViewBlock
{
protected static string $id = 'builtin_books-index-recents';
protected static string $view = 'books.parts.index-sidebar-section-recents';
protected static string $labelTranslationKey = 'entities.recently_viewed';
public function __construct(
protected BookQueries $queries,
) {
}
public function withData(array $viewData): array
{
$recents = null;
if (!user()->isGuest()) {
$recents = $this->queries->recentlyViewedForCurrentUser()->take(4)->get();
}
return [
'recents' => $recents,
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksShowActions extends ViewBlock
{
protected static string $id = 'builtin_books-show-actions';
protected static string $view = 'books.parts.show-sidebar-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
return [
'book' => $book,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\ActivityQueries;
use BookStack\Entities\Models\Book;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksShowActivity extends ViewBlock
{
protected static string $id = 'builtin_books-show-activity';
protected static string $view = 'books.parts.show-sidebar-section-activity';
protected static string $labelTranslationKey = 'entities.recent_activity';
public function __construct(
protected ActivityQueries $activityQueries,
) {
}
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
return [
'activity' => $this->activityQueries->entityActivity($book, 20, 1),
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\References\ReferenceFetcher;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksShowDetails extends ViewBlock
{
protected static string $id = 'builtin_books-show-details';
protected static string $view = 'books.parts.show-sidebar-section-details';
protected static string $labelTranslationKey = 'common.details';
public function __construct(
protected ReferenceFetcher $referenceFetcher,
) {
}
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
$referenceCount = $this->referenceFetcher->getReferenceCountToEntity($book);
return [
'book' => $book,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'referenceCount' => $referenceCount,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksShowSearchForm extends ViewBlock
{
protected static string $id = 'builtin_books-show-search-form';
protected static string $view = 'entities.search-form';
protected static string $labelTranslationKey = 'common.search';
public function withData(array $viewData): array
{
return [
'label' => trans('entities.books_search_this'),
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Book;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksShowShelves extends ViewBlock
{
protected static string $id = 'builtin_books-show-shelves';
protected static string $view = 'books.parts.show-sidebar-section-shelves';
protected static string $labelTranslationKey = 'entities.shelves';
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
$shelves = $book->shelves()->scopes('visible')->get();
return [
'shelves' => $shelves,
];
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class BooksShowTags extends ViewBlock
{
protected static string $id = 'builtin_books-show-tags';
protected static string $view = 'books.parts.show-sidebar-section-tags';
protected static string $labelTranslationKey = 'entities.tags';
public function withData(array $viewData): array
{
return [
'book' => $viewData['book'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Chapter;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ChaptersShowActions extends ViewBlock
{
protected static string $id = 'builtin_chapters-show-actions';
protected static string $view = 'chapters.parts.show-sidebar-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
/** @var Chapter $chapter */
$chapter = $viewData['chapter'];
return [
'chapter' => $chapter,
'watchOptions' => new UserEntityWatchOptions(user(), $chapter),
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Book;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ChaptersShowBookTree extends ViewBlock
{
protected static string $id = 'builtin_chapters-show-book-tree';
protected static string $view = 'entities.book-tree';
protected static string $labelTranslationKey = 'entities.books_navigation';
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
return [
'book' => $book,
'bookTree' => $viewData['bookTree'],
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\References\ReferenceFetcher;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ChaptersShowDetails extends ViewBlock
{
protected static string $id = 'builtin_chapters-show-details';
protected static string $view = 'chapters.parts.show-sidebar-section-details';
protected static string $labelTranslationKey = 'common.details';
public function __construct(
protected ReferenceFetcher $referenceFetcher,
) {
}
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
/** @var Chapter $chapter */
$chapter = $viewData['chapter'];
$referenceCount = $this->referenceFetcher->getReferenceCountToEntity($chapter);
return [
'chapter' => $chapter,
'book' => $book,
'watchOptions' => new UserEntityWatchOptions(user(), $chapter),
'referenceCount' => $referenceCount,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ChaptersShowSearchForm extends ViewBlock
{
protected static string $id = 'builtin_chapters-show-search-form';
protected static string $view = 'entities.search-form';
protected static string $labelTranslationKey = 'common.search';
public function withData(array $viewData): array
{
return [
'label' => trans('entities.chapters_search_this'),
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Chapter;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ChaptersShowTags extends ViewBlock
{
protected static string $id = 'builtin_chapters-show-tags';
protected static string $view = 'chapters.parts.show-sidebar-section-tags';
protected static string $labelTranslationKey = 'entities.tags';
public function withData(array $viewData): array
{
/** @var Chapter $chapter */
$chapter = $viewData['chapter'];
return [
'chapter' => $chapter,
];
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
class HomeActions extends ViewBlock
{
protected static string $id = 'builtin_home-actions';
protected static string $view = 'home.parts.configured-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
return [
'view' => $viewData['view'] ?? '',
'homeView' => $viewData['homeView'] ?? 'default',
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\ActivityQueries;
use BookStack\View\ViewBlockInterface;
use Illuminate\Http\Request;
class HomeRecentActivity implements ViewBlockInterface
{
public function __construct(
protected ActivityQueries $activityQueries
) {
}
public static function getId(): string
{
return 'builtin_home-recent-activity';
}
public static function getLabel(): string
{
return trans('entities.recent_activity');
}
public function getView(array $viewData): string
{
if ($viewData['homeView'] === 'default') {
return 'home.parts.default-card-recent-activity';
}
return 'home.parts.configured-section-recent-activity';
}
public function withData(array $viewData): array
{
$activity = $this->activityQueries->latest(10);
return [
'activity' => $activity,
];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\PageQueries;
use BookStack\View\ViewBlockInterface;
class HomeRecentDrafts implements ViewBlockInterface
{
public function __construct(
protected PageQueries $pageQueries
) {
}
public static function getId(): string
{
return 'builtin_home-recent-drafts';
}
public static function getLabel(): string
{
return trans('entities.my_recent_drafts');
}
public function getView(array $viewData): string
{
if ($viewData['homeView'] === 'default') {
return 'home.parts.default-card-recent-drafts';
}
return 'home.parts.configured-section-recent-drafts';
}
public function withData(array $viewData): array
{
$draftPages = [];
if (!user()->isGuest()) {
$draftPages = $this->pageQueries->currentUserDraftsForList()
->orderBy('updated_at', 'desc')
->with('book')
->take(6)
->get();
}
return [
'draftPages' => $draftPages,
];
}
}
@@ -0,0 +1,47 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\PageQueries;
use BookStack\View\ViewBlockInterface;
use Illuminate\Http\Request;
class HomeRecentlyUpdatedPages implements ViewBlockInterface
{
public function __construct(
protected PageQueries $queries
) {
}
public static function getId(): string
{
return 'builtin_home-recently-updated-pages';
}
public static function getLabel(): string
{
return trans('entities.recently_updated_pages');
}
public function getView(array $viewData): string
{
if ($viewData['homeView'] === 'default') {
return 'home.parts.default-card-recently-updated-pages';
}
return 'home.parts.configured-section-recently-updated-pages';
}
public function withData(array $viewData): array
{
$recentlyUpdatedPages = $this->queries->visibleForList()
->where('draft', false)
->orderBy('updated_at', 'desc')
->take(8)
->get();
return [
'recentlyUpdatedPages' => $recentlyUpdatedPages,
];
}
}
@@ -0,0 +1,54 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\QueryRecentlyViewed;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\View\ViewBlockInterface;
use Illuminate\Http\Request;
class HomeRecentlyViewedOrRecentBooks implements ViewBlockInterface
{
public function __construct(
protected EntityQueries $queries,
protected QueryRecentlyViewed $recentlyViewed,
) {
}
public static function getId(): string
{
return 'builtin_home-recently-viewed-or-recent-books';
}
public static function getLabel(): string
{
$key = user()->isGuest() ? 'books_recent' : 'my_recently_viewed';
return trans("entities.{$key}");
}
public function getView(array $viewData): string
{
if ($viewData['homeView'] === 'default') {
return 'home.parts.default-card-recently-viewed-or-recent-books';
}
return 'home.parts.configured-section-recently-viewed-or-recent-books';
}
public function withData(array $viewData): array
{
if (user()->isGuest()) {
$recents = $this->queries->books->visibleForList()
->orderBy('created_at', 'desc')
->take(10)
->get();
} else {
$recents = $this->recentlyViewed->run(10, 1);
}
return [
'recents' => $recents
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\View\ViewBlockInterface;
use Illuminate\Http\Request;
class HomeTopFavourites implements ViewBlockInterface
{
public function __construct(
protected QueryTopFavourites $topFavourites
) {
}
public static function getId(): string
{
return 'builtin_home-top-favourites';
}
public static function getLabel(): string
{
return trans('entities.my_most_viewed_favourites');
}
public function getView(array $viewData): string
{
if ($viewData['homeView'] === 'default') {
return 'home.parts.default-card-top-favourites';
}
return 'home.parts.configured-section-top-favourites';
}
public function withData(array $viewData): array
{
$favourites = $this->topFavourites->run(6);
return [
'favourites' => $favourites
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Page;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class PagesShowActions extends ViewBlock
{
protected static string $id = 'builtin_pages-show-actions';
protected static string $view = 'pages.parts.show-sidebar-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
/** @var Page $page */
$page = $viewData['page'];
return [
'page' => $page,
'watchOptions' => new UserEntityWatchOptions(user(), $page),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Page;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class PagesShowAttachments extends ViewBlock
{
protected static string $id = 'builtin_pages-show-attachments';
protected static string $view = 'pages.parts.show-sidebar-section-attachments';
protected static string $labelTranslationKey = 'entities.pages_attachments';
public function withData(array $viewData): array
{
/** @var Page $page */
$page = $viewData['page'];
return [
'page' => $page,
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Book;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class PagesShowBookTree extends ViewBlock
{
protected static string $id = 'builtin_pages-show-book-tree';
protected static string $view = 'entities.book-tree';
protected static string $labelTranslationKey = 'entities.books_navigation';
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
return [
'book' => $book,
'bookTree' => $viewData['bookTree'],
];
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\References\ReferenceFetcher;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class PagesShowDetails extends ViewBlock
{
protected static string $id = 'builtin_pages-show-details';
protected static string $view = 'pages.parts.show-sidebar-section-details';
protected static string $labelTranslationKey = 'common.details';
public function __construct(
protected ReferenceFetcher $referenceFetcher,
) {
}
public function withData(array $viewData): array
{
/** @var Book $book */
$book = $viewData['book'];
/** @var Page $page */
$page = $viewData['page'];
$referenceCount = $this->referenceFetcher->getReferenceCountToEntity($page);
return [
'page' => $page,
'book' => $book,
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'referenceCount' => $referenceCount,
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PageContent;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class PagesShowPageNav extends ViewBlock
{
protected static string $id = 'builtin_pages-show-page-nav';
protected static string $view = 'pages.parts.show-sidebar-section-page-nav';
protected static string $labelTranslationKey = 'entities.pages_navigation';
public function withData(array $viewData): array
{
/** @var Page $page */
$page = $viewData['page'];
$pageContent = new PageContent($page);
$pageNav = $pageContent->getNavigation($page->html);
return [
'pageNav' => $pageNav,
];
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Page;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class PagesShowTags extends ViewBlock
{
protected static string $id = 'builtin_pages-show-tags';
protected static string $view = 'pages.parts.show-sidebar-section-tags';
protected static string $labelTranslationKey = 'entities.tags';
public function withData(array $viewData): array
{
/** @var Page $page */
$page = $viewData['page'];
return [
'page' => $page,
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesIndexActions extends ViewBlock
{
protected static string $id = 'builtin_shelves-index-actions';
protected static string $view = 'shelves.parts.index-sidebar-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
return [
'view' => $viewData['view'],
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesIndexNew extends ViewBlock
{
protected static string $id = 'builtin_shelves-index-new';
protected static string $view = 'shelves.parts.index-sidebar-section-new';
protected static string $labelTranslationKey = 'entities.shelves_new';
public function __construct(
protected BookshelfQueries $queries,
) {
}
public function withData(array $viewData): array
{
$new = $this->queries->visibleForList()
->orderBy('created_at', 'desc')
->take(4)
->get();
return [
'new' => $new,
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesIndexPopular extends ViewBlock
{
protected static string $id = 'builtin_shelves-index-popular';
protected static string $view = 'shelves.parts.index-sidebar-section-popular';
protected static string $labelTranslationKey = 'entities.shelves_popular';
public function __construct(
protected BookshelfQueries $queries,
) {
}
public function withData(array $viewData): array
{
return [
'popular' => $this->queries->popularForList()->take(4)->get(),
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesIndexRecents extends ViewBlock
{
protected static string $id = 'builtin_shelves-index-recents';
protected static string $view = 'shelves.parts.index-sidebar-section-recents';
protected static string $labelTranslationKey = 'entities.recently_viewed';
public function __construct(
protected BookshelfQueries $queries,
) {
}
public function withData(array $viewData): array
{
$recents = null;
if (!user()->isGuest()) {
$recents = $this->queries->recentlyViewedForCurrentUser()->take(4)->get();
}
return [
'recents' => $recents,
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Bookshelf;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesShowActions extends ViewBlock
{
protected static string $id = 'builtin_shelves-show-actions';
protected static string $view = 'shelves.parts.show-sidebar-section-actions';
protected static string $labelTranslationKey = 'common.actions';
public function withData(array $viewData): array
{
/** @var Bookshelf $shelf */
$shelf = $viewData['shelf'];
return [
'shelf' => $shelf,
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Activity\ActivityQueries;
use BookStack\Entities\Models\Bookshelf;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesShowActivity extends ViewBlock
{
protected static string $id = 'builtin_shelves-show-activity';
protected static string $view = 'shelves.parts.show-sidebar-section-activity';
protected static string $labelTranslationKey = 'entities.recent_activity';
public function __construct(
protected ActivityQueries $activityQueries,
) {
}
public function withData(array $viewData): array
{
/** @var Bookshelf $shelf */
$shelf = $viewData['shelf'];
return [
'activity' => $this->activityQueries->entityActivity($shelf, 20, 1),
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\Entities\Models\Bookshelf;
use BookStack\References\ReferenceFetcher;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesShowDetails extends ViewBlock
{
protected static string $id = 'builtin_shelves-show-details';
protected static string $view = 'shelves.parts.show-sidebar-section-details';
protected static string $labelTranslationKey = 'common.details';
public function __construct(
protected ReferenceFetcher $referenceFetcher,
) {
}
public function withData(array $viewData): array
{
/** @var Bookshelf $shelf */
$shelf = $viewData['shelf'];
$referenceCount = $this->referenceFetcher->getReferenceCountToEntity($shelf);
return [
'shelf' => $shelf,
'referenceCount' => $referenceCount,
];
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace BookStack\View\ViewBlocks;
use BookStack\View\ViewBlock;
use Illuminate\Http\Request;
class ShelvesShowTags extends ViewBlock
{
protected static string $id = 'builtin_shelves-show-tags';
protected static string $view = 'shelves.parts.show-sidebar-section-tags';
protected static string $labelTranslationKey = 'entities.tags';
public function withData(array $viewData): array
{
return [
'shelf' => $viewData['shelf'],
];
}
}
+102 -1
View File
@@ -193,4 +193,105 @@ Theme::addSocialDriver('reddit', [
$driver->with(['prompt' => 'select_account']);
$driver->scopes(['open_id']);
});
```
```
## Custom View Layout Block Example
By listening to the `ThemeEvents::VIEW_BLOCKS_REGISTER` event, it's possible to register
custom view blocks which can be shown in a layout in the application. View blocks are typically the sections shown in the sidebar,
or the cards shown on the home view.
As an example of using this system, we'll define a custom view block which will show on the default home view to state how many
books there are in the system.
To start, we'll need to create a custom class to represent our view block. This must implement `\BookStack\View\ViewBlockInterface`:
```php
use BookStack\Entities\Queries\BookQueries;
use BookStack\View\ViewBlockInterface;
use BookStack\View\ViewBlockManager;
class BookTotalBlock implements ViewBlockInterface {
public function __construct(
protected BookQueries $bookQueries
) {
}
public static function getId(): string
{
return 'custom_book_total_block';
}
public static function getLabel(): string
{
return 'Total books displays';
}
public function getView(array $viewData): string
{
return 'blocks.total-blocks';
}
public function withData(array $viewData): array
{
$totalBooks = $this->bookQueries->visibleForList()->count();
return [
'totalBooks' => $totalBooks,
];
}
}
```
The interface has a few required methods:
- The `getId` method must provide a unique per-block-type string ID.
- The `getLabel` method must provide a general string label for the block.
- The `getView` method provides a string path to a view file (Can be one custom registered).
- This is provided the available view data at time of render, so it can be dynamic based on context.
- The `withData` method is called when block is being rendered, and it should return an array of data which will be merged with existing view data.
- This is also provided available view data, for use as context.
In this example, we're also making use of the `BookQueries` internal BookStack class.
You're able to inject any other dependent classes/services via the constructor like this, and BookStack will attempt to auto-resolve them.
We can then register this block class using the logical theme system like so:
```php
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\View\ViewBlockManager;
Theme::listen(ThemeEvents::VIEW_BLOCKS_REGISTER, function (ViewBlockManager $manager) {
$manager->register(
'home-default', // The location/layout where this block will be displayed
'right', // The default position for the block within the location
BookTotalBlock::class // The block class to register
);
});
```
The above registration code would typically be within your `functions.php` theme file.
You could also define the above block class in the same file or separate it into its own file
and include it from the `functions.php` via a `require_once()` call.
Lastly, we'll need to create the view for the registered block.
In our example we return `blocks.total-blocks` from the `getView` method, so our view needs to be located at
`blocks/total-blocks.blade.php` from a view providing directory.
In a theme folder, we can just create it at `blocks/total-blocks.blade.php` within the folder path.
If we were building in a module, we'd need to create this at `views/blocks/total-blocks.blade.php` within our module folder.
For our example, we'll use this content to make use of the data we're passing to the view:
```html
<div class="card mb-xl">
<h3 class="card-title">Total Books</h3>
<div class="px-m pb-xs">
<p>
There are currently {{ $totalBooks }} books in the system!
</p>
</div>
</div>
```
This will then show up on the default home grid view, in the right column.
The exact position within that location may change depending on user preferences, but the block will be limited
to the location provided during registration unless it has also been registered for other locations.
+2
View File
@@ -84,6 +84,8 @@ return [
'status_inactive' => 'Inactive',
'never' => 'Never',
'none' => 'None',
'move_left' => 'Move Left',
'move_right' => 'Move Right',
// Header
'homepage' => 'Homepage',
+21
View File
@@ -19,6 +19,27 @@ return [
'shortcuts_update_success' => 'Shortcut preferences have been updated!',
'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.',
'interface' => 'Interface Preferences',
'interface_desc' => 'Here you can find options to customize the appearance of the application user interface.',
'interface_display_mode' => 'Display Mode',
'interface_display_mode_desc' => 'Choose whether the application should show in dark or light mode. This can also be toggled from the home view, or via the profile dropdown in the header bar.',
'layouts' => 'UI Layout Preferences',
'layouts_desc' => 'Customize the layout of sections shown in the user interface for a range of views.',
'layout_edit' => 'Edit Layout',
'layout_edit_desc' => 'Drag and drop sections, or use the action menu found on each, to reconfigure which sections show within this layout in the interface, and where they are displayed.',
'layout_edit_column_hint' => 'When viewed on smaller screen sizes, right column sections will be stacked on top of left column sections.',
'layout_edit_save' => 'Save Layout',
'layout_edit_layouts' => 'Layouts',
'layout_edit_back_to_preferences' => 'Back to Preferences',
'layout_edit_left' => 'Left',
'layout_edit_right' => 'Right',
'layout_edit_center' => 'Center',
'layout_edit_unused' => 'Unused',
'layout_edit_empty' => 'No sections to display',
'layout_edit_reset_to_defaults' => 'Reset to Default',
'layout_update_success' => 'Layout preferences have been updated!',
'layout_reset_success' => 'Layout preferences have been reset!',
'notifications' => 'Notification Preferences',
'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.',
'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own',
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M14.59 6 16 7.41 11.42 12 16 16.59 14.59 18l-6-6z"/></svg>

After

Width:  |  Height:  |  Size: 127 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path d="M440-120H200q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h240v720Zm-80-80v-560H200v560h160Zm160-320v-320h240q33 0 56.5 23.5T840-760v240H520Zm80-80h160v-160H600v160Zm-80 480v-320h320v240q0 33-23.5 56.5T760-120H520Zm80-80h160v-160H600v160ZM360-480Zm240-120Zm0 240Z"/></svg>

After

Width:  |  Height:  |  Size: 379 B

+5 -10
View File
@@ -12,7 +12,7 @@ export class Dropdown extends Component {
this.container = this.$el;
this.menu = this.$refs.menu;
this.toggle = this.$refs.toggle;
this.moveMenu = this.$opts.moveMenu;
this.fixedPositionMenu = this.$opts.fixedPositionMenu === 'true';
this.bubbleEscapes = this.$opts.bubbleEscapes === 'true';
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
@@ -33,13 +33,13 @@ export class Dropdown extends Component {
const menuOriginalRect = this.menu.getBoundingClientRect();
let heightOffset = 0;
const toggleHeight = this.toggle.getBoundingClientRect().height;
const containerBounds = findClosestScrollContainer(this.menu).getBoundingClientRect();
const containerEl = this.fixedPositionMenu ? this.body : findClosestScrollContainer(this.menu);
const containerBounds = containerEl.getBoundingClientRect();
const dropUpwards = menuOriginalRect.bottom > containerBounds.bottom;
const containerRect = this.container.getBoundingClientRect();
// If enabled, Move to body to prevent being trapped within scrollable sections
if (this.moveMenu) {
this.body.appendChild(this.menu);
if (this.fixedPositionMenu) {
this.menu.style.position = 'fixed';
this.menu.style.width = `${menuOriginalRect.width}px`;
this.menu.style.left = `${menuOriginalRect.left}px`;
@@ -99,12 +99,11 @@ export class Dropdown extends Component {
this.menu.style.bottom = '';
this.menu.style.maxHeight = '';
if (this.moveMenu) {
if (this.fixedPositionMenu) {
this.menu.style.position = '';
this.menu.style[this.direction] = '';
this.menu.style.width = '';
this.menu.style.left = '';
this.container.appendChild(this.menu);
}
this.showing = false;
@@ -125,10 +124,6 @@ export class Dropdown extends Component {
this.hide();
});
if (this.moveMenu) {
keyboardNavHandler.shareHandlingToEl(this.menu);
}
// Hide menu on option click
this.container.addEventListener('click', event => {
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
+1
View File
@@ -30,6 +30,7 @@ export {GlobalSearch} from './global-search';
export {HeaderMobileToggle} from './header-mobile-toggle';
export {ImageManager} from './image-manager';
export {ImagePicker} from './image-picker';
export {LayoutEditor} from './layout-editor';
export {ListSortControl} from './list-sort-control';
export {LoadingButton} from './loading-button';
export {MarkdownEditor} from './markdown-editor';
+82
View File
@@ -0,0 +1,82 @@
import Sortable from "sortablejs";
import {Component} from "./component";
import {buildListActions, sortActionClickListener} from "../services/multi-lists";
export class LayoutEditor extends Component {
protected input!: HTMLInputElement;
protected columns!: HTMLElement[];
protected actionMenus!: HTMLElement[];
setup(): void {
this.input = this.$refs.input as HTMLInputElement;
this.columns = this.$manyRefs.column || [];
this.actionMenus = this.$manyRefs.actionMenu || [];
this.initSortable();
const unusedColumn = this.columns.find(column => column.dataset.column === 'unused') as HTMLElement;
const listActions = buildListActions(...this.columns);
listActions.add = this.addBlockToLeastPopulated.bind(this);
listActions.remove = function (item: HTMLElement) {
unusedColumn.appendChild(item);
};
const sortActionListener = sortActionClickListener(listActions, this.onChange.bind(this));
for (const actionMenu of this.actionMenus) {
actionMenu.addEventListener('click', sortActionListener);
}
}
protected initSortable(): void {
const sortAction = this.onChange.bind(this);
for (const column of this.columns) {
new Sortable(column, {
group: 'layout-editor-blocks',
ghostClass: 'primary-background-light',
handle: '.handle',
animation: 150,
onSort: sortAction,
});
}
}
protected onChange(): void {
const configured: Record<string, string[]> = {};
for (const column of this.columns) {
const location = column.dataset.column || '';
if (!location) continue;
configured[location] = [];
const blockNodes = column.children;
for (let i = 0; i < blockNodes.length; i++) {
const blockNode = blockNodes[i] as HTMLElement;
const blockId = blockNode.dataset.blockId || '';
if (!blockId) continue;
configured[location].push(blockId);
}
}
this.input.value = JSON.stringify(configured, null, 2);
}
protected addBlockToLeastPopulated(item: HTMLElement): void {
let populationCount = 100;
let leastPopulated: null|HTMLElement = null
for (const column of this.columns) {
const blockCount = column.children.length;
if (column.dataset.column === 'unused') {
continue;
}
if (blockCount < populationCount) {
leastPopulated = column;
populationCount = blockCount;
}
}
if (leastPopulated) {
leastPopulated.appendChild(item);
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
import Sortable from 'sortablejs';
import {Component} from './component';
import {buildListActions, sortActionClickListener} from '../services/dual-lists.ts';
import {buildListActions, sortActionClickListener} from '../services/multi-lists.ts';
export class ShelfSort extends Component {
+1 -1
View File
@@ -1,6 +1,6 @@
import {Component} from "./component.js";
import Sortable from "sortablejs";
import {buildListActions, sortActionClickListener} from "../services/dual-lists";
import {buildListActions, sortActionClickListener} from "../services/multi-lists";
export class SortRuleManager extends Component {
+1 -1
View File
@@ -259,7 +259,7 @@ export function hashElement(element: HTMLElement): string {
}
/**
* Find the closest scroll container parent for the given element
* Find the closest scroll container parent for the given element,
* otherwise will default to the body element.
*/
export function findClosestScrollContainer(start: HTMLElement): HTMLElement {
+3 -1
View File
@@ -87,7 +87,9 @@ export class KeyboardNavigationHandler {
const focusable: HTMLElement[] = [];
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])';
for (const container of this.containers) {
const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e));
const toAdd = [...container.querySelectorAll(selector)].filter(e => {
return isHTMLElement(e) && e.checkVisibility();
}) as HTMLElement[];
focusable.push(...toAdd);
}
@@ -1,13 +1,12 @@
/**
* Service for helping manage common dual-list scenarios.
* (Shelf book manager, sort set manager).
* (Shelf book manager, sort set manager, layout-editor).
*/
type ListActionsSet = Record<string, ((item: HTMLElement) => void)>;
export function buildListActions(
availableList: HTMLElement,
configuredList: HTMLElement,
...lists: HTMLElement[]
): ListActionsSet {
return {
move_up(item) {
@@ -22,11 +21,29 @@ export function buildListActions(
const newIndex = Math.min(index + 2, list.children.length);
list.insertBefore(item, list.children[newIndex] || null);
},
move_right(item) {
const list = item.parentNode as HTMLElement;
const listIndex = lists.indexOf(list);
const targetListIndex = Math.min(listIndex + 1, lists.length - 1);
lists[targetListIndex].appendChild(item);
},
move_left(item) {
const list = item.parentNode as HTMLElement;
const listIndex = lists.indexOf(list);
const targetListIndex = Math.max(listIndex - 1, 0);
lists[targetListIndex].appendChild(item);
},
remove(item) {
availableList.appendChild(item);
const otherList = lists.find(list => list !== item.parentNode);
if (otherList) {
otherList.appendChild(item);
}
},
add(item) {
configuredList.appendChild(item);
const otherList = lists.find(list => list !== item.parentNode);
if (otherList) {
otherList.appendChild(item);
}
},
};
}
+11 -3
View File
@@ -1246,11 +1246,19 @@ input.scroll-box-search, .scroll-box-header-item {
display: none;
}
.scroll-box > li.empty-state {
.scroll-box.layout-editor-column-left [data-action="move_left"],
.scroll-box.layout-editor-column-right [data-action="move_right"],
.scroll-box.layout-editor-column-unused [data-action="move_right"],
.scroll-box.layout-editor-column-unused [data-action="move_left"],
.scroll-box.layout-editor-column-unused [data-action="remove"],
.scroll-box.layout-editor-column-left [data-action="add"],
.scroll-box.layout-editor-column-right [data-action="add"],
.scroll-box.layout-editor-column-center [data-action="add"],
{
display: none;
}
.scroll-box > li.empty-state:last-child {
display: list-item;
.scroll-box:has(> li:not(.empty-state)) .empty-state {
display: none;
}
details.section-expander summary {
+2 -4
View File
@@ -5,11 +5,9 @@
@stop
@section('left')
@include('books.parts.index-sidebar-section-recents', ['recents' => $recents])
@include('books.parts.index-sidebar-section-popular', ['popular' => $popular])
@include('books.parts.index-sidebar-section-new', ['new' => $new])
@include('common.view-blocks', ['location' => 'books-index', 'position' => 'left'])
@stop
@section('right')
@include('books.parts.index-sidebar-section-actions', ['view' => $view])
@include('common.view-blocks', ['location' => 'books-index', 'position' => 'right'])
@stop
@@ -1,7 +1,7 @@
<div class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
<div class="blended-links">
@include('entities.meta', ['entity' => $book, 'watchOptions' => $watchOptions])
@include('entities.meta', ['entity' => $book, 'watchOptions' => $watchOptions, 'referenceCount' => $referenceCount])
@if($book->hasPermissions())
<div class="active-restriction">
@if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $book))
@@ -1,6 +1,6 @@
@if(count($bookParentShelves) > 0)
@if(count($shelves) > 0)
<div class="actions mb-xl">
<h5>{{ trans('entities.shelves') }}</h5>
@include('entities.list', ['entities' => $bookParentShelves, 'style' => 'compact'])
@include('entities.list', ['entities' => $shelves, 'style' => 'compact'])
</div>
@endif
+4 -9
View File
@@ -66,15 +66,10 @@
@stop
@section('right')
@include('books.parts.show-sidebar-section-details', ['book' => $book, 'watchOptions' => $watchOptions])
@include('books.parts.show-sidebar-section-actions', ['book' => $book, 'watchOptions' => $watchOptions])
@stop
@section('left')
@include('entities.search-form', ['label' => trans('entities.books_search_this')])
@include('books.parts.show-sidebar-section-tags', ['book' => $book])
@include('books.parts.show-sidebar-section-shelves', ['bookParentShelves' => $bookParentShelves])
@include('books.parts.show-sidebar-section-activity', ['activity' => $activity])
@include('common.view-blocks', ['location' => 'books-show', 'position' => 'left'])
@stop
@section('right')
@include('common.view-blocks', ['location' => 'books-show', 'position' => 'right'])
@stop
+4 -9
View File
@@ -62,15 +62,10 @@
@stop
@section('right')
@include('chapters.parts.show-sidebar-section-details', ['chapter' => $chapter, 'book' => $book, 'watchOptions' => $watchOptions])
@include('chapters.parts.show-sidebar-section-actions', ['chapter' => $chapter, 'watchOptions' => $watchOptions])
@stop
@section('left')
@include('entities.search-form', ['label' => trans('entities.chapters_search_this')])
@include('chapters.parts.show-sidebar-section-tags', ['chapter' => $chapter])
@include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
@include('common.view-blocks', ['location' => 'chapters-show', 'position' => 'left'])
@stop
@section('right')
@include('common.view-blocks', ['location' => 'chapters-show', 'position' => 'right'])
@stop
@@ -0,0 +1,10 @@
{{--
$location - string
$position - string
--}}
@php
$sectionVars = get_defined_vars();
@endphp
@foreach($viewBlocks->getInstancesForLocationAndPositionForCurrentUser($location, $position) as $block)
@include($block->getView($sectionVars), $block->withData($sectionVars))
@endforeach
+1 -1
View File
@@ -11,7 +11,7 @@
</li>
@endif
@foreach($sidebarTree as $bookChild)
@foreach($bookTree as $bookChild)
<li class="list-item-{{ $bookChild->getType() }} {{ $bookChild->getType() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
@include('entities.list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
+2 -19
View File
@@ -5,26 +5,9 @@
@stop
@section('left')
@include('home.parts.sidebar')
@include('common.view-blocks', ['location' => 'home-non-default', 'position' => 'left'])
@stop
@section('right')
<div class="actions mb-xl">
<h5>{{ trans('common.actions') }}</h5>
<div class="icon-list text-link">
@if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
<a href="{{ url("/create-book") }}" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.books_create') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
<a href="{{ url('/tags') }}" class="icon-list-item">
<span>@icon('tag')</span>
<span>{{ trans('entities.tags_view_tags') }}</span>
</a>
@include('home.parts.expand-toggle', ['classes' => 'text-link', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
@include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-link'])
</div>
</div>
@include('common.view-blocks', ['location' => 'home-non-default', 'position' => 'right'])
@stop
+3 -52
View File
@@ -16,65 +16,16 @@
<div class="container" id="home-default">
<div class="grid third gap-x-xxl no-row-gap">
<div>
@if(count($draftPages) > 0)
<div id="recent-drafts" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.my_recent_drafts') }}</h3>
<div class="px-m">
@include('entities.list', ['entities' => $draftPages, 'style' => 'compact'])
</div>
</div>
@endif
<div id="{{ auth()->check() ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h3>
<div class="px-m">
@include('entities.list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
])
</div>
</div>
@include('common.view-blocks', ['location' => 'home-default', 'position' => 'left'])
</div>
<div>
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
<div class="px-m">
@include('entities.list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
<a href="{{ url('/favourites') }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
</div>
@endif
<div id="recent-pages" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.recently_updated_pages') }}</h3>
<div id="recently-updated-pages" class="px-m">
@include('entities.list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => trans('entities.no_pages_recently_updated'),
])
</div>
@if(count($recentlyUpdatedPages) > 0)
<a href="{{ url("/pages/recently-updated") }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
@endif
</div>
@include('common.view-blocks', ['location' => 'home-default', 'position' => 'center'])
</div>
<div>
<div id="recent-activity" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.recent_activity') }}</h3>
<div class="px-m">
@include('common.activity-list', ['activity' => $activity])
</div>
</div>
@include('common.view-blocks', ['location' => 'home-default', 'position' => 'right'])
</div>
</div>
</div>
@@ -0,0 +1,36 @@
{{--
$homeView - string - The type of home view being used
$view - string - Configured view type from parent (for books/shelves list/grid view)
--}}
<div class="actions mb-xl">
<h5>{{ trans('common.actions') }}</h5>
<div class="icon-list text-link">
@if($homeView === 'books')
@if(userCan(\BookStack\Permissions\Permission::BookCreateAll))
<a href="{{ url("/create-book") }}" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.books_create') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
<a href="{{ url('/tags') }}" class="icon-list-item">
<span>@icon('tag')</span>
<span>{{ trans('entities.tags_view_tags') }}</span>
</a>
@elseif ($homeView === 'bookshelves')
@if(userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
<a href="{{ url("/create-shelf") }}" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.shelves_new_action') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
<a href="{{ url('/tags') }}" class="icon-list-item">
<span>@icon('tag')</span>
<span>{{ trans('entities.tags_view_tags') }}</span>
</a>
@endif
@include('home.parts.expand-toggle', ['classes' => 'text-link', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
@include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-link'])
</div>
</div>
@@ -0,0 +1,7 @@
{{--
$activity - array
--}}
<div id="recent-activity" class="mb-xl">
<h5>{{ trans('entities.recent_activity') }}</h5>
@include('common.activity-list', ['activity' => $activity])
</div>
@@ -0,0 +1,9 @@
{{--
$draftPages - array
--}}
@if(count($draftPages) > 0)
<div id="recent-drafts" class="mb-xl">
<h5>{{ trans('entities.my_recent_drafts') }}</h5>
@include('entities.list', ['entities' => $draftPages, 'style' => 'compact'])
</div>
@endif
@@ -0,0 +1,14 @@
{{--
$recentlyUpdatedPages - array
--}}
<div class="mb-xl">
<h5>{{ trans('entities.recently_updated_pages') }}</h5>
<div id="recently-updated-pages">
@include('entities.list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => trans('entities.no_pages_recently_updated')
])
</div>
<a href="{{ url('/pages/recently-updated') }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
</div>
@@ -0,0 +1,11 @@
{{--
$recents - array
--}}
<div class="mb-xl">
<h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
@include('entities.list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
])
</div>
@@ -0,0 +1,13 @@
{{--
$favourites - array
--}}
@if(count($favourites) > 0)
<div id="top-favourites" class="mb-xl">
<h5>{{ trans('entities.my_most_viewed_favourites') }}</h5>
@include('entities.list', [
'entities' => $favourites,
'style' => 'compact',
])
<a href="{{ url('/favourites') }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
</div>
@endif
@@ -0,0 +1,9 @@
{{--
$activity - array
--}}
<div id="recent-activity" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.recent_activity') }}</h3>
<div class="px-m">
@include('common.activity-list', ['activity' => $activity])
</div>
</div>
@@ -0,0 +1,11 @@
{{--
$draftPages - array
--}}
@if(count($draftPages) > 0)
<div id="recent-drafts" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.my_recent_drafts') }}</h3>
<div class="px-m">
@include('entities.list', ['entities' => $draftPages, 'style' => 'compact'])
</div>
</div>
@endif
@@ -0,0 +1,16 @@
{{--
$recentlyUpdatedPages - array
--}}
<div id="recent-pages" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.recently_updated_pages') }}</h3>
<div id="recently-updated-pages" class="px-m">
@include('entities.list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => trans('entities.no_pages_recently_updated'),
])
</div>
@if(count($recentlyUpdatedPages) > 0)
<a href="{{ url("/pages/recently-updated") }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
@endif
</div>
@@ -0,0 +1,14 @@
{{--
$recents - array
--}}
<div id="{{ auth()->check() ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h3>
<div class="px-m">
@include('entities.list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
])
</div>
</div>
@@ -0,0 +1,15 @@
{{--
$favourites - array
--}}
@if(count($favourites) > 0)
<div id="top-favourites" class="card mb-xl">
<h3 class="card-title">{{ trans('entities.my_most_viewed_favourites') }}</h3>
<div class="px-m">
@include('entities.list', [
'entities' => $favourites,
'style' => 'compact',
])
</div>
<a href="{{ url('/favourites') }}" class="card-footer-link">{{ trans('common.view_all') }}</a>
</div>
@endif
@@ -1,43 +0,0 @@
@if(count($draftPages) > 0)
<div id="recent-drafts" class="mb-xl">
<h5>{{ trans('entities.my_recent_drafts') }}</h5>
@include('entities.list', ['entities' => $draftPages, 'style' => 'compact'])
</div>
@endif
@if(count($favourites) > 0)
<div id="top-favourites" class="mb-xl">
<h5>{{ trans('entities.my_most_viewed_favourites') }}</h5>
@include('entities.list', [
'entities' => $favourites,
'style' => 'compact',
])
<a href="{{ url('/favourites') }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
</div>
@endif
<div class="mb-xl">
<h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
@include('entities.list', [
'entities' => $recents,
'style' => 'compact',
'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
])
</div>
<div class="mb-xl">
<h5>{{ trans('entities.recently_updated_pages') }}</h5>
<div id="recently-updated-pages">
@include('entities.list', [
'entities' => $recentlyUpdatedPages,
'style' => 'compact',
'emptyText' => trans('entities.no_pages_recently_updated')
])
</div>
<a href="{{ url('/pages/recently-updated') }}" class="text-muted block py-xs">{{ trans('common.view_all') }}</a>
</div>
<div id="recent-activity" class="mb-xl">
<h5>{{ trans('entities.recent_activity') }}</h5>
@include('common.activity-list', ['activity' => $activity])
</div>
+2 -19
View File
@@ -5,26 +5,9 @@
@stop
@section('left')
@include('home.parts.sidebar')
@include('common.view-blocks', ['location' => 'home-non-default', 'position' => 'left'])
@stop
@section('right')
<div class="actions mb-xl">
<h5>{{ trans('common.actions') }}</h5>
<div class="icon-list text-link">
@if(userCan(\BookStack\Permissions\Permission::BookshelfCreateAll))
<a href="{{ url("/create-shelf") }}" class="icon-list-item">
<span>@icon('add')</span>
<span>{{ trans('entities.shelves_new_action') }}</span>
</a>
@endif
@include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
<a href="{{ url('/tags') }}" class="icon-list-item">
<span>@icon('tag')</span>
<span>{{ trans('entities.tags_view_tags') }}</span>
</a>
@include('home.parts.expand-toggle', ['classes' => 'text-link', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
@include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-link'])
</div>
</div>
@include('common.view-blocks', ['location' => 'home-non-default', 'position' => 'right'])
@stop
+2 -8
View File
@@ -13,15 +13,9 @@
@stop
@section('left')
@include('home.parts.sidebar')
@include('common.view-blocks', ['location' => 'home-non-default', 'position' => 'left'])
@stop
@section('right')
<div class="actions mb-xl">
<h5>{{ trans('common.actions') }}</h5>
<div class="icon-list text-link">
@include('home.parts.expand-toggle', ['classes' => 'text-link', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
@include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-link'])
</div>
</div>
@include('common.view-blocks', ['location' => 'home-non-default', 'position' => 'right'])
@stop
@@ -9,7 +9,7 @@
<div class="text-center">
<div component="dropdown"
option:dropdown:move-menu="true"
option:dropdown:fixed-position-menu="true"
class="dropdown-container draft-display text {{ $draftsEnabled ? '' : 'hidden' }}">
<div class="flex-container-row items-center justify-center">
<button type="button"
@@ -97,7 +97,7 @@
<div class="flex-container-row justify-flex-end gap-x-m items-center">
<div component="dropdown"
option:dropdown:move-menu="true"
option:dropdown:fixed-position-menu="true"
class="dropdown-container">
<button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="icon-list-item text-link">
<span>@icon('edit')</span>
+2 -6
View File
@@ -36,13 +36,9 @@
@stop
@section('left')
@include('pages.parts.show-sidebar-section-tags', ['page' => $page])
@include('pages.parts.show-sidebar-section-attachments', ['page' => $page])
@include('pages.parts.show-sidebar-section-page-nav', ['pageNav' => $pageNav])
@include('entities.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
@include('common.view-blocks', ['location' => 'pages-show', 'position' => 'left'])
@stop
@section('right')
@include('pages.parts.show-sidebar-section-details', ['page' => $page, 'watchOptions' => $watchOptions, 'book' => $book])
@include('pages.parts.show-sidebar-section-actions', ['page' => $page, 'watchOptions' => $watchOptions])
@include('common.view-blocks', ['location' => 'pages-show', 'position' => 'right'])
@stop
@@ -0,0 +1,71 @@
@extends('layouts.simple')
@section('body')
<div class="container medium my-xl">
<div class="grid gap-xxl right-focus">
<div>
<div class="mb-l">
<a href="{{ url('/my-account/interface') }}" class="text-link">@icon('back') {{ trans('preferences.layout_edit_back_to_preferences') }}</a>
</div>
<h5>{{ trans('preferences.layout_edit_layouts') }}</h5>
<nav class="active-link-list in-sidebar">
@foreach($namedLocations as $locationOptKey => $locationOptName)
<a href="{{ url('/layouts/' . $locationOptKey) }}" class="{{ $locationOptKey === $location ? 'active' : '' }}">{{ $locationOptName }}</a>
@endforeach
</nav>
</div>
<div>
<div class="card content-wrap auto-height">
<div class="flex-container-row justify-space-between items-center">
<h1 class="list-heading">{{ trans('preferences.layout_edit') }}</h1>
<span class="text-muted text-bigger">{{ $locationName }}</span>
</div>
<p class="small text-muted">{{ trans('preferences.layout_edit_desc') }}</p>
@if(!isset($blocks['center']))
<p class="small text-muted">
{{ trans('preferences.layout_edit_column_hint') }}
</p>
@endif
<form component="layout-editor" action="{{ url('layouts/' . $location) }}" method="POST">
{{ method_field('PUT') }}
{{ csrf_field() }}
<input refs="layout-editor@input" type="hidden" name="layout" value="">
<div class="flex-container-row gap-m mb-m wrap">
@include('settings.layouts.parts.block-column', ['columnBlocks' => $blocks['left'] ?? [], 'label' => trans('preferences.layout_edit_left'), 'id' => 'left'])
@if(isset($blocks['center']))
@include('settings.layouts.parts.block-column', ['columnBlocks' => $blocks['center'] ?? [], 'label' => trans('preferences.layout_edit_center'), 'id' => 'center'])
@endif
@include('settings.layouts.parts.block-column', ['columnBlocks' => $blocks['right'] ?? [], 'label' => trans('preferences.layout_edit_right'), 'id' => 'right'])
</div>
<div class="mb-l">
@include('settings.layouts.parts.block-column', ['columnBlocks' => $blocks['unused'] ?? [], 'label' => trans('preferences.layout_edit_unused'), 'id' => 'unused'])
</div>
<div class="form-group text-right">
<a href="{{ url('/my-account/interface') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" form="layout-reset-form" class="button outline">{{ trans('preferences.layout_edit_reset_to_defaults') }}</button>
<button type="submit" class="button">{{ trans('preferences.layout_edit_save') }}</button>
</div>
</form>
<form id="layout-reset-form" action="{{ url('/layouts/' . $location . '/reset') }}" method="POST">
{{ csrf_field() }}
{{ method_field('PUT') }}
</form>
</div>
</div>
</div>
</div>
@stop
@@ -0,0 +1,17 @@
{{--
$columnBlocks - array - Blocks to list
$label - string - Section title
$id - string - identifier for location/column
--}}
<div class="form-group flex min-width-m">
<label id="layout-blocks-{{ $id }}">{{ $label }}</label>
<ul refs="layout-editor@column"
data-column="{{ $id }}"
aria-labelledby="layout-blocks-{{ $id }}"
class="scroll-box layout-editor-column-{{ $id }}">
<li class="text-muted empty-state px-m py-s italic text-small">{{ trans('preferences.layout_edit_empty') }}</li>
@foreach($columnBlocks as $block)
@include('settings.layouts.parts.block', ['block' => $block])
@endforeach
</ul>
</div>
@@ -0,0 +1,23 @@
@php /** @var $block class-string<\BookStack\View\ViewBlockInterface> */ @endphp
<li data-block-id="{{ $block::getId() }}"
class="scroll-box-item items-center">
<div class="handle px-s">@icon('grip')</div>
<div class="text-small">{{ $block::getLabel() }}</div>
<div component="dropdown"
option:dropdown:fixed-position-menu="true"
class="dropdown-container ml-auto">
<button type="button"
refs="dropdown@toggle"
aria-haspopup="menu"
aria-expanded="false"
class="icon-button p-xxs">@icon('more')</button>
<ul refs="dropdown@menu layout-editor@action-menu" role="menu" aria-label="{{ trans('common.actions') }}" class="dropdown-menu">
<li><button type="button" data-action="move_left" class="icon-item">@icon('chevron-left') {{ trans('common.move_left') }}</button></li>
<li><button type="button" data-action="move_up" class="icon-item">@icon('chevron-up') {{ trans('entities.books_sort_move_up') }}</button></li>
<li><button type="button" data-action="move_down" class="icon-item">@icon('chevron-down') {{ trans('entities.books_sort_move_down') }}</button></li>
<li><button type="button" data-action="move_right" class="icon-item">@icon('chevron-right') {{ trans('common.move_right') }}</button></li>
<li><button type="button" data-action="add" class="icon-item">@icon('add-small') {{ trans('common.add') }}</button></li>
<li><button type="button" data-action="remove" class="icon-item">@icon('remove') {{ trans('common.remove') }}</button></li>
</ul>
</div>
</li>
+5 -7
View File
@@ -4,12 +4,10 @@
@include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view, 'listOptions' => $listOptions])
@stop
@section('right')
@include('shelves.parts.index-sidebar-section-actions', ['view' => $view])
@section('left')
@include('common.view-blocks', ['location' => 'shelves-index', 'position' => 'left'])
@stop
@section('left')
@include('shelves.parts.index-sidebar-section-recents', ['recents' => $recents])
@include('shelves.parts.index-sidebar-section-popular', ['popular' => $popular])
@include('shelves.parts.index-sidebar-section-new', ['new' => $new])
@stop
@section('right')
@include('common.view-blocks', ['location' => 'shelves-index', 'position' => 'right'])
@stop
+2 -8
View File
@@ -69,15 +69,9 @@
@stop
@section('left')
@include('shelves.parts.show-sidebar-section-tags', ['shelf' => $shelf])
@include('shelves.parts.show-sidebar-section-details', ['shelf' => $shelf])
@include('shelves.parts.show-sidebar-section-activity', ['activity' => $activity])
@include('common.view-blocks', ['location' => 'shelves-show', 'position' => 'left'])
@stop
@section('right')
@include('shelves.parts.show-sidebar-section-actions', ['shelf' => $shelf, 'view' => $view])
@include('common.view-blocks', ['location' => 'shelves-show', 'position' => 'right'])
@stop
@@ -0,0 +1,54 @@
@extends('users.account.layout')
@section('main')
<section class="card content-wrap auto-height">
<form action="{{ url('/my-account/interface') }}" method="post">
{{ method_field('put') }}
{{ csrf_field() }}
<h1 class="list-heading">{{ trans('preferences.interface') }}</h1>
<p class="text-small text-muted mb-none">{{ trans('preferences.interface_desc') }}</p>
<div class="setting-list">
@include('users.parts.language-option-row', ['value' => old('language') ?? user()->getLocale()->appLocale()])
@include('users.account.parts.display-mode-option-row')
</div>
<div class="form-group text-right">
<button class="button">{{ trans('common.save') }}</button>
</div>
</form>
</section>
<section class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('preferences.layouts') }}</h2>
<p class="text-small text-muted">{{ trans('preferences.layouts_desc') }}</p>
<div class="item-list mb-m">
@foreach($namedLocations as $locationKey => $locationName)
<div class="flex-container-row justify-space-between item-list-row items-center wrap px-xs">
<div class="py-xs px-s min-width-m">
<a href="{{ url('/layouts/' . $locationKey) }}">{{ $locationName }}</a>
</div>
<div class="py-xs min-width-m text-m-right px-m">
<a class="button outline small" href="{{ url('/layouts/' . $locationKey) }}">{{ trans('common.configure') }}</a>
</div>
</div>
@endforeach
</div>
</section>
<section class="card content-wrap auto-height">
<div class="flex-container-row gap-l items-center wrap">
<div class="flex">
<h2 class="list-heading">{{ trans('preferences.shortcuts_interface') }}</h2>
<p class="text-small text-muted">{{ trans('preferences.shortcuts_overview_desc') }}</p>
</div>
<div class="text-m-right">
<a class="button outline" href="{{ url('/my-account/shortcuts') }}">{{ trans('common.open') }}</a>
</div>
</div>
</section>
@stop
@@ -11,6 +11,7 @@
<nav class="active-link-list in-sidebar">
<a href="{{ url('/my-account/profile') }}" class="{{ $category === 'profile' ? 'active' : '' }}">@icon('user') {{ trans('preferences.profile') }}</a>
<a href="{{ url('/my-account/auth') }}" class="{{ $category === 'auth' ? 'active' : '' }}">@icon('security') {{ trans('preferences.auth') }}</a>
<a href="{{ url('/my-account/interface') }}" class="{{ $category === 'interface' ? 'active' : '' }}">@icon('interface') {{ trans('preferences.interface') }}</a>
<a href="{{ url('/my-account/shortcuts') }}" class="{{ $category === 'shortcuts' ? 'active' : '' }}">@icon('shortcuts') {{ trans('preferences.shortcuts_interface') }}</a>
@if(userCan(\BookStack\Permissions\Permission::ReceiveNotifications))
<a href="{{ url('/my-account/notifications') }}" class="{{ $category === 'notifications' ? 'active' : '' }}">@icon('notifications') {{ trans('preferences.notifications') }}</a>

Some files were not shown because too many files have changed in this diff Show More