diff --git a/app/Auth/Access/GroupSyncService.php b/app/Auth/Access/GroupSyncService.php index db19b007a..74f0539d8 100644 --- a/app/Auth/Access/GroupSyncService.php +++ b/app/Auth/Access/GroupSyncService.php @@ -28,10 +28,8 @@ class GroupSyncService */ 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; } } @@ -39,6 +37,18 @@ class GroupSyncService 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. diff --git a/database/factories/Auth/RoleFactory.php b/database/factories/Auth/RoleFactory.php index a952e0487..6dbc1394b 100644 --- a/database/factories/Auth/RoleFactory.php +++ b/database/factories/Auth/RoleFactory.php @@ -23,6 +23,7 @@ class RoleFactory extends Factory return [ 'display_name' => $this->faker->sentence(3), 'description' => $this->faker->sentence(10), + 'external_auth_id' => '', ]; } } diff --git a/package.json b/package.json index 4b16f6f46..539e05057 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "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\"", diff --git a/resources/icons/download.svg b/resources/icons/download.svg new file mode 100644 index 000000000..6299571d6 --- /dev/null +++ b/resources/icons/download.svg @@ -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> \ No newline at end of file diff --git a/resources/js/code.mjs b/resources/js/code.mjs index 8e2ed72c8..537b0d108 100644 --- a/resources/js/code.mjs +++ b/resources/js/code.mjs @@ -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. * @param cmInstance diff --git a/resources/js/components/chapter-contents.js b/resources/js/components/chapter-contents.js new file mode 100644 index 000000000..c824d0f78 --- /dev/null +++ b/resources/js/components/chapter-contents.js @@ -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; diff --git a/resources/js/components/chapter-toggle.js b/resources/js/components/chapter-toggle.js deleted file mode 100644 index bfd0ac729..000000000 --- a/resources/js/components/chapter-toggle.js +++ /dev/null @@ -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; diff --git a/resources/js/components/code-textarea.js b/resources/js/components/code-textarea.js new file mode 100644 index 000000000..988e51f19 --- /dev/null +++ b/resources/js/components/code-textarea.js @@ -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; \ No newline at end of file diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js index e2d55f969..81fa940c2 100644 --- a/resources/js/components/dropdown-search.js +++ b/resources/js/components/dropdown-search.js @@ -1,4 +1,5 @@ import {debounce} from "../services/util"; +import {transitionHeight} from "../services/animations"; class DropdownSearch { @@ -51,7 +52,9 @@ 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); } diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index f761ecf01..473db37d4 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -28,18 +28,31 @@ class DropDown { 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 @@ -74,18 +87,21 @@ class DropDown { 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() { diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 6a4a8c2b0..f360e2b0c 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -6,9 +6,10 @@ import attachmentsList from "./attachments-list.js" 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" @@ -62,9 +63,10 @@ const componentMapping = { "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, diff --git a/resources/js/services/animations.js b/resources/js/services/animations.js index 278a765d5..12b8077cf 100644 --- a/resources/js/services/animations.js +++ b/resources/js/services/animations.js @@ -49,7 +49,7 @@ export function slideUp(element, animTime = 400) { 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'], @@ -73,7 +73,7 @@ export function slideDown(element, animTime = 400) { 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], @@ -82,6 +82,38 @@ export function slideDown(element, animTime = 400) { 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 diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 2f09e53d1..703a70c7e 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -47,6 +47,8 @@ return [ 'previous' => 'Previous', 'filter_active' => 'Active Filter:', 'filter_clear' => 'Clear Filter', + 'download' => 'Download', + 'open_in_tab' => 'Open in Tab', // Sort Options 'sort_options' => 'Sort Options', diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 7d408cd1b..0398224ca 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -66,7 +66,6 @@ @include lightDark(background-color, #FFF, #222); box-shadow: $bs-card; border-radius: 3px; - border: 1px solid transparent; .body, p.empty-text { padding: $-m; } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index bce456cf2..e3c9d5eea 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -61,7 +61,7 @@ } } -[chapter-toggle] { +.chapter-contents-toggle { cursor: pointer; margin: 0; transition: all ease-in-out 180ms; @@ -77,7 +77,7 @@ transform: rotate(90deg); } 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 { box-shadow: $bs-med; overflow: hidden; @@ -739,7 +788,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: none; position: absolute; z-index: 80; - right: -$-m; + right: 0; + top: 0; + margin-top: $-m; @include rtl { right: auto; left: -$-m; @@ -767,12 +818,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { 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) { @@ -784,10 +838,4 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .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 diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 665b1213b..73799f0a0 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -7,7 +7,8 @@ @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%; @@ -350,16 +351,13 @@ input[type=color] { } } -.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 { @@ -373,6 +371,7 @@ input[type=color] { max-width: 840px; margin: 0 auto; border: none; + height: auto; } } @@ -383,10 +382,12 @@ input[type=color] { } .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"] { @@ -413,9 +414,11 @@ 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%; diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index f070f5a18..923f026c2 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -21,19 +21,28 @@ header { 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; @@ -49,19 +58,25 @@ header { .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; @@ -79,22 +94,26 @@ header { 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; @@ -104,36 +123,39 @@ header .search-box { 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; } @@ -172,23 +194,29 @@ header .search-box { 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 { @@ -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 { a, button, span, span > div { color: #666; diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index b1c80cb53..14a37dd4a 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -155,6 +155,13 @@ body.flexbox { } } +.gap-m { + gap: $-m; +} + +.justify-flex-start { + justify-content: flex-start; +} .justify-flex-end { justify-content: flex-end; } @@ -295,9 +302,9 @@ body.flexbox { } @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; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 26d12a25d..19060fbbf 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -6,7 +6,7 @@ justify-self: stretch; align-self: stretch; height: auto; - margin-inline-end: $-l; + margin-inline-end: $-xs; } .icon:after { opacity: 0.5; @@ -56,13 +56,13 @@ > .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); } } @@ -157,22 +157,6 @@ @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; @@ -181,19 +165,20 @@ } .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; @@ -209,9 +194,18 @@ 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; @@ -220,7 +214,7 @@ align-self: stretch; flex-shrink: 0; border-radius: 1px; - opacity: 0.6; + opacity: 0.8; } .entity-list-item .icon:after { opacity: 1; @@ -230,15 +224,11 @@ } } -.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 @@ -415,6 +405,7 @@ ul.pagination { padding: $-s $-m; display: flex; align-items: center; + gap: $-m; background-color: transparent; border: 0; width: 100%; @@ -424,7 +415,6 @@ ul.pagination { color: #666; } > span:first-child { - margin-inline-end: $-m; flex-basis: 1.88em; flex: none; } @@ -439,8 +429,8 @@ ul.pagination { 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 { @@ -463,19 +453,74 @@ ul.pagination { } } -.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; @@ -485,6 +530,9 @@ ul.pagination { display: block; white-space: nowrap; } + > .entity-list > .entity-list-item:last-child { + margin-bottom: -$-xs; + } } .entity-list-item-image { @@ -531,6 +579,9 @@ ul.pagination { font-size: $fs-m * 0.8; padding-top: $-xs; } + .entity-list-item p:empty { + padding-top: 0; + } p { margin: 0; } @@ -574,8 +625,8 @@ ul.pagination { 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); @@ -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 .featured-image-container { 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; + } +} diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 73819975f..3ceec61d0 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -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; width: 1.88em; height: 1.88em; + flex-shrink: 0; display: flex; align-items: center; justify-content: center; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 582bf7c75..ee99d7668 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -79,17 +79,17 @@ $loadingSize: 10px; 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; @@ -138,7 +138,7 @@ $btt-size: 40px; .skip-to-content-link { position: fixed; - top: -$-xxl; + top: -52px; left: 0; background-color: #FFF; z-index: 15; diff --git a/resources/views/attachments/list.blade.php b/resources/views/attachments/list.blade.php index f0a1354ea..a6ffb709b 100644 --- a/resources/views/attachments/list.blade.php +++ b/resources/views/attachments/list.blade.php @@ -1,10 +1,27 @@ <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 diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 5263bc810..e0cb4b862 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -67,14 +67,20 @@ @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 diff --git a/resources/views/chapters/parts/child-menu.blade.php b/resources/views/chapters/parts/child-menu.blade.php index a00f0f7e1..8fdd09143 100644 --- a/resources/views/chapters/parts/child-menu.blade.php +++ b/resources/views/chapters/parts/child-menu.blade.php @@ -1,9 +1,14 @@ -<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' : '' ]) diff --git a/resources/views/chapters/parts/list-item.blade.php b/resources/views/chapters/parts/list-item.blade.php index 285e34893..c3e735e2b 100644 --- a/resources/views/chapters/parts/list-item.blade.php +++ b/resources/views/chapters/parts/list-item.blade.php @@ -5,18 +5,19 @@ <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> diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index edd39edde..3e015616a 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -64,15 +64,21 @@ <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 @@ -80,9 +86,15 @@ @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 diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index b5ac520c1..197b80c27 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -17,7 +17,7 @@ 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> @@ -28,76 +28,74 @@ @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> diff --git a/resources/views/entities/breadcrumb-listing.blade.php b/resources/views/entities/breadcrumb-listing.blade.php index 929f56ed3..1efe3ba34 100644 --- a/resources/views/entities/breadcrumb-listing.blade.php +++ b/resources/views/entities/breadcrumb-listing.blade.php @@ -2,7 +2,7 @@ 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> @@ -18,6 +18,6 @@ <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 diff --git a/resources/views/entities/export-menu.blade.php b/resources/views/entities/export-menu.blade.php index dd7231095..bac240b1e 100644 --- a/resources/views/entities/export-menu.blade.php +++ b/resources/views/entities/export-menu.blade.php @@ -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" 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> diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 298cc7c3e..83ff23762 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -1,50 +1,62 @@ <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 diff --git a/resources/views/form/entity-permissions.blade.php b/resources/views/form/entity-permissions.blade.php index ed04bc041..206955fe9 100644 --- a/resources/views/form/entity-permissions.blade.php +++ b/resources/views/form/entity-permissions.blade.php @@ -15,7 +15,7 @@ <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> diff --git a/resources/views/form/user-select.blade.php b/resources/views/form/user-select.blade.php index 8823bb075..743795a6d 100644 --- a/resources/views/form/user-select.blade.php +++ b/resources/views/form/user-select.blade.php @@ -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" > <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> diff --git a/resources/views/layouts/tri.blade.php b/resources/views/layouts/tri.blade.php index e95b21445..4571f4471 100644 --- a/resources/views/layouts/tri.blade.php +++ b/resources/views/layouts/tri.blade.php @@ -27,7 +27,7 @@ <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> @@ -39,7 +39,7 @@ </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> diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php index 4846f4b76..fa5cb7374 100644 --- a/resources/views/pages/parts/editor-toolbar.blade.php +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -65,7 +65,9 @@ </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"> diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 0111047c6..2a71c6021 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -76,15 +76,21 @@ @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 @@ -92,9 +98,15 @@ @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 @@ -102,16 +114,23 @@ @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> diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 506a735a2..b856d1150 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -9,8 +9,9 @@ <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"> @@ -21,37 +22,35 @@ </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 + @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 - - <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"> diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index b7be95b4a..a7392196b 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -119,7 +119,13 @@ <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> diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 0d592468d..4d440b635 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -81,14 +81,20 @@ <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 diff --git a/resources/views/users/delete.blade.php b/resources/views/users/delete.blade.php index 9ee5d4c05..b18c182eb 100644 --- a/resources/views/users/delete.blade.php +++ b/resources/views/users/delete.blade.php @@ -19,7 +19,7 @@ <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 diff --git a/tests/Auth/GroupSyncServiceTest.php b/tests/Auth/GroupSyncServiceTest.php new file mode 100644 index 000000000..ee8dee008 --- /dev/null +++ b/tests/Auth/GroupSyncServiceTest.php @@ -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)); + } + +} \ No newline at end of file