use BookStack\Facades\Activity as ActivityService;
use League\CommonMark\CommonMarkConverter;
-/**
- * Class CommentRepo.
- */
class CommentRepo
{
- /**
- * @var Comment
- */
- protected $comment;
-
- public function __construct(Comment $comment)
- {
- $this->comment = $comment;
- }
-
/**
* Get a comment by ID.
*/
public function getById(int $id): Comment
{
- return $this->comment->newQuery()->findOrFail($id);
+ return Comment::query()->findOrFail($id);
}
/**
public function create(Entity $entity, string $text, ?int $parent_id): Comment
{
$userId = user()->id;
- $comment = $this->comment->newInstance();
+ $comment = new Comment();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
'allow_unsafe_links' => false,
]);
- return $converter->convertToHtml($commentText);
+ return $converter->convert($commentText);
}
/**
*/
protected function getNextLocalId(Entity $entity): int
{
- /** @var Comment $comment */
- $comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
+ $currentMaxId = $entity->comments()->max('local_id');
- return ($comment->local_id ?? 0) + 1;
+ return $currentMaxId + 1;
}
}
class CommentController extends Controller
{
- protected $commentRepo;
-
- public function __construct(CommentRepo $commentRepo)
- {
- $this->commentRepo = $commentRepo;
+ public function __construct(
+ protected CommentRepo $commentRepo
+ ) {
}
/**
$this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
- return view('comments.comment', ['comment' => $comment]);
+ return view('comments.comment-branch', [
+ 'branch' => [
+ 'comment' => $comment,
+ 'children' => [],
+ ]
+ ]);
}
/**
--- /dev/null
+<?php
+
+namespace BookStack\Activity\Tools;
+
+use BookStack\Activity\Models\Comment;
+use BookStack\Entities\Models\Page;
+
+class CommentTree
+{
+ /**
+ * The built nested tree structure array.
+ * @var array{comment: Comment, depth: int, children: array}[]
+ */
+ protected array $tree;
+ protected array $comments;
+
+ public function __construct(
+ protected Page $page
+ ) {
+ $this->comments = $this->loadComments();
+ $this->tree = $this->createTree($this->comments);
+ }
+
+ public function enabled(): bool
+ {
+ return !setting('app-disable-comments');
+ }
+
+ public function empty(): bool
+ {
+ return count($this->tree) === 0;
+ }
+
+ public function count(): int
+ {
+ return count($this->comments);
+ }
+
+ public function get(): array
+ {
+ return $this->tree;
+ }
+
+ /**
+ * @param Comment[] $comments
+ */
+ protected function createTree(array $comments): array
+ {
+ $byId = [];
+ foreach ($comments as $comment) {
+ $byId[$comment->local_id] = $comment;
+ }
+
+ $childMap = [];
+ foreach ($comments as $comment) {
+ $parent = $comment->parent_id;
+ if (is_null($parent) || !isset($byId[$parent])) {
+ $parent = 0;
+ }
+
+ if (!isset($childMap[$parent])) {
+ $childMap[$parent] = [];
+ }
+ $childMap[$parent][] = $comment->local_id;
+ }
+
+ $tree = [];
+ foreach ($childMap[0] ?? [] as $childId) {
+ $tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
+ }
+
+ return $tree;
+ }
+
+ protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
+ {
+ $childIds = $childMap[$id] ?? [];
+ $children = [];
+
+ foreach ($childIds as $childId) {
+ $children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
+ }
+
+ return [
+ 'comment' => $byId[$id],
+ 'depth' => $depth,
+ 'children' => $children,
+ ];
+ }
+
+ protected function loadComments(): array
+ {
+ if (!$this->enabled()) {
+ return [];
+ }
+
+ return $this->page->comments()
+ ->with('createdBy')
+ ->get()
+ ->all();
+ }
+}
$token->save();
session()->flash('api-token-secret:' . $token->id, $secret);
- $this->showSuccessNotification(trans('settings.user_api_token_create_success'));
$this->logActivity(ActivityType::API_TOKEN_CREATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id));
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
])->save();
- $this->showSuccessNotification(trans('settings.user_api_token_update_success'));
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id));
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$token->delete();
- $this->showSuccessNotification(trans('settings.user_api_token_delete_success'));
$this->logActivity(ActivityType::API_TOKEN_DELETE, $token);
return redirect($user->getEditUrl('#api_tokens'));
return redirect()->back();
}
- $this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
-
return redirect($chapter->getUrl());
}
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
+use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
$pageContent = (new PageContent($page));
$page->html = $pageContent->render();
- $sidebarTree = (new BookContents($page->book))->getTree();
$pageNav = $pageContent->getNavigation($page->html);
- // Check if page comments are enabled
- $commentsEnabled = !setting('app-disable-comments');
- if ($commentsEnabled) {
- $page->load(['comments.createdBy']);
- }
-
+ $sidebarTree = (new BookContents($page->book))->getTree();
+ $commentTree = (new CommentTree($page));
$nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree);
View::incrementFor($page);
'book' => $page->book,
'current' => $page,
'sidebarTree' => $sidebarTree,
- 'commentsEnabled' => $commentsEnabled,
+ 'commentTree' => $commentTree,
'pageNav' => $pageNav,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
}
try {
- $parent = $this->pageRepo->move($page, $entitySelection);
+ $this->pageRepo->move($page, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (Exception $exception) {
return redirect()->back();
}
- $this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name]));
-
return redirect($page->getUrl());
}
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
class PageRevisionController extends Controller
{
- protected PageRepo $pageRepo;
-
- public function __construct(PageRepo $pageRepo)
- {
- $this->pageRepo = $pageRepo;
+ public function __construct(
+ protected PageRepo $pageRepo,
+ protected RevisionRepo $revisionRepo,
+ ) {
}
/**
$revision->delete();
Activity::add(ActivityType::REVISION_DELETE, $revision);
- $this->showSuccessNotification(trans('entities.revision_delete_success'));
return redirect($page->getUrl('/revisions'));
}
+
+ /**
+ * Destroys existing drafts, belonging to the current user, for the given page.
+ */
+ public function destroyUserDraft(string $pageId)
+ {
+ $page = $this->pageRepo->getById($pageId);
+ $this->revisionRepo->deleteDraftsForCurrentUser($page);
+
+ return response('', 200);
+ }
}
class RecycleBinController extends Controller
{
- protected $recycleBinBaseUrl = '/settings/recycle-bin';
+ protected string $recycleBinBaseUrl = '/settings/recycle-bin';
/**
* On each request to a method of this controller check permissions
namespace BookStack\Entities\Queries;
use BookStack\Activity\Models\View;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class Popular extends EntityQuery
$query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
}
- return $query->with('viewable')
+ $entities = $query->with('viewable')
->skip($count * ($page - 1))
->take($count)
->get()
->pluck('viewable')
->filter();
+
+ $this->loadBooksForChildren($entities);
+
+ return $entities;
+ }
+
+ protected function loadBooksForChildren(Collection $entities)
+ {
+ $bookChildren = $entities->filter(fn(Entity $entity) => $entity instanceof BookChild);
+ $eloquent = (new \Illuminate\Database\Eloquent\Collection($bookChildren));
+ $eloquent->load(['book' => function (BelongsTo $query) {
+ $query->scopes('visible');
+ }]);
}
}
*/
protected function getCurrentUserRoleIds(): array
{
- if (auth()->guest()) {
- return [Role::getSystemRole('public')->id];
- }
-
return $this->currentUser()->roles->pluck('id')->values()->all();
}
class SearchController extends Controller
{
- protected SearchRunner $searchRunner;
-
- public function __construct(SearchRunner $searchRunner)
- {
- $this->searchRunner = $searchRunner;
+ public function __construct(
+ protected SearchRunner $searchRunner
+ ) {
}
/**
]);
$store->storeFromUpdateRequest($request, $category);
-
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
- $this->showSuccessNotification(trans('settings.settings_save_success'));
return redirect("/settings/{$category}");
}
protected function formatForSingleResponse(Image $image): array
{
$this->imageRepo->loadThumbs($image);
- $data = $image->getAttributes();
+ $data = $image->toArray();
$data['created_by'] = $image->createdBy;
$data['updated_by'] = $image->updatedBy;
$data['content'] = [];
class UserController extends Controller
{
- protected UserRepo $userRepo;
- protected ImageRepo $imageRepo;
-
- public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
- {
- $this->userRepo = $userRepo;
- $this->imageRepo = $imageRepo;
+ public function __construct(
+ protected UserRepo $userRepo,
+ protected ImageRepo $imageRepo
+ ) {
}
/**
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ $guestUserId = DB::table('users')
+ ->where('system_name', '=', 'public')
+ ->first(['id'])->id;
+ $publicRoleId = DB::table('roles')
+ ->where('system_name', '=', 'public')
+ ->first(['id'])->id;
+
+ // This migration deletes secondary "Guest" user role assignments
+ // as a safety precaution upon upgrade since the logic is changing
+ // within the release this is introduced in, which could result in wider
+ // permissions being provided upon upgrade without this intervention.
+
+ // Previously, added roles would only partially apply their permissions
+ // since some permission checks would only consider the originally assigned
+ // public role, and not added roles. Within this release, additional roles
+ // will fully apply.
+ DB::table('role_user')
+ ->where('user_id', '=', $guestUserId)
+ ->where('role_id', '!=', $publicRoleId)
+ ->delete();
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ // No structural changes to make, and we cannot know the role ids to re-assign.
+ }
+};
"name": "Admin",
"slug": "admin"
},
- "updated_at": "2023-03-15 08:17:37",
- "created_at": "2023-03-15 08:17:37",
+ "updated_at": "2023-03-15T16:32:09.000000Z",
+ "created_at": "2023-03-15T16:32:09.000000Z",
"id": 618,
"thumbs": {
"gallery": "https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png",
"id": 618,
"name": "cute-cat-image.png",
"url": "https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png",
- "created_at": "2023-03-15 08:17:37",
- "updated_at": "2023-03-15 08:17:37",
+ "created_at": "2023-03-15T16:32:09.000000Z",
+ "updated_at": "2023-03-15T16:32:09.000000Z",
"created_by": {
"id": 1,
"name": "Admin",
"id": 618,
"name": "My updated image name",
"url": "https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png",
- "created_at": "2023-03-15 08:17:37",
- "updated_at": "2023-03-15 08:24:50",
+ "created_at": "2023-03-15T16:32:09.000000Z",
+ "updated_at": "2023-03-15T18:31:14.000000Z",
"created_by": {
"id": 1,
"name": "Admin",
'page_restore' => 'restored page',
'page_restore_notification' => 'Page successfully restored',
'page_move' => 'moved page',
+ 'page_move_notification' => 'Page successfully moved',
// Chapters
'chapter_create' => 'created chapter',
'chapter_delete' => 'deleted chapter',
'chapter_delete_notification' => 'Chapter successfully deleted',
'chapter_move' => 'moved chapter',
+ 'chapter_move_notification' => 'Chapter successfully moved',
// Books
'book_create' => 'created book',
'bookshelf_delete' => 'deleted shelf',
'bookshelf_delete_notification' => 'Shelf successfully deleted',
+ // Revisions
+ 'revision_restore' => 'restored revision',
+ 'revision_delete' => 'deleted revision',
+ 'revision_delete_notification' => 'Revision successfully deleted',
+
// Favourites
'favourite_add_notification' => '":name" has been added to your favourites',
'favourite_remove_notification' => '":name" has been removed from your favourites',
- // MFA
+ // Auth
+ 'auth_login' => 'logged in',
+ 'auth_register' => 'registered as new user',
+ 'auth_password_reset_request' => 'requested user password reset',
+ 'auth_password_reset_update' => 'reset user password',
+ 'mfa_setup_method' => 'configured MFA method',
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
+ 'mfa_remove_method' => 'removed MFA method',
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
+ // Settings
+ 'settings_update' => 'updated settings',
+ 'settings_update_notification' => 'Settings successfully updated',
+ 'maintenance_action_run' => 'ran maintenance action',
+
// Webhooks
'webhook_create' => 'created webhook',
'webhook_create_notification' => 'Webhook successfully created',
'webhook_delete_notification' => 'Webhook successfully deleted',
// Users
+ 'user_create' => 'created user',
+ 'user_create_notification' => 'User successfully created',
+ 'user_update' => 'updated user',
'user_update_notification' => 'User successfully updated',
+ 'user_delete' => 'deleted user',
'user_delete_notification' => 'User successfully removed',
+ // API Tokens
+ 'api_token_create' => 'created api token',
+ 'api_token_create_notification' => 'API token successfully created',
+ 'api_token_update' => 'updated api token',
+ 'api_token_update_notification' => 'API token successfully updated',
+ 'api_token_delete' => 'deleted api token',
+ 'api_token_delete_notification' => 'API token successfully deleted',
+
// Roles
+ 'role_create' => 'created role',
'role_create_notification' => 'Role successfully created',
+ 'role_update' => 'updated role',
'role_update_notification' => 'Role successfully updated',
+ 'role_delete' => 'deleted role',
'role_delete_notification' => 'Role successfully deleted',
+ // Recycle Bin
+ 'recycle_bin_empty' => 'emptied recycle bin',
+ 'recycle_bin_restore' => 'restored from recycle bin',
+ 'recycle_bin_destroy' => 'removed from recycle bin',
+
// Other
'commented_on' => 'commented on',
'permissions_update' => 'updated permissions',
'chapters_save' => 'Save Chapter',
'chapters_move' => 'Move Chapter',
'chapters_move_named' => 'Move Chapter :chapterName',
- 'chapter_move_success' => 'Chapter moved to :bookName',
'chapters_copy' => 'Copy Chapter',
'chapters_copy_success' => 'Chapter successfully copied',
'chapters_permissions' => 'Chapter Permissions',
'pages_editing_page' => 'Editing Page',
'pages_edit_draft_save_at' => 'Draft saved at ',
'pages_edit_delete_draft' => 'Delete Draft',
+ 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.',
'pages_edit_discard_draft' => 'Discard Draft',
'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor',
'pages_edit_switch_to_markdown_clean' => '(Clean Content)',
'pages_md_sync_scroll' => 'Sync preview scroll',
'pages_not_in_chapter' => 'Page is not in a chapter',
'pages_move' => 'Move Page',
- 'pages_move_success' => 'Page moved to ":parentName"',
'pages_copy' => 'Copy Page',
'pages_copy_desination' => 'Copy Destination',
'pages_copy_success' => 'Page successfully copied',
'time_b' => 'in the last :minCount minutes',
'message' => ':start :time. Take care not to overwrite each other\'s updates!',
],
- 'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content',
+ 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content',
+ 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content',
'pages_specific' => 'Specific Page',
'pages_is_template' => 'Page Template',
'comment_placeholder' => 'Leave a comment here',
'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
'comment_save' => 'Save Comment',
- 'comment_saving' => 'Saving comment...',
- 'comment_deleting' => 'Deleting comment...',
'comment_new' => 'New Comment',
'comment_created' => 'commented :createDiff',
'comment_updated' => 'Updated :updateDiff by :username',
+ 'comment_updated_indicator' => 'Updated',
'comment_deleted_success' => 'Comment deleted',
'comment_created_success' => 'Comment added',
'comment_updated_success' => 'Comment updated',
// Revision
'revision_delete_confirm' => 'Are you sure you want to delete this revision?',
'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.',
- 'revision_delete_success' => 'Revision deleted',
'revision_cannot_delete_latest' => 'Cannot delete the latest revision.',
// Copy view
// Pages
'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page',
+ 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content',
'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
// Entities
// Common Messages
'settings' => 'Settings',
'settings_save' => 'Save Settings',
- 'settings_save_success' => 'Settings saved',
'system_version' => 'System Version',
'categories' => 'Categories',
'user_api_token_expiry' => 'Expiry Date',
'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
- 'user_api_token_create_success' => 'API token successfully created',
- 'user_api_token_update_success' => 'API token successfully updated',
'user_api_token' => 'API Token',
'user_api_token_id' => 'Token ID',
'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
'user_api_token_delete' => 'Delete Token',
'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
- 'user_api_token_delete_success' => 'API token successfully deleted',
// Webhooks
'webhooks' => 'Webhooks',
export {NewUserPassword} from './new-user-password';
export {Notification} from './notification';
export {OptionalInput} from './optional-input';
+export {PageComment} from './page-comment';
export {PageComments} from './page-comments';
export {PageDisplay} from './page-display';
export {PageEditor} from './page-editor';
--- /dev/null
+import {Component} from './component';
+import {getLoading, htmlToDom} from '../services/dom';
+
+export class PageComment extends Component {
+
+ setup() {
+ // Options
+ this.commentId = this.$opts.commentId;
+ this.commentLocalId = this.$opts.commentLocalId;
+ this.commentParentId = this.$opts.commentParentId;
+ this.deletedText = this.$opts.deletedText;
+ this.updatedText = this.$opts.updatedText;
+
+ // Element References
+ this.container = this.$el;
+ this.contentContainer = this.$refs.contentContainer;
+ this.form = this.$refs.form;
+ this.formCancel = this.$refs.formCancel;
+ this.editButton = this.$refs.editButton;
+ this.deleteButton = this.$refs.deleteButton;
+ this.replyButton = this.$refs.replyButton;
+ this.input = this.$refs.input;
+
+ this.setupListeners();
+ }
+
+ setupListeners() {
+ if (this.replyButton) {
+ this.replyButton.addEventListener('click', () => this.$emit('reply', {
+ id: this.commentLocalId,
+ element: this.container,
+ }));
+ }
+
+ if (this.editButton) {
+ this.editButton.addEventListener('click', this.startEdit.bind(this));
+ this.form.addEventListener('submit', this.update.bind(this));
+ this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
+ }
+
+ if (this.deleteButton) {
+ this.deleteButton.addEventListener('click', this.delete.bind(this));
+ }
+ }
+
+ toggleEditMode(show) {
+ this.contentContainer.toggleAttribute('hidden', show);
+ this.form.toggleAttribute('hidden', !show);
+ }
+
+ startEdit() {
+ this.toggleEditMode(true);
+ const lineCount = this.$refs.input.value.split('\n').length;
+ this.$refs.input.style.height = `${(lineCount * 20) + 40}px`;
+ }
+
+ async update(event) {
+ event.preventDefault();
+ const loading = this.showLoading();
+ this.form.toggleAttribute('hidden', true);
+
+ const reqData = {
+ text: this.input.value,
+ parent_id: this.parentId || null,
+ };
+
+ try {
+ const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
+ const newComment = htmlToDom(resp.data);
+ this.container.replaceWith(newComment);
+ window.$events.success(this.updatedText);
+ } catch (err) {
+ console.error(err);
+ window.$events.showValidationErrors(err);
+ this.form.toggleAttribute('hidden', false);
+ loading.remove();
+ }
+ }
+
+ async delete() {
+ this.showLoading();
+
+ await window.$http.delete(`/comment/${this.commentId}`);
+ this.container.closest('.comment-branch').remove();
+ window.$events.success(this.deletedText);
+ this.$emit('delete');
+ }
+
+ showLoading() {
+ const loading = getLoading();
+ loading.classList.add('px-l');
+ this.container.append(loading);
+ return loading;
+ }
+
+}
-import {scrollAndHighlightElement} from '../services/util';
import {Component} from './component';
-import {htmlToDom} from '../services/dom';
+import {getLoading, htmlToDom} from '../services/dom';
export class PageComments extends Component {
// Element references
this.container = this.$refs.commentContainer;
- this.formContainer = this.$refs.formContainer;
this.commentCountBar = this.$refs.commentCountBar;
+ this.commentsTitle = this.$refs.commentsTitle;
this.addButtonContainer = this.$refs.addButtonContainer;
this.replyToRow = this.$refs.replyToRow;
+ this.formContainer = this.$refs.formContainer;
+ this.form = this.$refs.form;
+ this.formInput = this.$refs.formInput;
+ this.formReplyLink = this.$refs.formReplyLink;
+ this.addCommentButton = this.$refs.addCommentButton;
+ this.hideFormButton = this.$refs.hideFormButton;
+ this.removeReplyToButton = this.$refs.removeReplyToButton;
// Translations
- this.updatedText = this.$opts.updatedText;
- this.deletedText = this.$opts.deletedText;
this.createdText = this.$opts.createdText;
this.countText = this.$opts.countText;
// Internal State
- this.editingComment = null;
this.parentId = null;
+ this.formReplyText = this.formReplyLink.textContent;
- if (this.formContainer) {
- this.form = this.formContainer.querySelector('form');
- this.formInput = this.form.querySelector('textarea');
- this.form.addEventListener('submit', this.saveComment.bind(this));
- }
-
- this.elem.addEventListener('click', this.handleAction.bind(this));
- this.elem.addEventListener('submit', this.updateComment.bind(this));
+ this.setupListeners();
}
- handleAction(event) {
- const actionElem = event.target.closest('[action]');
-
- if (event.target.matches('a[href^="#"]')) {
- const id = event.target.href.split('#')[1];
- scrollAndHighlightElement(document.querySelector(`#${id}`));
- }
-
- if (actionElem === null) return;
- event.preventDefault();
-
- const action = actionElem.getAttribute('action');
- const comment = actionElem.closest('[comment]');
- if (action === 'edit') this.editComment(comment);
- if (action === 'closeUpdateForm') this.closeUpdateForm();
- if (action === 'delete') this.deleteComment(comment);
- if (action === 'addComment') this.showForm();
- if (action === 'hideForm') this.hideForm();
- if (action === 'reply') this.setReply(comment);
- if (action === 'remove-reply-to') this.removeReplyTo();
- }
+ setupListeners() {
+ this.removeReplyToButton.addEventListener('click', this.removeReplyTo.bind(this));
+ this.hideFormButton.addEventListener('click', this.hideForm.bind(this));
+ this.addCommentButton.addEventListener('click', this.showForm.bind(this));
- closeUpdateForm() {
- if (!this.editingComment) return;
- this.editingComment.querySelector('[comment-content]').style.display = 'block';
- this.editingComment.querySelector('[comment-edit-container]').style.display = 'none';
- }
-
- editComment(commentElem) {
- this.hideForm();
- if (this.editingComment) this.closeUpdateForm();
- commentElem.querySelector('[comment-content]').style.display = 'none';
- commentElem.querySelector('[comment-edit-container]').style.display = 'block';
- const textArea = commentElem.querySelector('[comment-edit-container] textarea');
- const lineCount = textArea.value.split('\n').length;
- textArea.style.height = `${(lineCount * 20) + 40}px`;
- this.editingComment = commentElem;
- }
-
- updateComment(event) {
- const form = event.target;
- event.preventDefault();
- const text = form.querySelector('textarea').value;
- const reqData = {
- text,
- parent_id: this.parentId || null,
- };
- this.showLoading(form);
- const commentId = this.editingComment.getAttribute('comment');
- window.$http.put(`/comment/${commentId}`, reqData).then(resp => {
- const newComment = document.createElement('div');
- newComment.innerHTML = resp.data;
- this.editingComment.innerHTML = newComment.children[0].innerHTML;
- window.$events.success(this.updatedText);
- window.$components.init(this.editingComment);
- this.closeUpdateForm();
- this.editingComment = null;
- }).catch(window.$events.showValidationErrors).then(() => {
- this.hideLoading(form);
- });
- }
-
- deleteComment(commentElem) {
- const id = commentElem.getAttribute('comment');
- this.showLoading(commentElem.querySelector('[comment-content]'));
- window.$http.delete(`/comment/${id}`).then(() => {
- commentElem.parentNode.removeChild(commentElem);
- window.$events.success(this.deletedText);
+ this.elem.addEventListener('page-comment-delete', () => {
this.updateCount();
this.hideForm();
});
+
+ this.elem.addEventListener('page-comment-reply', event => {
+ this.setReply(event.detail.id, event.detail.element);
+ });
+
+ if (this.form) {
+ this.form.addEventListener('submit', this.saveComment.bind(this));
+ }
}
saveComment(event) {
event.preventDefault();
event.stopPropagation();
+
+ const loading = getLoading();
+ loading.classList.add('px-l');
+ this.form.after(loading);
+ this.form.toggleAttribute('hidden', true);
+
const text = this.formInput.value;
const reqData = {
text,
parent_id: this.parentId || null,
};
- this.showLoading(this.form);
+
window.$http.post(`/comment/${this.pageId}`, reqData).then(resp => {
const newElem = htmlToDom(resp.data);
- this.container.appendChild(newElem);
- window.$components.init(newElem);
+ this.formContainer.after(newElem);
window.$events.success(this.createdText);
- this.resetForm();
+ this.hideForm();
this.updateCount();
}).catch(err => {
+ this.form.toggleAttribute('hidden', false);
window.$events.showValidationErrors(err);
- this.hideLoading(this.form);
});
+
+ this.form.toggleAttribute('hidden', false);
+ loading.remove();
}
updateCount() {
- const count = this.container.children.length;
- this.elem.querySelector('[comments-title]').textContent = window.trans_plural(this.countText, count, {count});
+ const count = this.getCommentCount();
+ this.commentsTitle.textContent = window.trans_plural(this.countText, count, {count});
}
resetForm() {
this.formInput.value = '';
- this.formContainer.appendChild(this.form);
- this.hideForm();
- this.removeReplyTo();
- this.hideLoading(this.form);
+ this.parentId = null;
+ this.replyToRow.toggleAttribute('hidden', true);
+ this.container.append(this.formContainer);
}
showForm() {
- this.formContainer.style.display = 'block';
- this.formContainer.parentNode.style.display = 'block';
- this.addButtonContainer.style.display = 'none';
- this.formInput.focus();
- this.formInput.scrollIntoView({behavior: 'smooth'});
+ this.formContainer.toggleAttribute('hidden', false);
+ this.addButtonContainer.toggleAttribute('hidden', true);
+ this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+ setTimeout(() => {
+ this.formInput.focus();
+ }, 100);
}
hideForm() {
- this.formContainer.style.display = 'none';
- this.formContainer.parentNode.style.display = 'none';
+ this.resetForm();
+ this.formContainer.toggleAttribute('hidden', true);
if (this.getCommentCount() > 0) {
- this.elem.appendChild(this.addButtonContainer);
+ this.elem.append(this.addButtonContainer);
} else {
- this.commentCountBar.appendChild(this.addButtonContainer);
+ this.commentCountBar.append(this.addButtonContainer);
}
- this.addButtonContainer.style.display = 'block';
+ this.addButtonContainer.toggleAttribute('hidden', false);
}
getCommentCount() {
- return this.elem.querySelectorAll('.comment-box[comment]').length;
+ return this.container.querySelectorAll('[component="page-comment"]').length;
}
- setReply(commentElem) {
+ setReply(commentLocalId, commentElement) {
+ const targetFormLocation = commentElement.closest('.comment-branch').querySelector('.comment-branch-children');
+ targetFormLocation.append(this.formContainer);
this.showForm();
- this.parentId = Number(commentElem.getAttribute('local-id'));
- this.replyToRow.style.display = 'block';
+ this.parentId = commentLocalId;
+ this.replyToRow.toggleAttribute('hidden', false);
const replyLink = this.replyToRow.querySelector('a');
- replyLink.textContent = `#${this.parentId}`;
+ replyLink.textContent = this.formReplyText.replace('1234', this.parentId);
replyLink.href = `#comment${this.parentId}`;
}
removeReplyTo() {
this.parentId = null;
- this.replyToRow.style.display = 'none';
- }
-
- showLoading(formElem) {
- const groups = formElem.querySelectorAll('.form-group');
- for (const group of groups) {
- group.style.display = 'none';
- }
- formElem.querySelector('.form-group.loading').style.display = 'block';
- }
-
- hideLoading(formElem) {
- const groups = formElem.querySelectorAll('.form-group');
- for (const group of groups) {
- group.style.display = 'block';
- }
- formElem.querySelector('.form-group.loading').style.display = 'none';
+ this.replyToRow.toggleAttribute('hidden', true);
+ this.container.append(this.formContainer);
+ this.showForm();
}
}
this.saveDraftButton = this.$refs.saveDraft;
this.discardDraftButton = this.$refs.discardDraft;
this.discardDraftWrap = this.$refs.discardDraftWrap;
+ this.deleteDraftButton = this.$refs.deleteDraft;
+ this.deleteDraftWrap = this.$refs.deleteDraftWrap;
this.draftDisplay = this.$refs.draftDisplay;
this.draftDisplayIcon = this.$refs.draftDisplayIcon;
this.changelogInput = this.$refs.changelogInput;
this.changelogDisplay = this.$refs.changelogDisplay;
this.changeEditorButtons = this.$manyRefs.changeEditor || [];
this.switchDialogContainer = this.$refs.switchDialog;
+ this.deleteDraftDialogContainer = this.$refs.deleteDraftDialog;
// Translations
this.draftText = this.$opts.draftText;
this.autosaveFailText = this.$opts.autosaveFailText;
this.editingPageText = this.$opts.editingPageText;
this.draftDiscardedText = this.$opts.draftDiscardedText;
+ this.draftDeleteText = this.$opts.draftDeleteText;
+ this.draftDeleteFailText = this.$opts.draftDeleteFailText;
this.setChangelogText = this.$opts.setChangelogText;
// State data
// Draft Controls
onSelect(this.saveDraftButton, this.saveDraft.bind(this));
onSelect(this.discardDraftButton, this.discardDraft.bind(this));
+ onSelect(this.deleteDraftButton, this.deleteDraft.bind(this));
// Change editor controls
onSelect(this.changeEditorButtons, this.changeEditor.bind(this));
try {
const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
if (!this.isNewDraft) {
- this.toggleDiscardDraftVisibility(true);
+ this.discardDraftWrap.toggleAttribute('hidden', false);
+ this.deleteDraftWrap.toggleAttribute('hidden', false);
}
this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
}, 2000);
}
- async discardDraft() {
+ async discardDraft(notify = true) {
let response;
try {
response = await window.$http.get(`/ajax/page/${this.pageId}`);
}
this.draftDisplay.innerText = this.editingPageText;
- this.toggleDiscardDraftVisibility(false);
+ this.discardDraftWrap.toggleAttribute('hidden', true);
window.$events.emit('editor::replace', {
html: response.data.html,
markdown: response.data.markdown,
window.setTimeout(() => {
this.startAutoSave();
}, 1000);
- window.$events.emit('success', this.draftDiscardedText);
+
+ if (notify) {
+ window.$events.success(this.draftDiscardedText);
+ }
+ }
+
+ async deleteDraft() {
+ /** @var {ConfirmDialog} * */
+ const dialog = window.$components.firstOnElement(this.deleteDraftDialogContainer, 'confirm-dialog');
+ const confirmed = await dialog.show();
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ const discard = this.discardDraft(false);
+ const draftDelete = window.$http.delete(`/page-revisions/user-drafts/${this.pageId}`);
+ await Promise.all([discard, draftDelete]);
+ window.$events.success(this.draftDeleteText);
+ this.deleteDraftWrap.toggleAttribute('hidden', true);
+ } catch (err) {
+ console.error(err);
+ window.$events.error(this.draftDeleteFailText);
+ }
}
updateChangelogDisplay() {
this.changelogDisplay.innerText = summary;
}
- toggleDiscardDraftVisibility(show) {
- this.discardDraftWrap.classList.toggle('hidden', !show);
- }
-
async changeEditor(event) {
event.preventDefault();
*/
#setText(text, selectionRange = null) {
selectionRange = selectionRange || this.#getSelectionRange();
- this.#dispatchChange(0, this.editor.cm.state.doc.length, text, selectionRange.from);
+ const newDoc = this.editor.cm.state.toText(text);
+ const newSelectFrom = Math.min(selectionRange.from, newDoc.length);
+ this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom);
this.focus();
}
-
-/**
- * Callouts
- */
-.callout {
- border-inline-start: 3px solid #BBB;
- background-color: #EEE;
- padding: $-s $-s $-s $-xl;
- display: block;
- position: relative;
- overflow: auto;
- &:before {
- background-image: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMwMTUzODAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTEgMTVoLTJ2LTZoMnY2em0wLThoLTJWN2gydjJ6Ii8+PC9zdmc+');
- background-repeat: no-repeat;
- content: '';
- width: 1.2em;
- height: 1.2em;
- left: $-xs + 2px;
- top: 50%;
- margin-top: -9px;
- display: inline-block;
- position: absolute;
- line-height: 1;
- opacity: 0.8;
- }
- &.success {
- border-left-color: $positive;
- @include lightDark(background-color, lighten($positive, 68%), darken($positive, 22%));
- @include lightDark(color, darken($positive, 16%), lighten($positive, 5%));
- }
- &.success:before {
- background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMzNzZjMzkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0yIDE1bC01LTUgMS40MS0xLjQxTDEwIDE0LjE3bDcuNTktNy41OUwxOSA4bC05IDl6Ii8+PC9zdmc+");
- }
- &.danger {
- border-left-color: $negative;
- @include lightDark(background-color, lighten($negative, 56%), darken($negative, 30%));
- @include lightDark(color, darken($negative, 20%), lighten($negative, 5%));
- }
- &.danger:before {
- background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiOTE4MTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0xNS43MyAzSDguMjdMMyA4LjI3djcuNDZMOC4yNyAyMWg3LjQ2TDIxIDE1LjczVjguMjdMMTUuNzMgM3pNMTIgMTcuM2MtLjcyIDAtMS4zLS41OC0xLjMtMS4zIDAtLjcyLjU4LTEuMyAxLjMtMS4zLjcyIDAgMS4zLjU4IDEuMyAxLjMgMCAuNzItLjU4IDEuMy0xLjMgMS4zem0xLTQuM2gtMlY3aDJ2NnoiLz4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg==");
- }
- &.info {
- border-left-color: $info;
- @include lightDark(color, darken($info, 20%), lighten($info, 10%));
- @include lightDark(background-color, lighten($info, 50%), darken($info, 35%));
- }
- &.warning {
- border-left-color: $warning;
- @include lightDark(background-color, lighten($warning, 50%), darken($warning, 36%));
- @include lightDark(color, darken($warning, 20%), $warning);
- }
- &.warning:before {
- background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiNjUzMWMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEgMjFoMjJMMTIgMiAxIDIxem0xMi0zaC0ydi0yaDJ2MnptMC00aC0ydi00aDJ2NHoiLz48L3N2Zz4=");
- }
- a {
- color: inherit;
- text-decoration: underline;
- }
-}
-
/**
* Card-style blocks
*/
border-radius: 4px;
}
+.cm-editor .cm-line, .cm-editor .cm-gutter {
+ font-family: var(--font-code);
+}
+
// Manual dark-mode definition so that it applies to code blocks within the shadow
// dom which are used within the WYSIWYG editor, as the .dark-mode on the parent
// <html> node are not applies so instead we have the class on the parent element.
fill: currentColor;
}
&.success {
- background: $positive;
+ background: var(--color-positive);
color: #FFF;
}
&:focus {
* Status text colors
*/
.text-pos, .text-pos:hover, .text-pos-hover:hover {
- color: $positive !important;
- fill: $positive !important;
+ color: var(--color-positive) !important;
+ fill: var(--color-positive) !important;
}
.text-warn, .text-warn:hover, .text-warn-hover:hover {
- color: $warning !important;
- fill: $warning !important;
+ color: var(--color-warning) !important;
+ fill: var(--color-warning) !important;
}
.text-neg, .text-neg:hover, .text-neg-hover:hover {
- color: $negative !important;
- fill: $negative !important;
+ color: var(--color-negative) !important;
+ fill: var(--color-negative) !important;
}
/*
}
}
&.pos {
- color: $positive;
+ color: var(--color-positive);
}
&.neg {
- color: $negative;
+ color: var(--color-negative);
}
&.warning {
- color: $warning;
+ color: var(--color-warning);
}
&.showing {
transform: translateX(0);
line-height: 1.2;
}
.dropzone-file-item-status[data-status="success"] {
- color: $positive;
+ color: var(--color-positive);
}
.dropzone-file-item-status[data-status="error"] {
- color: $negative;
+ color: var(--color-negative);
}
.dropzone-file-item-status[data-status] + .dropzone-file-item-label {
display: none;
cursor: pointer;
width: 100%;
text-align: left;
- font-family: $mono;
+ font-family: var(--font-code);
font-size: 0.7rem;
padding-left: 24px + $-xs;
&:hover, &.active {
}
}
+.comments-container {
+ padding-inline: $-xl;
+ @include smaller-than($m) {
+ padding-inline: $-xs;
+ }
+}
.comment-box {
border-radius: 4px;
border: 1px solid #DDD;
&:hover .actions, &:focus-within .actions {
opacity: 1;
}
+ .actions button:focus {
+ outline: 1px dotted var(--color-primary);
+ }
+ @include smaller-than($m) {
+ .actions {
+ opacity: 1;
+ }
+ }
}
.comment-box .header {
- .meta {
- img, a, span {
- display: inline-block;
- vertical-align: top;
- }
- a, span {
- padding: $-xxs 0 $-xxs 0;
- line-height: 1.6;
- }
- a { color: #666; }
- span {
- padding-inline-start: $-xxs;
- }
+ border-bottom: 1px solid #DDD;
+ @include lightDark(border-color, #DDD, #000);
+ button {
+ font-size: .8rem;
+ }
+ a {
+ color: inherit;
}
.text-muted {
color: #999;
}
+ .right-meta .text-muted {
+ opacity: .8;
+ }
+}
+
+.comment-thread-indicator {
+ border-inline-start: 3px dotted #DDD;
+ @include lightDark(border-color, #DDD, #444);
+ margin-inline-start: $-xs;
+ width: $-l;
+ height: calc(100% - $-m);
+}
+
+.comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator {
+ display: none;
+}
+
+.comment-reply {
+ display: none;
+}
+
+.comment-branch .comment-branch .comment-branch .comment-branch .comment-reply {
+ display: block;
}
#tag-manager .drag-card {
display: inline-block;
}
.status-indicator-active {
- background-color: $positive;
+ background-color: var(--color-positive);
}
.status-indicator-inactive {
- background-color: $negative;
+ background-color: var(--color-negative);
}
.shortcut-container {
--- /dev/null
+/**
+ * Page Content
+ * Styles specific to blocks used within page content.
+ */
+
+.page-content {
+ width: 100%;
+ max-width: 840px;
+ margin: 0 auto;
+ overflow-wrap: break-word;
+ .align-left {
+ text-align: left;
+ }
+ img.align-left, table.align-left {
+ float: left !important;
+ margin: $-xs $-m $-m 0;
+ }
+ .align-right {
+ text-align: right !important;
+ }
+ img.align-right, table.align-right {
+ float: right !important;
+ margin: $-xs 0 $-xs $-s;
+ }
+ .align-center {
+ text-align: center;
+ }
+ img.align-center {
+ display: block;
+ }
+ img.align-center, table.align-center {
+ margin-left: auto;
+ margin-right: auto;
+ }
+ img {
+ max-width: 100%;
+ height:auto;
+ }
+ h1, h2, h3, h4, h5, h6, pre {
+ clear: left;
+ }
+ hr {
+ clear: both;
+ margin: $-m 0;
+ }
+ table {
+ hyphens: auto;
+ table-layout: fixed;
+ max-width: 100%;
+ height: auto !important;
+ }
+
+ // diffs
+ ins,
+ del {
+ text-decoration: none;
+ }
+ ins {
+ background: #dbffdb;
+ }
+ del {
+ background: #FFECEC;
+ }
+
+ details {
+ border: 1px solid;
+ @include lightDark(border-color, #DDD, #555);
+ margin-bottom: 1em;
+ padding: $-s;
+ }
+ details > summary {
+ margin-top: -$-s;
+ margin-left: -$-s;
+ margin-right: -$-s;
+ margin-bottom: -$-s;
+ font-weight: bold;
+ @include lightDark(background-color, #EEE, #333);
+ padding: $-xs $-s;
+ }
+ details[open] > summary {
+ margin-bottom: $-s;
+ border-bottom: 1px solid;
+ @include lightDark(border-color, #DDD, #555);
+ }
+ details > summary + * {
+ margin-top: .2em;
+ }
+ details:after {
+ content: '';
+ display: block;
+ clear: both;
+ }
+
+ li > input[type="checkbox"] {
+ vertical-align: top;
+ margin-top: 0.3em;
+ }
+
+ p:empty {
+ min-height: 1.6em;
+ }
+
+ &.page-revision {
+ pre code {
+ white-space: pre-wrap;
+ }
+ }
+
+ .cm-editor {
+ margin-bottom: 1.375em;
+ }
+
+ video {
+ max-width: 100%;
+ }
+}
+
+/**
+ * Callouts
+ */
+.callout {
+ border-left: 3px solid #BBB;
+ background-color: #EEE;
+ padding: $-s $-s $-s $-xl;
+ display: block;
+ position: relative;
+ overflow: auto;
+ &:before {
+ background-image: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMwMTUzODAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bTEgMTVoLTJ2LTZoMnY2em0wLThoLTJWN2gydjJ6Ii8+PC9zdmc+');
+ background-repeat: no-repeat;
+ content: '';
+ width: 1.2em;
+ height: 1.2em;
+ left: $-xs + 2px;
+ top: 50%;
+ margin-top: -9px;
+ display: inline-block;
+ position: absolute;
+ line-height: 1;
+ opacity: 0.8;
+ }
+ &.success {
+ @include lightDark(border-left-color, $positive, $positive-dark);
+ @include lightDark(background-color, lighten($positive, 68%), darken($positive-dark, 36%));
+ @include lightDark(color, darken($positive, 16%), $positive-dark);
+ }
+ &.success:before {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMzNzZjMzkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0yIDE1bC01LTUgMS40MS0xLjQxTDEwIDE0LjE3bDcuNTktNy41OUwxOSA4bC05IDl6Ii8+PC9zdmc+");
+ }
+ &.danger {
+ @include lightDark(border-left-color, $negative, $negative-dark);
+ @include lightDark(background-color, lighten($negative, 56%), darken($negative-dark, 55%));
+ @include lightDark(color, darken($negative, 20%), $negative-dark);
+ }
+ &.danger:before {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiOTE4MTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0xNS43MyAzSDguMjdMMyA4LjI3djcuNDZMOC4yNyAyMWg3LjQ2TDIxIDE1LjczVjguMjdMMTUuNzMgM3pNMTIgMTcuM2MtLjcyIDAtMS4zLS41OC0xLjMtMS4zIDAtLjcyLjU4LTEuMyAxLjMtMS4zLjcyIDAgMS4zLjU4IDEuMyAxLjMgMCAuNzItLjU4IDEuMy0xLjMgMS4zem0xLTQuM2gtMlY3aDJ2NnoiLz4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg==");
+ }
+ &.info {
+ @include lightDark(border-left-color, $info, $info-dark);
+ @include lightDark(color, darken($info, 20%), $info-dark);
+ @include lightDark(background-color, lighten($info, 50%), darken($info-dark, 34%));
+ }
+ &.warning {
+ @include lightDark(border-left-color, $warning, $warning-dark);
+ @include lightDark(background-color, lighten($warning, 50%), darken($warning-dark, 50%));
+ @include lightDark(color, darken($warning, 20%), $warning-dark);
+ }
+ &.warning:before {
+ background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiNjUzMWMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEgMjFoMjJMMTIgMiAxIDIxem0xMi0zaC0ydi0yaDJ2MnptMC00aC0ydi00aDJ2NHoiLz48L3N2Zz4=");
+ }
+ a {
+ color: inherit;
+ text-decoration: underline;
+ }
+}
\ No newline at end of file
max-width: 100%;
&.neg, &.invalid {
- border: 1px solid $negative;
+ border: 1px solid var(--color-negative);
}
&.pos, &.valid {
- border: 1px solid $positive;
+ border: 1px solid var(--color-positive);
}
&.disabled, &[disabled] {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
padding: 0 !important;
}
-.page-content {
- width: 100%;
- max-width: 840px;
- margin: 0 auto;
- overflow-wrap: break-word;
- .align-left {
- text-align: left;
- }
- img.align-left, table.align-left {
- float: left !important;
- margin: $-xs $-m $-m 0;
- }
- .align-right {
- text-align: right !important;
- }
- img.align-right, table.align-right {
- float: right !important;
- margin: $-xs 0 $-xs $-s;
- }
- .align-center {
- text-align: center;
- }
- img.align-center {
- display: block;
- }
- img.align-center, table.align-center {
- margin-left: auto;
- margin-right: auto;
- }
- img {
- max-width: 100%;
- height:auto;
- }
- h1, h2, h3, h4, h5, h6, pre {
- clear: left;
- }
- hr {
- clear: both;
- margin: $-m 0;
- }
- table {
- hyphens: auto;
- table-layout: fixed;
- max-width: 100%;
- height: auto !important;
- }
-
- // diffs
- ins,
- del {
- text-decoration: none;
- }
- ins {
- background: #dbffdb;
- }
- del {
- background: #FFECEC;
- }
-
- details {
- border: 1px solid;
- @include lightDark(border-color, #DDD, #555);
- margin-bottom: 1em;
- padding: $-s;
- }
- details > summary {
- margin-top: -$-s;
- margin-left: -$-s;
- margin-right: -$-s;
- margin-bottom: -$-s;
- font-weight: bold;
- @include lightDark(background-color, #EEE, #333);
- padding: $-xs $-s;
- }
- details[open] > summary {
- margin-bottom: $-s;
- border-bottom: 1px solid;
- @include lightDark(border-color, #DDD, #555);
- }
- details > summary + * {
- margin-top: .2em;
- }
- details:after {
- content: '';
- display: block;
- clear: both;
- }
-
- li > input[type="checkbox"] {
- vertical-align: top;
- margin-top: 0.3em;
- }
-
- p:empty {
- min-height: 1.6em;
- }
-
- &.page-revision {
- pre code {
- white-space: pre-wrap;
- }
- }
-
- .cm-editor {
- margin-bottom: 1.375em;
- }
-
- video {
- max-width: 100%;
- }
-}
-
// Page content pointers
.pointer-container {
position: fixed;
*/
body, button, input, select, label, textarea {
- font-family: $text;
+ font-family: var(--font-body);
}
-.Codemirror, pre, #markdown-editor-input, .text-mono, .code-base {
- font-family: $mono;
+pre, #markdown-editor-input, .text-mono, .code-base {
+ font-family: var(--font-code);
}
/*
font-weight: 400;
position: relative;
display: block;
+ font-family: var(--font-heading);
@include lightDark(color, #222, #BBB);
.subheader {
font-size: 0.5em;
blockquote {
display: block;
position: relative;
- border-left: 4px solid var(--color-primary);
+ border-left: 4px solid transparent;
+ border-left-color: var(--color-primary);
@include lightDark(background-color, #f8f8f8, #333);
padding: $-s $-m $-s $-xl;
overflow: auto;
}
.text-mono {
- font-family: $mono;
+ font-family: var(--font-code);
}
.text-uppercase {
border-left: 3px solid currentColor !important;
}
.tox-menu .tox-collection__item[title^="<"] > div > div {
- font-family: $mono !important;
+ font-family: var(--font-code) !important;
border: 1px solid #DDD !important;
background-color: #EEE !important;
padding: 4px 6px !important;
$spacing: (('none', 0), ('xxs', $-xxs), ('xs', $-xs), ('s', $-s), ('m', $-m), ('l', $-l), ('xl', $-xl), ('xxl', $-xxl), ('auto', auto));
// Fonts
-$text: -apple-system, BlinkMacSystemFont,
+$font-body: -apple-system, BlinkMacSystemFont,
"Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell",
"Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-$mono: "Lucida Console", "DejaVu Sans Mono", "Ubuntu Mono", Monaco, monospace;
+$font-mono: "Lucida Console", "DejaVu Sans Mono", "Ubuntu Mono", Monaco, monospace;
$fs-m: 14px;
$fs-s: 12px;
// Colours
+$positive: #0f7d15;
+$negative: #ab0f0e;
+$info: #0288D1;
+$warning: #cf4d03;
+$positive-dark: #4aa850;
+$negative-dark: #e85c5b;
+$info-dark: #0288D1;
+$warning-dark: #de8a5a;
+
+// Text colours
+$text-dark: #444;
+
+// Shadows
+$bs-light: 0 0 4px 1px #CCC;
+$bs-dark: 0 0 4px 1px rgba(0, 0, 0, 0.5);
+$bs-med: 0 1px 3px 1px rgba(76, 76, 76, 0.26);
+$bs-large: 0 1px 6px 1px rgba(22, 22, 22, 0.2);
+$bs-card: 0 1px 6px -1px rgba(0, 0, 0, 0.1);
+$bs-card-dark: 0 1px 6px -1px rgba(0, 0, 0, 0.5);
+$bs-hover: 0 2px 2px 1px rgba(0,0,0,.13);
+
+// CSS root variables
:root {
+ --font-body: #{$font-body};
+ --font-heading: #{$font-body};
+ --font-code: #{$font-mono};
+
+
--color-primary: #206ea7;
--color-primary-light: rgba(32,110,167,0.15);
--color-link: #206ea7;
--color-book: #077b70;
--color-bookshelf: #a94747;
+ --color-positive: #{$positive};
+ --color-negative: #{$negative};
+ --color-info: #{$info};
+ --color-warning: #{$warning};
+
--bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(0, 0, 0,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E");
}
:root.dark-mode {
--bg-disabled: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='100%25' width='100%25'%3E%3Cdefs%3E%3Cpattern id='doodad' width='19' height='19' viewBox='0 0 40 40' patternUnits='userSpaceOnUse' patternTransform='rotate(143)'%3E%3Crect width='100%25' height='100%25' fill='rgba(42, 67, 101,0)'/%3E%3Cpath d='M-10 30h60v20h-60zM-10-10h60v20h-60' fill='rgba(26, 32, 44,0)'/%3E%3Cpath d='M-10 10h60v20h-60zM-10-30h60v20h-60z' fill='rgba(255, 255, 255,0.05)'/%3E%3C/pattern%3E%3C/defs%3E%3Crect fill='url(%23doodad)' height='200%25' width='200%25'/%3E%3C/svg%3E");
color-scheme: only dark;
+
+ --color-positive: #4aa850;
+ --color-negative: #e85c5b;
+ --color-warning: #de8a5a;
}
:root:not(.dark-mode) {
color-scheme: only light;
-}
-
-$positive: #0f7d15;
-$negative: #ab0f0e;
-$info: #0288D1;
-$warning: #cf4d03;
-
-// Text colours
-$text-dark: #444;
-
-// Shadows
-$bs-light: 0 0 4px 1px #CCC;
-$bs-dark: 0 0 4px 1px rgba(0, 0, 0, 0.5);
-$bs-med: 0 1px 3px 1px rgba(76, 76, 76, 0.26);
-$bs-large: 0 1px 6px 1px rgba(22, 22, 22, 0.2);
-$bs-card: 0 1px 6px -1px rgba(0, 0, 0, 0.1);
-$bs-card-dark: 0 1px 6px -1px rgba(0, 0, 0, 0.5);
-$bs-hover: 0 2px 2px 1px rgba(0,0,0,.13);
+}
\ No newline at end of file
@import "mixins";
@import "html";
@import "text";
-@import "layout";
-@import "blocks";
@import "tables";
-@import "lists";
-@import "pages";
+@import "content";
html, body {
background-color: #FFF;
@import "footer";
@import "lists";
@import "pages";
+@import "content";
// Jquery Sortable Styles
.dragged {
--- /dev/null
+<div class="comment-branch">
+ <div class="mb-m">
+ @include('comments.comment', ['comment' => $branch['comment']])
+ </div>
+ <div class="flex-container-row">
+ <div class="comment-thread-indicator-parent">
+ <div class="comment-thread-indicator"></div>
+ </div>
+ <div class="comment-branch-children flex">
+ @foreach($branch['children'] as $childBranch)
+ @include('comments.comment-branch', ['branch' => $childBranch])
+ @endforeach
+ </div>
+ </div>
+</div>
\ No newline at end of file
-<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 component="page-comment"
+ option:page-comment:comment-id="{{ $comment->id }}"
+ option:page-comment:comment-local-id="{{ $comment->local_id }}"
+ option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
+ option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
+ option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+ id="comment{{$comment->local_id}}"
+ class="comment-box">
<div class="header p-s">
- <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>
-
+ <div class="flex-container-row justify-space-between wrap">
+ <div class="meta text-muted flex-container-row items-center">
@if ($comment->createdBy)
- <img width="50" src="{{ $comment->createdBy->getAvatar(50) }}" class="avatar" alt="{{ $comment->createdBy->name }}">
+ <img width="50" src="{{ $comment->createdBy->getAvatar(50) }}" class="avatar mx-xs" alt="{{ $comment->createdBy->name }}">
- <a href="{{ $comment->createdBy->getProfileUrl() }}">{{ $comment->createdBy->name }}</a>
+ <a href="{{ $comment->createdBy->getProfileUrl() }}">{{ $comment->createdBy->getShortName(16) }}</a>
@else
- <span>{{ trans('common.deleted_user') }}</span>
+ {{ trans('common.deleted_user') }}
@endif
- <span title="{{ $comment->created_at }}">{{ trans('entities.comment_created', ['createDiff' => $comment->created]) }}</span>
+ <span title="{{ $comment->created_at }}"> {{ trans('entities.comment_created', ['createDiff' => $comment->created]) }}</span>
@if($comment->isUpdated())
- <span title="{{ $comment->updated_at }}">
- •
- {{ trans('entities.comment_updated', ['updateDiff' => $comment->updated, 'username' => $comment->updatedBy? $comment->updatedBy->name : trans('common.deleted_user')]) }}
- </span>
+ <span class="mx-xs">•</span>
+ <span title="{{ trans('entities.comment_updated', ['updateDiff' => $comment->updated_at, 'username' => $comment->updatedBy->name ?? trans('common.deleted_user')]) }}">
+ {{ trans('entities.comment_updated_indicator') }}
+ </span>
@endif
</div>
- <div class="actions text-right">
- @if(userCan('comment-update', $comment))
- <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" aria-label="{{ trans('common.reply') }}" title="{{ trans('common.reply') }}">@icon('reply')</button>
- @endif
- @if(userCan('comment-delete', $comment))
- <div component="dropdown" class="dropdown-container">
- <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" class="text-button" title="{{ trans('common.delete') }}">@icon('delete')</button>
- <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
- <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
- <li>
- <button action="delete" type="button" class="text-button text-neg icon-item">
- @icon('delete')
- <div>{{ trans('common.delete') }}</div>
- </button>
- </li>
- </ul>
- </div>
- @endif
+ <div class="right-meta flex-container-row justify-flex-end items-center px-s">
+ <div class="actions mr-s">
+ @if(userCan('comment-create-all'))
+ <button refs="page-comment@reply-button" type="button" class="text-button text-muted hover-underline p-xs">@icon('reply') {{ trans('common.reply') }}</button>
+ @endif
+ @if(userCan('comment-update', $comment))
+ <button refs="page-comment@edit-button" type="button" class="text-button text-muted hover-underline p-xs">@icon('edit') {{ trans('common.edit') }}</button>
+ @endif
+ @if(userCan('comment-delete', $comment))
+ <div component="dropdown" class="dropdown-container">
+ <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" class="text-button text-muted hover-underline p-xs">@icon('delete') {{ trans('common.delete') }}</button>
+ <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+ <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
+ <li>
+ <button refs="page-comment@delete-button" type="button" class="text-button text-neg icon-item">
+ @icon('delete')
+ <div>{{ trans('common.delete') }}</div>
+ </button>
+ </li>
+ </ul>
+ </div>
+ @endif
+ <span class="text-muted">
+ •
+ </span>
+ </div>
+ <div>
+ <a class="bold text-muted" href="#comment{{$comment->local_id}}">#{{$comment->local_id}}</a>
+ </div>
</div>
</div>
</div>
- @if ($comment->parent_id)
- <div class="reply-row primary-background-light text-muted px-s py-xs mb-s">
- {!! trans('entities.comment_in_reply_to', ['commentId' => '<a href="#comment'.$comment->parent_id.'">#'.$comment->parent_id.'</a>']) !!}
- </div>
- @endif
-
- <div comment-content class="content px-s pb-s">
- <div class="form-group loading" style="display: none;">
- @include('common.loading-icon', ['text' => trans('entities.comment_deleting')])
- </div>
+ <div refs="page-comment@content-container" class="content px-m py-s">
+ @if ($comment->parent_id)
+ <p class="comment-reply mb-xxs">
+ <a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
+ </p>
+ @endif
{!! $comment->html !!}
</div>
@if(userCan('comment-update', $comment))
- <div comment-edit-container style="display: none;" class="content px-s">
- <form novalidate>
- <div class="form-group description-input">
- <textarea name="markdown" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $comment->text }}</textarea>
- </div>
- <div class="form-group text-right">
- <button type="button" class="button outline" action="closeUpdateForm">{{ trans('common.cancel') }}</button>
- <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
- </div>
- <div class="form-group loading" style="display: none;">
- @include('common.loading-icon', ['text' => trans('entities.comment_saving')])
- </div>
- </form>
- </div>
+ <form novalidate refs="page-comment@form" hidden class="content pt-s px-s block">
+ <div class="form-group description-input">
+ <textarea refs="page-comment@input" name="markdown" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $comment->text }}</textarea>
+ </div>
+ <div class="form-group text-right">
+ <button type="button" class="button outline" refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
+ <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
+ </div>
+ </form>
@endif
</div>
\ No newline at end of file
<section component="page-comments"
option:page-comments:page-id="{{ $page->id }}"
- option:page-comments:updated-text="{{ trans('entities.comment_updated_success') }}"
- option:page-comments:deleted-text="{{ trans('entities.comment_deleted_success') }}"
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
option:page-comments:count-text="{{ trans('entities.comment_count') }}"
class="comments-list"
aria-label="{{ trans('entities.comments') }}">
- <div refs="page-comments@commentCountBar" 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 && userCan('comment-create-all'))
- <div class="text-m-right" refs="page-comments@addButtonContainer">
- <button type="button" action="addComment"
+ <div refs="page-comments@comment-count-bar" class="grid half left-focus v-center no-row-gap">
+ <h5 refs="page-comments@comments-title">{{ trans_choice('entities.comment_count', $commentTree->count(), ['count' => $commentTree->count()]) }}</h5>
+ @if ($commentTree->empty() && userCan('comment-create-all'))
+ <div class="text-m-right" refs="page-comments@add-button-container">
+ <button type="button"
+ refs="page-comments@add-comment-button"
class="button outline">{{ trans('entities.comment_add') }}</button>
</div>
@endif
</div>
<div refs="page-comments@commentContainer" class="comment-container">
- @foreach($page->comments as $comment)
- @include('comments.comment', ['comment' => $comment])
+ @foreach($commentTree->get() as $branch)
+ @include('comments.comment-branch', ['branch' => $branch])
@endforeach
</div>
@if(userCan('comment-create-all'))
@include('comments.create')
- @if (count($page->comments) > 0)
+ @if (!$commentTree->empty())
<div refs="page-comments@addButtonContainer" class="text-right">
- <button type="button" action="addComment"
+ <button type="button"
+ refs="page-comments@add-comment-button"
class="button outline">{{ trans('entities.comment_add') }}</button>
</div>
@endif
-<div class="comment-box" style="display:none;">
+<div refs="page-comments@form-container" hidden class="comment-branch mb-m">
+ <div class="comment-box">
- <div class="header p-s">{{ trans('entities.comment_new') }}</div>
- <div refs="page-comments@replyToRow" class="reply-row primary-background-light text-muted px-s py-xs mb-s" style="display: none;">
- <div class="grid left-focus v-center">
- <div>
- {!! trans('entities.comment_in_reply_to', ['commentId' => '<a href=""></a>']) !!}
- </div>
- <div class="text-right">
- <button class="text-button" action="remove-reply-to">{{ trans('common.remove') }}</button>
+ <div class="header p-s">{{ trans('entities.comment_new') }}</div>
+ <div refs="page-comments@reply-to-row" hidden class="primary-background-light text-muted px-s py-xs">
+ <div class="grid left-focus v-center">
+ <div>
+ <a refs="page-comments@form-reply-link" href="#">{{ trans('entities.comment_in_reply_to', ['commentId' => '1234']) }}</a>
+ </div>
+ <div class="text-right">
+ <button refs="page-comments@remove-reply-to-button" class="text-button">{{ trans('common.remove') }}</button>
+ </div>
</div>
</div>
- </div>
- <div refs="page-comments@formContainer" class="content px-s">
- <form novalidate>
- <div class="form-group description-input">
- <textarea name="markdown" rows="3"
- placeholder="{{ trans('entities.comment_placeholder') }}"></textarea>
- </div>
- <div class="form-group text-right">
- <button type="button" class="button outline"
- action="hideForm">{{ trans('common.cancel') }}</button>
- <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
- </div>
- <div class="form-group loading" style="display: none;">
- @include('common.loading-icon', ['text' => trans('entities.comment_saving')])
- </div>
- </form>
- </div>
+ <div class="content px-s pt-s">
+ <form refs="page-comments@form" novalidate>
+ <div class="form-group description-input">
+ <textarea refs="page-comments@form-input" name="markdown"
+ rows="3"
+ placeholder="{{ trans('entities.comment_placeholder') }}"></textarea>
+ </div>
+ <div class="form-group text-right">
+ <button type="button" class="button outline"
+ refs="page-comments@hide-form-button">{{ trans('common.cancel') }}</button>
+ <button type="submit" class="button">{{ trans('entities.comment_save') }}</button>
+ </div>
+ </form>
+ </div>
+ </div>
</div>
\ No newline at end of file
</a>
</li>
@endif
- <li refs="page-editor@discardDraftWrap" class="{{ $isDraftRevision ? '' : 'hidden' }}">
- <button refs="page-editor@discardDraft" type="button" class="text-neg icon-item">
+ <li refs="page-editor@discard-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
+ <button refs="page-editor@discard-draft" type="button" class="text-warn icon-item">
@icon('cancel')
<div>{{ trans('entities.pages_edit_discard_draft') }}</div>
</button>
</li>
+ <li refs="page-editor@delete-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
+ <button refs="page-editor@delete-draft" type="button" class="text-neg icon-item">
+ @icon('delete')
+ <div>{{ trans('entities.pages_edit_delete_draft') }}</div>
+ </button>
+ </li>
@if(userCan('editor-change'))
+ <li>
+ <hr>
+ </li>
<li>
@if($editor === 'wysiwyg')
<a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item">
option:page-editor:autosave-fail-text="{{ trans('errors.page_draft_autosave_fail') }}"
option:page-editor:editing-page-text="{{ trans('entities.pages_editing_page') }}"
option:page-editor:draft-discarded-text="{{ trans('entities.pages_draft_discarded') }}"
+ option:page-editor:draft-delete-text="{{ trans('entities.pages_draft_deleted') }}"
+ option:page-editor:draft-delete-fail-text="{{ trans('errors.page_draft_delete_fail') }}"
option:page-editor:set-changelog-text="{{ trans('entities.pages_edit_set_changelog') }}">
{{--Header Toolbar--}}
class="text-link text-button hide-over-m page-save-mobile-button">@icon('save')</button>
{{--Editor Change Dialog--}}
- @component('common.confirm-dialog', ['title' => trans('entities.pages_editor_switch_title'), 'ref' => 'page-editor@switchDialog'])
+ @component('common.confirm-dialog', ['title' => trans('entities.pages_editor_switch_title'), 'ref' => 'page-editor@switch-dialog'])
<p>
{{ trans('entities.pages_editor_switch_are_you_sure') }}
<br>
<li>{{ trans('entities.pages_editor_switch_consideration_c') }}</li>
</ul>
@endcomponent
+
+ {{--Delete Draft Dialog--}}
+ @component('common.confirm-dialog', ['title' => trans('entities.pages_edit_delete_draft'), 'ref' => 'page-editor@delete-draft-dialog'])
+ <p>
+ {{ trans('entities.pages_edit_delete_draft_confirm') }}
+ </p>
+ @endcomponent
</div>
\ No newline at end of file
@include('entities.sibling-navigation', ['next' => $next, 'previous' => $previous])
- @if ($commentsEnabled)
+ @if ($commentTree->enabled())
@if(($previous || $next))
<div class="px-xl">
<hr class="darker">
</div>
@endif
- <div class="px-xl comments-container mb-l print-hidden">
- @include('comments.comments', ['page' => $page])
+ <div class="comments-container mb-l print-hidden">
+ @include('comments.comments', ['commentTree' => $commentTree, 'page' => $page])
<div class="clearfix"></div>
</div>
@endif
Route::get('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', [EntityControllers\PageRevisionController::class, 'changes']);
Route::put('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', [EntityControllers\PageRevisionController::class, 'restore']);
Route::delete('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', [EntityControllers\PageRevisionController::class, 'destroy']);
+ Route::delete('/page-revisions/user-drafts/{pageId}', [EntityControllers\PageRevisionController::class, 'destroyUserDraft']);
// Chapters
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/create-page', [EntityControllers\PageController::class, 'create']);
'html' => "<a href=\"{$image->url}\" target=\"_blank\"><img src=\"{$displayUrl}\" alt=\"{$image->name}\"></a>",
'markdown' => "",
],
+ 'created_at' => $image->created_at->toISOString(),
+ 'updated_at' => $image->updated_at->toISOString(),
]);
$this->assertStringStartsWith('http://', $resp->json('thumbs.gallery'));
$this->assertStringStartsWith('http://', $resp->json('thumbs.display'));
$pageView->assertDontSee($script, false);
$pageView->assertSee('sometextinthecommentupdated');
}
+
+ public function test_reply_comments_are_nested()
+ {
+ $this->asAdmin();
+ $page = $this->entities->page();
+
+ $this->postJson("/comment/$page->id", ['text' => 'My new comment']);
+ $this->postJson("/comment/$page->id", ['text' => 'My new comment']);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl()));
+ $respHtml->assertElementCount('.comment-branch', 3);
+ $respHtml->assertElementNotExists('.comment-branch .comment-branch');
+
+ $comment = $page->comments()->first();
+ $resp = $this->postJson("/comment/$page->id", ['text' => 'My nested comment', 'parent_id' => $comment->local_id]);
+ $resp->assertStatus(200);
+
+ $respHtml = $this->withHtml($this->get($page->getUrl()));
+ $respHtml->assertElementCount('.comment-branch', 4);
+ $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment');
+ }
}
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
use Tests\TestCase;
class EntitySearchTest extends TestCase
$chapterSearch->assertSee($chapter->book->getShortName(42));
}
+ public function test_entity_selector_shows_breadcrumbs_on_default_view()
+ {
+ $page = $this->entities->pageWithinChapter();
+ $this->asEditor()->get($page->chapter->getUrl());
+
+ $resp = $this->asEditor()->get('/search/entity-selector?types=book,chapter&permission=page-create');
+ $html = $this->withHtml($resp);
+ $html->assertElementContains('.chapter.entity-list-item', $page->chapter->name);
+ $html->assertElementContains('.chapter.entity-list-item .entity-item-snippet', $page->book->getShortName(42));
+ }
+
public function test_entity_selector_search_reflects_items_without_permission()
{
$page = $this->entities->page();
]);
}
+ public function test_user_draft_removed_on_user_drafts_delete_call()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [
+ 'name' => $page->name,
+ 'html' => '<p>updated draft again</p>',
+ ]);
+
+ $revisionData = [
+ 'type' => 'update_draft',
+ 'created_by' => $editor->id,
+ 'page_id' => $page->id,
+ ];
+
+ $this->assertDatabaseHas('page_revisions', $revisionData);
+
+ $resp = $this->delete("/page-revisions/user-drafts/{$page->id}");
+
+ $resp->assertOk();
+ $this->assertDatabaseMissing('page_revisions', $revisionData);
+ }
+
public function test_updating_page_draft_with_markdown_retains_markdown_content()
{
$book = $this->entities->book();
namespace Tests;
+use BookStack\Activity\ActivityType;
+
class LanguageTest extends TestCase
{
protected array $langs;
$loginReq->assertOk();
$loginReq->assertSee('Log In');
}
+
+ public function test_all_activity_types_have_activity_text()
+ {
+ foreach (ActivityType::all() as $activityType) {
+ $langKey = 'activities.' . $activityType;
+ $this->assertNotEquals($langKey, trans($langKey, [], 'en'));
+ }
+ }
}
$resp = $this->post('/settings/features', []);
$resp->assertRedirect('/settings/features');
$resp = $this->get('/settings/features');
- $resp->assertSee('Settings saved');
+ $resp->assertSee('Settings successfully updated');
}
public function test_restrictions_manage_all_permission()
$resp->assertRedirect($book->getUrl());
$this->followRedirects($resp)->assertSee($book->name);
}
+
+ public function test_public_view_can_take_on_other_roles()
+ {
+ $this->setSettings(['app-public' => 'true']);
+ $newRole = $this->users->attachNewRole(User::getDefault(), []);
+ $page = $this->entities->page();
+ $this->permissions->disableEntityInheritedPermissions($page);
+ $this->permissions->addEntityPermission($page, ['view', 'update'], $newRole);
+
+ $resp = $this->get($page->getUrl());
+ $resp->assertOk();
+
+ $this->withHtml($resp)->assertLinkExists($page->getUrl('/edit'));
+ }
}