*/
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
{
- $externalAuthIds = explode(',', strtolower($externalId));
-
- foreach ($externalAuthIds as $externalAuthId) {
- if (in_array(trim($externalAuthId), $groupNames)) {
+ foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
+ if (in_array($externalAuthId, $groupNames)) {
return true;
}
}
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.
* Formats group names to be lower-case and hyphenated.
return [
'display_name' => $this->faker->sentence(3),
'description' => $this->faker->sentence(10),
+ 'external_auth_id' => '',
];
}
}
{
"private": true,
"scripts": {
- "build:css:dev": "sass ./resources/sass:./public/dist",
- "build:css:watch": "sass ./resources/sass:./public/dist --watch",
+ "build:css:dev": "sass ./resources/sass:./public/dist --embed-sources",
+ "build:css:watch": "sass ./resources/sass:./public/dist --watch --embed-sources",
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
"build:js:dev": "node dev/build/esbuild.js",
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
--- /dev/null
+<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>
\ No newline at end of file
});
}
+/**
+ * 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.
* @param cmInstance
--- /dev/null
+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;
+++ /dev/null
-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;
--- /dev/null
+/**
+ * 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;
\ No newline at end of file
import {debounce} from "../services/util";
+import {transitionHeight} from "../services/animations";
class DropdownSearch {
try {
const resp = await window.$http.get(this.getAjaxUrl(searchTerm));
+ const animate = transitionHeight(this.listContainerElem, 80);
this.listContainerElem.innerHTML = resp.data;
+ animate();
} catch (err) {
console.error(err);
}
this.menu.classList.add('anim', 'menuIn');
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) {
- // Move to body to prevent being trapped within scrollable sections
- this.rect = this.menu.getBoundingClientRect();
this.body.appendChild(this.menu);
this.menu.style.position = 'fixed';
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 {
- 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 = `${this.rect.width}px`;
+ this.menu.style.width = `${menuOriginalRect.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
this.menu.style.display = 'none';
this.menu.classList.remove('anim', 'menuIn');
this.toggle.setAttribute('aria-expanded', 'false');
+ this.menu.style.top = '';
+ this.menu.style.bottom = '';
+
if (this.moveMenu) {
this.menu.style.position = '';
this.menu.style[this.direction] = '';
- this.menu.style.top = '';
this.menu.style.width = '';
this.container.appendChild(this.menu);
}
+
this.showing = false;
}
getFocusable() {
- return Array.from(this.menu.querySelectorAll('[tabindex],[href],button,input:not([type=hidden])'));
+ return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
}
focusNext() {
import autoSuggest from "./auto-suggest.js"
import backToTop from "./back-to-top.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 codeHighlighter from "./code-highlighter.js"
+import codeTextarea from "./code-textarea.js"
import collapsible from "./collapsible.js"
import confirmDialog from "./confirm-dialog"
import customCheckbox from "./custom-checkbox.js"
"auto-suggest": autoSuggest,
"back-to-top": backToTop,
"book-sort": bookSort,
- "chapter-toggle": chapterToggle,
+ "chapter-contents": chapterContents,
"code-editor": codeEditor,
"code-highlighter": codeHighlighter,
+ "code-textarea": codeTextarea,
"collapsible": collapsible,
"confirm-dialog": confirmDialog,
"custom-checkbox": customCheckbox,
const currentPaddingTop = computedStyles.getPropertyValue('padding-top');
const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
- height: [`${currentHeight}px`, '0px'],
+ maxHeight: [`${currentHeight}px`, '0px'],
overflow: ['hidden', 'hidden'],
paddingTop: [currentPaddingTop, '0px'],
paddingBottom: [currentPaddingBottom, '0px'],
const targetPaddingTop = computedStyles.getPropertyValue('padding-top');
const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom');
const animStyles = {
- height: ['0px', `${targetHeight}px`],
+ maxHeight: ['0px', `${targetHeight}px`],
overflow: ['hidden', 'hidden'],
paddingTop: ['0px', targetPaddingTop],
paddingBottom: ['0px', targetPaddingBottom],
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.
* Styles must be an object where the keys are style properties, camelcase, and the values
'previous' => 'Previous',
'filter_active' => 'Active Filter:',
'filter_clear' => 'Clear Filter',
+ 'download' => 'Download',
+ 'open_in_tab' => 'Open in Tab',
// Sort Options
'sort_options' => 'Sort Options',
@include lightDark(background-color, #FFF, #222);
box-shadow: $bs-card;
border-radius: 3px;
- border: 1px solid transparent;
.body, p.empty-text {
padding: $-m;
}
}
}
-[chapter-toggle] {
+.chapter-contents-toggle {
cursor: pointer;
margin: 0;
transition: all ease-in-out 180ms;
transform: rotate(90deg);
}
svg[data-icon="caret-right"] + * {
- margin-inline-start: $-xs;
+ margin-inline-start: $-xxs;
}
}
}
}
+
+.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 {
box-shadow: $bs-med;
overflow: hidden;
display: none;
position: absolute;
z-index: 80;
- right: -$-m;
+ right: 0;
+ top: 0;
+ margin-top: $-m;
@include rtl {
right: auto;
left: -$-m;
text-decoration: none;
}
}
- input {
+ input, input:focus {
padding-inline-start: $-xl;
border-radius: 0;
border: 0;
border-bottom: 1px solid #DDD;
}
+ input:focus {
+ outline: 0;
+ }
}
@include smaller-than($m) {
.dropdown-search-dropdown .dropdown-search-list {
max-height: 240px;
}
-}
-
-.custom-select-input {
- max-width: 280px;
- border: 1px solid #D4D4D4;
- border-radius: 3px;
}
\ No newline at end of file
@include lightDark(color, #666, #AAA);
display: inline-block;
font-size: $fs-m;
- padding: $-xs*1.5;
+ padding: $-xs*1.8;
+ height: 40px;
width: 250px;
max-width: 100%;
}
}
-.inline-input-style {
+.title-input input[type="text"] {
display: block;
width: 100%;
padding: $-s;
-}
-
-.title-input input[type="text"] {
- @extend .inline-input-style;
margin-top: 0;
font-size: 2em;
+ height: auto;
}
.title-input.page-title {
max-width: 840px;
margin: 0 auto;
border: none;
+ height: auto;
}
}
}
.description-input textarea {
- @extend .inline-input-style;
+ display: block;
+ width: 100%;
+ padding: $-s;
font-size: $fs-m;
color: #666;
- width: 100%;
+ height: auto;
}
div[editor-type="markdown"] .title-input.page-title input[type="text"] {
}
input {
display: block;
+ padding: $-xs * 1.5;
padding-inline-start: $-l + 4px;
width: 300px;
max-width: 100%;
+ height: auto;
}
&.flexible input {
width: 100%;
color: rgb(250, 250, 250);
border-bottom: 1px solid #DDD;
box-shadow: $bs-card;
- padding: $-xxs 0;
@include lightDark(border-bottom-color, #DDD, #000);
@include whenDark {
filter: saturate(0.8) brightness(0.8);
}
+ .header-links {
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ }
.links {
display: inline-block;
vertical-align: top;
}
.links a {
display: inline-block;
- padding: $-m;
+ padding: 10px $-m;
color: #FFF;
+ border-radius: 3px;
+ }
+ .links a:hover {
+ text-decoration: none;
+ background-color: rgba(255, 255, 255, .15);
}
.dropdown-container {
padding-inline-start: $-m;
.user-name {
vertical-align: top;
position: relative;
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
cursor: pointer;
- > * {
- vertical-align: top;
- }
+ padding: $-s;
+ margin: 0 (-$-s);
+ border-radius: 3px;
+ gap: $-xs;
> span {
padding-inline-start: $-xs;
display: inline-block;
- padding-top: $-xxs;
+ line-height: 1;
}
> svg {
- padding-top: 4px;
font-size: 18px;
+ margin-top: -2px;
+ margin-inline-end: 0;
+ }
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.15);
}
@include between($l, $xl) {
padding-inline-start: $-xs;
header .search-box {
display: inline-block;
- margin-top: 10px;
input {
background-color: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 40px;
color: #EEE;
z-index: 2;
+ height: auto;
+ padding: $-xs*1.5;
padding-inline-start: 40px;
&:focus {
outline: none;
- border: 1px solid rgba(255, 255, 255, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.4);
}
}
button {
z-index: 1;
left: 16px;
+ top: 10px;
+ color: #FFF;
+ opacity: 0.6;
@include lightDark(color, rgba(255, 255, 255, 0.8), #AAA);
@include rtl {
left: auto;
margin-block-end: 0;
}
}
- ::-webkit-input-placeholder { /* Chrome/Opera/Safari */
- color: #DDD;
- }
- ::-moz-placeholder { /* Firefox 19+ */
- color: #DDD;
+ input::placeholder {
+ color: #FFF;
+ opacity: 0.6;
}
@include between($l, $xl) {
max-width: 200px;
}
+ &:focus-within button {
+ opacity: 1;
+ }
}
.logo {
- display: inline-block;
+ display: inline-flex;
+ padding: ($-s - 6px) $-s;
+ margin: 6px (-$-s);
+ gap: $-s;
+ align-items: center;
+ border-radius: 4px;
&:hover {
color: #FFF;
text-decoration: none;
+ background-color: rgba(255, 255, 255, .15);
}
}
+
.logo-text {
- display: inline-block;
font-size: 1.8em;
color: #fff;
font-weight: 400;
- @include padding(14px, $-l, 14px, 0);
- vertical-align: top;
line-height: 1;
}
.logo-image {
- @include margin($-xs, $-s, $-xs, 0);
- vertical-align: top;
height: 43px;
}
overflow: hidden;
position: absolute;
box-shadow: $bs-hover;
- margin-top: -$-xs;
+ margin-top: $-m;
+ padding: $-xs 0;
&.show {
display: block;
}
}
header .links a, header .dropdown-container ul li a, header .dropdown-container ul li button {
text-align: start;
- display: block;
- padding: $-s $-m;
+ display: grid;
+ align-items: center;
+ padding: 8px $-m;
+ gap: $-m;
color: $text-dark;
+ grid-template-columns: 16px auto;
+ line-height: 1.4;
@include lightDark(color, $text-dark, #eee);
svg {
margin-inline-end: $-s;
+ width: 16px;
}
&:hover {
- @include lightDark(background-color, #eee, #333);
- @include lightDark(color, #000, #fff);
+ background-color: var(--color-primary-light);
+ color: var(--color-primary);
text-decoration: none;
}
&:focus {
}
}
-.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 {
a, button, span, span > div {
color: #666;
}
}
+.gap-m {
+ gap: $-m;
+}
+
+.justify-flex-start {
+ justify-content: flex-start;
+}
.justify-flex-end {
justify-content: flex-end;
}
}
@include larger-than($xxl) {
.tri-layout-left-contents, .tri-layout-right-contents {
- padding: $-m;
+ padding: $-xl $-m;
position: sticky;
- top: $-m;
+ top: 0;
max-height: 100vh;
min-height: 50vh;
overflow-y: scroll;
justify-self: stretch;
align-self: stretch;
height: auto;
- margin-inline-end: $-l;
+ margin-inline-end: $-xs;
}
.icon:after {
opacity: 0.5;
> .content {
flex: 1;
}
- .chapter-expansion-toggle {
+ .chapter-contents-toggle {
border-radius: 0 4px 4px 0;
- padding: $-xs $-m;
+ padding: $-xs ($-m + $-xxs);
width: 100%;
text-align: start;
}
- .chapter-expansion-toggle:hover {
+ .chapter-contents-toggle:hover {
background-color: rgba(0, 0, 0, 0.06);
}
}
@include margin($-xs, -$-s, 0, -$-s);
padding-inline-start: 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 {
list-style: none;
}
.entity-list-item {
- padding-top: $-xxs;
- padding-bottom: $-xxs;
+ padding-top: 2px;
+ padding-bottom: 2px;
background-clip: content-box;
border-radius: 0 3px 3px 0;
padding-inline-end: 0;
.content {
+ width: 100%;
padding-top: $-xs;
padding-bottom: $-xs;
max-width: calc(100% - 20px);
}
}
.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 {
margin-top: -$-xs;
margin-top: -.2rem;
margin-inline-start: -1rem;
}
- [chapter-toggle] {
- padding-inline-start: .7rem;
- padding-bottom: .2rem;
+ .chapter-contents-toggle {
+ display: block;
+ 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 {
z-index: 2;
align-self: stretch;
flex-shrink: 0;
border-radius: 1px;
- opacity: 0.6;
+ opacity: 0.8;
}
.entity-list-item .icon:after {
opacity: 1;
}
}
-.chapter-child-menu {
- ul.sub-menu {
- display: none;
- padding-inline-start: 0;
- position: relative;
- }
- [chapter-toggle].open + .sub-menu {
- display: block;
- }
+.chapter-child-menu ul.sub-menu {
+ display: none;
+ padding-inline-start: 0;
+ position: relative;
+ margin-bottom: 0;
}
// Sortable Lists
padding: $-s $-m;
display: flex;
align-items: center;
+ gap: $-m;
background-color: transparent;
border: 0;
width: 100%;
color: #666;
}
> span:first-child {
- margin-inline-end: $-m;
flex-basis: 1.88em;
flex: none;
}
cursor: pointer;
}
&:not(.no-hover):hover {
+ @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06));
text-decoration: none;
- background-color: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
&.outline-hover:hover {
}
}
-.card .entity-list-item:not(.no-hover):hover {
- @include lightDark(background-color, #F2F2F2, #2d2d2d)
+.split-icon-list-item {
+ 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 {
background-color: #EEEEEE;
}
.entity-list-item-children {
- padding: $-m;
+ padding: $-m $-l;
> div {
overflow: hidden;
- padding: $-xs 0;
- margin-top: -$-xs;
+ padding: 0 0 $-xs 0;
}
.entity-chip {
text-overflow: ellipsis;
display: block;
white-space: nowrap;
}
+ > .entity-list > .entity-list-item:last-child {
+ margin-bottom: -$-xs;
+ }
}
.entity-list-item-image {
font-size: $fs-m * 0.8;
padding-top: $-xs;
}
+ .entity-list-item p:empty {
+ padding-top: 0;
+ }
p {
margin: 0;
}
right: 0;
margin: $-m 0;
@include lightDark(background-color, #fff, #333);
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18);
- border-radius: 1px;
+ box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.18);
+ border-radius: 3px;
min-width: 180px;
padding: $-xs 0;
@include lightDark(color, #555, #eee);
}
}
+// 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
.featured-image-container {
position: relative;
}
}
}
+
+.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;
+ }
+}
}
}
-.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;
width: 1.88em;
height: 1.88em;
+ flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
animation-timing-function: cubic-bezier(.62, .28, .23, .99);
margin-inline-end: 4px;
background-color: var(--color-page);
- animation-delay: 0.3s;
+ animation-delay: -300ms;
}
> div:first-child {
left: -($loadingSize+$-xs);
background-color: var(--color-book);
- animation-delay: 0s;
+ animation-delay: -600ms;
}
> div:last-of-type {
left: $loadingSize+$-xs;
background-color: var(--color-chapter);
- animation-delay: 0.6s;
+ animation-delay: 0ms;
}
> span {
margin-inline-start: $-s;
.skip-to-content-link {
position: fixed;
- top: -$-xxl;
+ top: -52px;
left: 0;
background-color: #FFF;
z-index: 15;
<div component="attachments-list">
@foreach($attachments as $attachment)
<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>
- <span class="icon">@icon($attachment->external ? 'export' : 'file')</span>
- <span>{{ $attachment->name }}</span>
- </a>
+ <div class="split-icon-list-item attachment-{{ $attachment->external ? 'link' : 'file' }}">
+ <a href="{{ $attachment->getUrl() }}" @if($attachment->external) target="_blank" @endif>
+ <div class="icon">@icon($attachment->external ? 'export' : 'file')</div>
+ <div class="label">{{ $attachment->name }}</div>
+ </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>
@endforeach
</div>
\ No newline at end of file
@section('right')
<div class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
- <div class="text-small text-muted blended-links">
+ <div class="blended-links">
@include('entities.meta', ['entity' => $book])
@if($book->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.books_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.books_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
-<div class="chapter-child-menu">
- <button chapter-toggle type="button" aria-expanded="{{ $isOpen ? 'true' : 'false' }}"
- class="text-muted @if($isOpen) open @endif">
+<div component="chapter-contents" class="chapter-child-menu">
+ <button type="button"
+ 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>
</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)
<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' : '' ])
<div class="content">
<h4 class="entity-list-item-name break-text">{{ $chapter->name }}</h4>
<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>
</a>
@if ($chapter->visible_pages->count() > 0)
<div class="chapter chapter-expansion">
<span class="icon text-chapter">@icon('page')</span>
- <div class="content">
- <button type="button" chapter-toggle
+ <div component="chapter-contents" class="content">
+ <button type="button"
+ refs="chapter-contents@toggle"
aria-expanded="false"
- class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->visible_pages->count()) }}</span></button>
- <div class="inset-list">
+ class="text-muted chapter-contents-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->visible_pages->count()) }}</span></button>
+ <div refs="chapter-contents@list" class="inset-list chapter-contents-list">
<div class="entity-list-item-children">
@include('entities.list', ['entities' => $chapter->visible_pages])
</div>
<div class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
- <div class="blended-links text-small text-muted">
+ <div class="blended-links">
@include('entities.meta', ['entity' => $chapter])
@if($book->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.books_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.books_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
@if($chapter->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.chapters_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.chapters_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
class="mobile-menu-toggle hide-over-l">@icon('more')</button>
</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())
<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>
@endif
</div>
- <div class="text-right">
- <nav refs="header-mobile-toggle@menu" class="header-links">
- <div class="links text-center">
- @if (hasAppAccess())
- <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
- @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
- <a href="{{ url('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
- @endif
- <a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
- @if(signedInUser() && userCan('settings-manage'))
- <a href="{{ url('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
- @endif
- @if(signedInUser() && userCan('users-manage') && !userCan('settings-manage'))
- <a href="{{ url('/settings/users') }}">@icon('users'){{ trans('settings.users') }}</a>
- @endif
+ <nav refs="header-mobile-toggle@menu" class="header-links">
+ <div class="links text-center">
+ @if (hasAppAccess())
+ <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
+ @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+ <a href="{{ url('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
@endif
+ <a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
+ @if(signedInUser() && userCan('settings-manage'))
+ <a href="{{ url('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
+ @endif
+ @if(signedInUser() && userCan('users-manage') && !userCan('settings-manage'))
+ <a href="{{ url('/settings/users') }}">@icon('users'){{ trans('settings.users') }}</a>
+ @endif
+ @endif
- @if(!signedInUser())
- @if(setting('registration-enabled') && config('auth.method') === 'standard')
- <a href="{{ url('/register') }}">@icon('new-user'){{ trans('auth.sign_up') }}</a>
- @endif
- <a href="{{ url('/login') }}">@icon('login'){{ trans('auth.log_in') }}</a>
+ @if(!signedInUser())
+ @if(setting('registration-enabled') && config('auth.method') === 'standard')
+ <a href="{{ url('/register') }}">@icon('new-user'){{ trans('auth.sign_up') }}</a>
@endif
- </div>
- @if(signedInUser())
- <?php $currentUser = user(); ?>
- <div class="dropdown-container" component="dropdown" option:dropdown:bubble-escapes="true">
+ <a href="{{ url('/login') }}">@icon('login'){{ trans('auth.log_in') }}</a>
+ @endif
+ </div>
+ @if(signedInUser())
+ <?php $currentUser = user(); ?>
+ <div class="dropdown-container" component="dropdown" option:dropdown:bubble-escapes="true">
<span class="user-name py-s hide-under-l" refs="dropdown@toggle"
aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.profile_menu') }}" tabindex="0">
<img class="avatar" src="{{$currentUser->getAvatar(30)}}" alt="{{ $currentUser->name }}">
<span class="name">{{ $currentUser->getShortName(9) }}</span> @icon('caret-down')
</span>
- <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
- <li>
- <a href="{{ url('/favourites') }}" class="icon-item">
- @icon('star')
- <div>{{ trans('entities.my_favourites') }}</div>
- </a>
- </li>
- <li>
- <a href="{{ $currentUser->getProfileUrl() }}" class="icon-item">
- @icon('user')
- <div>{{ trans('common.view_profile') }}</div>
- </a>
- </li>
- <li>
- <a href="{{ $currentUser->getEditUrl() }}" class="icon-item">
- @icon('edit')
- <div>{{ trans('common.edit_profile') }}</div>
- </a>
- </li>
- <li>
- <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
- method="post">
- {{ csrf_field() }}
- <button class="icon-item">
- @icon('logout')
- <div>{{ trans('auth.logout') }}</div>
- </button>
- </form>
- </li>
- <li><hr></li>
- <li>
- @include('common.dark-mode-toggle', ['classes' => 'icon-item'])
- </li>
- </ul>
- </div>
- @endif
- </nav>
- </div>
+ <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+ <li>
+ <a href="{{ url('/favourites') }}" class="icon-item">
+ @icon('star')
+ <div>{{ trans('entities.my_favourites') }}</div>
+ </a>
+ </li>
+ <li>
+ <a href="{{ $currentUser->getProfileUrl() }}" class="icon-item">
+ @icon('user')
+ <div>{{ trans('common.view_profile') }}</div>
+ </a>
+ </li>
+ <li>
+ <a href="{{ $currentUser->getEditUrl() }}" class="icon-item">
+ @icon('edit')
+ <div>{{ trans('common.edit_profile') }}</div>
+ </a>
+ </li>
+ <li>
+ <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
+ method="post">
+ {{ csrf_field() }}
+ <button class="icon-item">
+ @icon('logout')
+ <div>{{ trans('auth.logout') }}</div>
+ </button>
+ </form>
+ </li>
+ <li><hr></li>
+ <li>
+ @include('common.dark-mode-toggle', ['classes' => 'icon-item'])
+ </li>
+ </ul>
+ </div>
+ @endif
+ </nav>
</div>
</header>
option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}"
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">
<div class="separator">@icon('chevron-right')</div>
</div>
<div refs="dropdown-search@loading">
@include('common.loading-icon')
</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>
\ No newline at end of file
-<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"
aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('entities.export') }}" tabindex="0">
<span>@icon('export')</span>
<span>{{ trans('entities.export') }}</span>
</div>
+
<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/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/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
</ul>
+
</div>
<div class="entity-meta">
@if($entity->isA('revision'))
- <div>
- @icon('history'){{ trans('entities.pages_revision') }}
- {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
+ <div class="entity-meta-item">
+ @icon('history')
+ <div>
+ {{ trans('entities.pages_revision') }}
+ {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
+ </div>
</div>
@endif
@if ($entity->isA('page'))
- <div>
- @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif
- @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
- @if (userCan('page-update', $entity))</a>@endif
- </div>
+ @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}" class="entity-meta-item"> @else <div class="entity-meta-item"> @endif
+ @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
+ @if (userCan('page-update', $entity))</a> @else </div> @endif
@endif
@if ($entity->ownedBy && $entity->owned_by !== $entity->created_by)
- <div>
- @icon('user'){!! trans('entities.meta_owned_name', [
- 'user' => "<a href='{$entity->ownedBy->getProfileUrl()}'>".e($entity->ownedBy->name). "</a>"
- ]) !!}
+ <div class="entity-meta-item">
+ @icon('user')
+ <div>
+ {!! trans('entities.meta_owned_name', [
+ 'user' => "<a href='{$entity->ownedBy->getProfileUrl()}'>".e($entity->ownedBy->name). "</a>"
+ ]) !!}
+ </div>
</div>
@endif
@if ($entity->createdBy)
- <div>
- @icon('star'){!! trans('entities.meta_created_name', [
- 'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
- 'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
- ]) !!}
+ <div class="entity-meta-item">
+ @icon('star')
+ <div>
+ {!! trans('entities.meta_created_name', [
+ 'timeLength' => '<span title="'.$entity->created_at->toDayDateTimeString().'">'.$entity->created_at->diffForHumans() . '</span>',
+ 'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".e($entity->createdBy->name). "</a>"
+ ]) !!}
+ </div>
</div>
@else
- <div>
- @icon('star')<span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
+ <div class="entity-meta-item">
+ @icon('star')
+ <span title="{{$entity->created_at->toDayDateTimeString()}}">{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->diffForHumans()]) }}</span>
</div>
@endif
@if ($entity->updatedBy)
- <div>
- @icon('edit'){!! trans('entities.meta_updated_name', [
- 'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
- 'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
- ]) !!}
+ <div class="entity-meta-item">
+ @icon('edit')
+ <div>
+ {!! trans('entities.meta_updated_name', [
+ 'timeLength' => '<span title="' . $entity->updated_at->toDayDateTimeString() .'">' . $entity->updated_at->diffForHumans() .'</span>',
+ 'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".e($entity->updatedBy->name). "</a>"
+ ]) !!}
+ </div>
</div>
@elseif (!$entity->isA('revision'))
- <div>
- @icon('edit')<span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
+ <div class="entity-meta-item">
+ @icon('edit')
+ <span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
</div>
@endif
</div>
\ No newline at end of file
<div>
<div class="form-group">
<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 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"
>
<input refs="user-select@input" type="hidden" name="{{ $name }}" value="{{ $user->id ?? '' }}">
<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">
- <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)
- <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>
@else
<span>{{ trans('settings.users_none_selected') }}</span>
@endif
</div>
- <span style="font-size: {{ $compact ? '1.15rem' : '1.5rem' }}; margin-left: auto;">
+ <span class="dropdown-search-toggle-select-caret">
@icon('caret-down')
</span>
</div>
<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">
@yield('left')
</aside>
</div>
</div>
- <div class="tri-layout-right print-hidden pt-m">
+ <div class="tri-layout-right print-hidden">
<aside class="tri-layout-right-contents">
@yield('right')
</aside>
</div>
<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>
<ul refs="dropdown@menu" class="wide dropdown-menu">
<li class="px-l py-m">
@section('right')
<div id="page-details" class="entity-details mb-xl">
<h5>{{ trans('common.details') }}</h5>
- <div class="body text-small blended-links">
+ <div class="blended-links">
@include('entities.meta', ['entity' => $page])
@if($book->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.books_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.books_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
@if($page->chapter && $page->chapter->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.chapters_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.chapters_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
@if($page->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.pages_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.pages_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
@if($page->template)
- <div>
- @icon('template'){{ trans('entities.pages_is_template') }}
+ <div class="entity-meta-item">
+ @icon('template')
+ <div>{{ trans('entities.pages_is_template') }}</div>
</div>
@endif
</div>
<h1 class="list-heading">{{ trans('settings.audit') }}</h1>
<p class="text-muted">{{ trans('settings.audit_desc') }}</p>
- <div class="flex-container-row">
- <div component="dropdown" class="list-sort-type dropdown-container mr-m">
+ <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">
<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>
<ul refs="dropdown@menu" class="dropdown-menu">
</ul>
</div>
- <form action="{{ url('/settings/audit') }}" method="get" class="flex-container-row mr-m">
- @if(!empty($listDetails['event']))
- <input type="hidden" name="event" value="{{ $listDetails['event'] }}">
- @endif
-
- @foreach(['date_from', 'date_to'] as $filterKey)
- <div class="mr-m">
- <label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label>
- <input id="audit_filter_{{ $filterKey }}"
- component="submit-on-change"
- type="date"
- name="{{ $filterKey }}"
- value="{{ $listDetails[$filterKey] ?? '' }}">
- </div>
- @endforeach
+ @if(!empty($listDetails['event']))
+ <input type="hidden" name="event" value="{{ $listDetails['event'] }}">
+ @endif
- <div class="form-group ml-auto mr-m"
- component="submit-on-change"
- option:submit-on-change:filter='[name="user"]'>
- <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])
+ @foreach(['date_from', 'date_to'] as $filterKey)
+ <div class=>
+ <label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label>
+ <input id="audit_filter_{{ $filterKey }}"
+ component="submit-on-change"
+ type="date"
+ name="{{ $filterKey }}"
+ value="{{ $listDetails[$filterKey] ?? '' }}">
</div>
+ @endforeach
+ <div class="form-group"
+ component="submit-on-change"
+ option:submit-on-change:filter='[name="user"]'>
+ <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'])
+ </div>
- <div class="form-group ml-auto">
- <label for="ip">{{ trans('settings.audit_table_ip') }}</label>
- @include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
- <input type="submit" style="display: none">
- </div>
- </form>
- </div>
+
+ <div class="form-group">
+ <label for="ip">{{ trans('settings.audit_table_ip') }}</label>
+ @include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
+ <input type="submit" style="display: none">
+ </div>
+ </form>
<hr class="mt-l mb-s">
<div>
<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>
- <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>
</div>
<div id="details" class="mb-xl">
<h5>{{ trans('common.details') }}</h5>
- <div class="text-small text-muted blended-links">
+ <div class="blended-links">
@include('entities.meta', ['entity' => $shelf])
@if($shelf->restricted)
<div class="active-restriction">
@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
- @icon('lock'){{ trans('entities.shelves_permissions_active') }}
+ <div class="entity-meta-item">
+ @icon('lock')
+ <div>{{ trans('entities.shelves_permissions_active') }}</div>
+ </div>
@endif
</div>
@endif
<p class="small">{{ trans('settings.users_migrate_ownership_desc') }}</p>
</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>
@endif
--- /dev/null
+<?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));
+ }
+
+}
\ No newline at end of file