Merge branch 'development' of github.com:BookStackApp/BookStack into development

This commit is contained in:
Dan Brown 2022-05-30 17:01:46 +01:00
commit 43cbab2822
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
40 changed files with 725 additions and 336 deletions

View File

@ -28,10 +28,8 @@ class GroupSyncService
*/ */
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
{ {
$externalAuthIds = explode(',', strtolower($externalId)); foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
if (in_array($externalAuthId, $groupNames)) {
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true; return true;
} }
} }
@ -39,6 +37,18 @@ class GroupSyncService
return false; return false;
} }
protected function parseRoleExternalAuthId(string $externalId): array
{
$inputIds = preg_split('/(?<!\\\),/', $externalId);
$cleanIds = [];
foreach ($inputIds as $inputId) {
$cleanIds[] = str_replace('\,', ',', trim($inputId));
}
return $cleanIds;
}
/** /**
* Match an array of group names to BookStack system roles. * Match an array of group names to BookStack system roles.
* Formats group names to be lower-case and hyphenated. * Formats group names to be lower-case and hyphenated.

View File

@ -23,6 +23,7 @@ class RoleFactory extends Factory
return [ return [
'display_name' => $this->faker->sentence(3), 'display_name' => $this->faker->sentence(3),
'description' => $this->faker->sentence(10), 'description' => $this->faker->sentence(10),
'external_auth_id' => '',
]; ];
} }
} }

View File

@ -1,8 +1,8 @@
{ {
"private": true, "private": true,
"scripts": { "scripts": {
"build:css:dev": "sass ./resources/sass:./public/dist", "build:css:dev": "sass ./resources/sass:./public/dist --embed-sources",
"build:css:watch": "sass ./resources/sass:./public/dist --watch", "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources",
"build:css:production": "sass ./resources/sass:./public/dist -s compressed", "build:css:production": "sass ./resources/sass:./public/dist -s compressed",
"build:js:dev": "node dev/build/esbuild.js", "build:js:dev": "node dev/build/esbuild.js",
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"", "build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M16.59 9H15V4c0-.55-.45-1-1-1h-4c-.55 0-1 .45-1 1v5H7.41c-.89 0-1.34 1.08-.71 1.71l4.59 4.59c.39.39 1.02.39 1.41 0l4.59-4.59c.63-.63.19-1.71-.7-1.71zM5 19c0 .55.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1z"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -242,6 +242,21 @@ export function popupEditor(elem, modeSuggestion) {
}); });
} }
/**
* Create an inline editor to replace the given textarea.
* @param {HTMLTextAreaElement} textArea
* @param {String} mode
* @returns {CodeMirror3}
*/
export function inlineEditor(textArea, mode) {
return CodeMirror.fromTextArea(textArea, {
mode: getMode(mode, textArea.value),
lineNumbers: true,
lineWrapping: false,
theme: getTheme(),
});
}
/** /**
* Set the mode of a codemirror instance. * Set the mode of a codemirror instance.
* @param cmInstance * @param cmInstance

View File

@ -0,0 +1,37 @@
import {slideUp, slideDown} from "../services/animations";
/**
* @extends {Component}
*/
class ChapterContents {
setup() {
this.list = this.$refs.list;
this.toggle = this.$refs.toggle;
this.isOpen = this.toggle.classList.contains('open');
this.toggle.addEventListener('click', this.click.bind(this));
}
open() {
this.toggle.classList.add('open');
this.toggle.setAttribute('aria-expanded', 'true');
slideDown(this.list, 180);
this.isOpen = true;
}
close() {
this.toggle.classList.remove('open');
this.toggle.setAttribute('aria-expanded', 'false');
slideUp(this.list, 180);
this.isOpen = false;
}
click(event) {
event.preventDefault();
this.isOpen ? this.close() : this.open();
}
}
export default ChapterContents;

View File

@ -1,33 +0,0 @@
import {slideUp, slideDown} from "../services/animations";
class ChapterToggle {
constructor(elem) {
this.elem = elem;
this.isOpen = elem.classList.contains('open');
elem.addEventListener('click', this.click.bind(this));
}
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);
}
click(event) {
event.preventDefault();
this.isOpen ? this.close() : this.open();
this.isOpen = !this.isOpen;
}
}
export default ChapterToggle;

View File

@ -0,0 +1,16 @@
/**
* A simple component to render a code editor within the textarea
* this exists upon.
* @extends {Component}
*/
class CodeTextarea {
async setup() {
const mode = this.$opts.mode;
const Code = await window.importVersioned('code');
Code.inlineEditor(this.$el, mode);
}
}
export default CodeTextarea;

View File

@ -1,4 +1,5 @@
import {debounce} from "../services/util"; import {debounce} from "../services/util";
import {transitionHeight} from "../services/animations";
class DropdownSearch { class DropdownSearch {
@ -51,7 +52,9 @@ class DropdownSearch {
try { try {
const resp = await window.$http.get(this.getAjaxUrl(searchTerm)); const resp = await window.$http.get(this.getAjaxUrl(searchTerm));
const animate = transitionHeight(this.listContainerElem, 80);
this.listContainerElem.innerHTML = resp.data; this.listContainerElem.innerHTML = resp.data;
animate();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }

View File

@ -28,18 +28,31 @@ class DropDown {
this.menu.classList.add('anim', 'menuIn'); this.menu.classList.add('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'true'); this.toggle.setAttribute('aria-expanded', 'true');
const menuOriginalRect = this.menu.getBoundingClientRect();
let heightOffset = 0;
const toggleHeight = this.toggle.getBoundingClientRect().height;
const dropUpwards = menuOriginalRect.bottom > window.innerHeight;
// If enabled, Move to body to prevent being trapped within scrollable sections
if (this.moveMenu) { if (this.moveMenu) {
// Move to body to prevent being trapped within scrollable sections
this.rect = this.menu.getBoundingClientRect();
this.body.appendChild(this.menu); this.body.appendChild(this.menu);
this.menu.style.position = 'fixed'; this.menu.style.position = 'fixed';
if (this.direction === 'right') { if (this.direction === 'right') {
this.menu.style.right = `${(this.rect.right - this.rect.width)}px`; this.menu.style.right = `${(menuOriginalRect.right - menuOriginalRect.width)}px`;
} else { } else {
this.menu.style.left = `${this.rect.left}px`; this.menu.style.left = `${menuOriginalRect.left}px`;
} }
this.menu.style.top = `${this.rect.top}px`; this.menu.style.width = `${menuOriginalRect.width}px`;
this.menu.style.width = `${this.rect.width}px`; heightOffset = dropUpwards ? (window.innerHeight - menuOriginalRect.top - toggleHeight / 2) : menuOriginalRect.top;
}
// Adjust menu to display upwards if near the bottom of the screen
if (dropUpwards) {
this.menu.style.top = 'initial';
this.menu.style.bottom = `${heightOffset}px`;
} else {
this.menu.style.top = `${heightOffset}px`;
this.menu.style.bottom = 'initial';
} }
// Set listener to hide on mouse leave or window click // Set listener to hide on mouse leave or window click
@ -74,18 +87,21 @@ class DropDown {
this.menu.style.display = 'none'; this.menu.style.display = 'none';
this.menu.classList.remove('anim', 'menuIn'); this.menu.classList.remove('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'false'); this.toggle.setAttribute('aria-expanded', 'false');
this.menu.style.top = '';
this.menu.style.bottom = '';
if (this.moveMenu) { if (this.moveMenu) {
this.menu.style.position = ''; this.menu.style.position = '';
this.menu.style[this.direction] = ''; this.menu.style[this.direction] = '';
this.menu.style.top = '';
this.menu.style.width = ''; this.menu.style.width = '';
this.container.appendChild(this.menu); this.container.appendChild(this.menu);
} }
this.showing = false; this.showing = false;
} }
getFocusable() { getFocusable() {
return Array.from(this.menu.querySelectorAll('[tabindex],[href],button,input:not([type=hidden])')); return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
} }
focusNext() { focusNext() {

View File

@ -6,9 +6,10 @@ import attachmentsList from "./attachments-list.js"
import autoSuggest from "./auto-suggest.js" import autoSuggest from "./auto-suggest.js"
import backToTop from "./back-to-top.js" import backToTop from "./back-to-top.js"
import bookSort from "./book-sort.js" import bookSort from "./book-sort.js"
import chapterToggle from "./chapter-toggle.js" import chapterContents from "./chapter-contents.js"
import codeEditor from "./code-editor.js" import codeEditor from "./code-editor.js"
import codeHighlighter from "./code-highlighter.js" import codeHighlighter from "./code-highlighter.js"
import codeTextarea from "./code-textarea.js"
import collapsible from "./collapsible.js" import collapsible from "./collapsible.js"
import confirmDialog from "./confirm-dialog" import confirmDialog from "./confirm-dialog"
import customCheckbox from "./custom-checkbox.js" import customCheckbox from "./custom-checkbox.js"
@ -62,9 +63,10 @@ const componentMapping = {
"auto-suggest": autoSuggest, "auto-suggest": autoSuggest,
"back-to-top": backToTop, "back-to-top": backToTop,
"book-sort": bookSort, "book-sort": bookSort,
"chapter-toggle": chapterToggle, "chapter-contents": chapterContents,
"code-editor": codeEditor, "code-editor": codeEditor,
"code-highlighter": codeHighlighter, "code-highlighter": codeHighlighter,
"code-textarea": codeTextarea,
"collapsible": collapsible, "collapsible": collapsible,
"confirm-dialog": confirmDialog, "confirm-dialog": confirmDialog,
"custom-checkbox": customCheckbox, "custom-checkbox": customCheckbox,

View File

@ -49,7 +49,7 @@ export function slideUp(element, animTime = 400) {
const currentPaddingTop = computedStyles.getPropertyValue('padding-top'); const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = { const animStyles = {
height: [`${currentHeight}px`, '0px'], maxHeight: [`${currentHeight}px`, '0px'],
overflow: ['hidden', 'hidden'], overflow: ['hidden', 'hidden'],
paddingTop: [currentPaddingTop, '0px'], paddingTop: [currentPaddingTop, '0px'],
paddingBottom: [currentPaddingBottom, '0px'], paddingBottom: [currentPaddingBottom, '0px'],
@ -73,7 +73,7 @@ export function slideDown(element, animTime = 400) {
const targetPaddingTop = computedStyles.getPropertyValue('padding-top'); const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = { const animStyles = {
height: ['0px', `${targetHeight}px`], maxHeight: ['0px', `${targetHeight}px`],
overflow: ['hidden', 'hidden'], overflow: ['hidden', 'hidden'],
paddingTop: ['0px', targetPaddingTop], paddingTop: ['0px', targetPaddingTop],
paddingBottom: ['0px', targetPaddingBottom], paddingBottom: ['0px', targetPaddingBottom],
@ -82,6 +82,38 @@ export function slideDown(element, animTime = 400) {
animateStyles(element, animStyles, animTime); animateStyles(element, animStyles, animTime);
} }
/**
* Transition the height of the given element between two states.
* Call with first state, and you'll receive a function in return.
* Call the returned function in the second state to animate between those two states.
* If animating to/from 0-height use the slide-up/slide down as easier alternatives.
* @param {Element} element - Element to animate
* @param {Number} animTime - Animation time in ms
* @returns {function} - Function to run in second state to trigger animation.
*/
export function transitionHeight(element, animTime = 400) {
const startHeight = element.getBoundingClientRect().height;
const initialComputedStyles = getComputedStyle(element);
const startPaddingTop = initialComputedStyles.getPropertyValue('padding-top');
const startPaddingBottom = initialComputedStyles.getPropertyValue('padding-bottom');
return () => {
cleanupExistingElementAnimation(element);
const targetHeight = element.getBoundingClientRect().height;
const computedStyles = getComputedStyle(element);
const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
height: [`${startHeight}px`, `${targetHeight}px`],
overflow: ['hidden', 'hidden'],
paddingTop: [startPaddingTop, targetPaddingTop],
paddingBottom: [startPaddingBottom, targetPaddingBottom],
};
animateStyles(element, animStyles, animTime);
};
}
/** /**
* Animate the css styles of an element using FLIP animation techniques. * Animate the css styles of an element using FLIP animation techniques.
* Styles must be an object where the keys are style properties, camelcase, and the values * Styles must be an object where the keys are style properties, camelcase, and the values

View File

@ -47,6 +47,8 @@ return [
'previous' => 'Previous', 'previous' => 'Previous',
'filter_active' => 'Active Filter:', 'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter', 'filter_clear' => 'Clear Filter',
'download' => 'Download',
'open_in_tab' => 'Open in Tab',
// Sort Options // Sort Options
'sort_options' => 'Sort Options', 'sort_options' => 'Sort Options',

View File

@ -66,7 +66,6 @@
@include lightDark(background-color, #FFF, #222); @include lightDark(background-color, #FFF, #222);
box-shadow: $bs-card; box-shadow: $bs-card;
border-radius: 3px; border-radius: 3px;
border: 1px solid transparent;
.body, p.empty-text { .body, p.empty-text {
padding: $-m; padding: $-m;
} }

View File

@ -61,7 +61,7 @@
} }
} }
[chapter-toggle] { .chapter-contents-toggle {
cursor: pointer; cursor: pointer;
margin: 0; margin: 0;
transition: all ease-in-out 180ms; transition: all ease-in-out 180ms;
@ -77,7 +77,7 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
svg[data-icon="caret-right"] + * { svg[data-icon="caret-right"] + * {
margin-inline-start: $-xs; margin-inline-start: $-xxs;
} }
} }
@ -731,6 +731,55 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
} }
} }
.dropdown-search {
position: relative;
}
.dropdown-search-toggle-breadcrumb {
border: 1px solid transparent;
border-radius: 4px;
line-height: normal;
padding: $-xs;
&:hover {
border-color: #DDD;
}
.svg-icon {
margin-inline-end: 0;
}
}
.dropdown-search-toggle-select {
display: flex;
gap: $-s;
line-height: normal;
.svg-icon {
height: 16px;
margin: 0;
}
.avatar {
height: 22px;
width: 22px;
}
.avatar + span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-search-toggle-caret {
font-size: 1.15rem;
}
}
.dropdown-search-toggle-select-label {
min-width: 0;
white-space: nowrap;
}
.dropdown-search-toggle-select-caret {
font-size: 1.5rem;
line-height: 0;
margin-left: auto;
margin-top: -2px;
}
.dropdown-search-dropdown { .dropdown-search-dropdown {
box-shadow: $bs-med; box-shadow: $bs-med;
overflow: hidden; overflow: hidden;
@ -739,7 +788,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
display: none; display: none;
position: absolute; position: absolute;
z-index: 80; z-index: 80;
right: -$-m; right: 0;
top: 0;
margin-top: $-m;
@include rtl { @include rtl {
right: auto; right: auto;
left: -$-m; left: -$-m;
@ -767,12 +818,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
text-decoration: none; text-decoration: none;
} }
} }
input { input, input:focus {
padding-inline-start: $-xl; padding-inline-start: $-xl;
border-radius: 0; border-radius: 0;
border: 0; border: 0;
border-bottom: 1px solid #DDD; border-bottom: 1px solid #DDD;
} }
input:focus {
outline: 0;
}
} }
@include smaller-than($m) { @include smaller-than($m) {
@ -785,9 +839,3 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
max-height: 240px; max-height: 240px;
} }
} }
.custom-select-input {
max-width: 280px;
border: 1px solid #D4D4D4;
border-radius: 3px;
}

View File

@ -7,7 +7,8 @@
@include lightDark(color, #666, #AAA); @include lightDark(color, #666, #AAA);
display: inline-block; display: inline-block;
font-size: $fs-m; font-size: $fs-m;
padding: $-xs*1.5; padding: $-xs*1.8;
height: 40px;
width: 250px; width: 250px;
max-width: 100%; max-width: 100%;
@ -350,16 +351,13 @@ input[type=color] {
} }
} }
.inline-input-style { .title-input input[type="text"] {
display: block; display: block;
width: 100%; width: 100%;
padding: $-s; padding: $-s;
}
.title-input input[type="text"] {
@extend .inline-input-style;
margin-top: 0; margin-top: 0;
font-size: 2em; font-size: 2em;
height: auto;
} }
.title-input.page-title { .title-input.page-title {
@ -373,6 +371,7 @@ input[type=color] {
max-width: 840px; max-width: 840px;
margin: 0 auto; margin: 0 auto;
border: none; border: none;
height: auto;
} }
} }
@ -383,10 +382,12 @@ input[type=color] {
} }
.description-input textarea { .description-input textarea {
@extend .inline-input-style; display: block;
width: 100%;
padding: $-s;
font-size: $fs-m; font-size: $fs-m;
color: #666; color: #666;
width: 100%; height: auto;
} }
div[editor-type="markdown"] .title-input.page-title input[type="text"] { div[editor-type="markdown"] .title-input.page-title input[type="text"] {
@ -413,9 +414,11 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
} }
input { input {
display: block; display: block;
padding: $-xs * 1.5;
padding-inline-start: $-l + 4px; padding-inline-start: $-l + 4px;
width: 300px; width: 300px;
max-width: 100%; max-width: 100%;
height: auto;
} }
&.flexible input { &.flexible input {
width: 100%; width: 100%;

View File

@ -21,19 +21,28 @@ header {
color: rgb(250, 250, 250); color: rgb(250, 250, 250);
border-bottom: 1px solid #DDD; border-bottom: 1px solid #DDD;
box-shadow: $bs-card; box-shadow: $bs-card;
padding: $-xxs 0;
@include lightDark(border-bottom-color, #DDD, #000); @include lightDark(border-bottom-color, #DDD, #000);
@include whenDark { @include whenDark {
filter: saturate(0.8) brightness(0.8); filter: saturate(0.8) brightness(0.8);
} }
.header-links {
display: flex;
align-items: center;
justify-content: end;
}
.links { .links {
display: inline-block; display: inline-block;
vertical-align: top; vertical-align: top;
} }
.links a { .links a {
display: inline-block; display: inline-block;
padding: $-m; padding: 10px $-m;
color: #FFF; color: #FFF;
border-radius: 3px;
}
.links a:hover {
text-decoration: none;
background-color: rgba(255, 255, 255, .15);
} }
.dropdown-container { .dropdown-container {
padding-inline-start: $-m; padding-inline-start: $-m;
@ -49,19 +58,25 @@ header {
.user-name { .user-name {
vertical-align: top; vertical-align: top;
position: relative; position: relative;
display: inline-block; display: inline-flex;
align-items: center;
cursor: pointer; cursor: pointer;
> * { padding: $-s;
vertical-align: top; margin: 0 (-$-s);
} border-radius: 3px;
gap: $-xs;
> span { > span {
padding-inline-start: $-xs; padding-inline-start: $-xs;
display: inline-block; display: inline-block;
padding-top: $-xxs; line-height: 1;
} }
> svg { > svg {
padding-top: 4px;
font-size: 18px; font-size: 18px;
margin-top: -2px;
margin-inline-end: 0;
}
&:hover {
background-color: rgba(255, 255, 255, 0.15);
} }
@include between($l, $xl) { @include between($l, $xl) {
padding-inline-start: $-xs; padding-inline-start: $-xs;
@ -79,22 +94,26 @@ header {
header .search-box { header .search-box {
display: inline-block; display: inline-block;
margin-top: 10px;
input { input {
background-color: rgba(0, 0, 0, 0.2); background-color: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 40px; border-radius: 40px;
color: #EEE; color: #EEE;
z-index: 2; z-index: 2;
height: auto;
padding: $-xs*1.5;
padding-inline-start: 40px; padding-inline-start: 40px;
&:focus { &:focus {
outline: none; outline: none;
border: 1px solid rgba(255, 255, 255, 0.6); border: 1px solid rgba(255, 255, 255, 0.4);
} }
} }
button { button {
z-index: 1; z-index: 1;
left: 16px; left: 16px;
top: 10px;
color: #FFF;
opacity: 0.6;
@include lightDark(color, rgba(255, 255, 255, 0.8), #AAA); @include lightDark(color, rgba(255, 255, 255, 0.8), #AAA);
@include rtl { @include rtl {
left: auto; left: auto;
@ -104,36 +123,39 @@ header .search-box {
margin-block-end: 0; margin-block-end: 0;
} }
} }
::-webkit-input-placeholder { /* Chrome/Opera/Safari */ input::placeholder {
color: #DDD; color: #FFF;
} opacity: 0.6;
::-moz-placeholder { /* Firefox 19+ */
color: #DDD;
} }
@include between($l, $xl) { @include between($l, $xl) {
max-width: 200px; max-width: 200px;
} }
&:focus-within button {
opacity: 1;
}
} }
.logo { .logo {
display: inline-block; display: inline-flex;
padding: ($-s - 6px) $-s;
margin: 6px (-$-s);
gap: $-s;
align-items: center;
border-radius: 4px;
&:hover { &:hover {
color: #FFF; color: #FFF;
text-decoration: none; text-decoration: none;
background-color: rgba(255, 255, 255, .15);
} }
} }
.logo-text { .logo-text {
display: inline-block;
font-size: 1.8em; font-size: 1.8em;
color: #fff; color: #fff;
font-weight: 400; font-weight: 400;
@include padding(14px, $-l, 14px, 0);
vertical-align: top;
line-height: 1; line-height: 1;
} }
.logo-image { .logo-image {
@include margin($-xs, $-s, $-xs, 0);
vertical-align: top;
height: 43px; height: 43px;
} }
@ -172,23 +194,29 @@ header .search-box {
overflow: hidden; overflow: hidden;
position: absolute; position: absolute;
box-shadow: $bs-hover; box-shadow: $bs-hover;
margin-top: -$-xs; margin-top: $-m;
padding: $-xs 0;
&.show { &.show {
display: block; display: block;
} }
} }
header .links a, header .dropdown-container ul li a, header .dropdown-container ul li button { header .links a, header .dropdown-container ul li a, header .dropdown-container ul li button {
text-align: start; text-align: start;
display: block; display: grid;
padding: $-s $-m; align-items: center;
padding: 8px $-m;
gap: $-m;
color: $text-dark; color: $text-dark;
grid-template-columns: 16px auto;
line-height: 1.4;
@include lightDark(color, $text-dark, #eee); @include lightDark(color, $text-dark, #eee);
svg { svg {
margin-inline-end: $-s; margin-inline-end: $-s;
width: 16px;
} }
&:hover { &:hover {
@include lightDark(background-color, #eee, #333); background-color: var(--color-primary-light);
@include lightDark(color, #000, #fff); color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
&:focus { &:focus {
@ -279,29 +307,6 @@ header .search-box {
} }
} }
.dropdown-search {
position: relative;
.dropdown-search-toggle {
padding: $-xs;
border: 1px solid transparent;
border-radius: 4px;
&:hover {
border-color: #DDD;
}
}
.svg-icon {
margin-inline-end: 0;
}
}
.dropdown-search-toggle.compact {
padding: $-xxs $-xs;
.avatar {
height: 22px;
width: 22px;
}
}
.faded { .faded {
a, button, span, span > div { a, button, span, span > div {
color: #666; color: #666;

View File

@ -155,6 +155,13 @@ body.flexbox {
} }
} }
.gap-m {
gap: $-m;
}
.justify-flex-start {
justify-content: flex-start;
}
.justify-flex-end { .justify-flex-end {
justify-content: flex-end; justify-content: flex-end;
} }
@ -295,9 +302,9 @@ body.flexbox {
} }
@include larger-than($xxl) { @include larger-than($xxl) {
.tri-layout-left-contents, .tri-layout-right-contents { .tri-layout-left-contents, .tri-layout-right-contents {
padding: $-m; padding: $-xl $-m;
position: sticky; position: sticky;
top: $-m; top: 0;
max-height: 100vh; max-height: 100vh;
min-height: 50vh; min-height: 50vh;
overflow-y: scroll; overflow-y: scroll;

View File

@ -6,7 +6,7 @@
justify-self: stretch; justify-self: stretch;
align-self: stretch; align-self: stretch;
height: auto; height: auto;
margin-inline-end: $-l; margin-inline-end: $-xs;
} }
.icon:after { .icon:after {
opacity: 0.5; opacity: 0.5;
@ -56,13 +56,13 @@
> .content { > .content {
flex: 1; flex: 1;
} }
.chapter-expansion-toggle { .chapter-contents-toggle {
border-radius: 0 4px 4px 0; border-radius: 0 4px 4px 0;
padding: $-xs $-m; padding: $-xs ($-m + $-xxs);
width: 100%; width: 100%;
text-align: start; text-align: start;
} }
.chapter-expansion-toggle:hover { .chapter-contents-toggle:hover {
background-color: rgba(0, 0, 0, 0.06); background-color: rgba(0, 0, 0, 0.06);
} }
} }
@ -157,22 +157,6 @@
@include margin($-xs, -$-s, 0, -$-s); @include margin($-xs, -$-s, 0, -$-s);
padding-inline-start: 0; padding-inline-start: 0;
padding-inline-end: 0; padding-inline-end: 0;
position: relative;
&:after, .sub-menu:after {
content: '';
display: block;
position: absolute;
left: $-m;
top: 1rem;
bottom: 1rem;
border-inline-start: 4px solid rgba(0, 0, 0, 0.1);
z-index: 0;
@include rtl {
left: auto;
right: $-m;
}
}
ul { ul {
list-style: none; list-style: none;
@ -181,19 +165,20 @@
} }
.entity-list-item { .entity-list-item {
padding-top: $-xxs; padding-top: 2px;
padding-bottom: $-xxs; padding-bottom: 2px;
background-clip: content-box; background-clip: content-box;
border-radius: 0 3px 3px 0; border-radius: 0 3px 3px 0;
padding-inline-end: 0; padding-inline-end: 0;
.content { .content {
width: 100%;
padding-top: $-xs; padding-top: $-xs;
padding-bottom: $-xs; padding-bottom: $-xs;
max-width: calc(100% - 20px); max-width: calc(100% - 20px);
} }
} }
.entity-list-item.selected { .entity-list-item.selected {
@include lightDark(background-color, rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.08)); @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06));
} }
.entity-list-item.no-hover { .entity-list-item.no-hover {
margin-top: -$-xs; margin-top: -$-xs;
@ -209,9 +194,18 @@
margin-top: -.2rem; margin-top: -.2rem;
margin-inline-start: -1rem; margin-inline-start: -1rem;
} }
[chapter-toggle] { .chapter-contents-toggle {
padding-inline-start: .7rem; display: block;
padding-bottom: .2rem; width: 100%;
text-align: left;
padding: $-xxs $-s ($-xxs * 2) $-s;
border-radius: 0 3px 3px 0;
line-height: 1;
margin-top: -$-xxs;
margin-bottom: -$-xxs;
&:hover {
@include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06));
}
} }
.entity-list-item .icon { .entity-list-item .icon {
z-index: 2; z-index: 2;
@ -220,7 +214,7 @@
align-self: stretch; align-self: stretch;
flex-shrink: 0; flex-shrink: 0;
border-radius: 1px; border-radius: 1px;
opacity: 0.6; opacity: 0.8;
} }
.entity-list-item .icon:after { .entity-list-item .icon:after {
opacity: 1; opacity: 1;
@ -230,15 +224,11 @@
} }
} }
.chapter-child-menu { .chapter-child-menu ul.sub-menu {
ul.sub-menu {
display: none; display: none;
padding-inline-start: 0; padding-inline-start: 0;
position: relative; position: relative;
} margin-bottom: 0;
[chapter-toggle].open + .sub-menu {
display: block;
}
} }
// Sortable Lists // Sortable Lists
@ -415,6 +405,7 @@ ul.pagination {
padding: $-s $-m; padding: $-s $-m;
display: flex; display: flex;
align-items: center; align-items: center;
gap: $-m;
background-color: transparent; background-color: transparent;
border: 0; border: 0;
width: 100%; width: 100%;
@ -424,7 +415,6 @@ ul.pagination {
color: #666; color: #666;
} }
> span:first-child { > span:first-child {
margin-inline-end: $-m;
flex-basis: 1.88em; flex-basis: 1.88em;
flex: none; flex: none;
} }
@ -439,8 +429,8 @@ ul.pagination {
cursor: pointer; cursor: pointer;
} }
&:not(.no-hover):hover { &:not(.no-hover):hover {
@include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06));
text-decoration: none; text-decoration: none;
background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px; border-radius: 4px;
} }
&.outline-hover:hover { &.outline-hover:hover {
@ -463,19 +453,74 @@ ul.pagination {
} }
} }
.card .entity-list-item:not(.no-hover):hover { .split-icon-list-item {
@include lightDark(background-color, #F2F2F2, #2d2d2d) display: flex;
align-items: center;
gap: $-m;
background-color: transparent;
border: 0;
width: 100%;
position: relative;
word-break: break-word;
border-radius: 4px;
> a {
padding: $-s $-m;
display: flex;
align-items: center;
gap: $-m;
flex: 1;
}
> a:hover {
text-decoration: none;
}
.icon {
flex-basis: 1.88em;
flex: none;
}
&:hover {
@include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06));
}
}
.icon-list-item-dropdown {
margin-inline-start: auto;
align-self: stretch;
display: flex;
align-items: stretch;
border-inline-start: 1px solid rgba(0, 0, 0, .1);
visibility: hidden;
}
.split-icon-list-item:hover .icon-list-item-dropdown,
.split-icon-list-item:focus-within .icon-list-item-dropdown {
visibility: visible;
}
.icon-list-item-dropdown-toggle {
padding: $-xs;
display: flex;
align-items: center;
cursor: pointer;
@include lightDark(color, #888, #999);
svg {
margin: 0;
}
&:hover {
@include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06));
}
}
.card .entity-list-item:not(.no-hover, .book-contents .entity-list-item):hover {
@include lightDark(background-color, #F2F2F2, #2d2d2d);
border-radius: 0;
} }
.card .entity-list-item .entity-list-item:hover { .card .entity-list-item .entity-list-item:hover {
background-color: #EEEEEE; background-color: #EEEEEE;
} }
.entity-list-item-children { .entity-list-item-children {
padding: $-m; padding: $-m $-l;
> div { > div {
overflow: hidden; overflow: hidden;
padding: $-xs 0; padding: 0 0 $-xs 0;
margin-top: -$-xs;
} }
.entity-chip { .entity-chip {
text-overflow: ellipsis; text-overflow: ellipsis;
@ -485,6 +530,9 @@ ul.pagination {
display: block; display: block;
white-space: nowrap; white-space: nowrap;
} }
> .entity-list > .entity-list-item:last-child {
margin-bottom: -$-xs;
}
} }
.entity-list-item-image { .entity-list-item-image {
@ -531,6 +579,9 @@ ul.pagination {
font-size: $fs-m * 0.8; font-size: $fs-m * 0.8;
padding-top: $-xs; padding-top: $-xs;
} }
.entity-list-item p:empty {
padding-top: 0;
}
p { p {
margin: 0; margin: 0;
} }
@ -574,8 +625,8 @@ ul.pagination {
right: 0; right: 0;
margin: $-m 0; margin: $-m 0;
@include lightDark(background-color, #fff, #333); @include lightDark(background-color, #fff, #333);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18); box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.18);
border-radius: 1px; border-radius: 3px;
min-width: 180px; min-width: 180px;
padding: $-xs 0; padding: $-xs 0;
@include lightDark(color, #555, #eee); @include lightDark(color, #555, #eee);
@ -652,6 +703,13 @@ ul.pagination {
} }
} }
// Shift in sidebar dropdown menus to prevent shadows
// being cut by scrollable container.
.tri-layout-right .dropdown-menu,
.tri-layout-left .dropdown-menu {
right: $-xs;
}
// Books grid view // Books grid view
.featured-image-container { .featured-image-container {
position: relative; position: relative;
@ -719,3 +777,19 @@ ul.pagination {
} }
} }
} }
.entity-meta-item {
display: flex;
line-height: 1.2;
margin: 0.6em 0;
align-content: start;
gap: $-s;
a {
line-height: 1.2;
}
svg {
flex-shrink: 0;
width: 1em;
margin: 0;
}
}

View File

@ -396,10 +396,14 @@ body.tox-fullscreen, body.markdown-fullscreen {
} }
} }
.entity-list-item > span:first-child, .icon-list-item > span:first-child, .chapter-expansion > .icon { .entity-list-item > span:first-child,
.icon-list-item > span:first-child,
.split-icon-list-item > a > .icon,
.chapter-expansion > .icon {
font-size: 0.8rem; font-size: 0.8rem;
width: 1.88em; width: 1.88em;
height: 1.88em; height: 1.88em;
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -79,17 +79,17 @@ $loadingSize: 10px;
animation-timing-function: cubic-bezier(.62, .28, .23, .99); animation-timing-function: cubic-bezier(.62, .28, .23, .99);
margin-inline-end: 4px; margin-inline-end: 4px;
background-color: var(--color-page); background-color: var(--color-page);
animation-delay: 0.3s; animation-delay: -300ms;
} }
> div:first-child { > div:first-child {
left: -($loadingSize+$-xs); left: -($loadingSize+$-xs);
background-color: var(--color-book); background-color: var(--color-book);
animation-delay: 0s; animation-delay: -600ms;
} }
> div:last-of-type { > div:last-of-type {
left: $loadingSize+$-xs; left: $loadingSize+$-xs;
background-color: var(--color-chapter); background-color: var(--color-chapter);
animation-delay: 0.6s; animation-delay: 0ms;
} }
> span { > span {
margin-inline-start: $-s; margin-inline-start: $-s;
@ -138,7 +138,7 @@ $btt-size: 40px;
.skip-to-content-link { .skip-to-content-link {
position: fixed; position: fixed;
top: -$-xxl; top: -52px;
left: 0; left: 0;
background-color: #FFF; background-color: #FFF;
z-index: 15; z-index: 15;

View File

@ -1,10 +1,27 @@
<div component="attachments-list"> <div component="attachments-list">
@foreach($attachments as $attachment) @foreach($attachments as $attachment)
<div class="attachment icon-list"> <div class="attachment icon-list">
<a class="icon-list-item py-xs attachment-{{ $attachment->external ? 'link' : 'file' }}" href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif> <div class="split-icon-list-item attachment-{{ $attachment->external ? 'link' : 'file' }}">
<span class="icon">@icon($attachment->external ? 'export' : 'file')</span> <a href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif>
<span>{{ $attachment->name }}</span> <div class="icon">@icon($attachment->external ? 'export' : 'file')</div>
<div class="label">{{ $attachment->name }}</div>
</a> </a>
@if(!$attachment->external)
<div component="dropdown" class="icon-list-item-dropdown">
<button refs="dropdown@toggle" type="button" class="icon-list-item-dropdown-toggle">@icon('caret-down')</button>
<ul refs="dropdown@menu" class="dropdown-menu" role="menu">
<a href="{{ $attachment->getUrl(false) }}" class="icon-item">
@icon('download')
<div>{{ trans('common.download') }}</div>
</a>
<a href="{{ $attachment->getUrl(true) }}" target="_blank" class="icon-item">
@icon('export')
<div>{{ trans('common.open_in_tab') }}</div>
</a>
</ul>
</div>
@endif
</div>
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -67,14 +67,20 @@
@section('right') @section('right')
<div class="mb-xl"> <div class="mb-xl">
<h5>{{ trans('common.details') }}</h5> <h5>{{ trans('common.details') }}</h5>
<div class="text-small text-muted blended-links"> <div class="blended-links">
@include('entities.meta', ['entity' => $book]) @include('entities.meta', ['entity' => $book])
@if($book->restricted) @if($book->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $book)) @if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.books_permissions_active') }}</a> <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.books_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.books_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.books_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif

View File

@ -1,9 +1,14 @@
<div class="chapter-child-menu"> <div component="chapter-contents" class="chapter-child-menu">
<button chapter-toggle type="button" aria-expanded="{{ $isOpen ? 'true' : 'false' }}" <button type="button"
class="text-muted @if($isOpen) open @endif"> refs="chapter-contents@toggle"
aria-expanded="{{ $isOpen ? 'true' : 'false' }}"
class="text-muted chapter-contents-toggle @if($isOpen) open @endif">
@icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->visible_pages->count()) }}</span> @icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->visible_pages->count()) }}</span>
</button> </button>
<ul class="sub-menu inset-list @if($isOpen) open @endif" @if($isOpen) style="display: block;" @endif role="menu"> <ul refs="chapter-contents@list"
class="chapter-contents-list sub-menu inset-list @if($isOpen) open @endif" @if($isOpen)
style="display: block;" @endif
role="menu">
@foreach($bookChild->visible_pages as $childPage) @foreach($bookChild->visible_pages as $childPage)
<li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}" role="presentation"> <li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}" role="presentation">
@include('entities.list-item-basic', ['entity' => $childPage, 'classes' => $current->matches($childPage)? 'selected' : '' ]) @include('entities.list-item-basic', ['entity' => $childPage, 'classes' => $current->matches($childPage)? 'selected' : '' ])

View File

@ -5,18 +5,19 @@
<div class="content"> <div class="content">
<h4 class="entity-list-item-name break-text">{{ $chapter->name }}</h4> <h4 class="entity-list-item-name break-text">{{ $chapter->name }}</h4>
<div class="entity-item-snippet"> <div class="entity-item-snippet">
<p class="text-muted break-text mb-s">{{ $chapter->getExcerpt() }}</p> <p class="text-muted break-text">{{ $chapter->getExcerpt() }}</p>
</div> </div>
</div> </div>
</a> </a>
@if ($chapter->visible_pages->count() > 0) @if ($chapter->visible_pages->count() > 0)
<div class="chapter chapter-expansion"> <div class="chapter chapter-expansion">
<span class="icon text-chapter">@icon('page')</span> <span class="icon text-chapter">@icon('page')</span>
<div class="content"> <div component="chapter-contents" class="content">
<button type="button" chapter-toggle <button type="button"
refs="chapter-contents@toggle"
aria-expanded="false" aria-expanded="false"
class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->visible_pages->count()) }}</span></button> class="text-muted chapter-contents-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->visible_pages->count()) }}</span></button>
<div class="inset-list"> <div refs="chapter-contents@list" class="inset-list chapter-contents-list">
<div class="entity-list-item-children"> <div class="entity-list-item-children">
@include('entities.list', ['entities' => $chapter->visible_pages]) @include('entities.list', ['entities' => $chapter->visible_pages])
</div> </div>

View File

@ -64,15 +64,21 @@
<div class="mb-xl"> <div class="mb-xl">
<h5>{{ trans('common.details') }}</h5> <h5>{{ trans('common.details') }}</h5>
<div class="blended-links text-small text-muted"> <div class="blended-links">
@include('entities.meta', ['entity' => $chapter]) @include('entities.meta', ['entity' => $chapter])
@if($book->restricted) @if($book->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $book)) @if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.books_permissions_active') }}</a> <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.books_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.books_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.books_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif
@ -80,9 +86,15 @@
@if($chapter->restricted) @if($chapter->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $chapter)) @if(userCan('restrictions-manage', $chapter))
<a href="{{ $chapter->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.chapters_permissions_active') }}</a> <a href="{{ $chapter->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.chapters_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.chapters_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.chapters_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif

View File

@ -17,7 +17,7 @@
class="mobile-menu-toggle hide-over-l">@icon('more')</button> class="mobile-menu-toggle hide-over-l">@icon('more')</button>
</div> </div>
<div class="flex-container-row justify-center hide-under-l"> <div class="flex-container-column items-center justify-center hide-under-l">
@if (hasAppAccess()) @if (hasAppAccess())
<form action="{{ url('/search') }}" method="GET" class="search-box" role="search"> <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> <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
@ -28,7 +28,6 @@
@endif @endif
</div> </div>
<div class="text-right">
<nav refs="header-mobile-toggle@menu" class="header-links"> <nav refs="header-mobile-toggle@menu" class="header-links">
<div class="links text-center"> <div class="links text-center">
@if (hasAppAccess()) @if (hasAppAccess())
@ -97,7 +96,6 @@
</div> </div>
@endif @endif
</nav> </nav>
</div>
</div> </div>
</header> </header>

View File

@ -2,7 +2,7 @@
option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}" option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}"
option:dropdown-search:local-search-selector=".entity-list-item" option:dropdown-search:local-search-selector=".entity-list-item"
> >
<div class="dropdown-search-toggle" refs="dropdown@toggle" <div class="dropdown-search-toggle-breadcrumb" refs="dropdown@toggle"
aria-haspopup="true" aria-expanded="false" tabindex="0"> aria-haspopup="true" aria-expanded="false" tabindex="0">
<div class="separator">@icon('chevron-right')</div> <div class="separator">@icon('chevron-right')</div>
</div> </div>
@ -18,6 +18,6 @@
<div refs="dropdown-search@loading"> <div refs="dropdown-search@loading">
@include('common.loading-icon') @include('common.loading-icon')
</div> </div>
<div refs="dropdown-search@listContainer" class="dropdown-search-list px-m"></div> <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m" tabindex="-1"></div>
</div> </div>
</div> </div>

View File

@ -1,13 +1,18 @@
<div component="dropdown" class="dropdown-container" id="export-menu"> <div component="dropdown"
class="dropdown-container"
id="export-menu">
<div refs="dropdown@toggle" class="icon-list-item" <div refs="dropdown@toggle" class="icon-list-item"
aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('entities.export') }}" tabindex="0"> aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('entities.export') }}" tabindex="0">
<span>@icon('export')</span> <span>@icon('export')</span>
<span>{{ trans('entities.export') }}</span> <span>{{ trans('entities.export') }}</span>
</div> </div>
<ul refs="dropdown@menu" class="wide dropdown-menu" role="menu"> <ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
<li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li> <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li>
<li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li> <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
<li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li> <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
<li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li> <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
</ul> </ul>
</div> </div>

View File

@ -1,50 +1,62 @@
<div class="entity-meta"> <div class="entity-meta">
@if($entity->isA('revision')) @if($entity->isA('revision'))
<div class="entity-meta-item">
@icon('history')
<div> <div>
@icon('history'){{ trans('entities.pages_revision') }} {{ trans('entities.pages_revision') }}
{{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }} {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
</div> </div>
</div>
@endif @endif
@if ($entity->isA('page')) @if ($entity->isA('page'))
<div> @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}" class="entity-meta-item"> @else <div class="entity-meta-item"> @endif
@if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
@if (userCan('page-update', $entity))</a>@endif @if (userCan('page-update', $entity))</a> @else </div> @endif
</div>
@endif @endif
@if ($entity->ownedBy && $entity->owned_by !== $entity->created_by) @if ($entity->ownedBy && $entity->owned_by !== $entity->created_by)
<div class="entity-meta-item">
@icon('user')
<div> <div>
@icon('user'){!! trans('entities.meta_owned_name', [ {!! trans('entities.meta_owned_name', [
'user' => "<a href='{$entity->ownedBy->getProfileUrl()}'>".e($entity->ownedBy->name). "</a>" 'user' => "<a href='{$entity->ownedBy->getProfileUrl()}'>".e($entity->ownedBy->name). "</a>"
]) !!} ]) !!}
</div> </div>
</div>
@endif @endif
@if ($entity->createdBy) @if ($entity->createdBy)
<div class="entity-meta-item">
@icon('star')
<div> <div>
@icon('star'){!! trans('entities.meta_created_name', [ {!! trans('entities.meta_created_name', [
'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>', 'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>" 'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
]) !!} ]) !!}
</div> </div>
</div>
@else @else
<div> <div class="entity-meta-item">
@icon('star')<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span> @icon('star')
<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
</div> </div>
@endif @endif
@if ($entity->updatedBy) @if ($entity->updatedBy)
<div class="entity-meta-item">
@icon('edit')
<div> <div>
@icon('edit'){!! trans('entities.meta_updated_name', [ {!! trans('entities.meta_updated_name', [
'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>', 'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>" 'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
]) !!} ]) !!}
</div> </div>
</div>
@elseif (!$entity->isA('revision')) @elseif (!$entity->isA('revision'))
<div> <div class="entity-meta-item">
@icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span> @icon('edit')
<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
</div> </div>
@endif @endif
</div> </div>

View File

@ -15,7 +15,7 @@
<div> <div>
<div class="form-group"> <div class="form-group">
<label for="owner">{{ trans('entities.permissions_owner') }}</label> <label for="owner">{{ trans('entities.permissions_owner') }}</label>
@include('form.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by', 'compact' => false]) @include('form.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by'])
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,19 +1,19 @@
<div class="dropdown-search custom-select-input" components="dropdown dropdown-search user-select" <div class="dropdown-search" components="dropdown dropdown-search user-select"
option:dropdown-search:url="/search/users/select" option:dropdown-search:url="/search/users/select"
> >
<input refs="user-select@input" type="hidden" name="{{ $name }}" value="{{ $user->id ?? '' }}"> <input refs="user-select@input" type="hidden" name="{{ $name }}" value="{{ $user->id ?? '' }}">
<div refs="dropdown@toggle" <div refs="dropdown@toggle"
class="dropdown-search-toggle {{ $compact ? 'compact' : '' }} flex-container-row items-center" class="dropdown-search-toggle-select input-base"
aria-haspopup="true" aria-expanded="false" tabindex="0"> aria-haspopup="true" aria-expanded="false" tabindex="0">
<div refs="user-select@user-info" class="flex-container-row items-center px-s"> <div refs="user-select@user-info" class="dropdown-search-toggle-select-label flex-container-row items-center">
@if($user) @if($user)
<img class="avatar small mr-m" src="{{ $user->getAvatar($compact ? 22 : 30) }}" alt="{{ $user->name }}"> <img class="avatar small mr-m" src="{{ $user->getAvatar(30) }}" width="30" height="30" alt="{{ $user->name }}">
<span>{{ $user->name }}</span> <span>{{ $user->name }}</span>
@else @else
<span>{{ trans('settings.users_none_selected') }}</span> <span>{{ trans('settings.users_none_selected') }}</span>
@endif @endif
</div> </div>
<span style="font-size: {{ $compact ? '1.15rem' : '1.5rem' }}; margin-left: auto;"> <span class="dropdown-search-toggle-select-caret">
@icon('caret-down') @icon('caret-down')
</span> </span>
</div> </div>

View File

@ -27,7 +27,7 @@
<div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') > <div refs="tri-layout@container" class="tri-layout-container" @yield('container-attrs') >
<div class="tri-layout-left print-hidden pt-m" id="sidebar"> <div class="tri-layout-left print-hidden" id="sidebar">
<aside class="tri-layout-left-contents"> <aside class="tri-layout-left-contents">
@yield('left') @yield('left')
</aside> </aside>
@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div class="tri-layout-right print-hidden pt-m"> <div class="tri-layout-right print-hidden">
<aside class="tri-layout-right-contents"> <aside class="tri-layout-right-contents">
@yield('right') @yield('right')
</aside> </aside>

View File

@ -65,7 +65,9 @@
</div> </div>
<div class="action-buttons px-m py-xs"> <div class="action-buttons px-m py-xs">
<div component="dropdown" dropdown-move-menu class="dropdown-container"> <div component="dropdown"
option:dropdown:move-menu="true"
class="dropdown-container">
<button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span></button> <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span></button>
<ul refs="dropdown@menu" class="wide dropdown-menu"> <ul refs="dropdown@menu" class="wide dropdown-menu">
<li class="px-l py-m"> <li class="px-l py-m">

View File

@ -76,15 +76,21 @@
@section('right') @section('right')
<div id="page-details" class="entity-details mb-xl"> <div id="page-details" class="entity-details mb-xl">
<h5>{{ trans('common.details') }}</h5> <h5>{{ trans('common.details') }}</h5>
<div class="body text-small blended-links"> <div class="blended-links">
@include('entities.meta', ['entity' => $page]) @include('entities.meta', ['entity' => $page])
@if($book->restricted) @if($book->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $book)) @if(userCan('restrictions-manage', $book))
<a href="{{ $book->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.books_permissions_active') }}</a> <a href="{{ $book->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.books_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.books_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.books_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif
@ -92,9 +98,15 @@
@if($page->chapter && $page->chapter->restricted) @if($page->chapter && $page->chapter->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $page->chapter)) @if(userCan('restrictions-manage', $page->chapter))
<a href="{{ $page->chapter->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.chapters_permissions_active') }}</a> <a href="{{ $page->chapter->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.chapters_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.chapters_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.chapters_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif
@ -102,16 +114,23 @@
@if($page->restricted) @if($page->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $page)) @if(userCan('restrictions-manage', $page))
<a href="{{ $page->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.pages_permissions_active') }}</a> <a href="{{ $page->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.pages_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.pages_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.pages_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif
@if($page->template) @if($page->template)
<div> <div class="entity-meta-item">
@icon('template'){{ trans('entities.pages_is_template') }} @icon('template')
<div>{{ trans('entities.pages_is_template') }}</div>
</div> </div>
@endif @endif
</div> </div>

View File

@ -9,8 +9,9 @@
<h1 class="list-heading">{{ trans('settings.audit') }}</h1> <h1 class="list-heading">{{ trans('settings.audit') }}</h1>
<p class="text-muted">{{ trans('settings.audit_desc') }}</p> <p class="text-muted">{{ trans('settings.audit_desc') }}</p>
<div class="flex-container-row"> <form action="{{ url('/settings/audit') }}" method="get" class="flex-container-row wrap justify-flex-start gap-m">
<div component="dropdown" class="list-sort-type dropdown-container mr-m">
<div component="dropdown" class="list-sort-type dropdown-container">
<label for="">{{ trans('settings.audit_event_filter') }}</label> <label for="">{{ trans('settings.audit_event_filter') }}</label>
<button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button> <button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
<ul refs="dropdown@menu" class="dropdown-menu"> <ul refs="dropdown@menu" class="dropdown-menu">
@ -21,13 +22,12 @@
</ul> </ul>
</div> </div>
<form action="{{ url('/settings/audit') }}" method="get" class="flex-container-row mr-m">
@if(!empty($listDetails['event'])) @if(!empty($listDetails['event']))
<input type="hidden" name="event" value="{{ $listDetails['event'] }}"> <input type="hidden" name="event" value="{{ $listDetails['event'] }}">
@endif @endif
@foreach(['date_from', 'date_to'] as $filterKey) @foreach(['date_from', 'date_to'] as $filterKey)
<div class="mr-m"> <div class=>
<label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label> <label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label>
<input id="audit_filter_{{ $filterKey }}" <input id="audit_filter_{{ $filterKey }}"
component="submit-on-change" component="submit-on-change"
@ -37,21 +37,20 @@
</div> </div>
@endforeach @endforeach
<div class="form-group ml-auto mr-m" <div class="form-group"
component="submit-on-change" component="submit-on-change"
option:submit-on-change:filter='[name="user"]'> option:submit-on-change:filter='[name="user"]'>
<label for="owner">{{ trans('settings.audit_table_user') }}</label> <label for="owner">{{ trans('settings.audit_table_user') }}</label>
@include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user', 'compact' => true]) @include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user'])
</div> </div>
<div class="form-group ml-auto"> <div class="form-group">
<label for="ip">{{ trans('settings.audit_table_ip') }}</label> <label for="ip">{{ trans('settings.audit_table_ip') }}</label>
@include('form.text', ['name' => 'ip', 'model' => (object) $listDetails]) @include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
<input type="submit" style="display: none"> <input type="submit" style="display: none">
</div> </div>
</form> </form>
</div>
<hr class="mt-l mb-s"> <hr class="mt-l mb-s">

View File

@ -119,7 +119,13 @@
<div> <div>
<label for="setting-app-custom-head" class="setting-list-label">{{ trans('settings.app_custom_html') }}</label> <label for="setting-app-custom-head" class="setting-list-label">{{ trans('settings.app_custom_html') }}</label>
<p class="small">{{ trans('settings.app_custom_html_desc') }}</p> <p class="small">{{ trans('settings.app_custom_html_desc') }}</p>
<textarea name="setting-app-custom-head" id="setting-app-custom-head" class="simple-code-input mt-m">{{ setting('app-custom-head', '') }}</textarea> <div class="mt-m">
<textarea component="code-textarea"
option:code-textarea:mode="html"
name="setting-app-custom-head"
id="setting-app-custom-head"
class="simple-code-input">{{ setting('app-custom-head', '') }}</textarea>
</div>
<p class="small text-right">{{ trans('settings.app_custom_html_disabled_notice') }}</p> <p class="small text-right">{{ trans('settings.app_custom_html_disabled_notice') }}</p>
</div> </div>

View File

@ -81,14 +81,20 @@
<div id="details" class="mb-xl"> <div id="details" class="mb-xl">
<h5>{{ trans('common.details') }}</h5> <h5>{{ trans('common.details') }}</h5>
<div class="text-small text-muted blended-links"> <div class="blended-links">
@include('entities.meta', ['entity' => $shelf]) @include('entities.meta', ['entity' => $shelf])
@if($shelf->restricted) @if($shelf->restricted)
<div class="active-restriction"> <div class="active-restriction">
@if(userCan('restrictions-manage', $shelf)) @if(userCan('restrictions-manage', $shelf))
<a href="{{ $shelf->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.shelves_permissions_active') }}</a> <a href="{{ $shelf->getUrl('/permissions') }}" class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.shelves_permissions_active') }}</div>
</a>
@else @else
@icon('lock'){{ trans('entities.shelves_permissions_active') }} <div class="entity-meta-item">
@icon('lock')
<div>{{ trans('entities.shelves_permissions_active') }}</div>
</div>
@endif @endif
</div> </div>
@endif @endif

View File

@ -19,7 +19,7 @@
<p class="small">{{ trans('settings.users_migrate_ownership_desc') }}</p> <p class="small">{{ trans('settings.users_migrate_ownership_desc') }}</p>
</div> </div>
<div> <div>
@include('form.user-select', ['name' => 'new_owner_id', 'user' => null, 'compact' => false]) @include('form.user-select', ['name' => 'new_owner_id', 'user' => null])
</div> </div>
</div> </div>
@endif @endif

View File

@ -0,0 +1,59 @@
<?php
namespace Tests\Auth;
use BookStack\Auth\Access\GroupSyncService;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Tests\TestCase;
class GroupSyncServiceTest extends TestCase
{
public function test_user_is_assigned_to_matching_roles()
{
$user = $this->getViewer();
$roleA = Role::factory()->create(['display_name' => 'Wizards']);
$roleB = Role::factory()->create(['display_name' => 'Gremlins']);
$roleC = Role::factory()->create(['display_name' => 'ABC123', 'external_auth_id' => 'sales']);
$roleD = Role::factory()->create(['display_name' => 'DEF456', 'external_auth_id' => 'admin-team']);
foreach([$roleA, $roleB, $roleC, $roleD] as $role) {
$this->assertFalse($user->hasRole($role->id));
}
(new GroupSyncService())->syncUserWithFoundGroups($user, ['Wizards', 'Gremlinz', 'Sales', 'Admin Team'], false);
$user = User::query()->find($user->id);
$this->assertTrue($user->hasRole($roleA->id));
$this->assertFalse($user->hasRole($roleB->id));
$this->assertTrue($user->hasRole($roleC->id));
$this->assertTrue($user->hasRole($roleD->id));
}
public function test_multiple_values_in_role_external_auth_id_handled()
{
$user = $this->getViewer();
$role = Role::factory()->create(['display_name' => 'ABC123', 'external_auth_id' => 'sales, engineering, developers, marketers']);
$this->assertFalse($user->hasRole($role->id));
(new GroupSyncService())->syncUserWithFoundGroups($user, ['Developers'], false);
$user = User::query()->find($user->id);
$this->assertTrue($user->hasRole($role->id));
}
public function test_commas_can_be_used_in_external_auth_id_if_escaped()
{
$user = $this->getViewer();
$role = Role::factory()->create(['display_name' => 'ABC123', 'external_auth_id' => 'sales\,-developers, marketers']);
$this->assertFalse($user->hasRole($role->id));
(new GroupSyncService())->syncUserWithFoundGroups($user, ['Sales, Developers'], false);
$user = User::query()->find($user->id);
$this->assertTrue($user->hasRole($role->id));
}
}