<?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)
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;
- }
}
--- /dev/null
+<?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));
+ }
+
+}
--- /dev/null
+<?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
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;
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;
'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,
$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) {
use DOMDocument;
use DOMElement;
use DOMXPath;
+use Illuminate\Support\Collection;
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);
$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);
$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);
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;
+ }
}
--- /dev/null
+<?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
--- /dev/null
+<?php namespace BookStack\Exceptions;
+
+class UserTokenNotFoundException extends \Exception {}
\ No newline at end of file
--- /dev/null
+<?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');
+ }
+
+}
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;
* 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)
$newUser->socialAccounts()->save($socialAccount);
}
- if ((setting('registration-confirmation') || $registrationRestrict) && !$emailVerified) {
+ if ($this->emailConfirmationService->confirmationRequired() && !$emailVerified) {
$newUser->save();
try {
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
--- /dev/null
+<?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;
+ }
+
+}
$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,
]);
}
}
$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,
]);
}
$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'));
}
/**
--- /dev/null
+<?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,
+ ]);
+ }
+
+}
<?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;
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();
}
];
$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';
$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);
$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')) {
$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);
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.
*/
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');
--- /dev/null
+<?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));
+ }
+}
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) {
--- /dev/null
+<?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();
+ }
+}
--- /dev/null
+<?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');
+ }
+}
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
- Options -MultiViews
+ Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
*
-!.gitignore
\ No newline at end of file
+!.gitignore
+!.htaccess
\ No newline at end of file
--- /dev/null
+Options -Indexes
\ No newline at end of file
[](https://github.com/BookStackApp/BookStack/releases/latest)
[](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
[](https://travis-ci.org/BookStackApp/BookStack)
+[](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/.
* [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.
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.
- **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.
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:
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`.
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).
--- /dev/null
+<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
--- /dev/null
+<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
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() {
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);
}
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);
}
+import {onSelect} from "../services/dom";
+
/**
* Dropdown
* Provides some simple logic to create simple dropdown menus.
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
});
// 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() {
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 = '';
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();
+ }
});
}
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) {
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,
'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 = {};
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);
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');
});
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];
}
});
- // 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
})
}
+ 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 ;
--- /dev/null
+
+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
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;
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));
requestAnimationFrame(setOpacity.bind(this));
}
- hide() { this.toggle(false); }
- show() { this.toggle(true); }
+ focusOnBody() {
+ const body = this.container.querySelector('.popup-body');
+ if (body) {
+ body.focus();
+ }
+ }
}
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();
});
}
--- /dev/null
+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
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);
}
}
}
+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) {
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);
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();
}
}
+/**
+ * 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.
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) {
result = (count === 1) ? splitText[0] : splitText[1];
}
- if (result === null) result = splitText[0];
+ if (result === null) {
+ result = splitText[0];
+ }
+
return this.performReplacements(result, replacements);
}
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);
@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"
},
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;
}
},
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>
`;
import draggable from 'vuedraggable';
import autosuggest from './components/autosuggest';
-let data = {
+const data = {
entityId: false,
entityType: null,
tags: [],
const components = {draggable, autosuggest};
const directives = {};
-let methods = {
+const methods = {
addEmptyTag() {
this.tags.push({name: '', value: '', key: Math.random().toString(36).substring(7)});
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;
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;
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);
}
}
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 {
.button[disabled] {
background-color: #BBB;
cursor: default;
+ border-color: #CCC;
&:hover {
background-color: #BBB;
cursor: default;
+/**
+ * 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
.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;
}
}
+.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;
}
opacity: 0;
transition: opacity ease-in-out 120ms;
}
- &:hover .actions {
+ &:hover .actions, &:focus-within .actions {
opacity: 1;
}
}
}
a { color: #666; }
span {
- color: #888;
padding-left: $-xxs;
}
}
}
.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
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);
}
}
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;
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%;
}
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
display: block;
z-index: 11;
top: 0;
- background-color: $primary-dark;
color: #fff;
fill: #fff;
border-bottom: 1px solid #DDD;
}
.user-name {
vertical-align: top;
- padding-top: $-m;
position: relative;
- top: -3px;
display: inline-block;
cursor: pointer;
> * {
}
}
+.header *, .primary-background * {
+ outline-color: #FFF;
+}
.header-search {
color: #EEE;
z-index: 2;
padding-left: 40px;
+ &:focus {
+ outline: none;
+ border: 1px solid rgba(255, 255, 255, 0.6);
+ }
}
button {
fill: #EEE;
::-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;
}
line-height: 0.8;
margin: -2px 0 0;
}
- &:hover {
+ &:hover, &:focus-within {
opacity: 1;
}
}
.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
* {
box-sizing: border-box;
+ outline-color: #444444;
+}
+
+*:focus {
+ outline-style: dotted;
}
html {
min-height: 0;
max-width: 100%;
position: relative;
+ overflow-y: hidden;
}
.flex {
.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) {
.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 {
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;
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 {
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;
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);
}
* 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;
position: relative;
display: inline-block;
}
+ &:focus img:only-child {
+ outline: 2px dashed var(--color-primary);
+ outline-offset: 2px;
+ }
}
.blended-links a {
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 {
}
span.highlight {
- //background-color: rgba($primary, 0.2);
font-weight: bold;
padding: 2px 4px;
}
.page-content.mce-content-body {
padding-top: 16px;
+ outline: none;
}
// Fix to prevent 'No color' option from not being clickable.
$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;
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
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 {
// Back to top link
$btt-size: 40px;
[back-to-top] {
- background-color: $primary;
+ background-color: var(--color-primary);
position: fixed;
bottom: $-m;
right: $-l;
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;
.list-sort-label {
font-weight: bold;
display: inline-block;
- color: #888;
+ color: #555;
}
.list-sort-type {
text-align: left;
'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
'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',
'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',
'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',
],
'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',
'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.',
'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',
'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.',
'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.',
'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.',
--- /dev/null
+@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
</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>
</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>
</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>
<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>
@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>
<!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>
@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
{!! 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>
]])
</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
<!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>
</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>
</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>
<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
-<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">
<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
]])
</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
]])
</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>
</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>
<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>
<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
]])
</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
<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>
]])
</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>
<!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>
</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>
<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>
<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])
]])
</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>
]])
</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
@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>
</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
<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>
@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
</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')])
-<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',
<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
<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')])
<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>
</ul>
</div>
@endif
- </div>
+ </nav>
</div>
</div>
@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
<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>
</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>
<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>
$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>
])
<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>
<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>
</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">
<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>
<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
<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))
--- /dev/null
+<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
</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>
<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>
<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>
@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">
<div class="text-center">
{!! $pages->links() !!}
</div>
- </div>
+ </main>
</div>
@stop
\ No newline at end of file
<input type="hidden" name="_method" value="PUT">
@endif
@include('pages.form', ['model' => $page])
- @include('pages.form-toolbox')
+ @include('pages.editor-toolbox')
</form>
</div>
--- /dev/null
+<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>
<!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>
+++ /dev/null
-
-<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>
<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> @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> @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>
<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>
]])
</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
<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"/>
]])
</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
]])
</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
@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
]])
</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"> | </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">
</div>
<span class="text-muted"> | </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">
@else
<p>{{ trans('entities.pages_revisions_none') }}</p>
@endif
- </div>
+ </main>
</div>
@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>
@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">
@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>
--- /dev/null
+{{ $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
--- /dev/null
+<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
-<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>
</li>
@endforeach
</ul>
-</div>
\ No newline at end of file
+</nav>
\ No newline at end of file
<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>
-<div class="breadcrumbs text-center">
+<nav class="breadcrumbs text-center" aria-label="{{ trans('common.breadcrumb') }}">
<?php $breadcrumbCount = 0; ?>
{{-- Show top level books item --}}
@endif
<?php $breadcrumbCount++; ?>
@endforeach
-</div>
\ No newline at end of file
+</nav>
\ No newline at end of file
<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
<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
--- /dev/null
+<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
<?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 ?? '' }}
-<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>
<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
</table>
- <button type="submit" class="button primary">{{ trans('entities.search_update') }}</button>
+ <button type="submit" class="button">{{ trans('entities.search_update') }}</button>
</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>
<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>
-<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>
@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
<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>
<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>
]])
</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>
]])
</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
<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>
</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>
<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
-<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>
@endif
@endif
-</div>
+</main>
]])
</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>
</div>
@endif
</div>
- </div>
+ </main>
@stop
@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') }}
<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">
</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>
@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
<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>
@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>
<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
@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>
<div>
{{ $users->links() }}
</div>
- </div>
+ </main>
</div>
<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">
</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)
@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)
@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)
@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
<!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" />
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');
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');
--- /dev/null
+<?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',
+ '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
$page->save();
$pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
$pageView->assertDontSee($script);
$pageView->assertSee('abc123abc123');
}
$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();
$page->save();
$pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
$pageView->assertDontSee($script);
$pageView->assertSee('<p>Hello</p>');
}
'<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();
$page->save();
$pageView = $this->get($page->getUrl());
+ $pageView->assertStatus(200);
$pageView->assertElementNotContains('.page-content', 'onclick');
}
// 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();
--- /dev/null
+<?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
$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',
+ ]);
+ $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',
+ ]);
+
+ $this->seeInDatabase('users', [
+ 'id' => $this->user->id,
+ 'name' => 'my_new_name_2',
+ ]);
+ }
+
public function test_user_roles_manage_permission()
{
$this->actingAs($this->user)->visit('/settings/roles')