]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #1573 from miles75/lang-hu
authorDan Brown <redacted>
Mon, 26 Aug 2019 15:07:45 +0000 (16:07 +0100)
committerGitHub <redacted>
Mon, 26 Aug 2019 15:07:45 +0000 (16:07 +0100)
Typo fix

155 files changed:
app/Auth/Access/EmailConfirmationService.php
app/Auth/Access/UserInviteService.php [new file with mode: 0644]
app/Auth/Access/UserTokenService.php [new file with mode: 0644]
app/Auth/User.php
app/Config/setting-defaults.php
app/Entities/Repos/EntityRepo.php
app/Entities/Repos/PageRepo.php
app/Exceptions/UserTokenExpiredException.php [new file with mode: 0644]
app/Exceptions/UserTokenNotFoundException.php [new file with mode: 0644]
app/Http/Controllers/Auth/ConfirmEmailController.php [new file with mode: 0644]
app/Http/Controllers/Auth/RegisterController.php
app/Http/Controllers/Auth/UserInviteController.php [new file with mode: 0644]
app/Http/Controllers/PageController.php
app/Http/Controllers/PageTemplateController.php [new file with mode: 0644]
app/Http/Controllers/UserController.php
app/Http/Middleware/Localization.php
app/Notifications/UserInvite.php [new file with mode: 0644]
app/helpers.php
database/migrations/2019_07_07_112515_add_template_support.php [new file with mode: 0644]
database/migrations/2019_08_17_140214_add_user_invites_table.php [new file with mode: 0644]
public/.htaccess
public/uploads/.gitignore
public/uploads/.htaccess [new file with mode: 0755]
readme.md
resources/assets/icons/chevron-down.svg [new file with mode: 0644]
resources/assets/icons/template.svg [new file with mode: 0644]
resources/assets/js/components/breadcrumb-listing.js
resources/assets/js/components/chapter-toggle.js
resources/assets/js/components/collapsible.js
resources/assets/js/components/dropdown.js
resources/assets/js/components/editor-toolbox.js
resources/assets/js/components/index.js
resources/assets/js/components/markdown-editor.js
resources/assets/js/components/new-user-password.js [new file with mode: 0644]
resources/assets/js/components/overlay.js
resources/assets/js/components/setting-app-color-picker.js
resources/assets/js/components/template-manager.js [new file with mode: 0644]
resources/assets/js/components/toggle-switch.js
resources/assets/js/components/wysiwyg-editor.js
resources/assets/js/services/dom.js
resources/assets/js/services/translations.js
resources/assets/js/vues/code-editor.js
resources/assets/js/vues/components/autosuggest.js
resources/assets/js/vues/components/dropzone.js
resources/assets/js/vues/tag-manager.js
resources/assets/sass/_blocks.scss
resources/assets/sass/_buttons.scss
resources/assets/sass/_colors.scss
resources/assets/sass/_components.scss
resources/assets/sass/_forms.scss
resources/assets/sass/_header.scss
resources/assets/sass/_html.scss
resources/assets/sass/_layout.scss
resources/assets/sass/_lists.scss
resources/assets/sass/_pages.scss
resources/assets/sass/_text.scss
resources/assets/sass/_tinymce.scss
resources/assets/sass/_variables.scss
resources/assets/sass/print-styles.scss
resources/assets/sass/styles.scss
resources/lang/en/auth.php
resources/lang/en/common.php
resources/lang/en/entities.php
resources/lang/en/errors.php
resources/lang/en/settings.php
resources/views/auth/invite-set-password.blade.php [new file with mode: 0644]
resources/views/auth/login.blade.php
resources/views/auth/passwords/email.blade.php
resources/views/auth/passwords/reset.blade.php
resources/views/auth/register.blade.php
resources/views/auth/user-unconfirmed.blade.php
resources/views/base.blade.php
resources/views/books/create.blade.php
resources/views/books/delete.blade.php
resources/views/books/edit.blade.php
resources/views/books/export.blade.php
resources/views/books/form.blade.php
resources/views/books/list.blade.php
resources/views/books/permissions.blade.php
resources/views/books/show.blade.php
resources/views/books/sort.blade.php
resources/views/chapters/child-menu.blade.php
resources/views/chapters/create.blade.php
resources/views/chapters/delete.blade.php
resources/views/chapters/edit.blade.php
resources/views/chapters/export.blade.php
resources/views/chapters/form.blade.php
resources/views/chapters/list-item.blade.php
resources/views/chapters/move.blade.php
resources/views/chapters/permissions.blade.php
resources/views/chapters/show.blade.php
resources/views/comments/comment.blade.php
resources/views/comments/comments.blade.php
resources/views/comments/create.blade.php
resources/views/common/header.blade.php
resources/views/common/home-custom.blade.php
resources/views/components/code-editor.blade.php
resources/views/components/entity-selector-popup.blade.php
resources/views/components/expand-toggle.blade.php
resources/views/components/image-manager.blade.php
resources/views/components/image-picker.blade.php
resources/views/components/tag-manager.blade.php
resources/views/form/entity-permissions.blade.php
resources/views/form/text.blade.php
resources/views/pages/attachment-manager.blade.php [new file with mode: 0644]
resources/views/pages/copy.blade.php
resources/views/pages/delete.blade.php
resources/views/pages/detailed-listing.blade.php
resources/views/pages/edit.blade.php
resources/views/pages/editor-toolbox.blade.php [new file with mode: 0644]
resources/views/pages/export.blade.php
resources/views/pages/form-toolbox.blade.php [deleted file]
resources/views/pages/form.blade.php
resources/views/pages/guest-create.blade.php
resources/views/pages/markdown-editor.blade.php
resources/views/pages/move.blade.php
resources/views/pages/permissions.blade.php
resources/views/pages/revision.blade.php
resources/views/pages/revisions.blade.php
resources/views/pages/show.blade.php
resources/views/pages/template-manager-list.blade.php [new file with mode: 0644]
resources/views/pages/template-manager.blade.php [new file with mode: 0644]
resources/views/partials/book-tree.blade.php
resources/views/partials/breadcrumb-listing.blade.php
resources/views/partials/breadcrumbs.blade.php
resources/views/partials/custom-styles.blade.php
resources/views/partials/entity-dashboard-search-box.blade.php
resources/views/partials/entity-export-menu.blade.php [new file with mode: 0644]
resources/views/partials/entity-list-item-basic.blade.php
resources/views/partials/notifications.blade.php
resources/views/partials/sort.blade.php
resources/views/search/all.blade.php
resources/views/settings/index.blade.php
resources/views/settings/navbar.blade.php
resources/views/settings/roles/delete.blade.php
resources/views/settings/roles/form.blade.php
resources/views/shelves/create.blade.php
resources/views/shelves/edit.blade.php
resources/views/shelves/form.blade.php
resources/views/shelves/list.blade.php
resources/views/shelves/show.blade.php
resources/views/tri-layout.blade.php
resources/views/users/create.blade.php
resources/views/users/delete.blade.php
resources/views/users/edit.blade.php
resources/views/users/form.blade.php
resources/views/users/index.blade.php
resources/views/users/profile.blade.php
resources/views/vendor/notifications/email.blade.php
routes/web.php
tests/Auth/UserInviteTest.php [new file with mode: 0644]
tests/Entity/PageContentTest.php
tests/Entity/PageRevisionTest.php
tests/Entity/PageTemplateTest.php [new file with mode: 0644]
tests/Permissions/RolesTest.php

index 4df014116c5c3d1c393e6e4594923c0d65a5d519..a94c54d19b4258c7e6fd771c4fdf71c9aa11df1a 100644 (file)
@@ -1,33 +1,18 @@
 <?php namespace BookStack\Auth\Access;
 
 use BookStack\Auth\User;
-use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\ConfirmationEmailException;
-use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Notifications\ConfirmEmail;
-use Carbon\Carbon;
-use Illuminate\Database\Connection as Database;
 
-class EmailConfirmationService
+class EmailConfirmationService extends UserTokenService
 {
-    protected $db;
-    protected $users;
-
-    /**
-     * EmailConfirmationService constructor.
-     * @param Database $db
-     * @param \BookStack\Auth\UserRepo $users
-     */
-    public function __construct(Database $db, UserRepo $users)
-    {
-        $this->db = $db;
-        $this->users = $users;
-    }
+    protected $tokenTable = 'email_confirmations';
+    protected $expiryTime = 24;
 
     /**
      * Create new confirmation for a user,
      * Also removes any existing old ones.
-     * @param \BookStack\Auth\User $user
+     * @param User $user
      * @throws ConfirmationEmailException
      */
     public function sendConfirmation(User $user)
@@ -36,76 +21,20 @@ class EmailConfirmationService
             throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
         }
 
-        $this->deleteConfirmationsByUser($user);
-        $token = $this->createEmailConfirmation($user);
+        $this->deleteByUser($user);
+        $token = $this->createTokenForUser($user);
 
         $user->notify(new ConfirmEmail($token));
     }
 
     /**
-     * Creates a new email confirmation in the database and returns the token.
-     * @param User $user
-     * @return string
+     * Check if confirmation is required in this instance.
+     * @return bool
      */
-    public function createEmailConfirmation(User $user)
+    public function confirmationRequired() : bool
     {
-        $token = $this->getToken();
-        $this->db->table('email_confirmations')->insert([
-            'user_id' => $user->id,
-            'token' => $token,
-            'created_at' => Carbon::now(),
-            'updated_at' => Carbon::now()
-        ]);
-        return $token;
+        return setting('registration-confirmation')
+            || setting('registration-restrict');
     }
 
-    /**
-     * Gets an email confirmation by looking up the token,
-     * Ensures the token has not expired.
-     * @param string $token
-     * @return array|null|\stdClass
-     * @throws UserRegistrationException
-     */
-    public function getEmailConfirmationFromToken($token)
-    {
-        $emailConfirmation = $this->db->table('email_confirmations')->where('token', '=', $token)->first();
-
-        // If not found show error
-        if ($emailConfirmation === null) {
-            throw new UserRegistrationException(trans('errors.email_confirmation_invalid'), '/register');
-        }
-
-        // If more than a day old
-        if (Carbon::now()->subDay()->gt(new Carbon($emailConfirmation->created_at))) {
-            $user = $this->users->getById($emailConfirmation->user_id);
-            $this->sendConfirmation($user);
-            throw new UserRegistrationException(trans('errors.email_confirmation_expired'), '/register/confirm');
-        }
-
-        $emailConfirmation->user = $this->users->getById($emailConfirmation->user_id);
-        return $emailConfirmation;
-    }
-
-    /**
-     * Delete all email confirmations that belong to a user.
-     * @param \BookStack\Auth\User $user
-     * @return mixed
-     */
-    public function deleteConfirmationsByUser(User $user)
-    {
-        return $this->db->table('email_confirmations')->where('user_id', '=', $user->id)->delete();
-    }
-
-    /**
-     * Creates a unique token within the email confirmation database.
-     * @return string
-     */
-    protected function getToken()
-    {
-        $token = str_random(24);
-        while ($this->db->table('email_confirmations')->where('token', '=', $token)->exists()) {
-            $token = str_random(25);
-        }
-        return $token;
-    }
 }
diff --git a/app/Auth/Access/UserInviteService.php b/app/Auth/Access/UserInviteService.php
new file mode 100644 (file)
index 0000000..8e04d7b
--- /dev/null
@@ -0,0 +1,23 @@
+<?php namespace BookStack\Auth\Access;
+
+use BookStack\Auth\User;
+use BookStack\Notifications\UserInvite;
+
+class UserInviteService extends UserTokenService
+{
+    protected $tokenTable = 'user_invites';
+    protected $expiryTime = 336; // Two weeks
+
+    /**
+     * Send an invitation to a user to sign into BookStack
+     * Removes existing invitation tokens.
+     * @param User $user
+     */
+    public function sendInvitation(User $user)
+    {
+        $this->deleteByUser($user);
+        $token = $this->createTokenForUser($user);
+        $user->notify(new UserInvite($token));
+    }
+
+}
diff --git a/app/Auth/Access/UserTokenService.php b/app/Auth/Access/UserTokenService.php
new file mode 100644 (file)
index 0000000..34f3b28
--- /dev/null
@@ -0,0 +1,134 @@
+<?php namespace BookStack\Auth\Access;
+
+use BookStack\Auth\User;
+use BookStack\Exceptions\UserTokenExpiredException;
+use BookStack\Exceptions\UserTokenNotFoundException;
+use Carbon\Carbon;
+use Illuminate\Database\Connection as Database;
+use stdClass;
+
+class UserTokenService
+{
+
+    /**
+     * Name of table where user tokens are stored.
+     * @var string
+     */
+    protected $tokenTable = 'user_tokens';
+
+    /**
+     * Token expiry time in hours.
+     * @var int
+     */
+    protected $expiryTime = 24;
+
+    protected $db;
+
+    /**
+     * UserTokenService constructor.
+     * @param Database $db
+     */
+    public function __construct(Database $db)
+    {
+        $this->db = $db;
+    }
+
+    /**
+     * Delete all email confirmations that belong to a user.
+     * @param User $user
+     * @return mixed
+     */
+    public function deleteByUser(User $user)
+    {
+        return $this->db->table($this->tokenTable)
+            ->where('user_id', '=', $user->id)
+            ->delete();
+    }
+
+    /**
+     * Get the user id from a token, while check the token exists and has not expired.
+     * @param string $token
+     * @return int
+     * @throws UserTokenNotFoundException
+     * @throws UserTokenExpiredException
+     */
+    public function checkTokenAndGetUserId(string $token) : int
+    {
+        $entry = $this->getEntryByToken($token);
+
+        if (is_null($entry)) {
+            throw new UserTokenNotFoundException('Token "' . $token . '" not found');
+        }
+
+        if ($this->entryExpired($entry)) {
+            throw new UserTokenExpiredException("Token of id {$entry->id} has expired.", $entry->user_id);
+        }
+
+        return $entry->user_id;
+    }
+
+    /**
+     * Creates a unique token within the email confirmation database.
+     * @return string
+     */
+    protected function generateToken() : string
+    {
+        $token = str_random(24);
+        while ($this->tokenExists($token)) {
+            $token = str_random(25);
+        }
+        return $token;
+    }
+
+    /**
+     * Generate and store a token for the given user.
+     * @param User $user
+     * @return string
+     */
+    protected function createTokenForUser(User $user) : string
+    {
+        $token = $this->generateToken();
+        $this->db->table($this->tokenTable)->insert([
+            'user_id' => $user->id,
+            'token' => $token,
+            'created_at' => Carbon::now(),
+            'updated_at' => Carbon::now()
+        ]);
+        return $token;
+    }
+
+    /**
+     * Check if the given token exists.
+     * @param string $token
+     * @return bool
+     */
+    protected function tokenExists(string $token) : bool
+    {
+        return $this->db->table($this->tokenTable)
+            ->where('token', '=', $token)->exists();
+    }
+
+    /**
+     * Get a token entry for the given token.
+     * @param string $token
+     * @return object|null
+     */
+    protected function getEntryByToken(string $token)
+    {
+        return $this->db->table($this->tokenTable)
+            ->where('token', '=', $token)
+            ->first();
+    }
+
+    /**
+     * Check if the given token entry has expired.
+     * @param stdClass $tokenEntry
+     * @return bool
+     */
+    protected function entryExpired(stdClass $tokenEntry) : bool
+    {
+        return Carbon::now()->subHours($this->expiryTime)
+            ->gt(new Carbon($tokenEntry->created_at));
+    }
+
+}
\ No newline at end of file
index e5a8a393147969c42d0b079a3ce036359da61c5a..7ad14d9f0c2a495646eb4a4151f958a4a5e2fbf2 100644 (file)
@@ -3,6 +3,7 @@
 use BookStack\Model;
 use BookStack\Notifications\ResetPassword;
 use BookStack\Uploads\Image;
+use Carbon\Carbon;
 use Illuminate\Auth\Authenticatable;
 use Illuminate\Auth\Passwords\CanResetPassword;
 use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
@@ -10,6 +11,20 @@ use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Notifications\Notifiable;
 
+/**
+ * Class User
+ * @package BookStack\Auth
+ * @property string $id
+ * @property string $name
+ * @property string $email
+ * @property string $password
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ * @property bool $email_confirmed
+ * @property int $image_id
+ * @property string $external_auth_id
+ * @property string $system_name
+ */
 class User extends Model implements AuthenticatableContract, CanResetPasswordContract
 {
     use Authenticatable, CanResetPassword, Notifiable;
index b48253eb190f1cc408c50fc944bc41b8a8d79f28..4a135573bd5ba78cfd7bc814fc1ec9c2489698c0 100644 (file)
@@ -14,8 +14,8 @@ return [
     'app-logo'             => '',
     'app-name-header'      => true,
     'app-editor'           => 'wysiwyg',
-    'app-color'            => '#0288D1',
-    'app-color-light'      => 'rgba(21, 101, 192, 0.15)',
+    'app-color'            => '#206ea7',
+    'app-color-light'      => 'rgba(32,110,167,0.15)',
     'app-custom-head'      => false,
     'registration-enabled' => false,
 
index aad9a1205895a18bfce0b0143ae244e2d9bffd51..996873bccaa1629dbbee56e7bca8b60c7cf2e776 100644 (file)
@@ -765,6 +765,12 @@ class EntityRepo
             $scriptElem->parentNode->removeChild($scriptElem);
         }
 
+        // Remove data or JavaScript iFrames
+        $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
+        foreach ($badIframes as $badIframe) {
+            $badIframe->parentNode->removeChild($badIframe);
+        }
+
         // Remove 'on*' attributes
         $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
         foreach ($onAttributes as $attr) {
index 6b004984f389e5f7d553ccbf366bf23ef148e292..ed142eb611b53f3d715722ded3bf12bdb2e4455f 100644 (file)
@@ -9,6 +9,7 @@ use Carbon\Carbon;
 use DOMDocument;
 use DOMElement;
 use DOMXPath;
+use Illuminate\Support\Collection;
 
 class PageRepo extends EntityRepo
 {
@@ -69,6 +70,10 @@ class PageRepo extends EntityRepo
             $this->tagRepo->saveTagsToEntity($page, $input['tags']);
         }
 
+        if (isset($input['template']) && userCan('templates-manage')) {
+            $page->template = ($input['template'] === 'true');
+        }
+
         // Update with new details
         $userId = user()->id;
         $page->fill($input);
@@ -85,8 +90,9 @@ class PageRepo extends EntityRepo
         $this->userUpdatePageDraftsQuery($page, $userId)->delete();
 
         // Save a revision after updating
-        if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $input['summary'] !== null) {
-            $this->savePageRevision($page, $input['summary']);
+        $summary = $input['summary'] ?? null;
+        if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) {
+            $this->savePageRevision($page, $summary);
         }
 
         $this->searchService->indexEntity($page);
@@ -300,6 +306,10 @@ class PageRepo extends EntityRepo
             $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
         }
 
+        if (isset($input['template']) && userCan('templates-manage')) {
+            $draftPage->template = ($input['template'] === 'true');
+        }
+
         $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
         $draftPage->html = $this->formatHtml($input['html']);
         $draftPage->text = $this->pageToPlainText($draftPage);
@@ -523,4 +533,29 @@ class PageRepo extends EntityRepo
 
         return $this->publishPageDraft($copyPage, $pageData);
     }
+
+    /**
+     * Get pages that have been marked as templates.
+     * @param int $count
+     * @param int $page
+     * @param string $search
+     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+     */
+    public function getPageTemplates(int $count = 10, int $page = 1,  string $search = '')
+    {
+        $query = $this->entityQuery('page')
+            ->where('template', '=', true)
+            ->orderBy('name', 'asc')
+            ->skip( ($page - 1) * $count)
+            ->take($count);
+
+        if ($search) {
+            $query->where('name', 'like', '%' . $search . '%');
+        }
+
+        $paginator = $query->paginate($count, ['*'], 'page', $page);
+        $paginator->withPath('/templates');
+
+        return $paginator;
+    }
 }
diff --git a/app/Exceptions/UserTokenExpiredException.php b/app/Exceptions/UserTokenExpiredException.php
new file mode 100644 (file)
index 0000000..203e08c
--- /dev/null
@@ -0,0 +1,19 @@
+<?php namespace BookStack\Exceptions;
+
+class UserTokenExpiredException extends \Exception {
+
+    public $userId;
+
+    /**
+     * UserTokenExpiredException constructor.
+     * @param string $message
+     * @param int $userId
+     */
+    public function __construct(string $message, int $userId)
+    {
+        $this->userId = $userId;
+        parent::__construct($message);
+    }
+
+
+}
\ No newline at end of file
diff --git a/app/Exceptions/UserTokenNotFoundException.php b/app/Exceptions/UserTokenNotFoundException.php
new file mode 100644 (file)
index 0000000..08c1fd8
--- /dev/null
@@ -0,0 +1,3 @@
+<?php namespace BookStack\Exceptions;
+
+class UserTokenNotFoundException extends \Exception {}
\ No newline at end of file
diff --git a/app/Http/Controllers/Auth/ConfirmEmailController.php b/app/Http/Controllers/Auth/ConfirmEmailController.php
new file mode 100644 (file)
index 0000000..3e240b9
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Auth\Access\EmailConfirmationService;
+use BookStack\Auth\UserRepo;
+use BookStack\Exceptions\ConfirmationEmailException;
+use BookStack\Exceptions\UserTokenExpiredException;
+use BookStack\Exceptions\UserTokenNotFoundException;
+use BookStack\Http\Controllers\Controller;
+use Exception;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Redirector;
+use Illuminate\View\View;
+
+class ConfirmEmailController extends Controller
+{
+    protected $emailConfirmationService;
+    protected $userRepo;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @param EmailConfirmationService $emailConfirmationService
+     * @param UserRepo $userRepo
+     */
+    public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
+    {
+        $this->emailConfirmationService = $emailConfirmationService;
+        $this->userRepo = $userRepo;
+        parent::__construct();
+    }
+
+
+    /**
+     * Show the page to tell the user to check their email
+     * and confirm their address.
+     */
+    public function show()
+    {
+        return view('auth.register-confirm');
+    }
+
+    /**
+     * Shows a notice that a user's email address has not been confirmed,
+     * Also has the option to re-send the confirmation email.
+     * @return View
+     */
+    public function showAwaiting()
+    {
+        return view('auth.user-unconfirmed');
+    }
+
+    /**
+     * Confirms an email via a token and logs the user into the system.
+     * @param $token
+     * @return RedirectResponse|Redirector
+     * @throws ConfirmationEmailException
+     * @throws Exception
+     */
+    public function confirm($token)
+    {
+        try {
+            $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
+        } catch (Exception $exception) {
+
+            if ($exception instanceof UserTokenNotFoundException) {
+                session()->flash('error', trans('errors.email_confirmation_invalid'));
+                return redirect('/register');
+            }
+
+            if ($exception instanceof UserTokenExpiredException) {
+                $user = $this->userRepo->getById($exception->userId);
+                $this->emailConfirmationService->sendConfirmation($user);
+                session()->flash('error', trans('errors.email_confirmation_expired'));
+                return redirect('/register/confirm');
+            }
+
+            throw $exception;
+        }
+
+        $user = $this->userRepo->getById($userId);
+        $user->email_confirmed = true;
+        $user->save();
+
+        auth()->login($user);
+        session()->flash('success', trans('auth.email_confirm_success'));
+        $this->emailConfirmationService->deleteByUser($user);
+
+        return redirect('/');
+    }
+
+
+    /**
+     * Resend the confirmation email
+     * @param Request $request
+     * @return View
+     */
+    public function resend(Request $request)
+    {
+        $this->validate($request, [
+            'email' => 'required|email|exists:users,email'
+        ]);
+        $user = $this->userRepo->getByEmail($request->get('email'));
+
+        try {
+            $this->emailConfirmationService->sendConfirmation($user);
+        } catch (Exception $e) {
+            session()->flash('error', trans('auth.email_confirm_send_error'));
+            return redirect('/register/confirm');
+        }
+
+        session()->flash('success', trans('auth.email_confirm_resent'));
+        return redirect('/register/confirm');
+    }
+
+}
index a285899ccb9cfc40eb911933591e18e2d6239df0..c411f2363210999c6b44e578a19024b734e85fba 100644 (file)
@@ -18,7 +18,6 @@ use Illuminate\Http\RedirectResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Response;
 use Illuminate\Routing\Redirector;
-use Illuminate\View\View;
 use Laravel\Socialite\Contracts\User as SocialUser;
 use Validator;
 
@@ -53,7 +52,7 @@ class RegisterController extends Controller
      * Create a new controller instance.
      *
      * @param SocialAuthService $socialAuthService
-     * @param \BookStack\Auth\EmailConfirmationService $emailConfirmationService
+     * @param EmailConfirmationService $emailConfirmationService
      * @param UserRepo $userRepo
      */
     public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
@@ -159,7 +158,7 @@ class RegisterController extends Controller
             $newUser->socialAccounts()->save($socialAccount);
         }
 
-        if ((setting('registration-confirmation') || $registrationRestrict) && !$emailVerified) {
+        if ($this->emailConfirmationService->confirmationRequired() && !$emailVerified) {
             $newUser->save();
 
             try {
@@ -176,66 +175,6 @@ class RegisterController extends Controller
         return redirect($this->redirectPath());
     }
 
-    /**
-     * Show the page to tell the user to check their email
-     * and confirm their address.
-     */
-    public function getRegisterConfirmation()
-    {
-        return view('auth.register-confirm');
-    }
-
-    /**
-     * Confirms an email via a token and logs the user into the system.
-     * @param $token
-     * @return RedirectResponse|Redirector
-     * @throws UserRegistrationException
-     */
-    public function confirmEmail($token)
-    {
-        $confirmation = $this->emailConfirmationService->getEmailConfirmationFromToken($token);
-        $user = $confirmation->user;
-        $user->email_confirmed = true;
-        $user->save();
-        auth()->login($user);
-        session()->flash('success', trans('auth.email_confirm_success'));
-        $this->emailConfirmationService->deleteConfirmationsByUser($user);
-        return redirect($this->redirectPath);
-    }
-
-    /**
-     * Shows a notice that a user's email address has not been confirmed,
-     * Also has the option to re-send the confirmation email.
-     * @return View
-     */
-    public function showAwaitingConfirmation()
-    {
-        return view('auth.user-unconfirmed');
-    }
-
-    /**
-     * Resend the confirmation email
-     * @param Request $request
-     * @return View
-     */
-    public function resendConfirmation(Request $request)
-    {
-        $this->validate($request, [
-            'email' => 'required|email|exists:users,email'
-        ]);
-        $user = $this->userRepo->getByEmail($request->get('email'));
-
-        try {
-            $this->emailConfirmationService->sendConfirmation($user);
-        } catch (Exception $e) {
-            session()->flash('error', trans('auth.email_confirm_send_error'));
-            return redirect('/register/confirm');
-        }
-
-        session()->flash('success', trans('auth.email_confirm_resent'));
-        return redirect('/register/confirm');
-    }
-
     /**
      * Redirect to the social site for authentication intended to register.
      * @param $socialDriver
diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php
new file mode 100644 (file)
index 0000000..5d9373f
--- /dev/null
@@ -0,0 +1,106 @@
+<?php
+
+namespace BookStack\Http\Controllers\Auth;
+
+use BookStack\Auth\Access\UserInviteService;
+use BookStack\Auth\UserRepo;
+use BookStack\Exceptions\UserTokenExpiredException;
+use BookStack\Exceptions\UserTokenNotFoundException;
+use BookStack\Http\Controllers\Controller;
+use Exception;
+use Illuminate\Contracts\View\Factory;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Redirector;
+use Illuminate\View\View;
+
+class UserInviteController extends Controller
+{
+    protected $inviteService;
+    protected $userRepo;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @param UserInviteService $inviteService
+     * @param UserRepo $userRepo
+     */
+    public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
+    {
+        $this->inviteService = $inviteService;
+        $this->userRepo = $userRepo;
+        $this->middleware('guest');
+        parent::__construct();
+    }
+
+    /**
+     * Show the page for the user to set the password for their account.
+     * @param string $token
+     * @return Factory|View|RedirectResponse
+     * @throws Exception
+     */
+    public function showSetPassword(string $token)
+    {
+        try {
+            $this->inviteService->checkTokenAndGetUserId($token);
+        } catch (Exception $exception) {
+            return $this->handleTokenException($exception);
+        }
+
+        return view('auth.invite-set-password', [
+            'token' => $token,
+        ]);
+    }
+
+    /**
+     * Sets the password for an invited user and then grants them access.
+     * @param string $token
+     * @param Request $request
+     * @return RedirectResponse|Redirector
+     * @throws Exception
+     */
+    public function setPassword(string $token, Request $request)
+    {
+        $this->validate($request, [
+            'password' => 'required|min:6'
+        ]);
+
+        try {
+            $userId = $this->inviteService->checkTokenAndGetUserId($token);
+        } catch (Exception $exception) {
+            return $this->handleTokenException($exception);
+        }
+
+        $user = $this->userRepo->getById($userId);
+        $user->password = bcrypt($request->get('password'));
+        $user->email_confirmed = true;
+        $user->save();
+
+        auth()->login($user);
+        session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')]));
+        $this->inviteService->deleteByUser($user);
+
+        return redirect('/');
+    }
+
+    /**
+     * Check and validate the exception thrown when checking an invite token.
+     * @param Exception $exception
+     * @return RedirectResponse|Redirector
+     * @throws Exception
+     */
+    protected function handleTokenException(Exception $exception)
+    {
+        if ($exception instanceof UserTokenNotFoundException) {
+            return redirect('/');
+        }
+
+        if ($exception instanceof UserTokenExpiredException) {
+            session()->flash('error', trans('errors.invite_token_expired'));
+            return redirect('/password/email');
+        }
+
+        throw $exception;
+    }
+
+}
index 89fb83fd97f116b2005827b30cce0ff459d81e5b..ad1e3266597f2183aa0cd72a4b668d052e7eb2a8 100644 (file)
@@ -110,11 +110,14 @@ class PageController extends Controller
         $this->setPageTitle(trans('entities.pages_edit_draft'));
 
         $draftsEnabled = $this->signedIn;
+        $templates = $this->pageRepo->getPageTemplates(10);
+
         return view('pages.edit', [
             'page' => $draft,
             'book' => $draft->book,
             'isDraft' => true,
-            'draftsEnabled' => $draftsEnabled
+            'draftsEnabled' => $draftsEnabled,
+            'templates' => $templates,
         ]);
     }
 
@@ -239,11 +242,14 @@ class PageController extends Controller
         }
 
         $draftsEnabled = $this->signedIn;
+        $templates = $this->pageRepo->getPageTemplates(10);
+
         return view('pages.edit', [
             'page' => $page,
             'book' => $page->book,
             'current' => $page,
-            'draftsEnabled' => $draftsEnabled
+            'draftsEnabled' => $draftsEnabled,
+            'templates' => $templates,
         ]);
     }
 
@@ -489,7 +495,7 @@ class PageController extends Controller
 
         $revision->delete();
         session()->flash('success', trans('entities.revision_delete_success'));
-        return view('pages.revisions', ['page' => $page, 'book' => $page->book, 'current' => $page]);
+        return redirect($page->getUrl('/revisions'));
     }
 
     /**
diff --git a/app/Http/Controllers/PageTemplateController.php b/app/Http/Controllers/PageTemplateController.php
new file mode 100644 (file)
index 0000000..0594335
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exceptions\NotFoundException;
+use Illuminate\Http\Request;
+
+class PageTemplateController extends Controller
+{
+    protected $pageRepo;
+
+    /**
+     * PageTemplateController constructor.
+     * @param $pageRepo
+     */
+    public function __construct(PageRepo $pageRepo)
+    {
+        $this->pageRepo = $pageRepo;
+        parent::__construct();
+    }
+
+    /**
+     * Fetch a list of templates from the system.
+     * @param Request $request
+     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+     */
+    public function list(Request $request)
+    {
+        $page = $request->get('page', 1);
+        $search = $request->get('search', '');
+        $templates = $this->pageRepo->getPageTemplates(10, $page, $search);
+
+        if ($search) {
+            $templates->appends(['search' => $search]);
+        }
+
+        return view('pages.template-manager-list', [
+            'templates' => $templates
+        ]);
+    }
+
+    /**
+     * Get the content of a template.
+     * @param $templateId
+     * @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
+     * @throws NotFoundException
+     */
+    public function get($templateId)
+    {
+        $page = $this->pageRepo->getById('page', $templateId);
+
+        if (!$page->template) {
+            throw new NotFoundException();
+        }
+
+        return response()->json([
+            'html' => $page->html,
+            'markdown' => $page->markdown,
+        ]);
+    }
+
+}
index 8191fbfe276226ab70bed45825d470b353905b88..c9d2560ba0fff4cf9cb4360579ad71c3afd1c591 100644 (file)
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\Access\SocialAuthService;
+use BookStack\Auth\Access\UserInviteService;
 use BookStack\Auth\User;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\UserUpdateException;
@@ -13,18 +14,21 @@ class UserController extends Controller
 
     protected $user;
     protected $userRepo;
+    protected $inviteService;
     protected $imageRepo;
 
     /**
      * UserController constructor.
      * @param User $user
      * @param UserRepo $userRepo
+     * @param UserInviteService $inviteService
      * @param ImageRepo $imageRepo
      */
-    public function __construct(User $user, UserRepo $userRepo, ImageRepo $imageRepo)
+    public function __construct(User $user, UserRepo $userRepo, UserInviteService $inviteService, ImageRepo $imageRepo)
     {
         $this->user = $user;
         $this->userRepo = $userRepo;
+        $this->inviteService = $inviteService;
         $this->imageRepo = $imageRepo;
         parent::__construct();
     }
@@ -75,8 +79,10 @@ class UserController extends Controller
         ];
 
         $authMethod = config('auth.method');
-        if ($authMethod === 'standard') {
-            $validationRules['password'] = 'required|min:5';
+        $sendInvite = ($request->get('send_invite', 'false') === 'true');
+
+        if ($authMethod === 'standard' && !$sendInvite) {
+            $validationRules['password'] = 'required|min:6';
             $validationRules['password-confirm'] = 'required|same:password';
         } elseif ($authMethod === 'ldap') {
             $validationRules['external_auth_id'] = 'required';
@@ -86,13 +92,17 @@ class UserController extends Controller
         $user = $this->user->fill($request->all());
 
         if ($authMethod === 'standard') {
-            $user->password = bcrypt($request->get('password'));
+            $user->password = bcrypt($request->get('password', str_random(32)));
         } elseif ($authMethod === 'ldap') {
             $user->external_auth_id = $request->get('external_auth_id');
         }
 
         $user->save();
 
+        if ($sendInvite) {
+            $this->inviteService->sendInvitation($user);
+        }
+
         if ($request->filled('roles')) {
             $roles = $request->get('roles');
             $this->userRepo->setUserRoles($user, $roles);
@@ -139,14 +149,19 @@ class UserController extends Controller
         $this->validate($request, [
             'name'             => 'min:2',
             'email'            => 'min:2|email|unique:users,email,' . $id,
-            'password'         => 'min:5|required_with:password_confirm',
+            'password'         => 'min:6|required_with:password_confirm',
             'password-confirm' => 'same:password|required_with:password',
             'setting'          => 'array',
             'profile_image'    => $this->imageRepo->getImageValidationRules(),
         ]);
 
         $user = $this->userRepo->getById($id);
-        $user->fill($request->all());
+        $user->fill($request->except(['email']));
+
+        // Email updates
+        if (userCan('users-manage') && $request->filled('email')) {
+            $user->email = $request->get('email');
+        }
 
         // Role updates
         if (userCan('users-manage') && $request->filled('roles')) {
index 07852bb00f735a8f265a80c8ac0c7a204a193ba6..b5e702781ef53b6d7bd28b76cbe5ca42728a283b 100644 (file)
@@ -57,6 +57,8 @@ class Localization
             $locale = setting()->getUser(user(), 'language', $defaultLang);
         }
 
+        config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
+
         // Set text direction
         if (in_array($locale, $this->rtlLocales)) {
             config()->set('app.rtl', true);
@@ -86,6 +88,16 @@ class Localization
         return $default;
     }
 
+    /**
+     * Get the ISO version of a BookStack language name
+     * @param  string $locale
+     * @return string
+     */
+    public function getLocaleIso(string $locale)
+    {
+        return $this->localeMap[$locale] ?? $locale;
+    }
+
     /**
      * Set the system date locale for localized date formatting.
      * Will try both the standard locale name and the UTF8 variant.
@@ -93,7 +105,7 @@ class Localization
      */
     protected function setSystemDateLocale(string $locale)
     {
-        $systemLocale = $this->localeMap[$locale] ?? $locale;
+        $systemLocale = $this->getLocaleIso($locale);
         $set = setlocale(LC_TIME, $systemLocale);
         if ($set === false) {
             setlocale(LC_TIME, $systemLocale . '.utf8');
diff --git a/app/Notifications/UserInvite.php b/app/Notifications/UserInvite.php
new file mode 100644 (file)
index 0000000..b01911b
--- /dev/null
@@ -0,0 +1,31 @@
+<?php namespace BookStack\Notifications;
+
+class UserInvite extends MailNotification
+{
+    public $token;
+
+    /**
+     * Create a new notification instance.
+     * @param string $token
+     */
+    public function __construct($token)
+    {
+        $this->token = $token;
+    }
+
+    /**
+     * Get the mail representation of the notification.
+     *
+     * @param  mixed  $notifiable
+     * @return \Illuminate\Notifications\Messages\MailMessage
+     */
+    public function toMail($notifiable)
+    {
+        $appName = ['appName' => setting('app-name')];
+        return $this->newMailMessage()
+                ->subject(trans('auth.user_invite_email_subject', $appName))
+                ->greeting(trans('auth.user_invite_email_greeting', $appName))
+                ->line(trans('auth.user_invite_email_text'))
+                ->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
+    }
+}
index 9bbfcfbf0fc6535a25cec4750d90006c48d444ea..f36f2e59dcef11b461cd5efdf239f6ea683dda1c 100644 (file)
@@ -133,8 +133,9 @@ function theme_path($path = '') : string
 function icon($name, $attrs = [])
 {
     $attrs = array_merge([
-        'class' => 'svg-icon',
-        'data-icon' => $name
+        'class'     => 'svg-icon',
+        'data-icon' => $name,
+        'role'      => 'presentation',
     ], $attrs);
     $attrString = ' ';
     foreach ($attrs as $attrName => $attr) {
diff --git a/database/migrations/2019_07_07_112515_add_template_support.php b/database/migrations/2019_07_07_112515_add_template_support.php
new file mode 100644 (file)
index 0000000..a545081
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddTemplateSupport extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('pages', function (Blueprint $table) {
+            $table->boolean('template')->default(false);
+            $table->index('template');
+        });
+
+        // Create new templates-manage permission and assign to admin role
+        $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
+        $permissionId = DB::table('role_permissions')->insertGetId([
+            'name' => 'templates-manage',
+            'display_name' => 'Manage Page Templates',
+            'created_at' => Carbon::now()->toDateTimeString(),
+            'updated_at' => Carbon::now()->toDateTimeString()
+        ]);
+        DB::table('permission_role')->insert([
+            'role_id' => $adminRoleId,
+            'permission_id' => $permissionId
+        ]);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('pages', function (Blueprint $table) {
+            $table->dropColumn('template');
+        });
+
+        // Remove templates-manage permission
+        $templatesManagePermission = DB::table('role_permissions')
+            ->where('name', '=', 'templates_manage')->first();
+
+        DB::table('permission_role')->where('permission_id', '=', $templatesManagePermission->id)->delete();
+        DB::table('role_permissions')->where('name', '=', 'templates_manage')->delete();
+    }
+}
diff --git a/database/migrations/2019_08_17_140214_add_user_invites_table.php b/database/migrations/2019_08_17_140214_add_user_invites_table.php
new file mode 100644 (file)
index 0000000..23bd698
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class AddUserInvitesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('user_invites', function (Blueprint $table) {
+            $table->increments('id');
+            $table->integer('user_id')->index();
+            $table->string('token')->index();
+            $table->nullableTimestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('user_invites');
+    }
+}
index 8eb2dd0ddfa5f7b57bd0f351684dfa2e4e3ac0d3..0d55354ec6fb756086be2bcace7be11078064f29 100644 (file)
@@ -1,6 +1,6 @@
 <IfModule mod_rewrite.c>
     <IfModule mod_negotiation.c>
-        Options -MultiViews
+        Options -MultiViews -Indexes
     </IfModule>
 
     RewriteEngine On
index c96a04f008ee21e260b28f7701595ed59e2839e3..cb7328e1934841ba2afd78b3b7ceb100961337bb 100755 (executable)
@@ -1,2 +1,3 @@
 *
-!.gitignore
\ No newline at end of file
+!.gitignore
+!.htaccess
\ No newline at end of file
diff --git a/public/uploads/.htaccess b/public/uploads/.htaccess
new file mode 100755 (executable)
index 0000000..45552cb
--- /dev/null
@@ -0,0 +1 @@
+Options -Indexes
\ No newline at end of file
index 276a3de2017e35fdf3856c55ba4826a8054ccfe9..4e93607b686471af955bc540aab8e1dc983004f1 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -3,6 +3,7 @@
 [![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest)
 [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
 [![Build Status](https://travis-ci.org/BookStackApp/BookStack.svg)](https://travis-ci.org/BookStackApp/BookStack)
+[![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
 
 A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
 
@@ -12,7 +13,7 @@ A platform for storing and organising information and documentation. General inf
     * [Admin Login](https://demo.bookstackapp.com/[email protected]&password=password)
 * [BookStack Blog](https://www.bookstackapp.com/blog)
 
-## Project Definition
+## 📚 Project Definition
 
 BookStack is an opinionated wiki system that provides a pleasant and simple out of the box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
 
@@ -20,7 +21,7 @@ BookStack is not designed as an extensible platform to be used for purposes that
 
 In regards to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
 
-## Road Map
+## 🛣️ Road Map
 
 Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in the "Release Process" section below.
 
@@ -33,7 +34,7 @@ Below is a high-level road map view for BookStack to provide a sense of directio
 - **Installation & Deployment Process Revamp**
     - *Creation of a streamlined & secure process for users to deploy & update BookStack with reduced development requirements (No git or composer requirement).*
 
-## Release Versioning & Process
+## 🚀 Release Versioning & Process
 
 BookStack releases are each assigned a version number, such as "v0.25.2", in the format `v<phase>.<feature>.<patch>`. A change only in the `patch` number indicates a fairly minor release that mainly contains fixes and therefore is very unlikely to cause breakages upon update. A change in the `feature` number indicates a release which will generally bring new features in addition to fixes and enhancements. These releases have a small chance of introducing breaking changes upon update so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/). A change in the `phase` indicates a much large change in BookStack that will likely incur breakages requiring manual intervention.
 
@@ -41,7 +42,7 @@ Each BookStack release will have a [milestone](https://github.com/BookStackApp/B
 
 For feature releases, and some patch releases, the release will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](http://eepurl.com/cmmq5j).
 
-## Development & Testing
+## 🛠️ Development & Testing
 
 All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
 
@@ -74,7 +75,11 @@ php artisan db:seed --class=DummyContentSeeder --database=mysql_testing
 
 Once done you can run `php vendor/bin/phpunit` in the application root directory to run all tests.
 
-## Translations
+### 📜 Code Standards
+
+PHP code within BookStack is generally to [PSR-2](http://www.php-fig.org/psr/psr-2/) standards. From the BookStack root folder you can run `./vendor/bin/phpcs` to check code is formatted correctly and `./vendor/bin/phpcbf` to auto-fix non-PSR-2 code.
+
+## 🌎 Translations
 
 All text strings can be found in the `resources/lang` folder where each language option has its own folder. To add a new language you should copy the `en` folder to an new folder (eg. `fr` for french) then go through and translate all text strings in those files, leaving the keys and file-names intact. If a language string is missing then the `en` translation will be used. To show the language option in the user preferences language drop-down you will need to add your language to the options found at the bottom of the `resources/lang/en/settings.php` file. A system-wide language can also be set in the `.env` file like so: `APP_LANG=en`.
 
@@ -93,42 +98,35 @@ php resources/lang/check.php pt_BR
 
 Some strings have colon-prefixed variables in such as `:userName`. Leave these values as they are as they will be replaced at run-time.
 
-## Contributing & Maintenance
+## 🎁 Contributing, Issues & Pull Requests
+
+Feel free to create issues to request new features or to report bugs & problems. Just please follow the template given when creating the issue.
 
-Feel free to create issues to request new features or to report bugs and problems. Just please follow the template given when creating the issue.
+Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
 
 The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
 
-### Code Standards
+## 🔒 Security
 
-PHP code within BookStack is generally to [PSR-2](http://www.php-fig.org/psr/psr-2/) standards. From the BookStack root folder you can run `./vendor/bin/phpcs` to check code is formatted correctly and `./vendor/bin/phpcbf` to auto-fix non-PSR-2 code.
+Security information for administering a BookStack instance can be found on the [documentation site here](https://www.bookstackapp.com/docs/admin/security/).
 
-### Pull Requests
+If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](http://eepurl.com/glIh8z).
 
-Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge.
+If you would like to report a security concern in a more confidential manner than via a GitHub issue, You can directly email the lead maintainer [ssddanbrown](https://github.com/ssddanbrown). You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). Alternatively you can send a DM via twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
 
-Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases.
+## ♿ Accessibility
 
-If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
+We want BookStack to remain accessible to as many people as possible. We aim for at least WCAG 2.1 Level A standards where possible although we do not strictly test this upon each release. If you come across any accessibility issues please feel free to open an issue.
 
-## Website, Docs & Blog
+## 🖥️ Website, Docs & Blog
 
 The website which contains the project docs & Blog can be found in the [BookStackApp/website](https://github.com/BookStackApp/website) repo.
 
-## Security
-
-Security information for administering a BookStack instance can be found on the [documentation site here](https://www.bookstackapp.com/docs/admin/security/).
-
-If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](http://eepurl.com/glIh8z).
-
-If you would like to report a security concern in a more confidential manner than via a GitHub issue, You can directly email the lead maintainer [ssddanbrown](https://github.com/ssddanbrown). You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). Alternatively you can send a DM via twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
-
-
-## License
+## ⚖️ License
 
-The BookStack source is provided under the MIT License.
+The BookStack source is provided under the MIT License. The libraries used by, and included with, BookStack are provided under their own licenses.
 
-## Attribution
+## 👪 Attribution
 
 The great people that have worked to build and improve BookStack can [be seen here](https://github.com/BookStackApp/BookStack/graphs/contributors).
 
diff --git a/resources/assets/icons/chevron-down.svg b/resources/assets/icons/chevron-down.svg
new file mode 100644 (file)
index 0000000..f08dfaf
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 8L12 12.58 16.59 8 18 9.41l-6 6-6-6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
\ No newline at end of file
diff --git a/resources/assets/icons/template.svg b/resources/assets/icons/template.svg
new file mode 100644 (file)
index 0000000..7c14212
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 4h7V2H4c-1.1 0-2 .9-2 2v7h2zm16-2h-7v2h7v7h2V4c0-1.1-.9-2-2-2zm0 18h-7v2h7c1.1 0 2-.9 2-2v-7h-2zM4 13H2v7c0 1.1.9 2 2 2h7v-2H4zM16.475 15.356h-8.95v-2.237h8.95zm0-4.475h-8.95V8.644h8.95z"/></svg>
\ No newline at end of file
index 11e1522db30753356cc90ee969269055d3235389..7f4344b17d7f1b4452f920d30eda7a3d4d72049e 100644 (file)
@@ -7,35 +7,14 @@ class BreadcrumbListing {
         this.searchInput = elem.querySelector('input');
         this.loadingElem = elem.querySelector('.loading-container');
         this.entityListElem = elem.querySelector('.breadcrumb-listing-entity-list');
-        this.toggleElem = elem.querySelector('[dropdown-toggle]');
 
         // this.loadingElem.style.display = 'none';
         const entityDescriptor = elem.getAttribute('breadcrumb-listing').split(':');
         this.entityType = entityDescriptor[0];
         this.entityId = Number(entityDescriptor[1]);
 
-        this.toggleElem.addEventListener('click', this.onShow.bind(this));
+        this.elem.addEventListener('show', this.onShow.bind(this));
         this.searchInput.addEventListener('input', this.onSearch.bind(this));
-        this.elem.addEventListener('keydown', this.keyDown.bind(this));
-    }
-
-    keyDown(event) {
-        if (event.key === 'ArrowDown') {
-            this.listFocusChange(1);
-            event.preventDefault();
-        } else if  (event.key === 'ArrowUp') {
-            this.listFocusChange(-1);
-            event.preventDefault();
-        }
-    }
-
-    listFocusChange(indexChange = 1) {
-        const links = Array.from(this.entityListElem.querySelectorAll('a:not(.hidden)'));
-        const currentFocused = this.entityListElem.querySelector('a:focus');
-        const currentFocusedIndex = links.indexOf(currentFocused);
-        const defaultFocus = (indexChange > 0) ? links[0] : this.searchInput;
-        const nextElem = links[currentFocusedIndex + indexChange] || defaultFocus;
-        nextElem.focus();
     }
 
     onShow() {
index a751206d16afa1dd2b76708c33f90131f9579c83..bfd0ac7296576f6151aa61e686a3591fd6b72e6a 100644 (file)
@@ -11,12 +11,14 @@ class ChapterToggle {
     open() {
         const list = this.elem.parentNode.querySelector('.inset-list');
         this.elem.classList.add('open');
+        this.elem.setAttribute('aria-expanded', 'true');
         slideDown(list, 240);
     }
 
     close() {
         const list = this.elem.parentNode.querySelector('.inset-list');
         this.elem.classList.remove('open');
+        this.elem.setAttribute('aria-expanded', 'false');
         slideUp(list, 240);
     }
 
index 40ab325082df08a3a2cc37c968a3ffeabe6675b8..464f394c1e7e42a8d1dc568c94568d53f1498819 100644 (file)
@@ -18,11 +18,13 @@ class Collapsible {
 
     open() {
         this.elem.classList.add('open');
+        this.trigger.setAttribute('aria-expanded', 'true');
         slideDown(this.content, 300);
     }
 
     close() {
         this.elem.classList.remove('open');
+        this.trigger.setAttribute('aria-expanded', 'false');
         slideUp(this.content, 300);
     }
 
index 3887e8432289d23e2f81cd01424c7476428c8e33..4de1e239b93b3747bd7474ad188d7684b62dfb9e 100644 (file)
@@ -1,3 +1,5 @@
+import {onSelect} from "../services/dom";
+
 /**
  * Dropdown
  * Provides some simple logic to create simple dropdown menus.
@@ -10,14 +12,16 @@ class DropDown {
         this.moveMenu = elem.hasAttribute('dropdown-move-menu');
         this.toggle = elem.querySelector('[dropdown-toggle]');
         this.body = document.body;
+        this.showing = false;
         this.setupListeners();
     }
 
-    show(event) {
+    show(event = null) {
         this.hideAll();
 
         this.menu.style.display = 'block';
         this.menu.classList.add('anim', 'menuIn');
+        this.toggle.setAttribute('aria-expanded', 'true');
 
         if (this.moveMenu) {
             // Move to body to prevent being trapped within scrollable sections
@@ -38,10 +42,17 @@ class DropDown {
         });
 
         // Focus on first input if existing
-        let input = this.menu.querySelector('input');
+        const input = this.menu.querySelector('input');
         if (input !== null) input.focus();
 
-        event.stopPropagation();
+        this.showing = true;
+
+        const showEvent = new Event('show');
+        this.container.dispatchEvent(showEvent);
+
+        if (event) {
+            event.stopPropagation();
+        }
     }
 
     hideAll() {
@@ -53,6 +64,7 @@ class DropDown {
     hide() {
         this.menu.style.display = 'none';
         this.menu.classList.remove('anim', 'menuIn');
+        this.toggle.setAttribute('aria-expanded', 'false');
         if (this.moveMenu) {
             this.menu.style.position = '';
             this.menu.style.left = '';
@@ -60,22 +72,78 @@ class DropDown {
             this.menu.style.width = '';
             this.container.appendChild(this.menu);
         }
+        this.showing = false;
+    }
+
+    getFocusable() {
+        return Array.from(this.menu.querySelectorAll('[tabindex],[href],button,input:not([type=hidden])'));
+    }
+
+    focusNext() {
+        const focusable = this.getFocusable();
+        const currentIndex = focusable.indexOf(document.activeElement);
+        let newIndex = currentIndex + 1;
+        if (newIndex >= focusable.length) {
+            newIndex = 0;
+        }
+
+        focusable[newIndex].focus();
+    }
+
+    focusPrevious() {
+        const focusable = this.getFocusable();
+        const currentIndex = focusable.indexOf(document.activeElement);
+        let newIndex = currentIndex - 1;
+        if (newIndex < 0) {
+            newIndex = focusable.length - 1;
+        }
+
+        focusable[newIndex].focus();
     }
 
     setupListeners() {
         // Hide menu on option click
         this.container.addEventListener('click', event => {
-             let possibleChildren = Array.from(this.menu.querySelectorAll('a'));
-             if (possibleChildren.indexOf(event.target) !== -1) this.hide();
+             const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
+             if (possibleChildren.includes(event.target)) {
+                 this.hide();
+             }
+        });
+
+        onSelect(this.toggle, event => {
+            event.stopPropagation();
+            this.show(event);
+            if (event instanceof KeyboardEvent) {
+                this.focusNext();
+            }
         });
-        // Show dropdown on toggle click
-        this.toggle.addEventListener('click', this.show.bind(this));
-        // Hide menu on enter press
-        this.container.addEventListener('keypress', event => {
-                if (event.keyCode !== 13) return true;
+
+        // Keyboard navigation
+        const keyboardNavigation = event => {
+            if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
+                this.focusNext();
+                event.preventDefault();
+            } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
+                this.focusPrevious();
                 event.preventDefault();
+            } else if (event.key === 'Escape') {
                 this.hide();
-                return false;
+                this.toggle.focus();
+                event.stopPropagation();
+            }
+        };
+        this.container.addEventListener('keydown', keyboardNavigation);
+        if (this.moveMenu) {
+            this.menu.addEventListener('keydown', keyboardNavigation);
+        }
+
+        // Hide menu on enter press or escape
+        this.menu.addEventListener('keydown ', event => {
+            if (event.key === 'Enter') {
+                event.preventDefault();
+                event.stopPropagation();
+                this.hide();
+            }
         });
     }
 
index 10678edfaa4e880c5d3f076b061fd968abdb54a0..354bf0a86b3262f4a5e14a9e446ea87ee9405c65 100644 (file)
@@ -23,6 +23,8 @@ class EditorToolbox {
 
     toggle() {
         this.elem.classList.toggle('open');
+        const expanded = this.elem.classList.contains('open') ? 'true' : 'false';
+        this.toggleButton.setAttribute('aria-expanded', expanded);
     }
 
     setActiveTab(tabName, openToolbox = false) {
index 1c2abd5202e1b47b964cbb5d0239fddddf12019e..14cf08ae2da41014e4703c57bfae6f79c4dc1c9e 100644 (file)
@@ -27,6 +27,8 @@ import customCheckbox from "./custom-checkbox";
 import bookSort from "./book-sort";
 import settingAppColorPicker from "./setting-app-color-picker";
 import entityPermissionsEditor from "./entity-permissions-editor";
+import templateManager from "./template-manager";
+import newUserPassword from "./new-user-password";
 
 const componentMapping = {
     'dropdown': dropdown,
@@ -57,7 +59,9 @@ const componentMapping = {
     'custom-checkbox': customCheckbox,
     'book-sort': bookSort,
     'setting-app-color-picker': settingAppColorPicker,
-    'entity-permissions-editor': entityPermissionsEditor
+    'entity-permissions-editor': entityPermissionsEditor,
+    'template-manager': templateManager,
+    'new-user-password': newUserPassword,
 };
 
 window.components = {};
index b0e4d693a4e499810ad5bb40e549815095a9b8ec..7f3d4ef247d15f9aea1c88d496de415fb0c15df3 100644 (file)
@@ -18,6 +18,8 @@ class MarkdownEditor {
         this.markdown.use(mdTasksLists, {label: true});
 
         this.display = this.elem.querySelector('.markdown-display');
+        this.displayDoc = this.display.contentDocument;
+        this.displayStylesLoaded = false;
         this.input = this.elem.querySelector('textarea');
         this.htmlInput = this.elem.querySelector('input[name=html]');
         this.cm = code.markdownEditor(this.input);
@@ -38,7 +40,7 @@ class MarkdownEditor {
         let lastClick = 0;
 
         // Prevent markdown display link click redirect
-        this.display.addEventListener('click', event => {
+        this.displayDoc.addEventListener('click', event => {
             let isDblClick = Date.now() - lastClick < 300;
 
             let link = event.target.closest('a');
@@ -91,21 +93,42 @@ class MarkdownEditor {
         });
 
         this.codeMirrorSetup();
+        this.listenForBookStackEditorEvents();
     }
 
     // Update the input content and render the display.
     updateAndRender() {
-        let content = this.cm.getValue();
+        const content = this.cm.getValue();
         this.input.value = content;
-        let html = this.markdown.render(content);
+        const html = this.markdown.render(content);
         window.$events.emit('editor-html-change', html);
         window.$events.emit('editor-markdown-change', content);
-        this.display.innerHTML = html;
+
+        // Set body content
+        this.displayDoc.body.className = 'page-content';
+        this.displayDoc.body.innerHTML = html;
         this.htmlInput.value = html;
+
+        // Copy styles from page head and set custom styles for editor
+        this.loadStylesIntoDisplay();
+    }
+
+    loadStylesIntoDisplay() {
+        if (this.displayStylesLoaded) return;
+        this.displayDoc.documentElement.className = 'markdown-editor-display';
+
+        this.displayDoc.head.innerHTML = '';
+        const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
+        for (let style of styles) {
+            const copy = style.cloneNode(true);
+            this.displayDoc.head.appendChild(copy);
+        }
+
+        this.displayStylesLoaded = true;
     }
 
     onMarkdownScroll(lineCount) {
-        const elems = this.display.children;
+        const elems = this.displayDoc.body.children;
         if (elems.length <= lineCount) return;
 
         const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
@@ -199,16 +222,30 @@ class MarkdownEditor {
             }
         });
 
-        // Handle images on drag-drop
+        // Handle image & content drag n drop
         cm.on('drop', (cm, event) => {
-            event.stopPropagation();
-            event.preventDefault();
-            let cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
-            cm.setCursor(cursorPos);
-            if (!event.dataTransfer || !event.dataTransfer.files) return;
-            for (let i = 0; i < event.dataTransfer.files.length; i++) {
-                uploadImage(event.dataTransfer.files[i]);
+
+            const templateId = event.dataTransfer.getData('bookstack/template');
+            if (templateId) {
+                const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
+                cm.setCursor(cursorPos);
+                event.preventDefault();
+                window.$http.get(`/templates/${templateId}`).then(resp => {
+                    const content = resp.data.markdown || resp.data.html;
+                    cm.replaceSelection(content);
+                });
+            }
+
+            if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
+                const cursorPos = cm.coordsChar({left: event.pageX, top: event.pageY});
+                cm.setCursor(cursorPos);
+                event.stopPropagation();
+                event.preventDefault();
+                for (let i = 0; i < event.dataTransfer.files.length; i++) {
+                    uploadImage(event.dataTransfer.files[i]);
+                }
             }
+
         });
 
         // Helper to replace editor content
@@ -461,6 +498,37 @@ class MarkdownEditor {
         })
     }
 
+    listenForBookStackEditorEvents() {
+
+        function getContentToInsert({html, markdown}) {
+            return markdown || html;
+        }
+
+        // Replace editor content
+        window.$events.listen('editor::replace', (eventContent) => {
+            const markdown = getContentToInsert(eventContent);
+            this.cm.setValue(markdown);
+        });
+
+        // Append editor content
+        window.$events.listen('editor::append', (eventContent) => {
+            const cursorPos = this.cm.getCursor('from');
+            const markdown = getContentToInsert(eventContent);
+            const content = this.cm.getValue() + '\n' + markdown;
+            this.cm.setValue(content);
+            this.cm.setCursor(cursorPos.line, cursorPos.ch);
+        });
+
+        // Prepend editor content
+        window.$events.listen('editor::prepend', (eventContent) => {
+            const cursorPos = this.cm.getCursor('from');
+            const markdown = getContentToInsert(eventContent);
+            const content = markdown + '\n' + this.cm.getValue();
+            this.cm.setValue(content);
+            const prependLineCount = markdown.split('\n').length;
+            this.cm.setCursor(cursorPos.line + prependLineCount, cursorPos.ch);
+        });
+    }
 }
 
 export default MarkdownEditor ;
diff --git a/resources/assets/js/components/new-user-password.js b/resources/assets/js/components/new-user-password.js
new file mode 100644 (file)
index 0000000..9c4c21c
--- /dev/null
@@ -0,0 +1,28 @@
+
+class NewUserPassword {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.inviteOption = elem.querySelector('input[name=send_invite]');
+
+        if (this.inviteOption) {
+            this.inviteOption.addEventListener('change', this.inviteOptionChange.bind(this));
+            this.inviteOptionChange();
+        }
+    }
+
+    inviteOptionChange() {
+        const inviting = (this.inviteOption.value === 'true');
+        const passwordBoxes = this.elem.querySelectorAll('input[type=password]');
+        for (const input of passwordBoxes) {
+            input.disabled = inviting;
+        }
+        const container = this.elem.querySelector('#password-input-container');
+        if (container) {
+            container.style.display = inviting ? 'none' : 'block';
+        }
+    }
+
+}
+
+export default NewUserPassword;
\ No newline at end of file
index 1ba5efceadf553e3800f79ed50746d6984014f9f..ad6a01061ec033c0beaebc33fa003a665b57b0d7 100644 (file)
@@ -6,12 +6,22 @@ class Overlay {
         elem.addEventListener('click', event => {
              if (event.target === elem) return this.hide();
         });
+
+        window.addEventListener('keyup', event => {
+            if (event.key === 'Escape') {
+                this.hide();
+            }
+        });
+
         let closeButtons = elem.querySelectorAll('.popup-header-close');
         for (let i=0; i < closeButtons.length; i++) {
             closeButtons[i].addEventListener('click', this.hide.bind(this));
         }
     }
 
+    hide() { this.toggle(false); }
+    show() { this.toggle(true); }
+
     toggle(show = true) {
         let start = Date.now();
         let duration = 240;
@@ -22,6 +32,9 @@ class Overlay {
             this.container.style.opacity = targetOpacity;
             if (elapsedTime > duration) {
                 this.container.style.display = show ? 'flex' : 'none';
+                if (show) {
+                    this.focusOnBody();
+                }
                 this.container.style.opacity = '';
             } else {
                 requestAnimationFrame(setOpacity.bind(this));
@@ -31,8 +44,12 @@ class Overlay {
         requestAnimationFrame(setOpacity.bind(this));
     }
 
-    hide() { this.toggle(false); }
-    show() { this.toggle(true); }
+    focusOnBody() {
+        const body = this.container.querySelector('.popup-body');
+        if (body) {
+            body.focus();
+        }
+    }
 
 }
 
index 064596d86e421c9e22e42d2a0ac4298493a34fcc..6c0c0b31dcecb86fbf39fea7c6dbecb496d1822f 100644 (file)
@@ -10,7 +10,7 @@ class SettingAppColorPicker {
         this.colorInput.addEventListener('change', this.updateColor.bind(this));
         this.colorInput.addEventListener('input', this.updateColor.bind(this));
         this.resetButton.addEventListener('click', event => {
-            this.colorInput.value = '#0288D1';
+            this.colorInput.value = '#206ea7';
             this.updateColor();
         });
     }
diff --git a/resources/assets/js/components/template-manager.js b/resources/assets/js/components/template-manager.js
new file mode 100644 (file)
index 0000000..d004a43
--- /dev/null
@@ -0,0 +1,94 @@
+import * as DOM from "../services/dom";
+
+class TemplateManager {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.list = elem.querySelector('[template-manager-list]');
+        this.searching = false;
+
+        // Template insert action buttons
+        DOM.onChildEvent(this.elem, '[template-action]', 'click', this.handleTemplateActionClick.bind(this));
+
+        // Template list pagination click
+        DOM.onChildEvent(this.elem, '.pagination a', 'click', this.handlePaginationClick.bind(this));
+
+        // Template list item content click
+        DOM.onChildEvent(this.elem, '.template-item-content', 'click', this.handleTemplateItemClick.bind(this));
+
+        // Template list item drag start
+        DOM.onChildEvent(this.elem, '.template-item', 'dragstart', this.handleTemplateItemDragStart.bind(this));
+
+        this.setupSearchBox();
+    }
+
+    handleTemplateItemClick(event, templateItem) {
+        const templateId = templateItem.closest('[template-id]').getAttribute('template-id');
+        this.insertTemplate(templateId, 'replace');
+    }
+
+    handleTemplateItemDragStart(event, templateItem) {
+        const templateId = templateItem.closest('[template-id]').getAttribute('template-id');
+        event.dataTransfer.setData('bookstack/template', templateId);
+        event.dataTransfer.setData('text/plain', templateId);
+    }
+
+    handleTemplateActionClick(event, actionButton) {
+        event.stopPropagation();
+
+        const action = actionButton.getAttribute('template-action');
+        const templateId = actionButton.closest('[template-id]').getAttribute('template-id');
+        this.insertTemplate(templateId, action);
+    }
+
+    async insertTemplate(templateId, action = 'replace') {
+        const resp = await window.$http.get(`/templates/${templateId}`);
+        const eventName = 'editor::' + action;
+        window.$events.emit(eventName, resp.data);
+    }
+
+    async handlePaginationClick(event, paginationLink) {
+        event.preventDefault();
+        const paginationUrl = paginationLink.getAttribute('href');
+        const resp = await window.$http.get(paginationUrl);
+        this.list.innerHTML = resp.data;
+    }
+
+    setupSearchBox() {
+        const searchBox = this.elem.querySelector('.search-box');
+        const input = searchBox.querySelector('input');
+        const submitButton = searchBox.querySelector('button');
+        const cancelButton = searchBox.querySelector('button.search-box-cancel');
+
+        async function performSearch() {
+            const searchTerm = input.value;
+            const resp = await window.$http.get(`/templates`, {
+                search: searchTerm
+            });
+            cancelButton.style.display = searchTerm ? 'block' : 'none';
+            this.list.innerHTML = resp.data;
+        }
+        performSearch = performSearch.bind(this);
+
+        // Searchbox enter press
+        searchBox.addEventListener('keypress', event => {
+            if (event.key === 'Enter') {
+                event.preventDefault();
+                performSearch();
+            }
+        });
+
+        // Submit button press
+        submitButton.addEventListener('click', event => {
+            performSearch();
+        });
+
+        // Cancel button press
+        cancelButton.addEventListener('click', event => {
+            input.value = '';
+            performSearch();
+        });
+    }
+}
+
+export default TemplateManager;
\ No newline at end of file
index 3dd1ce85ccb4bd3d94983e34605a2651d3d47be0..b9b96afc5d07728d992e9cd49eab9e29a6df1f2a 100644 (file)
@@ -11,6 +11,11 @@ class ToggleSwitch {
 
     stateChange() {
         this.input.value = (this.checkbox.checked ? 'true' : 'false');
+
+        // Dispatch change event from hidden input so they can be listened to
+        // like a normal checkbox.
+        const changeEvent = new Event('change');
+        this.input.dispatchEvent(changeEvent);
     }
 
 }
index eb9f025a749d91edf62fbad3d8be131892523120..c03c0d2aa4e8bba75b8206e7060d50bb4dc26f06 100644 (file)
@@ -378,6 +378,27 @@ function customHrPlugin() {
 }
 
 
+function listenForBookStackEditorEvents(editor) {
+
+    // Replace editor content
+    window.$events.listen('editor::replace', ({html}) => {
+        editor.setContent(html);
+    });
+
+    // Append editor content
+    window.$events.listen('editor::append', ({html}) => {
+        const content = editor.getContent() + html;
+        editor.setContent(content);
+    });
+
+    // Prepend editor content
+    window.$events.listen('editor::prepend', ({html}) => {
+        const content = html + editor.getContent();
+        editor.setContent(content);
+    });
+
+}
+
 class WysiwygEditor {
 
     constructor(elem) {
@@ -553,6 +574,10 @@ class WysiwygEditor {
                     editor.focus();
                 }
 
+                listenForBookStackEditorEvents(editor);
+
+                // TODO - Update to standardise across both editors
+                // Use events within listenForBookStackEditorEvents instead (Different event signature)
                 window.$events.listen('editor-html-update', html => {
                     editor.setContent(html);
                     editor.selection.select(editor.getBody(), true);
@@ -583,6 +608,18 @@ class WysiwygEditor {
                     let dom = editor.dom,
                         rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc());
 
+                    // Template insertion
+                    const templateId = event.dataTransfer.getData('bookstack/template');
+                    if (templateId) {
+                        event.preventDefault();
+                        window.$http.get(`/templates/${templateId}`).then(resp => {
+                            editor.selection.setRng(rng);
+                            editor.undoManager.transact(function () {
+                                editor.execCommand('mceInsertContent', false, resp.data.html);
+                            });
+                        });
+                    }
+
                     // Don't allow anything to be dropped in a captioned image.
                     if (dom.getParent(rng.startContainer, '.mceTemp')) {
                         event.preventDefault();
index 797effd986431cfca0b52075ef6784ced7362539..966a4540e84eeb91117cec8ea89c41609d8b28e6 100644 (file)
@@ -22,6 +22,22 @@ export function onEvents(listenerElement, events, callback) {
     }
 }
 
+/**
+ * Helper to run an action when an element is selected.
+ * A "select" is made to be accessible, So can be a click, space-press or enter-press.
+ * @param listenerElement
+ * @param callback
+ */
+export function onSelect(listenerElement, callback) {
+    listenerElement.addEventListener('click', callback);
+    listenerElement.addEventListener('keydown', (event) => {
+        if (event.key === 'Enter' || event.key === ' ') {
+            event.preventDefault();
+            callback(event);
+        }
+    });
+}
+
 /**
  * Set a listener on an element for an event emitted by a child
  * matching the given childSelector param.
index 645286c08b9238ef8842846a80594cce7733ff51..b595a05e6f95b8e9a2d7862adbc5b5d003f6e163 100644 (file)
@@ -52,9 +52,7 @@ class Translator {
         const rangeRegex = /^\[([0-9]+),([0-9*]+)]/;
         let result = null;
 
-        for (const i = 0, len = splitText.length; i < len; i++) {
-            const t = splitText[i];
-
+        for (let t of splitText) {
             // Parse exact matches
             const exactMatches = t.match(exactCountRegex);
             if (exactMatches !== null && Number(exactMatches[1]) === count) {
@@ -77,7 +75,10 @@ class Translator {
             result = (count === 1) ? splitText[0] : splitText[1];
         }
 
-        if (result === null) result = splitText[0];
+        if (result === null) {
+            result = splitText[0];
+        }
+
         return this.performReplacements(result, replacements);
     }
 
index d6f9965a879f0547497d3f046b8056ba5c83ab06..c6df6b1a5de43aafc902fe45fc4a454f2a3ee2dd 100644 (file)
@@ -3,10 +3,10 @@ import codeLib from "../services/code";
 const methods = {
     show() {
         if (!this.editor) this.editor = codeLib.popupEditor(this.$refs.editor, this.language);
-        this.$refs.overlay.style.display = 'flex';
+        this.$refs.overlay.components.overlay.show();
     },
     hide() {
-        this.$refs.overlay.style.display = 'none';
+        this.$refs.overlay.components.overlay.hide();
     },
     updateEditorMode(language) {
         codeLib.setMode(this.editor, language);
index d76ee89f189dd517f9805663aa08c06eaf690df5..b809313cb641cc0b723e334f510542fb045975a2 100644 (file)
@@ -6,6 +6,7 @@ const template = `
             @input="inputUpdate($event.target.value)" @focus="inputUpdate($event.target.value)"
             @blur="inputBlur"
             @keydown="inputKeydown"
+            :aria-label="placeholder"
         />
         <ul class="suggestion-box" v-if="showSuggestions">
             <li v-for="(suggestion, i) in suggestions"
@@ -66,23 +67,23 @@ const methods = {
     },
 
     inputKeydown(event) {
-        if (event.keyCode === 13) event.preventDefault();
+        if (event.key === 'Enter') event.preventDefault();
         if (!this.showSuggestions) return;
 
         // Down arrow
-        if (event.keyCode === 40) {
+        if (event.key === 'ArrowDown') {
             this.active = (this.active === this.suggestions.length - 1) ? 0 : this.active+1;
         }
         // Up Arrow
-        else if (event.keyCode === 38) {
+        else if (event.key === 'ArrowUp') {
             this.active = (this.active === 0) ? this.suggestions.length - 1 : this.active-1;
         }
-        // Enter or tab keys
-        else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
+        // Enter key
+        else if ((event.key === 'Enter') && !event.shiftKey) {
             this.selectSuggestion(this.suggestions[this.active]);
         }
         // Escape key
-        else if (event.keyCode === 27) {
+        else if (event.key === 'Escape') {
             this.showSuggestions = false;
         }
     },
index 751cca330020c8d3cf3ffeba6e70e84d33774779..1c045727f842686ebbcb690ced96307c56b1017f 100644 (file)
@@ -2,8 +2,8 @@ import DropZone from "dropzone";
 import { fadeOut } from "../../services/animations";
 
 const template = `
-    <div class="dropzone-container">
-        <div class="dz-message">{{placeholder}}</div>
+    <div class="dropzone-container text-center">
+        <button type="button" class="dz-message">{{placeholder}}</button>
     </div>
 `;
 
index e0dab595ae79502d17655634691e210a799cca68..65233cbb676636bd7756ade1cd48b026d0572835 100644 (file)
@@ -1,7 +1,7 @@
 import draggable from 'vuedraggable';
 import autosuggest from './components/autosuggest';
 
-let data = {
+const data = {
     entityId: false,
     entityType: null,
     tags: [],
@@ -10,7 +10,7 @@ let data = {
 const components = {draggable, autosuggest};
 const directives = {};
 
-let methods = {
+const methods = {
 
     addEmptyTag() {
         this.tags.push({name: '', value: '', key: Math.random().toString(36).substring(7)});
index 032b1cbeb1c467983eaf8da8b1e02c5b0c38d0f2..2cb17a18db90e8f7185ffde7e52973ae1dd249b3 100644 (file)
   line-height: 1;
 }
 
+.card.border-card {
+  border: 1px solid #DDD;
+}
+
 .card.drag-card {
   border: 1px solid #DDD;
   border-radius: 4px;
   }
 }
 
-.bookshelf-grid-item .grid-card-content h2 a  {
-  color: $color-bookshelf;
-  fill: $color-bookshelf;
-}
-
 .book-grid-item .grid-card-footer {
   p.small {
     font-size: .8em;
index eb7a09342ad60079503534da6294393af6a75fbc..e3d9e17cad825beec15792dac617ce3914c45d9b 100644 (file)
@@ -1,29 +1,9 @@
 button {
+  background-color: transparent;
+  border: 0;
   font-size: 100%;
 }
 
-@mixin generate-button-colors($textColor, $backgroundColor) {
-  background-color: $backgroundColor;
-  color: $textColor;
-  fill: $textColor;
-  border: 1px solid $backgroundColor;
-  &:hover {
-    background-color: lighten($backgroundColor, 8%);
-    color: $textColor;
-  }
-  &:active {
-    background-color: darken($backgroundColor, 8%);
-  }
-  &:focus {
-    background-color: lighten($backgroundColor, 4%);
-    box-shadow: $bs-light;
-    color: $textColor;
-  }
-}
-
-// Button Specific Variables
-$button-border-radius: 2px;
-
 .button  {
   text-decoration: none;
   font-size: 0.85rem;
@@ -34,34 +14,54 @@ $button-border-radius: 2px;
   display: inline-block;
   font-weight: 400;
   outline: 0;
-  border-radius: $button-border-radius;
+  border-radius: 2px;
   cursor: pointer;
-  transition: background-color ease-in-out 120ms, box-shadow ease-in-out 120ms;
+  transition: background-color ease-in-out 120ms,
+    filter ease-in-out 120ms,
+    box-shadow ease-in-out 120ms;
   box-shadow: none;
-  background-color: $primary;
+  background-color: var(--color-primary);
   color: #FFF;
   fill: #FFF;
   text-transform: uppercase;
-  border: 1px solid $primary;
+  border: 1px solid var(--color-primary);
   vertical-align: top;
-  &:hover, &:focus {
+  &:hover, &:focus, &:active {
+    background-color: var(--color-primary);
     text-decoration: none;
+    color: #FFFFFF;
+  }
+  &:hover {
+    box-shadow: $bs-light;
+    filter: brightness(110%);
+  }
+  &:focus {
+    outline: 1px dotted currentColor;
+    outline-offset: -$-xs;
+    box-shadow: none;
+    filter: brightness(90%);
   }
   &:active {
-    background-color: darken($primary, 8%);
+    outline: 0;
   }
 }
-.button.primary {
-  @include generate-button-colors(#FFFFFF, $primary);
-}
+
 .button.outline {
   background-color: transparent;
-  color: #888;
-  fill: #888;
-  border: 1px solid #DDD;
+  color: #666;
+  fill: currentColor;
+  border: 1px solid #CCC;
   &:hover, &:focus, &:active {
+    border: 1px solid #CCC;
     box-shadow: none;
-    background-color: #EEE;
+    background-color: #F2F2F2;
+    filter: none;
+  }
+  &:active {
+    border-color: #BBB;
+    background-color: #DDD;
+    color: #666;
+    box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1);
   }
 }
 
@@ -83,12 +83,18 @@ $button-border-radius: 2px;
   user-select: none;
   font-size: 0.75rem;
   line-height: 1.4em;
-  &:focus, &:active {
+  color: var(--color-primary);
+  fill: var(--color-primary);
+  &:active {
     outline: 0;
   }
   &:hover {
     text-decoration: none;
   }
+  &:hover, &:focus {
+    color: var(--color-primary);
+    fill: var(--color-primary);
+  }
 }
 
 .button.block {
@@ -118,6 +124,7 @@ $button-border-radius: 2px;
 .button[disabled] {
   background-color: #BBB;
   cursor: default;
+  border-color: #CCC;
   &:hover {
     background-color: #BBB;
     cursor: default;
index 8f2de6c82007599538b126cac2a72ba189e6e5f9..8623d374af4b1f63ffa4a9f8a352b26b1de6247a 100644 (file)
@@ -1,3 +1,13 @@
+/**
+ * Background colors
+ */
+
+.primary-background {
+  background-color: var(--color-primary) !important;
+}
+.primary-background-light {
+  background-color: var(--color-primary-light);
+}
 
 /*
  * Status text colors
  * Style text colors
  */
 .text-primary, .text-primary:hover, .text-primary-hover:hover  {
-  color: $primary !important;
-  fill: $primary !important;
+  color: var(--color-primary) !important;
+  fill: var(--color-primary) !important;
 }
 
 .text-muted {
-  color: lighten($text-dark, 26%) !important;
-  fill: lighten($text-dark, 26%) !important;
-  &.small, .small {
-    color: lighten($text-dark, 32%) !important;
-    fill: lighten($text-dark, 32%) !important;
-  }
+  color: #575757 !important;
+  fill: #575757 !important;
 }
 
 /*
  * Entity text colors
  */
 .text-bookshelf, .text-bookshelf:hover {
-  color: $color-bookshelf;
-  fill: $color-bookshelf;
+  color: var(--color-bookshelf);
+  fill: var(--color-bookshelf);
 }
 .text-book, .text-book:hover {
-  color: $color-book;
-  fill: $color-book;
+  color: var(--color-book);
+  fill: var(--color-book);
 }
 .text-page, .text-page:hover {
-  color: $color-page;
-  fill: $color-page;
+  color: var(--color-page);
+  fill: var(--color-page);
 }
 .text-page.draft, .text-page.draft:hover {
-  color: $color-page-draft;
-  fill: $color-page-draft;
+  color: var(--color-page-draft);
+  fill: var(--color-page-draft);
 }
 .text-chapter, .text-chapter:hover {
-  color: $color-chapter;
-  fill: $color-chapter;
+  color: var(--color-chapter);
+  fill: var(--color-chapter);
 }
 
 /*
   background-color: #FFFFFF;
 }
 .bg-book {
-  background-color: $color-book;
+  background-color: var(--color-book);
 }
 .bg-chapter {
-  background-color: $color-chapter;
+  background-color: var(--color-chapter);
 }
 .bg-shelf {
-  background-color: $color-bookshelf;
+  background-color: var(--color-bookshelf);
 }
\ No newline at end of file
index 039ac4dc8d8c3580c7f0374025ac6aa43a0692e3..0172956a76ee48c79ea0b69499e97180b6d16859 100644 (file)
   .popup-content {
     overflow-y: auto;
   }
+  &:focus {
+    outline: 0;
+  }
 }
 
 .popup-footer button, .popup-header-close {
     padding: 8px $-m;
   }
 }
-.popup-footer {
-  margin-top: 1px;
-}
 body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   height: 444px;
   min-height: 444px;
@@ -579,6 +579,20 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   }
 }
 
+.nav-tabs {
+  text-align: center;
+  a, .tab-item {
+    padding: $-m;
+    display: inline-block;
+    color: #666;
+    fill: #666;
+    cursor: pointer;
+    &.selected {
+      border-bottom: 2px solid var(--color-primary);
+    }
+  }
+}
+
 .image-picker .none {
   display: none;
 }
@@ -620,7 +634,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
     opacity: 0;
     transition: opacity ease-in-out 120ms;
   }
-  &:hover .actions {
+  &:hover .actions, &:focus-within .actions {
     opacity: 1;
   }
 }
@@ -637,7 +651,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
     }
     a { color: #666; }
     span {
-      color: #888;
       padding-left: $-xxs;
     }
   }
@@ -655,4 +668,32 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 .permissions-table tr:hover [permissions-table-toggle-all-in-row] {
   display: inline;
+}
+
+.template-item {
+  cursor: pointer;
+  position: relative;
+  &:hover, .template-item-actions button:hover {
+    background-color: #F2F2F2;
+  }
+  .template-item-actions {
+    position: absolute;
+    top: 0;
+    right: 0;
+    width: 50px;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    border-left: 1px solid #DDD;
+  }
+  .template-item-actions button {
+    cursor: pointer;
+    flex: 1;
+    background: #FFF;
+    border: 0;
+    border-top: 1px solid #DDD;
+  }
+  .template-item-actions button:first-child {
+    border-top: 0;
+  }
 }
\ No newline at end of file
index 5fd19bb1f3b14613803b8ecaea6088ac22a23701..64308b29e725bbe0470189b0e259e31630ee0b73 100644 (file)
@@ -20,7 +20,8 @@
     background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
   }
   &:focus {
-    outline: 0;
+    border-color: var(--color-primary);
+    outline: 1px solid var(--color-primary);
   }
 }
 
 }
 
 .markdown-display {
-  padding: 0 $-m 0;
   margin-left: -1px;
-  overflow-y: scroll;
-  &.page-content {
-    margin: 0 auto;
-    width: 100%;
-    max-width: 100%;
+}
+
+.markdown-editor-display {
+  background-color: #FFFFFF;
+  body {
+    background-color: #FFFFFF;
+    padding-left: 16px;
+    padding-right: 16px;
   }
   [drawio-diagram]:hover {
-    outline: 2px solid $primary;
+    outline: 2px solid var(--color-primary);
   }
 }
 
@@ -269,6 +272,9 @@ input[type=color] {
     margin-left: -$-m;
     margin-right: -$-m;
     padding: $-s $-m;
+    display: block;
+    width: calc(100% + 32px);
+    text-align: left;
   }
   .collapse-title, .collapse-title label {
     cursor: pointer;
@@ -342,16 +348,16 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
   button {
     background-color: transparent;
     border: none;
-    color: $primary;
+    fill: #666;
     padding: 0;
     cursor: pointer;
     position: absolute;
     left: 8px;
-    top: 9.5px;
+    top: 9px;
   }
   input {
     display: block;
-    padding-left: $-l;
+    padding-left: $-l + 4px;
     width: 300px;
     max-width: 100%;
   }
@@ -380,3 +386,18 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
   background-color: #BBB;
   max-width: 100%;
 }
+
+.custom-file-input {
+  overflow: hidden;
+  padding: 0;
+  position: absolute;
+  white-space: nowrap;
+  width: 1px;
+  height: 1px;
+  border: 0;
+  clip: rect(0, 0, 0, 0);
+}
+.custom-file-input:focus + label {
+  border-color: var(--color-primary);
+  outline: 1px solid var(--color-primary);
+}
\ No newline at end of file
index adb014f4a956b135255982d0ea71830138ab3ece..687ddd8d2259c83dcf0cb94530e7b34b7d5e9b9d 100644 (file)
@@ -18,7 +18,6 @@ header {
   display: block;
   z-index: 11;
   top: 0;
-  background-color: $primary-dark;
   color: #fff;
   fill: #fff;
   border-bottom: 1px solid #DDD;
@@ -47,9 +46,7 @@ header {
   }
   .user-name {
     vertical-align: top;
-    padding-top: $-m;
     position: relative;
-    top: -3px;
     display: inline-block;
     cursor: pointer;
     > * {
@@ -73,6 +70,9 @@ header {
   }
 }
 
+.header *, .primary-background * {
+  outline-color: #FFF;
+}
 
 
 .header-search {
@@ -88,6 +88,10 @@ header .search-box {
     color: #EEE;
     z-index: 2;
     padding-left: 40px;
+    &:focus {
+      outline: none;
+      border: 1px solid rgba(255, 255, 255, 0.6);
+    }
   }
   button {
     fill: #EEE;
@@ -103,12 +107,6 @@ header .search-box {
   ::-moz-placeholder { /* Firefox 19+ */
     color: #DDD;
   }
-  :-ms-input-placeholder { /* IE 10+ */
-    color: #DDD;
-  }
-  :-moz-placeholder { /* Firefox 18- */
-    color: #DDD;
-  }
   @include between($l, $xl) {
     max-width: 200px;
   }
@@ -243,7 +241,7 @@ header .search-box {
     line-height: 0.8;
     margin: -2px 0 0;
   }
-  &:hover {
+  &:hover, &:focus-within {
     opacity: 1;
   }
 }
@@ -372,18 +370,4 @@ header .search-box {
   .action-buttons .dropdown-container:last-child a {
     padding-left: $-xs;
   }
-}
-
-.nav-tabs {
-  text-align: center;
-  a, .tab-item {
-    padding: $-m;
-    display: inline-block;
-    color: #666;
-    fill: #666;
-    cursor: pointer;
-    &.selected {
-      border-bottom: 2px solid $primary;
-    }
-  }
 }
\ No newline at end of file
index 7c3a3c49b41179aaf673b211ed834a399ca8a54e..de48c8ed1bbe8eac685067c399c583faafe0abdc 100644 (file)
@@ -1,5 +1,10 @@
 * {
   box-sizing: border-box;
+  outline-color: #444444;
+}
+
+*:focus {
+  outline-style: dotted;
 }
 
 html {
index b282b12e272c45455b4123527236d24fe27bacd2..1a7ff2cab029e615c9f7ad7025b4b4fcc8bc2b9a 100644 (file)
@@ -116,6 +116,7 @@ body.flexbox {
   min-height: 0;
   max-width: 100%;
   position: relative;
+  overflow-y: hidden;
 }
 
 .flex {
@@ -301,13 +302,17 @@ body.flexbox {
   .tri-layout-mobile-tabs {
     display: none;
   }
-  .tri-layout-left-contents > div, .tri-layout-right-contents > div {
+  .tri-layout-left-contents > *, .tri-layout-right-contents > * {
     opacity: 0.6;
     transition: opacity ease-in-out 120ms;
     &:hover {
       opacity: 1;
     }
+    &:focus-within {
+      opacity: 1;
+    }
   }
+
 }
 
 @include smaller-than($m) {
index c413bcd8eecf6fd2c7e1bfae8b4b89777962286e..7c7cc4b3589e8e3d8a4242055fc33d8d26a4301e 100644 (file)
@@ -59,6 +59,8 @@
   .chapter-expansion-toggle {
     border-radius: 0 4px 4px 0;
     padding: $-xs $-m;
+    width: 100%;
+    text-align: left;
   }
   .chapter-expansion-toggle:hover {
     background-color: rgba(0, 0, 0, 0.06);
 }
 .sort-box {
   margin-bottom: $-m;
-  border: 2px solid rgba($color-book, 0.6);
   padding: $-m $-xl;
-  border-radius: 4px;
+  position: relative;
+  &::before {
+    content: '';
+    border-radius: 4px;
+    opacity: 0.5;
+    border: 2px solid var(--color-book);
+    display: block;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    position: absolute;
+  }
 }
 .sort-box-options {
   display: flex;
     border: 1px solid #DDD;
     margin-top: -1px;
     min-height: 38px;
-    &.text-chapter {
-      border-left: 2px solid $color-chapter;
-    }
-    &.text-page {
-      border-left: 2px solid $color-page;
-    }
+  }
+  li.text-page, li.text-chapter {
+    border-left: 2px solid currentColor;
   }
   li:first-child {
     margin-top: $-xs;
   display: grid;
   grid-template-columns: min-content 1fr;
   grid-column-gap: $-m;
-  color: #888;
-  fill: #888;
   font-size: 0.9em;
 }
 .card .activity-list-item {
@@ -361,8 +369,8 @@ ul.pagination {
     margin-top: 0;
   }
   .page.draft .text-page {
-    color: $color-page-draft;
-    fill: $color-page-draft;
+    color: var(--color-page-draft);
+    fill: var(--color-page-draft);
   }
   > .dropdown-container {
     display: block;
@@ -416,6 +424,11 @@ ul.pagination {
     background-color: transparent;
     border-color: rgba(0, 0, 0, 0.1);
   }
+  &:focus {
+    background-color: #eee;
+    outline: 1px dotted #666;
+    outline-offset: -2px;
+  }
 }
 
 .entity-list-item-path-sep {
@@ -551,11 +564,16 @@ ul.pagination {
     display: block;
     padding: $-xs $-m;
     color: #555;
-    fill: #555;
+    fill: currentColor;
     white-space: nowrap;
-    &:hover {
+    &:hover, &:focus {
       text-decoration: none;
-      background-color: #EEE;
+      background-color: var(--color-primary-light);
+      color: var(--color-primary);
+    }
+    &:focus {
+      outline: 1px solid var(--color-primary);
+      outline-offset: -2px;
     }
     svg {
       margin-right: $-s;
index fc784eebe84bcf5a6d745cc5cb3be0ef8048cc2d..86db8782eef67db74471780a4e3259ac85579759 100755 (executable)
@@ -258,15 +258,15 @@ body.mce-fullscreen .page-editor .edit-area {
     padding: 0;
     margin: 0;
   }
-  .tabs > span {
+  .tabs > button {
     display: block;
     cursor: pointer;
     padding: $-s $-m;
-    font-size: 13.5px;
+    font-size: 16px;
     line-height: 1.6;
     border-bottom: 1px solid rgba(255, 255, 255, 0.3);
   }
-  &.open .tabs > span.active {
+  &.open .tabs > button.active {
     fill: #444;
     background-color: rgba(0, 0, 0, 0.1);
   }
index f1d165a47f991ddb94e31f20bcf88a86b9fa94fe..315f08c34b77261ce18ab6c1c7560441f8788dfb 100644 (file)
@@ -90,14 +90,14 @@ h2.list-heading {
  * Link styling
  */
 a {
-  color: $primary;
+  color: var(--color-primary);
+  fill: var(--color-primary);
   cursor: pointer;
   text-decoration: none;
-  transition: color ease-in-out 80ms;
+  transition: filter ease-in-out 80ms;
   line-height: 1.6;
   &:hover {
     text-decoration: underline;
-    color: darken($primary, 20%);
   }
   &.icon {
     display: inline-block;
@@ -106,6 +106,10 @@ a {
     position: relative;
     display: inline-block;
   }
+  &:focus img:only-child {
+    outline: 2px dashed var(--color-primary);
+    outline-offset: 2px;
+  }
 }
 
 .blended-links a {
@@ -195,7 +199,7 @@ pre {
 blockquote {
   display: block;
   position: relative;
-  border-left: 4px solid $primary;
+  border-left: 4px solid var(--color-primary);
   background-color: #F8F8F8;
   padding: $-s $-m $-s $-xl;
   &:before {
@@ -239,7 +243,6 @@ pre code {
 }
 
 span.highlight {
-  //background-color: rgba($primary, 0.2);
   font-weight: bold;
   padding: 2px 4px;
 }
index 4c50f14d2f020ed371b0e88a9ccdb4cb2fb5e10a..27c3b28d01b50874e035ce055362e3784f64967b 100644 (file)
@@ -61,6 +61,7 @@
 
 .page-content.mce-content-body {
   padding-top: 16px;
+  outline: none;
 }
 
 // Fix to prevent 'No color' option from not being clickable.
index 041b70edfdb4c6a9946dc6aa0705fac46e0b30b1..2d4d3970af2d028b98f825cf2b5833395db81fb1 100644 (file)
@@ -41,21 +41,21 @@ $fs-m: 14px;
 $fs-s: 12px;
 
 // Colours
-$primary: #0288D1;
-$primary-dark: #0288D1;
-$secondary: #cf4d03;
+:root {
+  --color-primary: #206ea7;
+  --color-primary-light: rgba(32,110,167,0.15);
+
+  --color-page: #206ea7;
+  --color-page-draft: #7e50b1;
+  --color-chapter: #af4d0d;
+  --color-book: #077b70;
+  --color-bookshelf: #a94747;
+}
+
 $positive: #0f7d15;
 $negative: #ab0f0e;
-$info: $primary;
-$warning: $secondary;
-$primary-faded: rgba(21, 101, 192, 0.15);
-
-// Item Colors
-$color-bookshelf: #af5a5a;
-$color-book: #009688;
-$color-chapter: #d7804a;
-$color-page: $primary;
-$color-page-draft: #9A60DA;
+$info: #0288D1;
+$warning: #cf4d03;
 
 // Text colours
 $text-dark: #444;
index 44107f2d4d58fa6f59422a5c6181678c8d928cd4..296afbe76f0cb11efbb7c0b327885858016c949d 100644 (file)
@@ -4,39 +4,32 @@ header {
   display: none;
 }
 
-body {
+html, body {
   font-size: 12px;
+  background-color: #FFF;
 }
 
 .page-content {
   margin: 0 auto;
 }
 
-.flex-fill {
-  display: block;
-}
-
-.flex.sidebar + .flex.content {
-  border-left: none;
-}
-
 .print-hidden {
-  display: none;
+  display: none !important;
 }
 
-.print-full-width {
-  width: 100%;
-  float: none;
+.tri-layout-container {
+  grid-template-columns: 1fr;
+  grid-template-areas: "b";
+  margin-left: 0;
+  margin-right: 0;
   display: block;
 }
 
-h2 {
-  font-size: 2em;
-  line-height: 1;
-  margin-top: 0.6em;
-  margin-bottom: 0.3em;
+.card {
+  box-shadow: none;
 }
 
-.comments-container {
-  display: none;
+.content-wrap.card {
+  padding-left: 0;
+  padding-right: 0;
 }
\ No newline at end of file
index 70f04f3ff52677a9d8639816536fae73f9b02630..8f1fef70c9c512b0629dcef8545304d9779f3b16 100644 (file)
@@ -75,17 +75,17 @@ $loadingSize: 10px;
     animation-iteration-count: infinite;
     animation-timing-function: cubic-bezier(.62, .28, .23, .99);
     margin-right: 4px;
-    background-color: $color-page;
+    background-color: var(--color-page);
     animation-delay: 0.3s;
   }
   > div:first-child {
       left: -($loadingSize+$-xs);
-      background-color: $color-book;
+      background-color: var(--color-book);
       animation-delay: 0s;
   }
   > div:last-of-type {
     left: $loadingSize+$-xs;
-    background-color: $color-chapter;
+    background-color: var(--color-chapter);
     animation-delay: 0.6s;
   }
   > span {
@@ -99,7 +99,7 @@ $loadingSize: 10px;
 // Back to top link
 $btt-size: 40px;
 [back-to-top] {
-  background-color: $primary;
+  background-color: var(--color-primary);
   position: fixed;
   bottom: $-m;
   right: $-l;
@@ -187,7 +187,7 @@ $btt-size: 40px;
     margin-bottom: 0;
   }
   .entity-list-item.selected {
-    background-color: rgba(0, 0, 0, 0.15) !important;
+    background-color: rgba(0, 0, 0, 0.05) !important;
   }
   .loading {
     height: 400px;
@@ -260,7 +260,7 @@ $btt-size: 40px;
   .list-sort-label {
     font-weight: bold;
     display: inline-block;
-    color: #888;
+    color: #555;
   }
   .list-sort-type {
     text-align: left;
index 1065945c0b0a55b89b70103fa2cfe554847aa65c..37346097f234d73e8fc9845fa9d652694a7d3f2d 100644 (file)
@@ -64,4 +64,14 @@ return [
     'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.',
     'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.',
     'email_not_confirmed_resend_button' => 'Resend Confirmation Email',
+
+    // User Invite
+    'user_invite_email_subject' => 'You have been invited to join :appName!',
+    'user_invite_email_greeting' => 'An account has been created for you on :appName.',
+    'user_invite_email_text' => 'Click the button below to set an account password and gain access:',
+    'user_invite_email_action' => 'Set Account Password',
+    'user_invite_page_welcome' => 'Welcome to :appName!',
+    'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.',
+    'user_invite_page_confirm_button' => 'Confirm Password',
+    'user_invite_success' => 'Password set, you now have access to :appName!'
 ];
\ No newline at end of file
index ed880afcf9ab24d0bdd0f30dcf6ae8f5b3de7394..1807217a375d2f0a77f5fe10ea80113ee57ea490 100644 (file)
@@ -40,6 +40,10 @@ return [
     'add' => 'Add',
 
     // Sort Options
+    'sort_options' => 'Sort Options',
+    'sort_direction_toggle' => 'Sort Direction Toggle',
+    'sort_ascending' => 'Sort Ascending',
+    'sort_descending' => 'Sort Descending',
     'sort_name' => 'Name',
     'sort_created_at' => 'Created Date',
     'sort_updated_at' => 'Updated Date',
@@ -55,8 +59,10 @@ return [
     'grid_view' => 'Grid View',
     'list_view' => 'List View',
     'default' => 'Default',
+    'breadcrumb' => 'Breadcrumb',
 
     // Header
+    'profile_menu' => 'Profile Menu',
     'view_profile' => 'View Profile',
     'edit_profile' => 'Edit Profile',
 
index f6df7e71b308db293da1844e3aa8682794f08ffc..6bbc723b0abfc1e6b1f69e270bbb9d2afdbe851b 100644 (file)
@@ -176,6 +176,7 @@ return [
     'pages_delete_confirm' => 'Are you sure you want to delete this page?',
     'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?',
     'pages_editing_named' => 'Editing Page :pageName',
+    'pages_edit_draft_options' => 'Draft Options',
     'pages_edit_save_draft' => 'Save Draft',
     'pages_edit_draft' => 'Edit Page Draft',
     'pages_editing_draft' => 'Editing Draft',
@@ -233,6 +234,7 @@ return [
     ],
     'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content',
     'pages_specific' => 'Specific Page',
+    'pages_is_template' => 'Page Template',
 
     // Editor Sidebar
     'page_tags' => 'Page Tags',
@@ -241,9 +243,11 @@ return [
     'shelf_tags' => 'Shelf Tags',
     'tag' => 'Tag',
     'tags' =>  'Tags',
+    'tag_name' =>  'Tag Name',
     'tag_value' => 'Tag Value (Optional)',
     'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
     'tags_add' => 'Add another tag',
+    'tags_remove' => 'Remove this tag',
     'attachments' => 'Attachments',
     'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.',
     'attachments_explain_instant_save' => 'Changes here are saved instantly.',
@@ -269,6 +273,12 @@ return [
     'attachments_file_uploaded' => 'File successfully uploaded',
     'attachments_file_updated' => 'File successfully updated',
     'attachments_link_attached' => 'Link successfully attached to page',
+    'templates' => 'Templates',
+    'templates_set_as_template' => 'Page is a template',
+    'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.',
+    'templates_replace_content' => 'Replace page content',
+    'templates_append_content' => 'Append to page content',
+    'templates_prepend_content' => 'Prepend to page content',
 
     // Profile View
     'profile_user_for_x' => 'User for :time',
index b91a0c3e11c96cfdc6f85d4f9940c472476610b2..c3b47744d31f5fc63081ec6d50136511721941c1 100644 (file)
@@ -27,6 +27,7 @@ return [
     'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.',
     'social_driver_not_found' => 'Social driver not found',
     'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.',
+    'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.',
 
     // System
     'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',
index d275e330a480b00bfde576d31bf28ed9fd7dcb57..bb542a588895b9cbedc1e2b2900d3883f64869f3 100755 (executable)
@@ -85,6 +85,7 @@ return [
     'role_manage_roles' => 'Manage roles & role permissions',
     'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions',
     'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages',
+    'role_manage_page_templates' => 'Manage page templates',
     'role_manage_settings' => 'Manage app settings',
     'role_asset' => 'Asset Permissions',
     'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
@@ -108,7 +109,9 @@ return [
     'users_role' => 'User Roles',
     'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.',
     'users_password' => 'User Password',
-    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 5 characters long.',
+    'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.',
+    'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.',
+    'users_send_invite_option' => 'Send user invite email',
     'users_external_auth_id' => 'External Authentication ID',
     'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your LDAP system.',
     'users_password_warning' => 'Only fill the below if you would like to change your password.',
diff --git a/resources/views/auth/invite-set-password.blade.php b/resources/views/auth/invite-set-password.blade.php
new file mode 100644 (file)
index 0000000..fbe62f2
--- /dev/null
@@ -0,0 +1,27 @@
+@extends('simple-layout')
+
+@section('content')
+
+    <div class="container very-small mt-xl">
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('auth.user_invite_page_welcome', ['appName' => setting('app-name')]) }}</h1>
+            <p>{{ trans('auth.user_invite_page_text', ['appName' => setting('app-name')]) }}</p>
+
+            <form action="{{ url('/register/invite/' . $token) }}" method="POST" class="stretch-inputs">
+                {!! csrf_field() !!}
+
+                <div class="form-group">
+                    <label for="password">{{ trans('auth.password') }}</label>
+                    @include('form.password', ['name' => 'password', 'placeholder' => trans('auth.password_hint')])
+                </div>
+
+                <div class="text-right">
+                    <button class="button">{{ trans('auth.user_invite_page_confirm_button') }}</button>
+                </div>
+
+            </form>
+
+        </div>
+    </div>
+
+@stop
index 76aa3a6e952201e0219bf62a6c239c376fe32d92..438339e92580ce19c028e19bd318ebd38cebdf6f 100644 (file)
@@ -28,7 +28,7 @@
                     </div>
 
                     <div class="text-right">
-                        <button class="button primary" tabindex="1">{{ title_case(trans('auth.log_in')) }}</button>
+                        <button class="button" tabindex="1">{{ title_case(trans('auth.log_in')) }}</button>
                     </div>
                 </div>
 
index 864b4e7d26b3940f6a4491f5519007126ebc587f..8273ed2356bf931f7fbe142fd6e4ad1e4841baec 100644 (file)
@@ -16,7 +16,7 @@
                 </div>
 
                 <div class="from-group text-right mt-m">
-                    <button class="button primary">{{ trans('auth.reset_password_send_button') }}</button>
+                    <button class="button">{{ trans('auth.reset_password_send_button') }}</button>
                 </div>
             </form>
 
index 227b39079d75879b517311c7999dd7071b041c21..930544cde40e870c788c1793afeb70661bc010a2 100644 (file)
@@ -26,7 +26,7 @@
                 </div>
 
                 <div class="from-group text-right mt-m">
-                    <button class="button primary">{{ trans('auth.reset_password') }}</button>
+                    <button class="button">{{ trans('auth.reset_password') }}</button>
                 </div>
             </form>
 
index 9cf34f501e0170571ac6ca2971c3a988889256e3..60ceba93c55b78cbc4feaee14a4e337cfed926f2 100644 (file)
@@ -31,7 +31,7 @@
                         <a href="{{ url('/login') }}">{{ trans('auth.already_have_account') }}</a>
                     </div>
                     <div class="from-group text-right">
-                        <button class="button primary">{{ trans('auth.create_account') }}</button>
+                        <button class="button">{{ trans('auth.create_account') }}</button>
                     </div>
                 </div>
 
index 2142a5dcb4afead277b8da223ef58059ec639d29..85473685b96207e108d07d3093a2c23fa4f43bcd 100644 (file)
@@ -24,7 +24,7 @@
                     @endif
                 </div>
                 <div class="form-group text-right mt-m">
-                    <button type="submit" class="button primary">{{ trans('auth.email_not_confirmed_resend_button') }}</button>
+                    <button type="submit" class="button">{{ trans('auth.email_not_confirmed_resend_button') }}</button>
                 </div>
             </form>
 
index da0e6eb44601fb2104a0410a36b2149a4d87c6be..07548162067404fa82061695c6753fe5bd15e27c 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html class="@yield('body-class')">
+<html lang="{{ config('app.lang') }}" class="@yield('body-class')">
 <head>
     <title>{{ isset($pageTitle) ? $pageTitle . ' | ' : '' }}{{ setting('app-name') }}</title>
 
     @include('partials.notifications')
     @include('common.header')
 
-    <section id="content" class="block">
+    <div id="content" class="block">
         @yield('content')
-    </section>
+    </div>
 
-    <div back-to-top class="primary-background">
+    <div back-to-top class="primary-background print-hidden">
         <div class="inner">
             @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
         </div>
index 65958e137e8aa357fb1613115e86edade88c8566..6de81cd462db648bcf11e294eff640c1c67c6f91 100644 (file)
             @endif
         </div>
 
-        <div class="content-wrap card">
+        <main class="content-wrap card">
             <h1 class="list-heading">{{ trans('entities.books_create') }}</h1>
             <form action="{{ isset($bookshelf) ? $bookshelf->getUrl('/create-book') : url('/books') }}" method="POST" enctype="multipart/form-data">
                 @include('books.form')
             </form>
-        </div>
+        </main>
     </div>
 
 @stop
\ No newline at end of file
index 2860e8bcdf15a63dbb2dec9cee399c4f39020ea0..be3f742cba5d13f5bc45c01864d3122ba4043283 100644 (file)
@@ -23,7 +23,7 @@
                 {!! csrf_field() !!}
                 <input type="hidden" name="_method" value="DELETE">
                 <a href="{{$book->getUrl()}}" class="button outline">{{ trans('common.cancel') }}</a>
-                <button type="submit" class="button primary">{{ trans('common.confirm') }}</button>
+                <button type="submit" class="button">{{ trans('common.confirm') }}</button>
             </form>
         </div>
 
index 2e51ed6e956fb947b905b97282d895ef38f1137c..400fd6e817a0a3dc6bdd5d7289abf59c0386f0c8 100644 (file)
             ]])
         </div>
 
-        <div class="content-wrap card">
+        <main class="content-wrap card">
             <h1 class="list-heading">{{ trans('entities.books_edit') }}</h1>
             <form action="{{ $book->getUrl() }}" method="POST" enctype="multipart/form-data">
                 <input type="hidden" name="_method" value="PUT">
                 @include('books.form', ['model' => $book])
             </form>
-        </div>
+        </main>
     </div>
 @stop
\ No newline at end of file
index 61c16c72dc586282f298cb0c2f98421dd0c3f688..4a7c45e0b81ce1b1cedcd431e94e269a19d95739 100644 (file)
@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en">
+<html lang="{{ config('app.lang') }}">
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <title>{{ $book->name }}</title>
index 5d3f11e2e86e85c12ba78ac360a6a19807df610f..8960b41359584ceb2cdec3b41039a90f0b1bfdde 100644 (file)
@@ -11,9 +11,9 @@
 </div>
 
 <div class="form-group" collapsible id="logo-control">
-    <div class="collapse-title text-primary" collapsible-trigger>
-        <label for="user-avatar">{{ trans('common.cover_image') }}</label>
-    </div>
+    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
+        <label>{{ trans('common.cover_image') }}</label>
+    </button>
     <div class="collapse-content" collapsible-content>
         <p class="small">{{ trans('common.cover_image_description') }}</p>
 
@@ -27,9 +27,9 @@
 </div>
 
 <div class="form-group" collapsible id="tags-control">
-    <div class="collapse-title text-primary" collapsible-trigger>
+    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
         <label for="tag-manager">{{ trans('entities.book_tags') }}</label>
-    </div>
+    </button>
     <div class="collapse-content" collapsible-content>
         @include('components.tag-manager', ['entity' => isset($book)?$book:null, 'entityType' => 'chapter'])
     </div>
@@ -37,5 +37,5 @@
 
 <div class="form-group text-right">
     <a href="{{ isset($book) ? $book->getUrl() : url('/books') }}" class="button outline">{{ trans('common.cancel') }}</a>
-    <button type="submit" class="button primary">{{ trans('entities.books_save') }}</button>
+    <button type="submit" class="button">{{ trans('entities.books_save') }}</button>
 </div>
\ No newline at end of file
index 84578e3a59ba5851ca4224143ccf9c96c8e6b10a..871d931f17953496478a7f456e910c5ec3d6ee36 100644 (file)
@@ -1,5 +1,5 @@
 
-<div class="content-wrap mt-m card">
+<main class="content-wrap mt-m card">
     <div class="grid half v-center no-row-gap">
         <h1 class="list-heading">{{ trans('entities.books') }}</h1>
         <div class="text-m-right my-m">
@@ -31,4 +31,4 @@
             <a href="{{ url("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_now') }}</a>
         @endif
     @endif
-</div>
\ No newline at end of file
+</main>
\ No newline at end of file
index 64322cf859696af63971a57b76abad784ac14be9..b387ed6c7c94b082800b1a72979c61668308a2f7 100644 (file)
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.books_permissions') }}</h1>
             @include('form.entity-permissions', ['model' => $book])
-        </div>
+        </main>
     </div>
 
 @stop
index b709b29dcd7ac6ac2d1e0512ec62f0d69845c745..cbafdb4364b0d18e350369d83e6457775edba5e5 100644 (file)
@@ -14,7 +14,7 @@
         ]])
     </div>
 
-    <div class="content-wrap card">
+    <main class="content-wrap card">
         <h1 class="break-text" v-pre>{{$book->name}}</h1>
         <div class="book-content" v-show="!searching">
             <p class="text-muted" v-pre>{!! nl2br(e($book->description)) !!}</p>
@@ -53,7 +53,7 @@
         </div>
 
         @include('partials.entity-dashboard-search-results')
-    </div>
+    </main>
 
 @stop
 
 
             <hr class="primary-background">
 
-            <div dropdown class="dropdown-container">
-                <div dropdown-toggle class="icon-list-item">
-                    <span>@icon('export')</span>
-                    <span>{{ trans('entities.export') }}</span>
-                </div>
-                <ul class="wide dropdown-menu">
-                    <li><a href="{{ $book->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
-                    <li><a href="{{ $book->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
-                    <li><a href="{{ $book->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
-                </ul>
-            </div>
+            @include('partials.entity-export-menu', ['entity' => $book])
         </div>
     </div>
 
index 676e7112e574fea4e2cae200cdd09559473a0218..642b88c873a1c442451494903a5db83db76c5c33 100644 (file)
                         <input book-sort-input type="hidden" name="sort-tree">
                         <div class="list text-right">
                             <a href="{{ $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                            <button class="button primary" type="submit">{{ trans('entities.books_sort_save') }}</button>
+                            <button class="button" type="submit">{{ trans('entities.books_sort_save') }}</button>
                         </div>
                     </form>
                 </div>
             </div>
 
             <div>
-                <div class="card content-wrap">
+                <main class="card content-wrap">
                     <h2 class="list-heading mb-m">{{ trans('entities.books_sort_show_other') }}</h2>
 
                     @include('components.entity-selector', ['name' => 'books_list', 'selectorSize' => 'compact', 'entityTypes' => 'book', 'entityPermission' => 'update', 'showAdd' => true])
 
-                </div>
+                </main>
             </div>
         </div>
 
index 36c7f9a243cbc0717a0a5da7637f650e578340f1..6137c34e8fce357653db2583e3a21cb413f01aba 100644 (file)
@@ -1,10 +1,11 @@
 <div class="chapter-child-menu">
-    <p chapter-toggle class="text-muted @if($bookChild->matchesOrContains($current)) open @endif">
+    <button chapter-toggle type="button" aria-expanded="{{ $isOpen ? 'true' : 'false' }}"
+            class="text-muted @if($isOpen) open @endif">
         @icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->pages->count()) }}</span>
-    </p>
-    <ul class="sub-menu inset-list @if($bookChild->matchesOrContains($current)) open @endif">
+    </button>
+    <ul class="sub-menu inset-list @if($isOpen) open @endif" @if($isOpen) style="display: block;" @endif role="menu">
         @foreach($bookChild->pages as $childPage)
-            <li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}">
+            <li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}" role="presentation">
                 @include('partials.entity-list-item-basic', ['entity' => $childPage, 'classes' => $current->matches($childPage)? 'selected' : '' ])
             </li>
         @endforeach
index fd2c82b46563e507f4b9647ddfb15fcc9ba8ac59..c9787e6348991073a5c3e265097e0926b8179338 100644 (file)
             ]])
         </div>
 
-        <div class="content-wrap card">
+        <main class="content-wrap card">
             <h1 class="list-heading">{{ trans('entities.chapters_create') }}</h1>
             <form action="{{ $book->getUrl('/create-chapter') }}" method="POST">
                 @include('chapters.form')
             </form>
-        </div>
+        </main>
 
     </div>
 @stop
\ No newline at end of file
index 3444ee0fb19271f242352621d4c5ea3cd5799607..60f8c99339022535b48019d4232b3a764c831574 100644 (file)
@@ -27,7 +27,7 @@
 
                 <div class="text-right">
                     <a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button type="submit" class="button primary">{{ trans('common.confirm') }}</button>
+                    <button type="submit" class="button">{{ trans('common.confirm') }}</button>
                 </div>
             </form>
         </div>
index d282fe1ddc9b614d5d40aede5f093dcacefa6792..d8bb056f632ce8d172da67ebb8b2e50ad7c092cb 100644 (file)
             ]])
         </div>
 
-        <div class="content-wrap card">
+        <main class="content-wrap card">
             <h1 class="list-heading">{{ trans('entities.chapters_edit') }}</h1>
             <form action="{{  $chapter->getUrl() }}" method="POST">
                 <input type="hidden" name="_method" value="PUT">
                 @include('chapters.form', ['model' => $chapter])
             </form>
-        </div>
+        </main>
 
     </div>
 
index 2830855b4f8a0fc53a132ebda8cc9c19b5e818e9..580c123ccf6d7901491f80e943ff03a264ae3bb6 100644 (file)
@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en">
+<html lang="{{ config('app.lang') }}">
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <title>{{ $chapter->name }}</title>
index 31d8597de44c60681153908ed9f834bd837056d4..cd240e685dd651f5501a4659e74d8e145c0c1760 100644 (file)
@@ -12,9 +12,9 @@
 </div>
 
 <div class="form-group" collapsible id="logo-control">
-    <div class="collapse-title text-primary" collapsible-trigger>
-        <label for="user-avatar">{{ trans('entities.chapter_tags') }}</label>
-    </div>
+    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
+        <label for="tags">{{ trans('entities.chapter_tags') }}</label>
+    </button>
     <div class="collapse-content" collapsible-content>
         @include('components.tag-manager', ['entity' => isset($chapter)?$chapter:null, 'entityType' => 'chapter'])
     </div>
@@ -22,5 +22,5 @@
 
 <div class="form-group text-right">
     <a href="{{ isset($chapter) ? $chapter->getUrl() : $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-    <button type="submit" class="button primary">{{ trans('entities.chapters_save') }}</button>
+    <button type="submit" class="button">{{ trans('entities.chapters_save') }}</button>
 </div>
index fd463e07a4fbfc51945c45678f2761e22cd8ef9f..7e2e0e1c539c9dca666540a245c5cf2342ea9384 100644 (file)
@@ -11,7 +11,9 @@
     <div class="chapter chapter-expansion">
         <span class="icon text-chapter">@icon('page')</span>
         <div class="content">
-            <div chapter-toggle class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->pages->count()) }}</span></div>
+            <button type="button" chapter-toggle
+                    aria-expanded="false"
+                    class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->pages->count()) }}</span></button>
             <div class="inset-list">
                 <div class="entity-list-item-children">
                     @include('partials.entity-list', ['entities' => $chapter->pages])
index 7f3de1322bdcaf94200ef5dfbeddbb358f190bf3..8663dca5050e53fb0dbbc9ab6b369d720bbab59a 100644 (file)
@@ -15,7 +15,7 @@
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.chapters_move') }}</h1>
 
             <form action="{{ $chapter->getUrl('/move') }}" method="POST">
 
                 <div class="form-group text-right">
                     <a href="{{ $chapter->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button type="submit" class="button primary">{{ trans('entities.chapters_move') }}</button>
+                    <button type="submit" class="button">{{ trans('entities.chapters_move') }}</button>
                 </div>
             </form>
 
-        </div>
+        </main>
 
 
 
index cb5808e7d5070f9fa0c3e2b0dfc1fa273248c24b..48c954dc96871b0f23154772ec2ba6e79a600d80 100644 (file)
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.chapters_permissions') }}</h1>
             @include('form.entity-permissions', ['model' => $chapter])
-        </div>
+        </main>
     </div>
 
 @stop
index 0915f9a16120afd97a9a9af1e4f0f0ed278fd36d..105cda760ff496c909aadf723c274fd12b64637b 100644 (file)
@@ -8,14 +8,14 @@
 
 @section('body')
 
-    <div class="mb-m">
+    <div class="mb-m print-hidden">
         @include('partials.breadcrumbs', ['crumbs' => [
             $chapter->book,
             $chapter,
         ]])
     </div>
 
-    <div class="content-wrap card">
+    <main class="content-wrap card">
         <h1 class="break-text" v-pre>{{ $chapter->name }}</h1>
         <div class="chapter-content" v-show="!searching">
             <p v-pre class="text-muted break-text">{!! nl2br(e($chapter->description)) !!}</p>
@@ -50,7 +50,7 @@
         </div>
 
         @include('partials.entity-dashboard-search-results')
-    </div>
+    </main>
 
 @stop
 
 
             <hr class="primary-background"/>
 
-            <div dropdown class="dropdown-container">
-                <div dropdown-toggle class="icon-list-item">
-                    <span>@icon('export')</span>
-                    <span>{{ trans('entities.export') }}</span>
-                </div>
-                <ul class="wide dropdown-menu">
-                    <li><a href="{{ $chapter->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
-                    <li><a href="{{ $chapter->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
-                    <li><a href="{{ $chapter->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
-                </ul>
-            </div>
+            @include('partials.entity-export-menu', ['entity' => $chapter])
         </div>
     </div>
 @stop
index a855031784dc2c181b36691467564064386078c9..5fbdfa500c6f10d14ddc64db887eb5ee2c8a824f 100644 (file)
@@ -1,8 +1,8 @@
 <div class="comment-box mb-m" comment="{{ $comment->id }}" local-id="{{$comment->local_id}}" parent-id="{{$comment->parent_id}}" id="comment{{$comment->local_id}}">
     <div class="header p-s">
-        <div class="grid half no-gap v-center">
-            <div class="meta">
-                <a href="#comment{{$comment->local_id}}" class="text-muted">#{{$comment->local_id}}</a>
+        <div class="grid half left-focus no-gap v-center">
+            <div class="meta text-muted text-small">
+                <a href="#comment{{$comment->local_id}}">#{{$comment->local_id}}</a>
                 &nbsp;&nbsp;
                 @if ($comment->createdBy)
                     <img width="50" src="{{ $comment->createdBy->getAvatar(50) }}" class="avatar" alt="{{ $comment->createdBy->name }}">
             </div>
             <div class="actions text-right">
                 @if(userCan('comment-update', $comment))
-                    <button type="button" class="text-button" action="edit" title="{{ trans('common.edit') }}">@icon('edit')</button>
+                    <button type="button" class="text-button" action="edit" aria-label="{{ trans('common.edit') }}" title="{{ trans('common.edit') }}">@icon('edit')</button>
                 @endif
                 @if(userCan('comment-create-all'))
-                    <button type="button" class="text-button" action="reply" title="{{ trans('common.reply') }}">@icon('reply')</button>
+                    <button type="button" class="text-button" action="reply" aria-label="{{ trans('common.reply') }}" title="{{ trans('common.reply') }}">@icon('reply')</button>
                 @endif
                 @if(userCan('comment-delete', $comment))
                     <div dropdown class="dropdown-container">
-                        <button type="button" dropdown-toggle class="text-button" title="{{ trans('common.delete') }}">@icon('delete')</button>
-                        <ul class="dropdown-menu">
+                        <button type="button" dropdown-toggle aria-haspopup="true" aria-expanded="false" class="text-button" title="{{ trans('common.delete') }}">@icon('delete')</button>
+                        <ul class="dropdown-menu" role="menu">
                             <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
-                            <li><a action="delete" class="text-button text-neg" >@icon('delete'){{ trans('common.delete') }}</a></li>
+                            <li><a action="delete" href="#" class="text-button text-neg" >@icon('delete'){{ trans('common.delete') }}</a></li>
                         </ul>
                     </div>
                 @endif
@@ -61,7 +61,7 @@
                 </div>
                 <div class="form-group text-right">
                     <button type="button" class="button outline" action="closeUpdateForm">{{ trans('common.cancel') }}</button>
-                    <button type="submit" class="button primary">{{ trans('entities.comment_save') }}</button>
+                    <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
                 </div>
                 <div class="form-group loading" style="display: none;">
                     @include('partials.loading-icon', ['text' => trans('entities.comment_saving')])
index 99b21b9b263a8e51c1c0c042a7f30c6008003d14..fc81f13ee73053e4c4e208f64cc64e9eb8183826 100644 (file)
@@ -1,4 +1,4 @@
-<div page-comments page-id="{{ $page->id }}" class="comments-list">
+<section page-comments page-id="{{ $page->id }}" class="comments-list" aria-label="{{ trans('entities.comments') }}">
 
     @exposeTranslations([
         'entities.comment_updated_success',
@@ -9,7 +9,7 @@
 
     <div comment-count-bar class="grid half left-focus v-center no-row-gap">
         <h5 comments-title>{{ trans_choice('entities.comment_count', count($page->comments), ['count' => count($page->comments)]) }}</h5>
-        @if (count($page->comments) === 0)
+        @if (count($page->comments) === 0 && userCan('comment-create-all'))
             <div class="text-m-right" comment-add-button-container>
                 <button type="button" action="addComment"
                         class="button outline">{{ trans('entities.comment_add') }}</button>
 
     @if(userCan('comment-create-all'))
         @include('comments.create')
-    @endif
 
-    @if (count($page->comments) > 0)
-        <div class="text-right" comment-add-button-container>
-            <button type="button" action="addComment"
-                    class="button outline">{{ trans('entities.comment_add') }}</button>
-        </div>
+        @if (count($page->comments) > 0)
+            <div class="text-right" comment-add-button-container>
+                <button type="button" action="addComment"
+                        class="button outline">{{ trans('entities.comment_add') }}</button>
+            </div>
+        @endif
     @endif
 
-</div>
\ No newline at end of file
+</section>
\ No newline at end of file
index abd95f008a1e5be44d550f7f1438f1890bdc54ea..61e41a354fab3214883296238e9ee55ac7c9a130 100644 (file)
@@ -19,7 +19,7 @@
             <div class="form-group text-right">
                 <button type="button" class="button outline"
                         action="hideForm">{{ trans('common.cancel') }}</button>
-                <button type="submit" class="button primary">{{ trans('entities.comment_save') }}</button>
+                <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
             </div>
             <div class="form-group loading" style="display: none;">
                 @include('partials.loading-icon', ['text' => trans('entities.comment_saving')])
index a5336c3f86216e4eb8b6739cd6a597a77668193d..19299695042e98ded5b55735865c31f6fee9528b 100644 (file)
 
         <div class="header-search hide-under-l">
             @if (hasAppAccess())
-            <form action="{{ url('/search') }}" method="GET" class="search-box">
-                <button id="header-search-box-button" type="submit">@icon('search') </button>
-                <input id="header-search-box-input" type="text" name="term" tabindex="2" placeholder="{{ trans('common.search') }}" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
+            <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
+                <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
+                <input id="header-search-box-input" type="text" name="term"
+                       aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
+                       value="{{ isset($searchTerm) ? $searchTerm : '' }}">
             </form>
             @endif
         </div>
 
         <div class="text-right">
-            <div class="header-links">
+            <nav class="header-links" >
                 <div class="links text-center">
                     @if (hasAppAccess())
                         <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
                 @if(signedInUser())
                     <?php $currentUser = user(); ?>
                     <div class="dropdown-container" dropdown>
-                        <span class="user-name hide-under-l" dropdown-toggle>
+                        <span class="user-name py-s hide-under-l" dropdown-toggle
+                              aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.profile_menu') }}" tabindex="0">
                             <img class="avatar" src="{{$currentUser->getAvatar(30)}}" alt="{{ $currentUser->name }}">
                             <span class="name">{{ $currentUser->getShortName(9) }}</span> @icon('caret-down')
                         </span>
-                        <ul class="dropdown-menu">
+                        <ul class="dropdown-menu" role="menu">
                             <li>
                                 <a href="{{ url("/user/{$currentUser->id}") }}">@icon('user'){{ trans('common.view_profile') }}</a>
                             </li>
@@ -66,7 +69,7 @@
                         </ul>
                     </div>
                 @endif
-            </div>
+            </nav>
         </div>
 
     </div>
index c93fa1a24c28c285c740372bf46edd552fa09128..56e281dcb7fd107c52317ba0f856ce9c4310a103 100644 (file)
@@ -2,11 +2,11 @@
 
 @section('body')
     <div class="mt-m">
-        <div class="content-wrap card">
+        <main class="content-wrap card">
             <div class="page-content" page-display="{{ $customHomepage->id }}">
                 @include('pages.page-display', ['page' => $customHomepage])
             </div>
-        </div>
+        </main>
     </div>
 @stop
 
index 7636cd581b63c8c8923ed96ea3101039d54332b0..31a583182cde7bf0b9d55fb26df1f6e71e295b8a 100644 (file)
@@ -1,6 +1,6 @@
 <div id="code-editor">
     <div overlay ref="overlay" v-cloak @click="hide()">
-        <div class="popup-body" @click.stop>
+        <div class="popup-body" tabindex="-1" @click.stop>
 
             <div class="popup-header primary-background">
                 <div class="popup-title">{{ trans('components.code_editor') }}</div>
@@ -43,7 +43,7 @@
                 </div>
 
                 <div class="form-group">
-                    <button type="button" class="button primary" @click="save()">{{ trans('components.code_save') }}</button>
+                    <button type="button" class="button" @click="save()">{{ trans('components.code_save') }}</button>
                 </div>
 
             </div>
index c497a16d594563db25c1eb6e0e902857d7814c57..0beee658d88334bd598d79b185dae116fe6aa838 100644 (file)
@@ -1,13 +1,13 @@
 <div id="entity-selector-wrap">
     <div overlay entity-selector-popup>
-        <div class="popup-body small">
+        <div class="popup-body small" tabindex="-1">
             <div class="popup-header primary-background">
                 <div class="popup-title">{{ trans('entities.entity_select') }}</div>
                 <button type="button" class="popup-header-close">x</button>
             </div>
             @include('components.entity-selector', ['name' => 'entity-selector'])
             <div class="popup-footer">
-                <button type="button" disabled="true" class="button entity-link-selector-confirm primary corner-button">{{ trans('common.select') }}</button>
+                <button type="button" disabled="true" class="button entity-link-selector-confirm corner-button">{{ trans('common.select') }}</button>
             </div>
         </div>
     </div>
index 28af63caf3969fb2087729bdd8e1112305f5b4c1..a24f9ac1e9ec79c7d1c55ed95250f177bf9f42ee 100644 (file)
@@ -3,13 +3,13 @@ $target - CSS selector of items to expand
 $key - Unique key for checking existing stored state.
 --}}
 <?php $isOpen = setting()->getForCurrentUser('section_expansion#'. $key); ?>
-<a expand-toggle="{{ $target }}"
+<button type="button" expand-toggle="{{ $target }}"
    expand-toggle-update-endpoint="{{ url('/settings/users/'. $currentUser->id .'/update-expansion-preference/' . $key) }}"
    expand-toggle-is-open="{{ $isOpen ? 'yes' : 'no' }}"
    class="text-muted icon-list-item text-primary">
     <span>@icon('expand-text')</span>
     <span>{{ trans('common.toggle_details') }}</span>
-</a>
+</button>
 @if($isOpen)
     @push('head')
         <style>
index 6781bca5fbbdd462b359336e934adbc3330c08a1..0971c3ed95ed7705da9c476c790d05acea3bc71d 100644 (file)
@@ -9,7 +9,7 @@
     ])
 
     <div overlay v-cloak @click="hide">
-        <div class="popup-body" @click.stop="">
+        <div class="popup-body" tabindex="-1" @click.stop>
 
             <div class="popup-header primary-background">
                 <div class="popup-title">{{ trans('components.image_select') }}</div>
@@ -72,7 +72,7 @@
                                     <button type="button" class="button icon outline" @click="deleteImage">@icon('delete')</button>
 
                                 </div>
-                                <button class="button primary anim fadeIn float right" v-show="selectedImage" @click="callbackAndHide(selectedImage)">
+                                <button class="button anim fadeIn float right" v-show="selectedImage" @click="callbackAndHide(selectedImage)">
                                     {{ trans('components.image_select_image') }}
                                 </button>
                                 <div class="clearfix"></div>
index 73885aeb4548c54aa085c795caed53f081de72f7..9c2661cccbd3d19c412a39af93837ad0be8ab205 100644 (file)
@@ -8,8 +8,8 @@
         </div>
         <div class="text-center">
 
+            <input type="file" class="custom-file-input" accept="image/*" name="{{ $name }}" id="{{ $name }}">
             <label for="{{ $name }}" class="button outline">{{ trans('components.image_select_image') }}</label>
-            <input type="file" class="hidden" accept="image/*" name="{{ $name }}" id="{{ $name }}">
             <input type="hidden" data-reset-input name="{{ $name }}_reset" value="true" disabled="disabled">
             @if(isset($removeName))
                 <input type="hidden" data-remove-input name="{{ $removeName }}" value="{{ $removeValue }}" disabled="disabled">
index 31585dc41aedb8bede78dba2fd7d6b5d43fa4803..2878569374d6db6bd80928a3fe34d8477d982124 100644 (file)
@@ -2,19 +2,18 @@
     <div class="tags">
         <p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
 
-
         <draggable :options="{handle: '.handle'}" :list="tags" element="div">
             <div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
                 <div class="handle" >@icon('grip')</div>
                 <div>
                     <autosuggest url="{{ url('/ajax/tags/suggest/names') }}" type="name" class="outline" :name="getTagFieldName(i, 'name')"
-                                 v-model="tag.name" @input="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag') }}"/>
+                                 v-model="tag.name" @input="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_name') }}"/>
                 </div>
                 <div>
                     <autosuggest url="{{ url('/ajax/tags/suggest/values') }}" type="value" class="outline" :name="getTagFieldName(i, 'value')"
                                  v-model="tag.value" @change="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_value') }}"/>
                 </div>
-                <div v-show="tags.length !== 1" class="text-center drag-card-action text-neg" @click="removeTag(tag)">@icon('close')</div>
+                <button type="button" aria-label="{{ trans('entities.tags_remove') }}" v-show="tags.length !== 1" class="text-center drag-card-action text-neg" @click="removeTag(tag)">@icon('close')</button>
             </div>
         </draggable>
 
index b3e148e21c14578f9e7827c3bcd83d9b7ef9096d..f27209c484f7e0def4786df72bc32397200dd5e4 100644 (file)
@@ -37,6 +37,6 @@
 
     <div class="text-right">
         <a href="{{ $model->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-        <button type="submit" class="button primary">{{ trans('entities.permissions_save') }}</button>
+        <button type="submit" class="button">{{ trans('entities.permissions_save') }}</button>
     </div>
 </form>
\ No newline at end of file
index 948a55cbc10a2b6e3f68e16626f8a0ff785394a8..909e87286247daaba449ebfc7ae320ba24346660 100644 (file)
@@ -1,6 +1,7 @@
 <input type="text" id="{{ $name }}" name="{{ $name }}"
        @if($errors->has($name)) class="text-neg" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
+       @if(isset($disabled) && $disabled) disabled="disabled" @endif
        @if(isset($tabindex)) tabindex="{{$tabindex}}" @endif
        @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif>
 @if($errors->has($name))
diff --git a/resources/views/pages/attachment-manager.blade.php b/resources/views/pages/attachment-manager.blade.php
new file mode 100644 (file)
index 0000000..dd00678
--- /dev/null
@@ -0,0 +1,102 @@
+<div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id ?? 0 }}">
+
+    @exposeTranslations([
+    'entities.attachments_file_uploaded',
+    'entities.attachments_file_updated',
+    'entities.attachments_link_attached',
+    'entities.attachments_updated_success',
+    'errors.server_upload_limit',
+    'components.image_upload_remove',
+    'components.file_upload_timeout',
+    ])
+
+    <h4>{{ trans('entities.attachments') }}</h4>
+    <div class="px-l files">
+
+        <div id="file-list" v-show="!fileToEdit">
+            <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
+
+            <div class="tab-container">
+                <div class="nav-tabs">
+                    <button type="button" @click="tab = 'list'" :class="{selected: tab === 'list'}"
+                            class="tab-item">{{ trans('entities.attachments_items') }}</button>
+                    <button type="button" @click="tab = 'file'" :class="{selected: tab === 'file'}"
+                            class="tab-item">{{ trans('entities.attachments_upload') }}</button>
+                    <button type="button" @click="tab = 'link'" :class="{selected: tab === 'link'}"
+                            class="tab-item">{{ trans('entities.attachments_link') }}</button>
+                </div>
+                <div v-show="tab === 'list'">
+                    <draggable style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div">
+                        <div v-for="(file, index) in files" :key="file.id" class="card drag-card">
+                            <div class="handle">@icon('grip')</div>
+                            <div class="py-s">
+                                <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a>
+                                <div v-if="file.deleting">
+                                    <span class="text-neg small">{{ trans('entities.attachments_delete_confirm') }}</span>
+                                    <br>
+                                    <button type="button" class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</button>
+                                </div>
+                            </div>
+                            <button type="button" @click="startEdit(file)" class="drag-card-action text-center text-primary">@icon('edit')</button>
+                            <button type="button" @click="deleteFile(file)" class="drag-card-action text-center text-neg">@icon('close')</button>
+                        </div>
+                    </draggable>
+                    <p class="small text-muted" v-if="files.length === 0">
+                        {{ trans('entities.attachments_no_files') }}
+                    </p>
+                </div>
+                <div v-show="tab === 'file'">
+                    <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone>
+                </div>
+                <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)">
+                    <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
+                    <div class="form-group">
+                        <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label>
+                        <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name">
+                        <p class="small text-neg" v-for="error in errors.link.name" v-text="error"></p>
+                    </div>
+                    <div class="form-group">
+                        <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label>
+                        <input type="text"  placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link">
+                        <p class="small text-neg" v-for="error in errors.link.link" v-text="error"></p>
+                    </div>
+                    <button @click.prevent="attachNewLink(file)" class="button">{{ trans('entities.attach') }}</button>
+
+                </div>
+            </div>
+
+        </div>
+
+        <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)">
+            <h5>{{ trans('entities.attachments_edit_file') }}</h5>
+
+            <div class="form-group">
+                <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label>
+                <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name">
+                <p class="small text-neg" v-for="error in errors.edit.name" v-text="error"></p>
+            </div>
+
+            <div class="tab-container">
+                <div class="nav-tabs">
+                    <button type="button" @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</button>
+                    <button type="button" @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</button>
+                </div>
+                <div v-if="editTab === 'file'">
+                    <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone>
+                    <br>
+                </div>
+                <div v-if="editTab === 'link'">
+                    <div class="form-group">
+                        <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label>
+                        <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link">
+                        <p class="small text-neg" v-for="error in errors.edit.link" v-text="error"></p>
+                    </div>
+                </div>
+            </div>
+
+            <button type="button" class="button outline" @click="cancelEdit">{{ trans('common.back') }}</button>
+            <button @click.enter.prevent="updateFile(fileToEdit)" class="button">{{ trans('common.save') }}</button>
+        </div>
+
+    </div>
+</div>
\ No newline at end of file
index f197421725874c893ac27a0c4dd95352346a06b1..0f2af0476e17143b5f8a48df42fccd75d0892f2a 100644 (file)
@@ -29,9 +29,9 @@
                 </div>
 
                 <div class="form-group" collapsible>
-                    <div class="collapse-title text-primary" collapsible-trigger>
+                    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
                         <label for="entity_selection">{{ trans('entities.pages_copy_desination') }}</label>
-                    </div>
+                    </button>
                     <div class="collapse-content" collapsible-content>
                         @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create'])
                     </div>
@@ -39,7 +39,7 @@
 
                 <div class="form-group text-right">
                     <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button type="submit" class="button primary">{{ trans('entities.pages_copy') }}</button>
+                    <button type="submit" class="button">{{ trans('entities.pages_copy') }}</button>
                 </div>
             </form>
 
index a72157a83c45ef6a3dab3d7ab467dc786df2fe10..2ec046fa03bf1bf39b4a89ff45bfefa8ebf58be6 100644 (file)
@@ -34,7 +34,7 @@
                         <input type="hidden" name="_method" value="DELETE">
                         <div class="form-group text-right">
                             <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                            <button type="submit" class="button primary">{{ trans('common.confirm') }}</button>
+                            <button type="submit" class="button">{{ trans('common.confirm') }}</button>
                         </div>
                     </form>
                 </div>
index eb2fab94cb3076602b909499ee5a29656c7fe257..c2bbdb53711629d86bc7a3272f8fcab12ff6ba20 100644 (file)
@@ -2,7 +2,7 @@
 
 @section('body')
     <div class="container small pt-xl">
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ $title }}</h1>
 
             <div class="book-contents">
@@ -12,6 +12,6 @@
             <div class="text-center">
                 {!! $pages->links() !!}
             </div>
-        </div>
+        </main>
     </div>
 @stop
\ No newline at end of file
index 4930e30a3d5710628cd017d2c7b2350b689b4b37..cfb66fdd0e34ad87827be468764670b230bc2a3b 100644 (file)
@@ -16,7 +16,7 @@
                 <input type="hidden" name="_method" value="PUT">
             @endif
             @include('pages.form', ['model' => $page])
-            @include('pages.form-toolbox')
+            @include('pages.editor-toolbox')
         </form>
     </div>
     
diff --git a/resources/views/pages/editor-toolbox.blade.php b/resources/views/pages/editor-toolbox.blade.php
new file mode 100644 (file)
index 0000000..6ea6518
--- /dev/null
@@ -0,0 +1,32 @@
+<div editor-toolbox class="floating-toolbox">
+
+    <div class="tabs primary-background-light">
+        <button type="button" toolbox-toggle aria-expanded="false">@icon('caret-left-circle')</button>
+        <button type="button" toolbox-tab-button="tags" title="{{ trans('entities.page_tags') }}" class="active">@icon('tag')</button>
+        @if(userCan('attachment-create-all'))
+            <button type="button" toolbox-tab-button="files" title="{{ trans('entities.attachments') }}">@icon('attach')</button>
+        @endif
+        <button type="button" toolbox-tab-button="templates" title="{{ trans('entities.templates') }}">@icon('template')</button>
+    </div>
+
+    <div toolbox-tab-content="tags">
+        <h4>{{ trans('entities.page_tags') }}</h4>
+        <div class="px-l">
+            @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page'])
+        </div>
+    </div>
+
+    @if(userCan('attachment-create-all'))
+        @include('pages.attachment-manager', ['page' => $page])
+    @endif
+
+    <div toolbox-tab-content="templates">
+        <h4>{{ trans('entities.templates') }}</h4>
+
+        <div class="px-l">
+            @include('pages.template-manager', ['page' => $page, 'templates' => $templates])
+        </div>
+
+    </div>
+
+</div>
index e40643c256ff5346124a36dc717c5dada02fadde..4746a56f37842a5f54dfe13cc1c4a1a4d6586b96 100644 (file)
@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en">
+<html lang="{{ config('app.lang') }}">
 <head>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
     <title>{{ $page->name }}</title>
diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php
deleted file mode 100644 (file)
index d69be20..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-
-<div editor-toolbox class="floating-toolbox">
-
-    <div class="tabs primary-background-light">
-        <span toolbox-toggle>@icon('caret-left-circle')</span>
-        <span toolbox-tab-button="tags" title="{{ trans('entities.page_tags') }}" class="active">@icon('tag')</span>
-        @if(userCan('attachment-create-all'))
-            <span toolbox-tab-button="files" title="{{ trans('entities.attachments') }}">@icon('attach')</span>
-        @endif
-    </div>
-
-    <div toolbox-tab-content="tags">
-        <h4>{{ trans('entities.page_tags') }}</h4>
-        <div class="px-l">
-            @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page'])
-        </div>
-    </div>
-
-    @if(userCan('attachment-create-all'))
-        <div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id ?? 0 }}">
-
-            @exposeTranslations([
-                'entities.attachments_file_uploaded',
-                'entities.attachments_file_updated',
-                'entities.attachments_link_attached',
-                'entities.attachments_updated_success',
-                'errors.server_upload_limit',
-                'components.image_upload_remove',
-                'components.file_upload_timeout',
-            ])
-
-            <h4>{{ trans('entities.attachments') }}</h4>
-            <div class="px-l files">
-
-                <div id="file-list" v-show="!fileToEdit">
-                    <p class="text-muted small">{{ trans('entities.attachments_explain') }} <span class="text-warn">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
-
-                    <div class="tab-container">
-                        <div class="nav-tabs">
-                            <div @click="tab = 'list'" :class="{selected: tab === 'list'}" class="tab-item">{{ trans('entities.attachments_items') }}</div>
-                            <div @click="tab = 'file'" :class="{selected: tab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
-                            <div @click="tab = 'link'" :class="{selected: tab === 'link'}" class="tab-item">{{ trans('entities.attachments_link') }}</div>
-                        </div>
-                        <div v-show="tab === 'list'">
-                            <draggable style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div">
-                                <div v-for="(file, index) in files" :key="file.id" class="card drag-card">
-                                    <div class="handle">@icon('grip')</div>
-                                    <div class="py-s">
-                                        <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a>
-                                        <div v-if="file.deleting">
-                                            <span class="text-neg small">{{ trans('entities.attachments_delete_confirm') }}</span>
-                                            <br>
-                                            <span class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</span>
-                                        </div>
-                                    </div>
-                                    <div @click="startEdit(file)" class="drag-card-action text-center text-primary">@icon('edit')</div>
-                                    <div @click="deleteFile(file)" class="drag-card-action text-center text-neg">@icon('close')</div>
-                                </div>
-                            </draggable>
-                            <p class="small text-muted" v-if="files.length === 0">
-                                {{ trans('entities.attachments_no_files') }}
-                            </p>
-                        </div>
-                        <div v-show="tab === 'file'">
-                            <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone>
-                        </div>
-                        <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)">
-                            <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
-                            <div class="form-group">
-                                <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label>
-                                <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name">
-                                <p class="small text-neg" v-for="error in errors.link.name" v-text="error"></p>
-                            </div>
-                            <div class="form-group">
-                                <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label>
-                                <input type="text"  placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link">
-                                <p class="small text-neg" v-for="error in errors.link.link" v-text="error"></p>
-                            </div>
-                            <button @click.prevent="attachNewLink(file)" class="button primary">{{ trans('entities.attach') }}</button>
-
-                        </div>
-                    </div>
-
-                </div>
-
-                <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)">
-                    <h5>{{ trans('entities.attachments_edit_file') }}</h5>
-
-                    <div class="form-group">
-                        <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label>
-                        <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name">
-                        <p class="small text-neg" v-for="error in errors.edit.name" v-text="error"></p>
-                    </div>
-
-                    <div class="tab-container">
-                        <div class="nav-tabs">
-                            <div @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
-                            <div @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</div>
-                        </div>
-                        <div v-if="editTab === 'file'">
-                            <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone>
-                            <br>
-                        </div>
-                        <div v-if="editTab === 'link'">
-                            <div class="form-group">
-                                <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label>
-                                <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link">
-                                <p class="small text-neg" v-for="error in errors.edit.link" v-text="error"></p>
-                            </div>
-                        </div>
-                    </div>
-
-                    <button type="button" class="button outline" @click="cancelEdit">{{ trans('common.back') }}</button>
-                    <button @click.enter.prevent="updateFile(fileToEdit)" class="button primary">{{ trans('common.save') }}</button>
-                </div>
-
-            </div>
-        </div>
-    @endif
-
-</div>
index 380718dd7f6a53e87a5d4636e75d54fd3d0ccd5d..ffc286c2cadadc32f3f9834dfd72ffb6be54ee3c 100644 (file)
 
             <div class="text-center px-m py-xs">
                 <div v-show="draftsEnabled" dropdown dropdown-move-menu class="dropdown-container draft-display text">
-                    <a dropdown-toggle  class="text-primary text-button"><span class="faded-text" v-text="draftText"></span>&nbsp; @icon('more')</a>
+                    <button type="button" dropdown-toggle aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button"><span class="faded-text" v-text="draftText"></span>&nbsp; @icon('more')</button>
                     @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', ':class' => '{visible: draftUpdated}'])
-                    <ul class="dropdown-menu">
+                    <ul class="dropdown-menu" role="menu">
                         <li>
-                            <a @click="saveDraft()" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</a>
+                            <button type="button" @click="saveDraft()" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</button>
                         </li>
                         <li v-if="isNewDraft">
                             <a href="{{ $model->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('entities.pages_edit_delete_draft') }}</a>
                         </li>
                         <li v-if="isUpdateDraft">
-                            <a type="button" @click="discardDraft" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</a>
+                            <button type="button" @click="discardDraft" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</button>
                         </li>
                     </ul>
                 </div>
@@ -44,7 +44,7 @@
 
             <div class="action-buttons px-m py-xs" v-cloak>
                 <div dropdown dropdown-move-menu class="dropdown-container">
-                    <a dropdown-toggle class="text-primary text-button">@icon('edit') <span v-text="changeSummaryShort"></span></a>
+                    <button type="button" dropdown-toggle aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span v-text="changeSummaryShort"></span></button>
                     <ul class="wide dropdown-menu">
                         <li class="px-l py-m">
                             <p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
index 4650f3a1c1377808f6e09357a5c2523b37f01b13..55db85144ae1f9ba147b4da17268c78cfc2589ec 100644 (file)
@@ -15,7 +15,7 @@
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.pages_new') }}</h1>
             <form action="{{  $parent->getUrl('/create-guest-page') }}" method="POST">
                 {!! csrf_field() !!}
 
                 <div class="form-group text-right">
                     <a href="{{ $parent->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button type="submit" class="button primary">{{ trans('common.continue') }}</button>
+                    <button type="submit" class="button">{{ trans('common.continue') }}</button>
                 </div>
 
             </form>
-        </div>
+        </main>
     </div>
 
 @stop
\ No newline at end of file
index 87bde33ac913d474b55137aec76a12d053a57ad4..d4f6323b01188538f1919c3453bb8d32e54c45e0 100644 (file)
@@ -28,8 +28,7 @@
         <div class="editor-toolbar">
             <div class="editor-toolbar-label">{{ trans('entities.pages_md_preview') }}</div>
         </div>
-        <div class="markdown-display page-content">
-        </div>
+        <iframe class="markdown-display" sandbox="allow-same-origin"></iframe>
     </div>
     <input type="hidden" name="html"/>
 
index 83421be934b4e169076285d8dc79721a7af23e51..3bf1db5e46671f98ead3e3cd8c879f0bb92887ec 100644 (file)
@@ -16,7 +16,7 @@
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.pages_move') }}</h1>
 
             <form action="{{ $page->getUrl('/move') }}" method="POST">
 
                 <div class="form-group text-right">
                     <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button type="submit" class="button primary">{{ trans('entities.pages_move') }}</button>
+                    <button type="submit" class="button">{{ trans('entities.pages_move') }}</button>
                 </div>
             </form>
 
-        </div>
+        </main>
     </div>
 
 @stop
index 260f0e49f961eae313aa276653d982fd12ba82ad..de28137dbe02952d52f5cb563d52d95e992074f2 100644 (file)
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.pages_permissions') }}</h1>
             @include('form.entity-permissions', ['model' => $page])
-        </div>
+        </main>
     </div>
 
 @stop
index 3ce5b349f1afc8c79645e3bcd16e500bc95e1ca3..0557b6b1cd79be45f57d51074bcbf32ab9753b86 100644 (file)
@@ -11,7 +11,7 @@
 
 @section('body')
 
-    <div class="mb-m">
+    <div class="mb-m print-hidden">
         @include('partials.breadcrumbs', ['crumbs' => [
             $page->$book,
             $page->chapter,
         ]])
     </div>
 
-    <div class="card content-wrap">
+    <main class="card content-wrap">
         <div class="page-content page-revision">
             @include('pages.page-display')
         </div>
-    </div>
+    </main>
 
 @stop
\ No newline at end of file
index f3fb048bc85ded30fe5fbd05ce2e3df452f8495e..feb3180775adaff018fe9dd739e21bc64354998c 100644 (file)
@@ -15,7 +15,7 @@
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.pages_revisions') }}</h1>
             @if(count($page->revisions) > 0)
 
                                 @else
                                     <a href="{{ $revision->getUrl() }}" target="_blank">{{ trans('entities.pages_revisions_preview') }}</a>
                                     <span class="text-muted">&nbsp;|&nbsp;</span>
-                                    <a href="{{ $revision->getUrl('restore') }}"></a>
                                     <div dropdown class="dropdown-container">
-                                        <a dropdown-toggle>{{ trans('entities.pages_revisions_restore') }}</a>
-                                        <ul class="dropdown-menu">
+                                        <a dropdown-toggle href="#" aria-haspopup="true" aria-expanded="false">{{ trans('entities.pages_revisions_restore') }}</a>
+                                        <ul class="dropdown-menu" role="menu">
                                             <li class="px-m py-s"><small class="text-muted">{{trans('entities.revision_restore_confirm')}}</small></li>
                                             <li>
                                                 <form action="{{ $revision->getUrl('/restore') }}" method="POST">
@@ -66,8 +65,8 @@
                                     </div>
                                     <span class="text-muted">&nbsp;|&nbsp;</span>
                                     <div dropdown class="dropdown-container">
-                                        <a dropdown-toggle>{{ trans('common.delete') }}</a>
-                                        <ul class="dropdown-menu">
+                                        <a dropdown-toggle href="#" aria-haspopup="true" aria-expanded="false">{{ trans('common.delete') }}</a>
+                                        <ul class="dropdown-menu" role="menu">
                                             <li class="px-m py-s"><small class="text-muted">{{trans('entities.revision_delete_confirm')}}</small></li>
                                             <li>
                                                 <form action="{{ $revision->getUrl('/delete/') }}" method="POST">
@@ -87,7 +86,7 @@
             @else
                 <p>{{ trans('entities.pages_revisions_none') }}</p>
             @endif
-        </div>
+        </main>
 
     </div>
 
index fb0df2ddd027e6e6c0c880f7c58271f8f12282c7..51ab5bbbe53531e315e6ae314694123adcfc7281 100644 (file)
@@ -2,7 +2,7 @@
 
 @section('body')
 
-    <div class="mb-m">
+    <div class="mb-m print-hidden">
         @include('partials.breadcrumbs', ['crumbs' => [
             $page->book,
             $page->hasChapter() ? $page->chapter : null,
         ]])
     </div>
 
-    <div class="content-wrap card">
+    <main class="content-wrap card">
         <div class="page-content" page-display="{{ $page->id }}">
             @include('pages.pointer', ['page' => $page])
             @include('pages.page-display')
         </div>
-    </div>
+    </main>
 
     @if ($commentsEnabled)
-        <div class="container small p-none comments-container mb-l">
+        <div class="container small p-none comments-container mb-l print-hidden">
             @include('comments.comments', ['page' => $page])
             <div class="clearfix"></div>
         </div>
@@ -50,7 +50,7 @@
     @endif
 
     @if (isset($pageNav) && count($pageNav))
-        <div id="page-navigation" class="mb-xl">
+        <nav id="page-navigation" class="mb-xl" aria-label="{{ trans('entities.pages_navigation') }}">
             <h5>{{ trans('entities.pages_navigation') }}</h5>
             <div class="body">
                 <div class="sidebar-page-nav menu">
@@ -62,7 +62,7 @@
                     @endforeach
                 </div>
             </div>
-        </div>
+        </nav>
     @endif
 
     @include('partials.book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
                     @endif
                 </div>
             @endif
+
+            @if($page->template)
+                <div>
+                    @icon('template'){{ trans('entities.pages_is_template') }}
+                </div>
+            @endif
         </div>
     </div>
 
             <hr class="primary-background"/>
 
             {{--Export--}}
-            <div dropdown class="dropdown-container block">
-                <div dropdown-toggle class="icon-list-item">
-                    <span>@icon('export')</span>
-                    <span>{{ trans('entities.export') }}</span>
-                </div>
-                <ul class="dropdown-menu wide">
-                    <li><a href="{{ $page->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
-                    <li><a href="{{ $page->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
-                    <li><a href="{{ $page->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
-                </ul>
-            </div>
+            @include('partials.entity-export-menu', ['entity' => $page])
         </div>
 
     </div>
diff --git a/resources/views/pages/template-manager-list.blade.php b/resources/views/pages/template-manager-list.blade.php
new file mode 100644 (file)
index 0000000..f2f70c1
--- /dev/null
@@ -0,0 +1,24 @@
+{{ $templates->links() }}
+
+@foreach($templates as $template)
+    <div class="card template-item border-card p-m mb-m" tabindex="0"
+         aria-label="{{ trans('entities.templates_replace_content') }} - {{ $template->name }}"
+         draggable="true" template-id="{{ $template->id }}">
+        <div class="template-item-content" title="{{ trans('entities.templates_replace_content') }}">
+            <div>{{ $template->name }}</div>
+            <div class="text-muted">{{ trans('entities.meta_updated', ['timeLength' => $template->updated_at->diffForHumans()]) }}</div>
+        </div>
+        <div class="template-item-actions">
+            <button type="button"
+                    title="{{ trans('entities.templates_prepend_content') }}"
+                    aria-label="{{ trans('entities.templates_prepend_content') }} - {{ $template->name }}"
+                    template-action="prepend">@icon('chevron-up')</button>
+            <button type="button"
+                    title="{{ trans('entities.templates_append_content') }}"
+                    aria-label="{{ trans('entities.templates_append_content') }} -- {{ $template->name }}"
+                    template-action="append">@icon('chevron-down')</button>
+        </div>
+    </div>
+@endforeach
+
+{{ $templates->links() }}
\ No newline at end of file
diff --git a/resources/views/pages/template-manager.blade.php b/resources/views/pages/template-manager.blade.php
new file mode 100644 (file)
index 0000000..fbdb70a
--- /dev/null
@@ -0,0 +1,25 @@
+<div template-manager>
+    @if(userCan('templates-manage'))
+        <p class="text-muted small mb-none">
+            {{ trans('entities.templates_explain_set_as_template') }}
+        </p>
+        @include('components.toggle-switch', [
+               'name' => 'template',
+               'value' => old('template', $page->template ? 'true' : 'false') === 'true',
+               'label' => trans('entities.templates_set_as_template')
+        ])
+        <hr>
+    @endif
+
+    @if(count($templates) > 0)
+        <div class="search-box flexible mb-m">
+            <input type="text" name="template-search" placeholder="{{ trans('common.search') }}">
+            <button type="button">@icon('search')</button>
+            <button class="search-box-cancel text-neg hidden" type="button">@icon('close')</button>
+        </div>
+    @endif
+
+    <div template-manager-list>
+        @include('pages.template-manager-list', ['templates' => $templates])
+    </div>
+</div>
\ No newline at end of file
index 73064dceb46b72b6d9f9f38b23c9aeb5e75d55af..c288e63674ff38b456a4b13ed530746510f11e29 100644 (file)
@@ -1,4 +1,4 @@
-<div id="book-tree" class="book-tree mb-xl" v-pre>
+<nav id="book-tree" class="book-tree mb-xl" v-pre aria-label="{{ trans('entities.books_navigation') }}">
     <h5>{{ trans('entities.books_navigation') }}</h5>
 
     <ul class="sidebar-page-list mt-xs menu entity-list">
 
                 @if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
                     <div class="entity-list-item no-hover">
-                        <span class="icon text-chapter">
-
-                        </span>
+                        <span role="presentation" class="icon text-chapter"></span>
                         <div class="content">
-                            @include('chapters.child-menu', ['chapter' => $bookChild, 'current' => $current])
+                            @include('chapters.child-menu', [
+                                'chapter' => $bookChild,
+                                'current' => $current,
+                                'isOpen'  => $bookChild->matchesOrContains($current)
+                            ])
                         </div>
                     </div>
 
@@ -27,4 +29,4 @@
             </li>
         @endforeach
     </ul>
-</div>
\ No newline at end of file
+</nav>
\ No newline at end of file
index 3dea3202355b6d5953cc7d48bb51fc71269555d4..e53cb4c53b9cf59200427f67d6134d90ce1c5bbd 100644 (file)
@@ -1,11 +1,12 @@
 <div class="breadcrumb-listing" dropdown breadcrumb-listing="{{ $entity->getType() }}:{{ $entity->id }}">
-    <div class="breadcrumb-listing-toggle" dropdown-toggle>
+    <div class="breadcrumb-listing-toggle" dropdown-toggle
+         aria-haspopup="true" aria-expanded="false" tabindex="0">
         <div class="separator">@icon('chevron-right')</div>
     </div>
-    <div dropdown-menu class="breadcrumb-listing-dropdown card">
+    <div dropdown-menu class="breadcrumb-listing-dropdown card" role="menu">
         <div class="breadcrumb-listing-search">
             @icon('search')
-            <input autocomplete="off" type="text" name="entity-search">
+            <input autocomplete="off" type="text" name="entity-search" placeholder="{{ trans('common.search') }}" aria-label="{{ trans('common.search') }}">
         </div>
         @include('partials.loading-icon')
         <div class="breadcrumb-listing-entity-list px-m"></div>
index 28c7196ee490c9e0e8a0039f984e200b00e128d6..5e11a9190b8a935748069a92288331c1e55fe927 100644 (file)
@@ -1,4 +1,4 @@
-<div class="breadcrumbs text-center">
+<nav class="breadcrumbs text-center" aria-label="{{ trans('common.breadcrumb') }}">
     <?php $breadcrumbCount = 0; ?>
 
     {{-- Show top level books item --}}
@@ -51,4 +51,4 @@
         @endif
         <?php $breadcrumbCount++; ?>
     @endforeach
-</div>
\ No newline at end of file
+</nav>
\ No newline at end of file
index 2a293edc5f4b5f22209d13860e038508978eee67..9080790825f74c4014f53368458f7c3fe3705570 100644 (file)
@@ -1,24 +1,6 @@
 <style id="custom-styles" data-color="{{ setting('app-color') }}" data-color-light="{{ setting('app-color-light') }}">
-    .primary-background {
-        background-color: {{ setting('app-color') }} !important;
+    :root {
+        --color-primary: {{ setting('app-color') }};
+        --color-primary-light: {{ setting('app-color-light') }};
     }
-    .primary-background-light {
-        background-color: {{ setting('app-color-light') }};
-    }
-    .button.primary, .button.primary:hover, .button.primary:active, .button.primary:focus {
-        background-color: {{ setting('app-color') }};
-        border-color: {{ setting('app-color') }};
-    }
-    .nav-tabs a.selected, .nav-tabs .tab-item.selected {
-        border-bottom-color: {{ setting('app-color') }};
-    }
-    .text-primary, .text-primary-hover:hover, .text-primary:hover {
-        color: {{ setting('app-color') }} !important;
-        fill: {{ setting('app-color') }} !important;
-    }
-
-    a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
-        color: {{ setting('app-color') }};
-        fill: {{ setting('app-color') }};
-    }
-</style>
+</style>
\ No newline at end of file
index 99d37c5f874bc390de36fbb595e5c9d750379b67..2e0395253b7680e5b21166228c59e8389118d4ce 100644 (file)
@@ -1,7 +1,8 @@
 <div class="mb-xl">
-    <form v-on:submit.prevent="searchBook" class="search-box flexible">
-        <input v-model="searchTerm" v-on:change="checkSearchForm" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}">
-        <button type="submit">@icon('search')</button>
-        <button v-if="searching" v-cloak class="search-box-cancel text-neg" v-on:click="clearSearch" type="button">@icon('close')</button>
+    <form v-on:submit.prevent="searchBook" class="search-box flexible" role="search">
+        <input v-model="searchTerm" v-on:change="checkSearchForm" type="text" aria-label="{{ trans('entities.books_search_this') }}" name="term" placeholder="{{ trans('entities.books_search_this') }}">
+        <button type="submit" aria-label="{{ trans('common.search') }}">@icon('search')</button>
+        <button v-if="searching" v-cloak class="search-box-cancel text-neg" v-on:click="clearSearch"
+                type="button" aria-label="{{ trans('common.search_clear') }}">@icon('close')</button>
     </form>
 </div>
\ No newline at end of file
diff --git a/resources/views/partials/entity-export-menu.blade.php b/resources/views/partials/entity-export-menu.blade.php
new file mode 100644 (file)
index 0000000..630d640
--- /dev/null
@@ -0,0 +1,12 @@
+<div dropdown class="dropdown-container" id="export-menu">
+    <div dropdown-toggle class="icon-list-item"
+         aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('entities.export') }}" tabindex="0">
+        <span>@icon('export')</span>
+        <span>{{ trans('entities.export') }}</span>
+    </div>
+    <ul class="wide dropdown-menu" role="menu">
+        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
+    </ul>
+</div>
\ No newline at end of file
index c4942c71f2dd38af6bb2de09290a710bd08b7912..2ec4bee5cc07dbf13db6b0978effd5990551cdc4 100644 (file)
@@ -1,6 +1,6 @@
 <?php $type = $entity->getType(); ?>
 <a href="{{ $entity->getUrl() }}" class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item" data-entity-type="{{$type}}" data-entity-id="{{$entity->id}}">
-    <span class="icon text-{{$type}}">@icon($type)</span>
+    <span role="presentation" class="icon text-{{$type}}">@icon($type)</span>
     <div class="content">
             <h4 class="entity-list-item-name break-text">{{ $entity->name }}</h4>
             {{ $slot ?? '' }}
index ac853a56cc01b79f7a1c7a681a9e0ec59da33158..52687149928b299777276912b6e1870c4d9c90e7 100644 (file)
@@ -1,11 +1,11 @@
-<div notification="success" style="display: none;" data-autohide class="pos" @if(session()->has('success')) data-show @endif>
+<div notification="success" style="display: none;" data-autohide class="pos" role="alert" @if(session()->has('success')) data-show @endif>
     @icon('check-circle') <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
 </div>
 
-<div notification="warning" style="display: none;" class="warning" @if(session()->has('warning')) data-show @endif>
+<div notification="warning" style="display: none;" class="warning" role="alert" @if(session()->has('warning')) data-show @endif>
     @icon('info') <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
 </div>
 
-<div notification="error" style="display: none;" class="neg" @if(session()->has('error')) data-show @endif>
+<div notification="error" style="display: none;" class="neg" role="alert" @if(session()->has('error')) data-show @endif>
     @icon('danger') <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
 </div>
index 38145df219f4c9d7c70d7a2a5d47f4273b12c287..09c61d01383fa01f2d5e6f0c1af54b586dff75c7 100644 (file)
 
         <div class="list-sort">
             <div class="list-sort-type dropdown-container" dropdown>
-                <div dropdown-toggle>{{ $options[$selectedSort] }}</div>
+                <div dropdown-toggle aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" tabindex="0">{{ $options[$selectedSort] }}</div>
                 <ul class="dropdown-menu">
                     @foreach($options as $key => $label)
                         <li @if($key === $selectedSort) class="active" @endif><a href="#" data-sort-value="{{$key}}">{{ $label }}</a></li>
                     @endforeach
                 </ul>
             </div>
-            <div class="list-sort-dir" data-sort-dir>
+            <button href="#" class="list-sort-dir" type="button" data-sort-dir
+                    aria-label="{{ trans('common.sort_direction_toggle') }} - {{ $order === 'asc' ? trans('common.sort_ascending') : trans('common.sort_descending') }}" tabindex="0">
                 @icon($order === 'desc' ? 'sort-up' : 'sort-down')
-            </div>
+            </button>
         </div>
     </form>
 </div>
\ No newline at end of file
index 7a2cf65bd35694f39ecdf8c60e9dce3905cd4901..f19e560a2d5f633b01bf063c074d5396a5819419 100644 (file)
                         </table>
 
 
-                        <button type="submit" class="button primary">{{ trans('entities.search_update') }}</button>
+                        <button type="submit" class="button">{{ trans('entities.search_update') }}</button>
                     </form>
 
                 </div>
index 510e3af1bb662f3a95f966a9d7d886684bbb724c..ba2b92fe7006fd211809c2e50bdec02afe2882b9 100644 (file)
@@ -72,7 +72,7 @@
                 </div>
 
                 <div class="form-group text-right">
-                    <button type="submit" class="button primary">{{ trans('settings.settings_save') }}</button>
+                    <button type="submit" class="button">{{ trans('settings.settings_save') }}</button>
                 </div>
             </form>
         </div>
                             <p class="small">{!! trans('settings.app_primary_color_desc') !!}</p>
                         </div>
                         <div setting-app-color-picker class="text-m-right">
-                            <input type="color" value="{{ setting('app-color') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
+                            <input type="color" value="{{ setting('app-color') }}" name="setting-app-color" id="setting-app-color" placeholder="#206ea7">
                             <input type="hidden" value="{{ setting('app-color-light') }}" name="setting-app-color-light" id="setting-app-color-light">
                             <br>
                             <button type="button" class="text-button text-muted mt-s mx-s" setting-app-color-picker-reset>{{ trans('common.reset') }}</button>
                 </div>
 
                 <div class="form-group text-right">
-                    <button type="submit" class="button primary">{{ trans('settings.settings_save') }}</button>
+                    <button type="submit" class="button">{{ trans('settings.settings_save') }}</button>
                 </div>
             </form>
         </div>
                 </div>
 
                 <div class="form-group text-right">
-                    <button type="submit" class="button primary">{{ trans('settings.settings_save') }}</button>
+                    <button type="submit" class="button">{{ trans('settings.settings_save') }}</button>
                 </div>
             </form>
         </div>
index 51fda5b9031e57f967e895b4b7ab6761f0e65fd4..896de9d97477c0c3e510ca806ed7e33cbf12e611 100644 (file)
@@ -1,5 +1,5 @@
 
-<div class="active-link-list">
+<nav class="active-link-list">
     @if($currentUser->can('settings-manage'))
         <a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
         <a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
@@ -10,4 +10,4 @@
     @if($currentUser->can('user-roles-manage'))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
     @endif
-</div>
\ No newline at end of file
+</nav>
\ No newline at end of file
index e0075fa8ad27d5a4811ec6aa89ac7c25b56ee113..4f40345df99947dec5de11fa2d44cf88dcf382e0 100644 (file)
@@ -32,7 +32,7 @@
                     <div>
                         <div class="form-group text-right">
                             <a href="{{ url("/settings/roles/{$role->id}") }}" class="button outline">{{ trans('common.cancel') }}</a>
-                            <button type="submit" class="button primary">{{ trans('common.confirm') }}</button>
+                            <button type="submit" class="button">{{ trans('common.confirm') }}</button>
                         </div>
                     </div>
                 </div>
index 68b841e034d8be9e09f896f24386eb31960713ea..20b8d65ed9c22fd12a2050d6e2f2ba3a060d04d5 100644 (file)
@@ -38,6 +38,7 @@
                 <div>@include('settings.roles.checkbox', ['permission' => 'user-roles-manage', 'label' => trans('settings.role_manage_roles')])</div>
                 <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-all', 'label' => trans('settings.role_manage_entity_permissions')])</div>
                 <div>@include('settings.roles.checkbox', ['permission' => 'restrictions-manage-own', 'label' => trans('settings.role_manage_own_entity_permissions')])</div>
+                <div>@include('settings.roles.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
                 <div>@include('settings.roles.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
             </div>
         </div>
         @if (isset($role) && $role->id)
             <a href="{{ url("/settings/roles/delete/{$role->id}") }}" class="button outline">{{ trans('settings.role_delete') }}</a>
         @endif
-        <button type="submit" class="button primary">{{ trans('settings.role_save') }}</button>
+        <button type="submit" class="button">{{ trans('settings.role_save') }}</button>
     </div>
 
 </div>
index aee1c5a4299bfe6352dfec6d550c6f982abcde9a..bea20eca93624cf234e89b73099b51fc5574c3c4 100644 (file)
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.shelves_create') }}</h1>
             <form action="{{ url("/shelves") }}" method="POST" enctype="multipart/form-data">
                 @include('shelves.form', ['shelf' => null, 'books' => $books])
             </form>
-        </div>
+        </main>
 
     </div>
 
index 8c2cd4f45e7d11d816a3472a9e0b109c0aeb392d..5ae3638fee955ec3f30ee3c068f5c45e6f7b7976 100644 (file)
             ]])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.shelves_edit') }}</h1>
             <form action="{{ $shelf->getUrl() }}" method="POST" enctype="multipart/form-data">
                 <input type="hidden" name="_method" value="PUT">
                 @include('shelves.form', ['model' => $shelf])
             </form>
-        </div>
+        </main>
     </div>
 
 @stop
\ No newline at end of file
index 1d152a143459aa411ad6535213ff41b371c6e199..5125e7e194ce250f53bbc6a4a0fd61b5a5837132 100644 (file)
@@ -40,9 +40,9 @@
 
 
 <div class="form-group" collapsible id="logo-control">
-    <div class="collapse-title text-primary" collapsible-trigger>
-        <label for="user-avatar">{{ trans('common.cover_image') }}</label>
-    </div>
+    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
+        <label>{{ trans('common.cover_image') }}</label>
+    </button>
     <div class="collapse-content" collapsible-content>
         <p class="small">{{ trans('common.cover_image_description') }}</p>
 
@@ -56,9 +56,9 @@
 </div>
 
 <div class="form-group" collapsible id="tags-control">
-    <div class="collapse-title text-primary" collapsible-trigger>
+    <button type="button" class="collapse-title text-primary" collapsible-trigger aria-expanded="false">
         <label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
-    </div>
+    </button>
     <div class="collapse-content" collapsible-content>
         @include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf'])
     </div>
@@ -66,5 +66,5 @@
 
 <div class="form-group text-right">
     <a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
-    <button type="submit" class="button primary">{{ trans('entities.shelves_save') }}</button>
+    <button type="submit" class="button">{{ trans('entities.shelves_save') }}</button>
 </div>
\ No newline at end of file
index 3f8c266e992b83e23cd261d3860a0d0e6973541e..b20b08a2c59e40f8a110394404f63ac198c91f8f 100644 (file)
@@ -1,5 +1,5 @@
 
-<div class="content-wrap mt-m card">
+<main class="content-wrap mt-m card">
 
     <div class="grid half v-center">
         <h1 class="list-heading">{{ trans('entities.shelves') }}</h1>
@@ -35,4 +35,4 @@
         @endif
     @endif
 
-</div>
+</main>
index 3a9d599519a95035e7d4a8cb8dfbcca23303669f..6bfc525a5dc458852ce750984891862857f9b010 100644 (file)
@@ -8,7 +8,7 @@
         ]])
     </div>
 
-    <div class="card content-wrap">
+    <main class="card content-wrap">
         <h1 class="break-text">{{$shelf->name}}</h1>
         <div class="book-content">
             <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
@@ -39,7 +39,7 @@
                 </div>
             @endif
         </div>
-    </div>
+    </main>
 
 @stop
 
index 00e9df2f970fbf9828bb11793eb3bbc3d64d4e17..71c546964ef6fb31d89a553256654324998de35b 100644 (file)
@@ -4,7 +4,7 @@
 
 @section('content')
 
-    <div class="tri-layout-mobile-tabs text-primary>
+    <div class="tri-layout-mobile-tabs text-primary print-hidden">
         <div class="grid half no-break no-gap">
             <div class="tri-layout-mobile-tab px-m py-s" tri-layout-mobile-tab="info">
                 {{ trans('common.tab_info') }}
@@ -18,9 +18,9 @@
     <div class="tri-layout-container" tri-layout @yield('container-attrs') >
 
         <div class="tri-layout-left print-hidden pt-m" id="sidebar">
-            <div class="tri-layout-left-contents">
+            <aside class="tri-layout-left-contents">
                 @yield('left')
-            </div>
+            </aside>
         </div>
 
         <div class="@yield('body-wrap-classes') tri-layout-middle">
@@ -30,9 +30,9 @@
         </div>
 
         <div class="tri-layout-right print-hidden pt-m">
-            <div class="tri-layout-right-contents">
+            <aside class="tri-layout-right-contents">
                 @yield('right')
-            </div>
+            </aside>
         </div>
     </div>
 
index b9f404bb712c9d0674f36ee5ebe51528b572d393..9971eeeeb54ca63ba42045982f076e6324936989 100644 (file)
@@ -8,7 +8,7 @@
             @include('settings.navbar', ['selected' => 'users'])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('settings.users_add_new') }}</h1>
 
             <form action="{{ url("/settings/users/create") }}" method="post">
 
                 <div class="form-group text-right">
                     <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button class="button primary" type="submit">{{ trans('common.save') }}</button>
+                    <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
 
             </form>
 
-        </div>
+        </main>
     </div>
 
 @stop
index aa9811bf5fb190013cf20da1facacae8a9ad3373..d3349c2f3fc29b93e6b10319e95d0cefc97df7e5 100644 (file)
@@ -20,7 +20,7 @@
 
                         <input type="hidden" name="_method" value="DELETE">
                         <a href="{{ url("/settings/users/{$user->id}") }}" class="button outline">{{ trans('common.cancel') }}</a>
-                        <button type="submit" class="button primary">{{ trans('common.confirm') }}</button>
+                        <button type="submit" class="button">{{ trans('common.confirm') }}</button>
                     </form>
                 </div>
             </div>
index 92a36c943aa1d25e2d12f4147e686179010ce29c..ff1e7cbe5d51577e2cd807dc4842aee8b268f1c8 100644 (file)
@@ -7,7 +7,7 @@
             @include('settings.navbar', ['selected' => 'users'])
         </div>
 
-        <div class="card content-wrap">
+        <section class="card content-wrap">
             <h1 class="list-heading">{{ $user->id === $currentUser->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
             <form action="{{ url("/settings/users/{$user->id}") }}" method="post" enctype="multipart/form-data">
                 {!! csrf_field() !!}
                     @if($authMethod !== 'system')
                         <a href="{{ url("/settings/users/{$user->id}/delete") }}" class="button outline">{{ trans('settings.users_delete') }}</a>
                     @endif
-                    <button class="button primary" type="submit">{{ trans('common.save') }}</button>
+                    <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
             </form>
-        </div>
+        </section>
 
         @if($currentUser->id === $user->id && count($activeSocialDrivers) > 0)
-            <div class="card content-wrap auto-height">
+            <section class="card content-wrap auto-height">
                 <h2 class="list-heading">{{ trans('settings.users_social_accounts') }}</h2>
                 <p class="text-muted">{{ trans('settings.users_social_accounts_info') }}</p>
                 <div class="container">
                     <div class="grid third">
                         @foreach($activeSocialDrivers as $driver => $enabled)
                             <div class="text-center mb-m">
-                                <div>@icon('auth/'. $driver, ['style' => 'width: 56px;height: 56px;'])</div>
+                                <div role="presentation">@icon('auth/'. $driver, ['style' => 'width: 56px;height: 56px;'])</div>
                                 <div>
                                     @if($user->hasSocialAccount($driver))
-                                        <a href="{{ url("/login/service/{$driver}/detach") }}" class="button small outline">{{ trans('settings.users_social_disconnect') }}</a>
+                                        <a href="{{ url("/login/service/{$driver}/detach") }}" aria-label="{{ trans('settings.users_social_disconnect') }} - {{ $driver }}"
+                                           class="button small outline">{{ trans('settings.users_social_disconnect') }}</a>
                                     @else
-                                        <a href="{{ url("/login/service/{$driver}") }}" class="button small outline">{{ trans('settings.users_social_connect') }}</a>
+                                        <a href="{{ url("/login/service/{$driver}") }}" aria-label="{{ trans('settings.users_social_connect') }} - {{ $driver }}"
+                                           class="button small outline">{{ trans('settings.users_social_connect') }}</a>
                                     @endif
                                 </div>
                             </div>
                         @endforeach
                     </div>
                 </div>
-            </div>
+            </section>
         @endif
     </div>
 
index 96beb7b2f97888bde380b582225b247bb8bc3040..32b717ec8c2ec4db4ef10211f54f0b1aab30f054 100644 (file)
@@ -19,7 +19,7 @@
         <div>
             @if($authMethod !== 'ldap' || userCan('users-manage'))
                 <label for="email">{{ trans('auth.email') }}</label>
-                @include('form.text', ['name' => 'email'])
+                @include('form.text', ['name' => 'email', 'disabled' => !userCan('users-manage')])
             @endif
         </div>
     </div>
 @endif
 
 @if($authMethod === 'standard')
-    <div>
+    <div new-user-password>
         <label class="setting-list-label">{{ trans('settings.users_password') }}</label>
-        <p class="small">{{ trans('settings.users_password_desc') }}</p>
-        @if(isset($model))
+
+        @if(!isset($model))
             <p class="small">
-                {{ trans('settings.users_password_warning') }}
+                {{ trans('settings.users_send_invite_text') }}
             </p>
+
+            @include('components.toggle-switch', [
+                'name' => 'send_invite',
+                'value' => old('send_invite', 'true') === 'true',
+                'label' => trans('settings.users_send_invite_option')
+            ])
+
         @endif
-        <div class="grid half mt-m gap-xl">
-            <div>
-                <label for="password">{{ trans('auth.password') }}</label>
-                @include('form.password', ['name' => 'password'])
-            </div>
-            <div>
-                <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
-                @include('form.password', ['name' => 'password-confirm'])
+
+        <div id="password-input-container" @if(!isset($model)) style="display: none;" @endif>
+            <p class="small">{{ trans('settings.users_password_desc') }}</p>
+            @if(isset($model))
+                <p class="small">
+                    {{ trans('settings.users_password_warning') }}
+                </p>
+            @endif
+            <div class="grid half mt-m gap-xl">
+                <div>
+                    <label for="password">{{ trans('auth.password') }}</label>
+                    @include('form.password', ['name' => 'password'])
+                </div>
+                <div>
+                    <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
+                    @include('form.password', ['name' => 'password-confirm'])
+                </div>
             </div>
         </div>
+
     </div>
 @endif
\ No newline at end of file
index 72db240758f228c7b56d045125b2b01140ec0323..da373c1618b563fddcc9768644b7d1ac608e29cf 100644 (file)
@@ -7,7 +7,7 @@
             @include('settings.navbar', ['selected' => 'users'])
         </div>
 
-        <div class="card content-wrap">
+        <main class="card content-wrap">
 
             <div class="grid right-focus v-center">
                 <h1 class="list-heading">{{ trans('settings.users') }}</h1>
@@ -62,7 +62,7 @@
             <div>
                 {{ $users->links() }}
             </div>
-        </div>
+        </main>
 
     </div>
 
index f817e328f410ae6358314b17dc9903ec23bebfd1..4028b5c1da731c45925d8d27caea334b46cc0e74 100644 (file)
@@ -7,14 +7,14 @@
         <div class="grid right-focus reverse-collapse">
 
             <div>
-                <div id="recent-user-activity" class="mb-xl">
+                <section id="recent-user-activity" class="mb-xl">
                     <h5>{{ trans('entities.recent_activity') }}</h5>
                     @include('partials.activity-list', ['activity' => $activity])
-                </div>
+                </section>
             </div>
 
             <div>
-                <div class="card content-wrap auto-height">
+                <section class="card content-wrap auto-height">
                     <div class="grid half v-center">
                         <div>
                             <div class="mr-m float left">
@@ -54,9 +54,9 @@
 
                         </div>
                     </div>
-                </div>
+                </section>
 
-                <div class="card content-wrap auto-height book-contents">
+                <section class="card content-wrap auto-height book-contents">
                     <h2 id="recent-pages" class="list-heading">
                         {{ trans('entities.recently_created_pages') }}
                         @if (count($recentlyCreated['pages']) > 0)
@@ -68,9 +68,9 @@
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_pages', ['userName' => $user->name]) }}</p>
                     @endif
-                </div>
+                </section>
 
-                <div class="card content-wrap auto-height book-contents">
+                <section class="card content-wrap auto-height book-contents">
                     <h2 id="recent-chapters" class="list-heading">
                         {{ trans('entities.recently_created_chapters') }}
                         @if (count($recentlyCreated['chapters']) > 0)
@@ -82,9 +82,9 @@
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_chapters', ['userName' => $user->name]) }}</p>
                     @endif
-                </div>
+                </section>
 
-                <div class="card content-wrap auto-height book-contents">
+                <section class="card content-wrap auto-height book-contents">
                     <h2 id="recent-books" class="list-heading">
                         {{ trans('entities.recently_created_books') }}
                         @if (count($recentlyCreated['books']) > 0)
@@ -96,9 +96,9 @@
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_books', ['userName' => $user->name]) }}</p>
                     @endif
-                </div>
+                </section>
 
-                <div class="card content-wrap auto-height book-contents">
+                <section class="card content-wrap auto-height book-contents">
                     <h2 id="recent-shelves" class="list-heading">
                         {{ trans('entities.recently_created_shelves') }}
                         @if (count($recentlyCreated['shelves']) > 0)
                     @else
                         <p class="text-muted">{{ trans('entities.profile_not_created_shelves', ['userName' => $user->name]) }}</p>
                     @endif
-                </div>
+                </section>
             </div>
 
         </div>
 
 
     </div>
-
-
 @stop
\ No newline at end of file
index b8a93643ae0424538c88a60b4b27326c345631e5..f73b87b597853ccf5becce9eacbf5c4c7db68048 100644 (file)
@@ -1,5 +1,5 @@
 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html>
+<html lang="{{ config('app.lang') }}">
 <head>
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
index 94dd576fe2e928a0229123013e14b824d9b14bdb..d9fdc7455586ca00feb76abd964ffc8d73a73184 100644 (file)
@@ -158,6 +158,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter');
     Route::get('/search/entity/siblings', 'SearchController@searchSiblings');
 
+    Route::get('/templates', 'PageTemplateController@list');
+    Route::get('/templates/{templateId}', 'PageTemplateController@get');
+
     // Other Pages
     Route::get('/', 'HomeController@index');
     Route::get('/home', 'HomeController@index');
@@ -208,12 +211,16 @@ Route::get('/login', 'Auth\LoginController@getLogin');
 Route::post('/login', 'Auth\LoginController@login');
 Route::get('/logout', 'Auth\LoginController@logout');
 Route::get('/register', 'Auth\RegisterController@getRegister');
-Route::get('/register/confirm', 'Auth\RegisterController@getRegisterConfirmation');
-Route::get('/register/confirm/awaiting', 'Auth\RegisterController@showAwaitingConfirmation');
-Route::post('/register/confirm/resend', 'Auth\RegisterController@resendConfirmation');
-Route::get('/register/confirm/{token}', 'Auth\RegisterController@confirmEmail');
+Route::get('/register/confirm', 'Auth\ConfirmEmailController@show');
+Route::get('/register/confirm/awaiting', 'Auth\ConfirmEmailController@showAwaiting');
+Route::post('/register/confirm/resend', 'Auth\ConfirmEmailController@resend');
+Route::get('/register/confirm/{token}', 'Auth\ConfirmEmailController@confirm');
 Route::post('/register', 'Auth\RegisterController@postRegister');
 
+// User invitation routes
+Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword');
+Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword');
+
 // Password reset link request routes...
 Route::get('/password/email', 'Auth\ForgotPasswordController@showLinkRequestForm');
 Route::post('/password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail');
diff --git a/tests/Auth/UserInviteTest.php b/tests/Auth/UserInviteTest.php
new file mode 100644 (file)
index 0000000..3312626
--- /dev/null
@@ -0,0 +1,111 @@
+<?php namespace Tests;
+
+
+use BookStack\Auth\Access\UserInviteService;
+use BookStack\Auth\User;
+use BookStack\Notifications\UserInvite;
+use Carbon\Carbon;
+use DB;
+use Notification;
+
+class UserInviteTest extends TestCase
+{
+
+    public function test_user_creation_creates_invite()
+    {
+        Notification::fake();
+        $admin = $this->getAdmin();
+
+        $this->actingAs($admin)->post('/settings/users/create', [
+            'name' => 'Barry',
+            'email' => '[email protected]',
+            'send_invite' => 'true',
+        ]);
+
+        $newUser = User::query()->where('email', '=', '[email protected]')->orderBy('id', 'desc')->first();
+
+        Notification::assertSentTo($newUser, UserInvite::class);
+        $this->assertDatabaseHas('user_invites', [
+            'user_id' => $newUser->id
+        ]);
+    }
+
+    public function test_invite_set_password()
+    {
+        Notification::fake();
+        $user = $this->getViewer();
+        $inviteService = app(UserInviteService::class);
+
+        $inviteService->sendInvitation($user);
+        $token = DB::table('user_invites')->where('user_id', '=', $user->id)->first()->token;
+
+        $setPasswordPageResp = $this->get('/register/invite/' . $token);
+        $setPasswordPageResp->assertSuccessful();
+        $setPasswordPageResp->assertSee('Welcome to BookStack!');
+        $setPasswordPageResp->assertSee('Password');
+        $setPasswordPageResp->assertSee('Confirm Password');
+
+        $setPasswordResp = $this->followingRedirects()->post('/register/invite/' . $token, [
+            'password' => 'my test password',
+        ]);
+        $setPasswordResp->assertSee('Password set, you now have access to BookStack!');
+        $newPasswordValid = auth()->validate([
+            'email' => $user->email,
+            'password' => 'my test password'
+        ]);
+        $this->assertTrue($newPasswordValid);
+        $this->assertDatabaseMissing('user_invites', [
+            'user_id' => $user->id
+        ]);
+    }
+
+    public function test_invite_set_has_password_validation()
+    {
+        Notification::fake();
+        $user = $this->getViewer();
+        $inviteService = app(UserInviteService::class);
+
+        $inviteService->sendInvitation($user);
+        $token = DB::table('user_invites')->where('user_id', '=', $user->id)->first()->token;
+
+        $shortPassword = $this->followingRedirects()->post('/register/invite/' . $token, [
+            'password' => 'mypas',
+        ]);
+        $shortPassword->assertSee('The password must be at least 6 characters.');
+
+        $noPassword = $this->followingRedirects()->post('/register/invite/' . $token, [
+            'password' => '',
+        ]);
+        $noPassword->assertSee('The password field is required.');
+
+        $this->assertDatabaseHas('user_invites', [
+            'user_id' => $user->id
+        ]);
+    }
+
+    public function test_non_existent_invite_token_redirects_to_home()
+    {
+        $setPasswordPageResp = $this->get('/register/invite/' . str_random(12));
+        $setPasswordPageResp->assertRedirect('/');
+
+        $setPasswordResp = $this->post('/register/invite/' . str_random(12), ['password' => 'Password Test']);
+        $setPasswordResp->assertRedirect('/');
+    }
+
+    public function test_token_expires_after_two_weeks()
+    {
+        Notification::fake();
+        $user = $this->getViewer();
+        $inviteService = app(UserInviteService::class);
+
+        $inviteService->sendInvitation($user);
+        $tokenEntry = DB::table('user_invites')->where('user_id', '=', $user->id)->first();
+        DB::table('user_invites')->update(['created_at' => Carbon::now()->subDays(14)->subHour(1)]);
+
+        $setPasswordPageResp = $this->get('/register/invite/' . $tokenEntry->token);
+        $setPasswordPageResp->assertRedirect('/password/email');
+        $setPasswordPageResp->assertSessionHas('error', 'This invitation link has expired. You can instead try to reset your account password.');
+    }
+
+
+}
\ No newline at end of file
index c80b5f1d96f1b9c85ec5bf281c42e7f0c3f17895..e812d5bfe7fece4d5bebda333b353a1668a75887 100644 (file)
@@ -80,6 +80,7 @@ class PageContentTest extends TestCase
         $page->save();
 
         $pageView = $this->get($page->getUrl());
+        $pageView->assertStatus(200);
         $pageView->assertDontSee($script);
         $pageView->assertSee('abc123abc123');
     }
@@ -103,12 +104,42 @@ class PageContentTest extends TestCase
             $page->save();
 
             $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
             $pageView->assertElementNotContains('.page-content', '<script>');
             $pageView->assertElementNotContains('.page-content', '</script>');
         }
 
     }
 
+    public function test_iframe_js_and_base64_urls_are_removed()
+    {
+        $checks = [
+            '<iframe src="javascript:alert(document.cookie)"></iframe>',
+            '<iframe SRC=" javascript: alert(document.cookie)"></iframe>',
+            '<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<iframe src=" data:text/html;base64,PHNjcmlwdD5hbGVydCgnaGVsbG8nKTwvc2NyaXB0Pg==" frameborder="0"></iframe>',
+            '<iframe srcdoc="<script>window.alert(document.cookie)</script>"></iframe>'
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<iframe>');
+            $pageView->assertElementNotContains('.page-content', '</iframe>');
+            $pageView->assertElementNotContains('.page-content', 'src=');
+            $pageView->assertElementNotContains('.page-content', 'javascript:');
+            $pageView->assertElementNotContains('.page-content', 'data:');
+            $pageView->assertElementNotContains('.page-content', 'base64');
+        }
+
+    }
+
     public function test_page_inline_on_attributes_removed_by_default()
     {
         $this->asEditor();
@@ -118,6 +149,7 @@ class PageContentTest extends TestCase
         $page->save();
 
         $pageView = $this->get($page->getUrl());
+        $pageView->assertStatus(200);
         $pageView->assertDontSee($script);
         $pageView->assertSee('<p>Hello</p>');
     }
@@ -130,6 +162,7 @@ class PageContentTest extends TestCase
             '<div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div>',
             '<div><div><div><div>Lorem ipsum dolor sit amet.<p onclick="console.log(\'test\')">Hello</p></div></div></div></div>',
             '<div onclick="console.log(\'test\')">Lorem ipsum dolor sit amet.</div><p onclick="console.log(\'test\')">Hello</p><div></div>',
+            '<a a="<img src=1 onerror=\'alert(1)\'> ',
         ];
 
         $this->asEditor();
@@ -140,6 +173,7 @@ class PageContentTest extends TestCase
             $page->save();
 
             $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
             $pageView->assertElementNotContains('.page-content', 'onclick');
         }
 
index 521ea79a4760ee6bcfa30c3497b8882f1a267ddb..140f67fe81abb339614978e9fd913531ac5b47e4 100644 (file)
@@ -87,7 +87,7 @@ class PageRevisionTest extends TestCase
         // Delete the first revision
         $revision = $page->revisions->get(1);
         $resp = $this->asEditor()->delete($revision->getUrl('/delete/'));
-        $resp->assertStatus(200);
+        $resp->assertRedirect($page->getUrl('/revisions'));
 
         $page = Page::find($page->id);
         $afterRevisionCount = $page->revisions->count();
diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php
new file mode 100644 (file)
index 0000000..883de4a
--- /dev/null
@@ -0,0 +1,90 @@
+<?php namespace Entity;
+
+use BookStack\Entities\Page;
+use Tests\TestCase;
+
+class PageTemplateTest extends TestCase
+{
+    public function test_active_templates_visible_on_page_view()
+    {
+        $page = Page::first();
+
+        $this->asEditor();
+        $templateView = $this->get($page->getUrl());
+        $templateView->assertDontSee('Page Template');
+
+        $page->template = true;
+        $page->save();
+
+        $templateView = $this->get($page->getUrl());
+        $templateView->assertSee('Page Template');
+    }
+
+    public function test_manage_templates_permission_required_to_change_page_template_status()
+    {
+        $page = Page::first();
+        $editor = $this->getEditor();
+        $this->actingAs($editor);
+
+        $pageUpdateData = [
+            'name' => $page->name,
+            'html' => $page->html,
+            'template' => 'true',
+        ];
+
+        $this->put($page->getUrl(), $pageUpdateData);
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id,
+            'template' => false,
+        ]);
+
+        $this->giveUserPermissions($editor, ['templates-manage']);
+
+        $this->put($page->getUrl(), $pageUpdateData);
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id,
+            'template' => true,
+        ]);
+    }
+
+    public function test_templates_content_should_be_fetchable_only_if_page_marked_as_template()
+    {
+        $content = '<div>my_custom_template_content</div>';
+        $page = Page::first();
+        $editor = $this->getEditor();
+        $this->actingAs($editor);
+
+        $templateFetch = $this->get('/templates/' . $page->id);
+        $templateFetch->assertStatus(404);
+
+        $page->html = $content;
+        $page->template = true;
+        $page->save();
+
+        $templateFetch = $this->get('/templates/' . $page->id);
+        $templateFetch->assertStatus(200);
+        $templateFetch->assertJson([
+            'html' => $content,
+            'markdown' => '',
+        ]);
+    }
+
+    public function test_template_endpoint_returns_paginated_list_of_templates()
+    {
+        $editor = $this->getEditor();
+        $this->actingAs($editor);
+
+        $toBeTemplates = Page::query()->orderBy('name', 'asc')->take(12)->get();
+        $page = $toBeTemplates->first();
+
+        $emptyTemplatesFetch = $this->get('/templates');
+        $emptyTemplatesFetch->assertDontSee($page->name);
+
+        Page::query()->whereIn('id', $toBeTemplates->pluck('id')->toArray())->update(['template' => true]);
+
+        $templatesFetch = $this->get('/templates');
+        $templatesFetch->assertSee($page->name);
+        $templatesFetch->assertSee('pagination');
+    }
+
+}
\ No newline at end of file
index 5bbdcf0bbb60c5f0c8ecf15d04f2285bbab5f7f5..a1f19364352c5f1ed74fa86f427ff8d0d95d4aac 100644 (file)
@@ -119,6 +119,43 @@ class RolesTest extends BrowserKitTest
         $this->actingAs($this->user)->visit('/')->dontSee($usersLink);
     }
 
+    public function test_user_cannot_change_email_unless_they_have_manage_users_permission()
+    {
+        $userProfileUrl = '/settings/users/' . $this->user->id;
+        $originalEmail = $this->user->email;
+        $this->actingAs($this->user);
+
+        $this->visit($userProfileUrl)
+            ->assertResponseOk()
+            ->seeElement('input[name=email][disabled]');
+        $this->put($userProfileUrl, [
+            'name' => 'my_new_name',
+            'email' => '[email protected]',
+        ]);
+        $this->seeInDatabase('users', [
+            'id' => $this->user->id,
+            'email' => $originalEmail,
+            'name' => 'my_new_name',
+        ]);
+
+        $this->giveUserPermissions($this->user, ['users-manage']);
+
+        $this->visit($userProfileUrl)
+            ->assertResponseOk()
+            ->dontSeeElement('input[name=email][disabled]')
+            ->seeElement('input[name=email]');
+        $this->put($userProfileUrl, [
+            'name' => 'my_new_name_2',
+            'email' => '[email protected]',
+        ]);
+
+        $this->seeInDatabase('users', [
+            'id' => $this->user->id,
+            'email' => '[email protected]',
+            'name' => 'my_new_name_2',
+        ]);
+    }
+
     public function test_user_roles_manage_permission()
     {
         $this->actingAs($this->user)->visit('/settings/roles')