]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #3830 from BookStackApp/shortcuts
authorDan Brown <redacted>
Thu, 10 Nov 2022 10:32:56 +0000 (10:32 +0000)
committerGitHub <redacted>
Thu, 10 Nov 2022 10:32:56 +0000 (10:32 +0000)
User interface shortcuts system

36 files changed:
app/Auth/UserRepo.php
app/Config/setting-defaults.php
app/Http/Controllers/UserPreferencesController.php
app/Settings/SettingService.php
app/Settings/UserShortcutMap.php [new file with mode: 0644]
resources/icons/shortcuts.svg [new file with mode: 0644]
resources/js/components/code-editor.js
resources/js/components/index.js
resources/js/components/shortcut-input.js [new file with mode: 0644]
resources/js/components/shortcuts.js [new file with mode: 0644]
resources/lang/en/common.php
resources/lang/en/preferences.php [new file with mode: 0644]
resources/sass/_components.scss
resources/sass/_forms.scss
resources/views/books/index.blade.php
resources/views/books/show.blade.php
resources/views/chapters/show.blade.php
resources/views/common/dark-mode-toggle.blade.php
resources/views/common/header.blade.php
resources/views/common/sort.blade.php
resources/views/entities/export-menu.blade.php
resources/views/entities/favourite-action.blade.php
resources/views/entities/sibling-navigation.blade.php
resources/views/entities/view-toggle.blade.php
resources/views/home/parts/expand-toggle.blade.php
resources/views/home/shelves.blade.php
resources/views/layouts/base.blade.php
resources/views/pages/show.blade.php
resources/views/shelves/index.blade.php
resources/views/shelves/show.blade.php
resources/views/users/preferences/parts/shortcut-control.blade.php [new file with mode: 0644]
resources/views/users/preferences/shortcuts.blade.php [new file with mode: 0644]
routes/web.php
tests/Entity/BookTest.php
tests/User/UserManagementTest.php
tests/User/UserPreferencesTest.php

index c589fd9647bcbca5a3e7228157d02626fb05aee3..78bcb978ebc997f3346d0e8dfb8a319cbfe641a4 100644 (file)
@@ -158,6 +158,9 @@ class UserRepo
         // Delete user profile images
         $this->userAvatar->destroyAllForUser($user);
 
+        // Delete related activities
+        setting()->deleteUserSettings($user->id);
+
         if (!empty($newOwnerId)) {
             $newOwner = User::query()->find($newOwnerId);
             if (!is_null($newOwner)) {
index cb6082c528619ba420d8e1d657704d9350038b0d..5e1e4348ab4e7e351d6067af59179ff83bf8ff42 100644 (file)
@@ -26,6 +26,8 @@ return [
 
     // User-level default settings
     'user' => [
+        'ui-shortcuts'          => '{}',
+        'ui-shortcuts-enabled'  => false,
         'dark-mode-enabled'     => env('APP_DEFAULT_DARK_MODE', false),
         'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
         'bookshelf_view_type'   => env('APP_VIEWS_BOOKSHELF', 'grid'),
index 972742e0318fc42155833015b82d1dc7cfbfc604..aef95971288986ceb6344d67b8508c66fce84d3f 100644 (file)
@@ -3,6 +3,7 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\UserRepo;
+use BookStack\Settings\UserShortcutMap;
 use Illuminate\Http\Request;
 
 class UserPreferencesController extends Controller
@@ -15,70 +16,76 @@ class UserPreferencesController extends Controller
     }
 
     /**
-     * Update the user's preferred book-list display setting.
+     * Show the user-specific interface shortcuts.
      */
-    public function switchBooksView(Request $request, int $id)
+    public function showShortcuts()
     {
-        return $this->switchViewType($id, $request, 'books');
-    }
+        $shortcuts = UserShortcutMap::fromUserPreferences();
+        $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
 
-    /**
-     * Update the user's preferred shelf-list display setting.
-     */
-    public function switchShelvesView(Request $request, int $id)
-    {
-        return $this->switchViewType($id, $request, 'bookshelves');
+        return view('users.preferences.shortcuts', [
+            'shortcuts' => $shortcuts,
+            'enabled' => $enabled,
+        ]);
     }
 
     /**
-     * Update the user's preferred shelf-view book list display setting.
+     * Update the user-specific interface shortcuts.
      */
-    public function switchShelfView(Request $request, int $id)
+    public function updateShortcuts(Request $request)
     {
-        return $this->switchViewType($id, $request, 'bookshelf');
+        $enabled = $request->get('enabled') === 'true';
+        $providedShortcuts = $request->get('shortcut', []);
+        $shortcuts = new UserShortcutMap($providedShortcuts);
+
+        setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
+        setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
+
+        $this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
+
+        return redirect('/preferences/shortcuts');
     }
 
     /**
-     * For a type of list, switch with stored view type for a user.
+     * Update the preferred view format for a list view of the given type.
      */
-    protected function switchViewType(int $userId, Request $request, string $listName)
+    public function changeView(Request $request, string $type)
     {
-        $this->checkPermissionOrCurrentUser('users-manage', $userId);
+        $valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
+        if (!in_array($type, $valueViewTypes)) {
+            return redirect()->back(500);
+        }
 
-        $viewType = $request->get('view_type');
-        if (!in_array($viewType, ['grid', 'list'])) {
-            $viewType = 'list';
+        $view = $request->get('view');
+        if (!in_array($view, ['grid', 'list'])) {
+            $view = 'list';
         }
 
-        $user = $this->userRepo->getById($userId);
-        $key = $listName . '_view_type';
-        setting()->putUser($user, $key, $viewType);
+        $key = $type . '_view_type';
+        setting()->putForCurrentUser($key, $view);
 
-        return redirect()->back(302, [], "/settings/users/$userId");
+        return redirect()->back(302, [], "/");
     }
 
     /**
      * Change the stored sort type for a particular view.
      */
-    public function changeSort(Request $request, string $id, string $type)
+    public function changeSort(Request $request, string $type)
     {
         $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
 
-        $this->checkPermissionOrCurrentUser('users-manage', $id);
-
         $sort = substr($request->get('sort') ?: 'name', 0, 50);
         $order = $request->get('order') === 'desc' ? 'desc' : 'asc';
 
-        $user = $this->userRepo->getById($id);
         $sortKey = $type . '_sort';
         $orderKey = $type . '_sort_order';
-        setting()->putUser($user, $sortKey, $sort);
-        setting()->putUser($user, $orderKey, $order);
+        setting()->putForCurrentUser($sortKey, $sort);
+        setting()->putForCurrentUser($orderKey, $order);
 
-        return redirect()->back(302, [], "/settings/users/{$id}");
+        return redirect()->back(302, [], "/");
     }
 
     /**
@@ -87,7 +94,7 @@ class UserPreferencesController extends Controller
     public function toggleDarkMode()
     {
         $enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
-        setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
+        setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
 
         return redirect()->back();
     }
@@ -95,18 +102,15 @@ class UserPreferencesController extends Controller
     /**
      * Update the stored section expansion preference for the given user.
      */
-    public function updateExpansionPreference(Request $request, string $id, string $key)
+    public function changeExpansion(Request $request, string $type)
     {
-        $this->checkPermissionOrCurrentUser('users-manage', $id);
-        $keyWhitelist = ['home-details'];
-        if (!in_array($key, $keyWhitelist)) {
+        $typeWhitelist = ['home-details'];
+        if (!in_array($type, $typeWhitelist)) {
             return response('Invalid key', 500);
         }
 
         $newState = $request->get('expand', 'false');
-
-        $user = $this->userRepo->getById($id);
-        setting()->putUser($user, 'section_expansion#' . $key, $newState);
+        setting()->putForCurrentUser('section_expansion#' . $type, $newState);
 
         return response('', 204);
     }
@@ -129,6 +133,6 @@ class UserPreferencesController extends Controller
             array_splice($currentFavorites, $index, 1);
         }
 
-        setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
+        setting()->putForCurrentUser('code-language-favourites', implode(',', $currentFavorites));
     }
 }
index f2c4c8305c47c2db227a79456e880422171dc500..9f0a41ea2fceb228bde62b8ad687ac502eb7a9b4 100644 (file)
@@ -194,6 +194,8 @@ class SettingService
 
     /**
      * Put a user-specific setting into the database.
+     * Can only take string value types since this may use
+     * the session which is less flexible to data types.
      */
     public function putUser(User $user, string $key, string $value): bool
     {
@@ -206,6 +208,16 @@ class SettingService
         return $this->put($this->userKey($user->id, $key), $value);
     }
 
+    /**
+     * Put a user-specific setting into the database for the current access user.
+     * Can only take string value types since this may use
+     * the session which is less flexible to data types.
+     */
+    public function putForCurrentUser(string $key, string $value)
+    {
+        return $this->putUser(user(), $key, $value);
+    }
+
     /**
      * Convert a setting key into a user-specific key.
      */
diff --git a/app/Settings/UserShortcutMap.php b/app/Settings/UserShortcutMap.php
new file mode 100644 (file)
index 0000000..da2ea3c
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+namespace BookStack\Settings;
+
+class UserShortcutMap
+{
+    protected const DEFAULTS = [
+        // Header actions
+        "home_view" => "1",
+        "shelves_view" => "2",
+        "books_view" => "3",
+        "settings_view" => "4",
+        "favourites_view" => "5",
+        "profile_view" => "6",
+        "global_search" => "/",
+        "logout" => "0",
+
+        // Common actions
+        "edit" => "e",
+        "new" => "n",
+        "copy" => "c",
+        "delete" => "d",
+        "favourite" => "f",
+        "export" => "x",
+        "sort" => "s",
+        "permissions" => "p",
+        "move" => "m",
+        "revisions" => "r",
+
+        // Navigation
+        "next" => "ArrowRight",
+        "previous" => "ArrowLeft",
+    ];
+
+    /**
+     * @var array<string, string>
+     */
+    protected array $mapping;
+
+    public function __construct(array $map)
+    {
+        $this->mapping = static::DEFAULTS;
+        $this->merge($map);
+    }
+
+    /**
+     * Merge the given map into the current shortcut mapping.
+     */
+    protected function merge(array $map): void
+    {
+        foreach ($map as $key => $value) {
+            if (is_string($value) && isset($this->mapping[$key])) {
+                $this->mapping[$key] = $value;
+            }
+        }
+    }
+
+    /**
+     * Get the shortcut defined for the given ID.
+     */
+    public function getShortcut(string $id): string
+    {
+        return $this->mapping[$id] ?? '';
+    }
+
+    /**
+     * Convert this mapping to JSON.
+     */
+    public function toJson(): string
+    {
+        return json_encode($this->mapping);
+    }
+
+    /**
+     * Create a new instance from the current user's preferences.
+     */
+    public static function fromUserPreferences(): self
+    {
+        $userKeyMap = setting()->getForCurrentUser('ui-shortcuts');
+        return new self(json_decode($userKeyMap, true) ?: []);
+    }
+}
diff --git a/resources/icons/shortcuts.svg b/resources/icons/shortcuts.svg
new file mode 100644 (file)
index 0000000..8d23aac
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 5H4c-1.1 0-1.99.9-1.99 2L2 17c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-9 3h2v2h-2V8zm0 3h2v2h-2v-2zM8 8h2v2H8V8zm0 3h2v2H8v-2zm-1 2H5v-2h2v2zm0-3H5V8h2v2zm8 7H9c-.55 0-1-.45-1-1s.45-1 1-1h6c.55 0 1 .45 1 1s-.45 1-1 1zm1-4h-2v-2h2v2zm0-3h-2V8h2v2zm3 3h-2v-2h2v2zm0-3h-2V8h2v2z"/></svg>
\ No newline at end of file
index 2d8031205f4d3fa600fef01f27f0be5f4e2dbb3d..d0c6c432a37df7fae8c87bd39334d9d3cbcc54bc 100644 (file)
@@ -73,7 +73,7 @@ class CodeEditor {
             isFavorite ? this.favourites.add(language) : this.favourites.delete(language);
             button.setAttribute('data-favourite', isFavorite ? 'true' : 'false');
 
-            window.$http.patch('/settings/users/update-code-language-favourite', {
+            window.$http.patch('/preferences/update-code-language-favourite', {
                 language: language,
                 active: isFavorite
             });
index 5b84edba035308aea25638927f35594dbbe5a5d3..ee282b1fd7b8094debd638e4feb17ac84a2b5fb3 100644 (file)
@@ -43,6 +43,8 @@ import popup from "./popup.js"
 import settingAppColorPicker from "./setting-app-color-picker.js"
 import settingColorPicker from "./setting-color-picker.js"
 import shelfSort from "./shelf-sort.js"
+import shortcuts from "./shortcuts";
+import shortcutInput from "./shortcut-input";
 import sidebar from "./sidebar.js"
 import sortableList from "./sortable-list.js"
 import submitOnChange from "./submit-on-change.js"
@@ -101,6 +103,8 @@ const componentMapping = {
     "setting-app-color-picker": settingAppColorPicker,
     "setting-color-picker": settingColorPicker,
     "shelf-sort": shelfSort,
+    "shortcuts": shortcuts,
+    "shortcut-input": shortcutInput,
     "sidebar": sidebar,
     "sortable-list": sortableList,
     "submit-on-change": submitOnChange,
diff --git a/resources/js/components/shortcut-input.js b/resources/js/components/shortcut-input.js
new file mode 100644 (file)
index 0000000..fa13789
--- /dev/null
@@ -0,0 +1,57 @@
+/**
+ * Keys to ignore when recording shortcuts.
+ * @type {string[]}
+ */
+const ignoreKeys = ['Control', 'Alt', 'Shift', 'Meta', 'Super', ' ', '+', 'Tab', 'Escape'];
+
+/**
+ * @extends {Component}
+ */
+class ShortcutInput {
+
+    setup() {
+        this.input = this.$el;
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        this.listenerRecordKey = this.listenerRecordKey.bind(this);
+
+        this.input.addEventListener('focus', () => {
+             this.startListeningForInput();
+        });
+
+        this.input.addEventListener('blur', () => {
+            this.stopListeningForInput();
+        })
+    }
+
+    startListeningForInput() {
+        this.input.addEventListener('keydown', this.listenerRecordKey)
+    }
+
+    /**
+     * @param {KeyboardEvent} event
+     */
+    listenerRecordKey(event) {
+        if (ignoreKeys.includes(event.key)) {
+            return;
+        }
+
+        const keys = [
+            event.ctrlKey ? 'Ctrl' : '',
+            event.metaKey ? 'Cmd' : '',
+            event.key,
+        ];
+
+        this.input.value = keys.filter(s => Boolean(s)).join(' + ');
+    }
+
+    stopListeningForInput() {
+        this.input.removeEventListener('keydown', this.listenerRecordKey);
+    }
+
+}
+
+export default ShortcutInput;
\ No newline at end of file
diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js
new file mode 100644 (file)
index 0000000..4efa3d4
--- /dev/null
@@ -0,0 +1,164 @@
+function reverseMap(map) {
+    const reversed = {};
+    for (const [key, value] of Object.entries(map)) {
+        reversed[value] = key;
+    }
+    return reversed;
+}
+
+/**
+ * @extends {Component}
+ */
+class Shortcuts {
+
+    setup() {
+        this.container = this.$el;
+        this.mapById = JSON.parse(this.$opts.keyMap);
+        this.mapByShortcut = reverseMap(this.mapById);
+
+        this.hintsShowing = false;
+
+        this.hideHints = this.hideHints.bind(this);
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        window.addEventListener('keydown', event => {
+
+            if (event.target.closest('input, select, textarea')) {
+                return;
+            }
+
+            this.handleShortcutPress(event);
+        });
+
+        window.addEventListener('keydown', event => {
+            if (event.key === '?') {
+                this.hintsShowing ? this.hideHints() : this.showHints();
+            }
+        });
+    }
+
+    /**
+     * @param {KeyboardEvent} event
+     */
+    handleShortcutPress(event) {
+
+        const keys = [
+            event.ctrlKey ? 'Ctrl' : '',
+            event.metaKey ? 'Cmd' : '',
+            event.key,
+        ];
+
+        const combo = keys.filter(s => Boolean(s)).join(' + ');
+
+        const shortcutId = this.mapByShortcut[combo];
+        if (shortcutId) {
+            const wasHandled = this.runShortcut(shortcutId);
+            if (wasHandled) {
+                event.preventDefault();
+            }
+        }
+    }
+
+    /**
+     * Run the given shortcut, and return a boolean to indicate if the event
+     * was successfully handled by a shortcut action.
+     * @param {String} id
+     * @return {boolean}
+     */
+    runShortcut(id) {
+        const el = this.container.querySelector(`[data-shortcut="${id}"]`);
+        if (!el) {
+            return false;
+        }
+
+        if (el.matches('input, textarea, select')) {
+            el.focus();
+            return true;
+        }
+
+        if (el.matches('a, button')) {
+            el.click();
+            return true;
+        }
+
+        if (el.matches('div[tabindex]')) {
+            el.click();
+            el.focus();
+            return true;
+        }
+
+        console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el);
+
+        return false;
+    }
+
+    showHints() {
+        const wrapper = document.createElement('div');
+        wrapper.classList.add('shortcut-container');
+        this.container.append(wrapper);
+
+        const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
+        const displayedIds = new Set();
+        for (const shortcutEl of shortcutEls) {
+            const id = shortcutEl.getAttribute('data-shortcut');
+            if (displayedIds.has(id)) {
+                continue;
+            }
+
+            const key = this.mapById[id];
+            this.showHintLabel(shortcutEl, key, wrapper);
+            displayedIds.add(id);
+        }
+
+        window.addEventListener('scroll', this.hideHints);
+        window.addEventListener('focus', this.hideHints);
+        window.addEventListener('blur', this.hideHints);
+        window.addEventListener('click', this.hideHints);
+
+        this.hintsShowing = true;
+    }
+
+    /**
+     * @param {Element} targetEl
+     * @param {String} key
+     * @param {Element} wrapper
+     */
+    showHintLabel(targetEl, key, wrapper) {
+        const targetBounds = targetEl.getBoundingClientRect();
+
+        const label = document.createElement('div');
+        label.classList.add('shortcut-hint');
+        label.textContent = key;
+
+        const linkage = document.createElement('div');
+        linkage.classList.add('shortcut-linkage');
+        linkage.style.left = targetBounds.x + 'px';
+        linkage.style.top = targetBounds.y + 'px';
+        linkage.style.width = targetBounds.width + 'px';
+        linkage.style.height = targetBounds.height + 'px';
+
+        wrapper.append(label, linkage);
+
+        const labelBounds = label.getBoundingClientRect();
+
+        label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 6))}px`;
+        label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
+    }
+
+    hideHints() {
+        const wrapper = this.container.querySelector('.shortcut-container');
+        wrapper.remove();
+
+        window.removeEventListener('scroll', this.hideHints);
+        window.removeEventListener('focus', this.hideHints);
+        window.removeEventListener('blur', this.hideHints);
+        window.removeEventListener('click', this.hideHints);
+
+        this.hintsShowing = false;
+    }
+}
+
+export default Shortcuts;
\ No newline at end of file
index 703a70c7e6aa5bea2b59f7319d4739c5f7162c13..c74dcc90775219416dfe1e56cd0c2d9c577c6f4d 100644 (file)
@@ -25,6 +25,7 @@ return [
     'actions' => 'Actions',
     'view' => 'View',
     'view_all' => 'View All',
+    'new' => 'New',
     'create' => 'Create',
     'update' => 'Update',
     'edit' => 'Edit',
@@ -80,12 +81,14 @@ return [
     'none' => 'None',
 
     // Header
+    'homepage' => 'Homepage',
     'header_menu_expand' => 'Expand Header Menu',
     'profile_menu' => 'Profile Menu',
     'view_profile' => 'View Profile',
     'edit_profile' => 'Edit Profile',
     'dark_mode' => 'Dark Mode',
     'light_mode' => 'Light Mode',
+    'global_search' => 'Global Search',
 
     // Layout tabs
     'tab_info' => 'Info',
diff --git a/resources/lang/en/preferences.php b/resources/lang/en/preferences.php
new file mode 100644 (file)
index 0000000..e9a4746
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * Text used for user-preference specific views within bookstack.
+ */
+
+return [
+    'shortcuts' => 'Shortcuts',
+    'shortcuts_interface' => 'Interface Keyboard Shortcuts',
+    'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.',
+    'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.',
+    'shortcuts_toggle_label' => 'Keyboard shortcuts enabled',
+    'shortcuts_section_navigation' => 'Navigation',
+    'shortcuts_section_actions' => 'Common Actions',
+    'shortcuts_save' => 'Save Shortcuts',
+    'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.',
+    'shortcuts_update_success' => 'Shortcut preferences have been updated!',
+];
\ No newline at end of file
index acb45100f61da21b0c5a90cd5c313d760287e95f..66d76aaa244133d16dcd4034bc6abeb55b0cc56e 100644 (file)
@@ -982,4 +982,32 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 .status-indicator-inactive {
   background-color: $negative;
+}
+
+.shortcut-container {
+  background-color: rgba(0, 0, 0, 0.25);
+  pointer-events: none;
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 99;
+}
+.shortcut-linkage {
+  position: fixed;
+  box-shadow: 0 0 4px 0 #FFF;
+  border-radius: 3px;
+}
+.shortcut-hint {
+  position: fixed;
+  padding: $-xxs $-xxs;
+  font-size: .85rem;
+  font-weight: 700;
+  line-height: 1;
+  background-color: #eee;
+  border-radius: 3px;
+  border: 1px solid #b4b4b4;
+  box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset;
+  color: #333;
 }
\ No newline at end of file
index 7e0f72355f3ee1f07275e07260483ecf8f168dea..7de8a9d7dc53782f47f07c00087769c8e08ecd63 100644 (file)
@@ -473,4 +473,10 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
 .custom-file-input:focus + label {
   border-color: var(--color-primary);
   outline: 1px solid var(--color-primary);
+}
+
+input.shortcut-input {
+  width: auto;
+  max-width: 120px;
+  height: auto;
 }
\ No newline at end of file
index 447d6fd44c21a3d5a313c9da913e778da2cecaaf..dc51a3a80af86cac004f03fb76335aec4af6b6ac 100644 (file)
@@ -37,7 +37,7 @@
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
             @if(user()->can('book-create-all'))
-                <a href="{{ url("/create-book") }}" class="icon-list-item">
+                <a href="{{ url("/create-book") }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.books_create') }}</span>
                 </a>
index b95b69d1b73748fc61985d3ecefd7dedfc9882b3..884082456b6c10dc39e05948590367349112c24f 100644 (file)
         <div class="icon-list text-primary">
 
             @if(userCan('page-create', $book))
-                <a href="{{ $book->getUrl('/create-page') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/create-page') }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.pages_new') }}</span>
                 </a>
             @endif
             @if(userCan('chapter-create', $book))
-                <a href="{{ $book->getUrl('/create-chapter') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/create-chapter') }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.chapters_new') }}</span>
                 </a>
             <hr class="primary-background">
 
             @if(userCan('book-update', $book))
-                <a href="{{ $book->getUrl('/edit') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
-                <a href="{{ $book->getUrl('/sort') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/sort') }}" data-shortcut="sort" class="icon-list-item">
                     <span>@icon('sort')</span>
                     <span>{{ trans('common.sort') }}</span>
                 </a>
             @endif
             @if(userCan('book-create-all'))
-                <a href="{{ $book->getUrl('/copy') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
             @endif
             @if(userCan('restrictions-manage', $book))
-                <a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
             @if(userCan('book-delete', $book))
-                <a href="{{ $book->getUrl('/delete') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
                 </a>
index b3496eae23717f8bb648430ba149d06a84ab7945..d2f8cec97f7353548d948ba6fec308c6f6303a4c 100644 (file)
         <div class="icon-list text-primary">
 
             @if(userCan('page-create', $chapter))
-                <a href="{{ $chapter->getUrl('/create-page') }}" class="icon-list-item">
+                <a href="{{ $chapter->getUrl('/create-page') }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.pages_new') }}</span>
                 </a>
             <hr class="primary-background"/>
 
             @if(userCan('chapter-update', $chapter))
-                <a href="{{ $chapter->getUrl('/edit') }}" class="icon-list-item">
+                <a href="{{ $chapter->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
             @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCan('chapter-create-all') || userCan('chapter-create-own'))
-                <a href="{{ $chapter->getUrl('/copy') }}" class="icon-list-item">
+                <a href="{{ $chapter->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
             @endif
             @if(userCan('chapter-update', $chapter) && userCan('chapter-delete', $chapter))
-                <a href="{{ $chapter->getUrl('/move') }}" class="icon-list-item">
+                <a href="{{ $chapter->getUrl('/move') }}" data-shortcut="move" class="icon-list-item">
                     <span>@icon('folder')</span>
                     <span>{{ trans('common.move') }}</span>
                 </a>
             @endif
             @if(userCan('restrictions-manage', $chapter))
-                <a href="{{ $chapter->getUrl('/permissions') }}" class="icon-list-item">
+                <a href="{{ $chapter->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
             @if(userCan('chapter-delete', $chapter))
-                <a href="{{ $chapter->getUrl('/delete') }}" class="icon-list-item">
+                <a href="{{ $chapter->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
                 </a>
 
             @if($chapter->book && userCan('book-update', $chapter->book))
                 <hr class="primary-background"/>
-                <a href="{{ $chapter->book->getUrl('/sort') }}" class="icon-list-item">
+                <a href="{{ $chapter->book->getUrl('/sort') }}" data-shortcut="sort" class="icon-list-item">
                     <span>@icon('sort')</span>
                     <span>{{ trans('entities.chapter_sort_book') }}</span>
                 </a>
index 0812e487a06799971359e8acda65265c8cab5b9d..d6ecbc4d624c94e9b0f5e5197586ad21f1af0545 100644 (file)
@@ -1,4 +1,4 @@
-<form action="{{ url('/settings/users/toggle-dark-mode') }}" method="post">
+<form action="{{ url('/preferences/toggle-dark-mode') }}" method="post">
     {{ csrf_field() }}
     {{ method_field('patch') }}
     @if(setting()->getForCurrentUser('dark-mode-enabled'))
index 197b80c27ec10c20da9399bd195d0e190d8e69e8..9fe97b853ae53b7ca491287a0e1b28b9c34c0323 100644 (file)
@@ -2,7 +2,7 @@
     <div class="grid mx-l">
 
         <div>
-            <a href="{{ url('/') }}" class="logo">
+            <a href="{{ url('/') }}" data-shortcut="home_view" class="logo">
                 @if(setting('app-logo', '') !== 'none')
                     <img class="logo-image" src="{{ setting('app-logo', '') === '' ? url('/logo.png') : url(setting('app-logo', '')) }}" alt="Logo">
                 @endif
@@ -22,6 +22,7 @@
             <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
                 <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
                 <input id="header-search-box-input" type="text" name="term"
+                       data-shortcut="global_search"
                        aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
                        value="{{ isset($searchTerm) ? $searchTerm : '' }}">
             </form>
                 @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>
+                        <a href="{{ url('/shelves') }}" data-shortcut="shelves_view">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
                     @endif
-                    <a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
+                    <a href="{{ url('/books') }}" data-shortcut="books_view">@icon('books'){{ trans('entities.books') }}</a>
                     @if(signedInUser() && userCan('settings-manage'))
-                        <a href="{{ url('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
+                        <a href="{{ url('/settings') }}" data-shortcut="settings_view">@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>
+                        <a href="{{ url('/settings/users') }}" data-shortcut="settings_view">@icon('users'){{ trans('settings.users') }}</a>
                     @endif
                 @endif
 
                         </span>
                     <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
                         <li>
-                            <a href="{{ url('/favourites') }}" class="icon-item">
+                            <a href="{{ url('/favourites') }}" data-shortcut="favourites_view" class="icon-item">
                                 @icon('star')
                                 <div>{{ trans('entities.my_favourites') }}</div>
                             </a>
                         </li>
                         <li>
-                            <a href="{{ $currentUser->getProfileUrl() }}" class="icon-item">
+                            <a href="{{ $currentUser->getProfileUrl() }}" data-shortcut="profile_view" class="icon-item">
                                 @icon('user')
                                 <div>{{ trans('common.view_profile') }}</div>
                             </a>
                             <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
                                   method="post">
                                 {{ csrf_field() }}
-                                <button class="icon-item">
+                                <button class="icon-item" data-shortcut="logout">
                                     @icon('logout')
                                     <div>{{ trans('auth.logout') }}</div>
                                 </button>
                             </form>
                         </li>
                         <li><hr></li>
+                        <li>
+                            <a href="{{ url('/preferences/shortcuts') }}" class="icon-item">
+                                @icon('shortcuts')
+                                <div>{{ trans('preferences.shortcuts') }}</div>
+                            </a>
+                        </li>
                         <li>
                             @include('common.dark-mode-toggle', ['classes' => 'icon-item'])
                         </li>
index 996f7a8376118c6d207c56e56d5aa5f07f35a89f..29dfa60a1ac593246ec8760c0ebeb1fe08b60bfc 100644 (file)
@@ -9,7 +9,7 @@
               action="{{ url()->current() }}"
               method="get"
           @else
-              action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}"
+              action="{{ url("/preferences/change-sort/{$type}") }}"
               method="post"
           @endif
     >
index bac240b1eed0c2a493b3ba8b09727067c08bf4c6..a55ab56d199cf174b6144f26bf0b7306bfaaa72d 100644 (file)
@@ -2,8 +2,13 @@
      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">
+    <div refs="dropdown@toggle"
+         class="icon-list-item"
+         aria-haspopup="true"
+         aria-expanded="false"
+         aria-label="{{ trans('entities.export') }}"
+         data-shortcut="export"
+         tabindex="0">
         <span>@icon('export')</span>
         <span>{{ trans('entities.export') }}</span>
     </div>
index 49ba6aa5db426224bfc0e9c6d367e4b31ab23a29..24bd40950e87e7ee5560f4ea2d29a940ee033a84 100644 (file)
@@ -5,7 +5,7 @@
     {{ csrf_field() }}
     <input type="hidden" name="type" value="{{ get_class($entity) }}">
     <input type="hidden" name="id" value="{{ $entity->id }}">
-    <button type="submit" class="icon-list-item text-primary">
+    <button type="submit" data-shortcut="favourite" class="icon-list-item text-primary">
         <span>@icon($isFavourite ? 'star' : 'star-outline')</span>
         <span>{{ $isFavourite ? trans('common.unfavourite') : trans('common.favourite') }}</span>
     </button>
index 1f64bac3e5d56aa13f7733b0c0d24b98a67d15b9..28a9cb0292aed07c8e0d27d981644fc3b301973a 100644 (file)
@@ -1,7 +1,7 @@
 <div id="sibling-navigation" class="grid half collapse-xs items-center mb-m px-m no-row-gap fade-in-when-active print-hidden">
     <div>
         @if($previous)
-            <a href="{{  $previous->getUrl()  }}" class="outline-hover no-link-style block rounded">
+            <a href="{{  $previous->getUrl()  }}" data-shortcut="previous" class="outline-hover no-link-style block rounded">
                 <div class="px-m pt-xs text-muted">{{ trans('common.previous') }}</div>
                 <div class="inline-block">
                     <div class="icon-list-item no-hover">
@@ -14,7 +14,7 @@
     </div>
     <div>
         @if($next)
-            <a href="{{  $next->getUrl()  }}" class="outline-hover no-link-style block rounded text-xs-right">
+            <a href="{{  $next->getUrl()  }}" data-shortcut="next" class="outline-hover no-link-style block rounded text-xs-right">
                 <div class="px-m pt-xs text-muted text-xs-right">{{ trans('common.next') }}</div>
                 <div class="inline block">
                     <div class="icon-list-item no-hover">
index 9ff1b49277d035c17f2df0169f90eab1ddbd2bfc..e376b878dda1c8f240e471279db8c005c05030af 100644 (file)
@@ -1,15 +1,15 @@
 <div>
-    <form action="{{ url("/settings/users/". user()->id ."/switch-${type}-view") }}" method="POST" class="inline">
+    <form action="{{ url("/preferences/change-view/" . $type) }}" method="POST" class="inline">
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
-        <input type="hidden" value="{{ $view === 'list'? 'grid' : 'list' }}" name="view_type">
+
         @if ($view === 'list')
-            <button type="submit" class="icon-list-item text-primary">
+            <button type="submit" name="view" value="grid" class="icon-list-item text-primary">
                 <span class="icon">@icon('grid')</span>
                 <span>{{ trans('common.grid_view') }}</span>
             </button>
         @else
-            <button type="submit" class="icon-list-item text-primary">
+            <button type="submit" name="view" value="list" class="icon-list-item text-primary">
                 <span>@icon('list')</span>
                 <span>{{ trans('common.list_view') }}</span>
             </button>
index 8ed7ff6e036167cb97a7428a57bb906d2f2e7f1e..291e5db344114d1cfe060e141099ad040c1b6d5c 100644 (file)
@@ -4,7 +4,7 @@ $key - Unique key for checking existing stored state.
 --}}
 <?php $isOpen = setting()->getForCurrentUser('section_expansion#'. $key); ?>
 <button type="button" expand-toggle="{{ $target }}"
-   expand-toggle-update-endpoint="{{ url('/settings/users/'. user()->id .'/update-expansion-preference/' . $key) }}"
+   expand-toggle-update-endpoint="{{ url('/preferences/change-expansion/' . $key) }}"
    expand-toggle-is-open="{{ $isOpen ? 'yes' : 'no' }}"
    class="icon-list-item {{ $classes ?? '' }}">
     <span>@icon('expand-text')</span>
index c525643b9c0fcb568f842067465d8e2a1061c174..fc99b915f3b3964722fcec359e3def557dace6b5 100644 (file)
@@ -18,7 +18,7 @@
                     <span>{{ trans('entities.shelves_new_action') }}</span>
                 </a>
             @endif
-            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
             @include('home.parts.expand-toggle', ['classes' => 'text-primary', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-primary'])
         </div>
index 9f6e9f89af0f99fd54c19d26a92c4e1ccefb2ae4..2f649423da2c155614f73dc53ede214d77b7db18 100644 (file)
     <!-- Translations for JS -->
     @stack('translations')
 </head>
-<body class="@stack('body-class')">
+<body
+    @if(setting()->getForCurrentUser('ui-shortcuts-enabled', false))
+        component="shortcuts"
+        option:shortcuts:key-map="{{ \BookStack\Settings\UserShortcutMap::fromUserPreferences()->toJson() }}"
+    @endif
+      class="@stack('body-class')">
 
     @include('layouts.parts.base-body-start')
     @include('common.skip-to-content')
index 66c3762a89f675bd0d2083864a1fafb44df81d38..32718b7f1e47873b98483fa9b0d5bbebebb55662 100644 (file)
 
             {{--User Actions--}}
             @if(userCan('page-update', $page))
-                <a href="{{ $page->getUrl('/edit') }}" class="icon-list-item">
+                <a href="{{ $page->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
             @if(userCanOnAny('create', \BookStack\Entities\Models\Book::class) || userCanOnAny('create', \BookStack\Entities\Models\Chapter::class) || userCan('page-create-all') || userCan('page-create-own'))
-                <a href="{{ $page->getUrl('/copy') }}" class="icon-list-item">
+                <a href="{{ $page->getUrl('/copy') }}" data-shortcut="copy" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
             @endif
             @if(userCan('page-update', $page))
                 @if(userCan('page-delete', $page))
-                       <a href="{{ $page->getUrl('/move') }}" class="icon-list-item">
+                       <a href="{{ $page->getUrl('/move') }}" data-shortcut="move" class="icon-list-item">
                            <span>@icon('folder')</span>
                            <span>{{ trans('common.move') }}</span>
                        </a>
                 @endif
             @endif
-            <a href="{{ $page->getUrl('/revisions') }}" class="icon-list-item">
+            <a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
                 <span>@icon('history')</span>
                 <span>{{ trans('entities.revisions') }}</span>
             </a>
             @if(userCan('restrictions-manage', $page))
-                <a href="{{ $page->getUrl('/permissions') }}" class="icon-list-item">
+                <a href="{{ $page->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
             @if(userCan('page-delete', $page))
-                <a href="{{ $page->getUrl('/delete') }}" class="icon-list-item">
+                <a href="{{ $page->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
                 </a>
index 75d46318f019a3f04827f196cc63e61349bda0bc..df3ca83eba2ba05bece88c35e3a46c258308e1de 100644 (file)
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
             @if(userCan('bookshelf-create-all'))
-                <a href="{{ url("/create-shelf") }}" class="icon-list-item">
+                <a href="{{ url("/create-shelf") }}" data-shortcut="new" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.shelves_new_action') }}</span>
                 </a>
             @endif
 
-            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
 
             <a href="{{ url('/tags') }}" class="icon-list-item">
                 <span>@icon('tag')</span>
index 0195759d818592ee421cac2ba46b1c22cad5c0c1..1ea37992ae477f826c0fd36e64de8c5c6048ce23 100644 (file)
         <div class="icon-list text-primary">
 
             @if(userCan('book-create-all') && userCan('bookshelf-update', $shelf))
-                <a href="{{ $shelf->getUrl('/create-book') }}" class="icon-list-item">
+                <a href="{{ $shelf->getUrl('/create-book') }}" data-shortcut="new" class="icon-list-item">
                     <span class="icon">@icon('add')</span>
                     <span>{{ trans('entities.books_new_action') }}</span>
                 </a>
             @endif
 
-            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelf'])
+            @include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelf'])
 
             <hr class="primary-background">
 
             @if(userCan('bookshelf-update', $shelf))
-                <a href="{{ $shelf->getUrl('/edit') }}" class="icon-list-item">
+                <a href="{{ $shelf->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
             @endif
 
             @if(userCan('restrictions-manage', $shelf))
-                <a href="{{ $shelf->getUrl('/permissions') }}" class="icon-list-item">
+                <a href="{{ $shelf->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
                     <span>@icon('lock')</span>
                     <span>{{ trans('entities.permissions') }}</span>
                 </a>
             @endif
 
             @if(userCan('bookshelf-delete', $shelf))
-                <a href="{{ $shelf->getUrl('/delete') }}" class="icon-list-item">
+                <a href="{{ $shelf->getUrl('/delete') }}" data-shortcut="delete" class="icon-list-item">
                     <span>@icon('delete')</span>
                     <span>{{ trans('common.delete') }}</span>
                 </a>
diff --git a/resources/views/users/preferences/parts/shortcut-control.blade.php b/resources/views/users/preferences/parts/shortcut-control.blade.php
new file mode 100644 (file)
index 0000000..b85813c
--- /dev/null
@@ -0,0 +1,12 @@
+<div class="flex-container-row justify-space-between items-center gap-m item-list-row">
+    <label for="shortcut-{{ $id }}" class="bold flex px-m py-xs">{{ $label }}</label>
+    <div class="px-m py-xs">
+        <input type="text"
+               component="shortcut-input"
+               class="small flex-none shortcut-input px-s py-xs"
+               id="shortcut-{{ $id }}"
+               name="shortcut[{{ $id }}]"
+               readonly
+               value="{{ $shortcuts->getShortcut($id) }}">
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/users/preferences/shortcuts.blade.php b/resources/views/users/preferences/shortcuts.blade.php
new file mode 100644 (file)
index 0000000..677892b
--- /dev/null
@@ -0,0 +1,75 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small my-xl">
+
+        <section class="card content-wrap">
+            <form action="{{ url('/preferences/shortcuts') }}" method="post">
+                {{ method_field('put') }}
+                {{ csrf_field() }}
+
+                <h1 class="list-heading">{{ trans('preferences.shortcuts_interface') }}</h1>
+
+                <div class="flex-container-row items-center gap-m wrap mb-m">
+                    <p class="flex mb-none min-width-m text-small text-muted">
+                        {{ trans('preferences.shortcuts_toggle_desc') }}
+                        {{ trans('preferences.shortcuts_customize_desc') }}
+                    </p>
+                    <div class="flex min-width-m text-m-center">
+                        @include('form.toggle-switch', [
+                            'name' => 'enabled',
+                            'value' => $enabled,
+                            'label' => trans('preferences.shortcuts_toggle_label'),
+                        ])
+                    </div>
+                </div>
+
+                <hr>
+
+                <h2 class="list-heading mb-m">{{ trans('preferences.shortcuts_section_navigation') }}</h2>
+                <div class="flex-container-row wrap gap-m mb-xl">
+                    <div class="flex min-width-l item-list">
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.homepage'), 'id' => 'home_view'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.shelves'), 'id' => 'shelves_view'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.books'), 'id' => 'books_view'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('settings.settings'), 'id' => 'settings_view'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.my_favourites'), 'id' => 'favourites_view'])
+                    </div>
+                    <div class="flex min-width-l item-list">
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.view_profile'), 'id' => 'profile_view'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('auth.logout'), 'id' => 'logout'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.global_search'), 'id' => 'global_search'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.next'), 'id' => 'next'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.previous'), 'id' => 'previous'])
+                    </div>
+                </div>
+
+                <h2 class="list-heading mb-m">{{ trans('preferences.shortcuts_section_actions') }}</h2>
+                <div class="flex-container-row wrap gap-m mb-xl">
+                    <div class="flex min-width-l item-list">
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.new'), 'id' => 'new'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.edit'), 'id' => 'edit'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.copy'), 'id' => 'copy'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.delete'), 'id' => 'delete'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.favourite'), 'id' => 'favourite'])
+                    </div>
+                    <div class="flex min-width-l item-list">
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.export'), 'id' => 'export'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.sort'), 'id' => 'sort'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.permissions'), 'id' => 'permissions'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('common.move'), 'id' => 'move'])
+                        @include('users.preferences.parts.shortcut-control', ['label' => trans('entities.revisions'), 'id' => 'revisions'])
+                    </div>
+                </div>
+
+                <p class="text-small text-muted">{{ trans('preferences.shortcuts_overlay_desc') }}</p>
+
+                <div class="form-group text-right">
+                    <button class="button">{{ trans('preferences.shortcuts_save') }}</button>
+                </div>
+
+            </form>
+        </section>
+
+    </div>
+@stop
index b3f11f53a7802a9fe3b522d532f2a337d2974b37..f66f0d984ba886b84b2c25c83ec6b41b88bea050 100644 (file)
@@ -246,13 +246,14 @@ Route::middleware('auth')->group(function () {
     Route::delete('/settings/users/{id}', [UserController::class, 'destroy']);
 
     // User Preferences
-    Route::patch('/settings/users/{id}/switch-books-view', [UserPreferencesController::class, 'switchBooksView']);
-    Route::patch('/settings/users/{id}/switch-shelves-view', [UserPreferencesController::class, 'switchShelvesView']);
-    Route::patch('/settings/users/{id}/switch-shelf-view', [UserPreferencesController::class, 'switchShelfView']);
-    Route::patch('/settings/users/{id}/change-sort/{type}', [UserPreferencesController::class, 'changeSort']);
-    Route::patch('/settings/users/{id}/update-expansion-preference/{key}', [UserPreferencesController::class, 'updateExpansionPreference']);
-    Route::patch('/settings/users/toggle-dark-mode', [UserPreferencesController::class, 'toggleDarkMode']);
-    Route::patch('/settings/users/update-code-language-favourite', [UserPreferencesController::class, 'updateCodeLanguageFavourite']);
+    Route::redirect('/preferences', '/');
+    Route::get('/preferences/shortcuts', [UserPreferencesController::class, 'showShortcuts']);
+    Route::put('/preferences/shortcuts', [UserPreferencesController::class, 'updateShortcuts']);
+    Route::patch('/preferences/change-view/{type}', [UserPreferencesController::class, 'changeView']);
+    Route::patch('/preferences/change-sort/{type}', [UserPreferencesController::class, 'changeSort']);
+    Route::patch('/preferences/change-expansion/{type}', [UserPreferencesController::class, 'changeExpansion']);
+    Route::patch('/preferences/toggle-dark-mode', [UserPreferencesController::class, 'toggleDarkMode']);
+    Route::patch('/preferences/update-code-language-favourite', [UserPreferencesController::class, 'updateCodeLanguageFavourite']);
 
     // User API Tokens
     Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']);
index cccff3a1f58da6edb792267e85ca4c0297ba0856..9e2750fd05e647cdb9fa1a350b05930e9c80b81d 100644 (file)
@@ -225,18 +225,18 @@ class BookTest extends TestCase
         setting()->putUser($editor, 'books_view_type', 'list');
 
         $resp = $this->actingAs($editor)->get('/books');
-        $this->withHtml($resp)->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'Grid View');
-        $this->withHtml($resp)->assertElementExists('input[name="view_type"][value="grid"]');
+        $this->withHtml($resp)->assertElementContains('form[action$="/preferences/change-view/books"]', 'Grid View');
+        $this->withHtml($resp)->assertElementExists('button[name="view"][value="grid"]');
 
-        $resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'grid']);
+        $resp = $this->patch("/preferences/change-view/books", ['view' => 'grid']);
         $resp->assertRedirect();
         $this->assertEquals('grid', setting()->getUser($editor, 'books_view_type'));
 
         $resp = $this->actingAs($editor)->get('/books');
-        $this->withHtml($resp)->assertElementContains('form[action$="/settings/users/' . $editor->id . '/switch-books-view"]', 'List View');
-        $this->withHtml($resp)->assertElementExists('input[name="view_type"][value="list"]');
+        $this->withHtml($resp)->assertElementContains('form[action$="/preferences/change-view/books"]', 'List View');
+        $this->withHtml($resp)->assertElementExists('button[name="view"][value="list"]');
 
-        $resp = $this->patch("/settings/users/{$editor->id}/switch-books-view", ['view_type' => 'list']);
+        $resp = $this->patch("/preferences/change-view/books", ['view_type' => 'list']);
         $resp->assertRedirect();
         $this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
     }
index e295034ceefb208bc32ebba6280790ef926e69b6..4991e052a3bf121a969916bf2714e2ab99b0c8ce 100644 (file)
@@ -160,6 +160,23 @@ class UserManagementTest extends TestCase
         ]);
     }
 
+    public function test_delete_removes_user_preferences()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'dark-mode-enabled', 'true');
+
+        $this->assertDatabaseHas('settings', [
+            'setting_key' => 'user:' . $editor->id . ':dark-mode-enabled',
+            'value' => 'true',
+        ]);
+
+        $this->asAdmin()->delete("settings/users/{$editor->id}");
+
+        $this->assertDatabaseMissing('settings', [
+            'setting_key' => 'user:' . $editor->id . ':dark-mode-enabled',
+        ]);
+    }
+
     public function test_guest_profile_shows_limited_form()
     {
         $guest = User::getDefault();
index 92e4158cd955d6a7623445e488c0e3d5ea82e8fc..03dad7990464f7228acf389818a8d2f6018a5462 100644 (file)
@@ -6,12 +6,51 @@ use Tests\TestCase;
 
 class UserPreferencesTest extends TestCase
 {
+    public function test_interface_shortcuts_updating()
+    {
+        $this->asEditor();
+
+        // View preferences with defaults
+        $resp = $this->get('/preferences/shortcuts');
+        $resp->assertSee('Interface Keyboard Shortcuts');
+
+        $html = $this->withHtml($resp);
+        $html->assertFieldHasValue('enabled', 'false');
+        $html->assertFieldHasValue('shortcut[home_view]', '1');
+
+        // Update preferences
+        $resp = $this->put('/preferences/shortcuts', [
+            'enabled' => 'true',
+            'shortcut' => ['home_view' => 'Ctrl + 1'],
+        ]);
+
+        $resp->assertRedirect('/preferences/shortcuts');
+        $resp->assertSessionHas('success', 'Shortcut preferences have been updated!');
+
+        // View updates to preferences page
+        $resp = $this->get('/preferences/shortcuts');
+        $html = $this->withHtml($resp);
+        $html->assertFieldHasValue('enabled', 'true');
+        $html->assertFieldHasValue('shortcut[home_view]', 'Ctrl + 1');
+    }
+
+    public function test_body_has_shortcuts_component_when_active()
+    {
+        $editor = $this->getEditor();
+        $this->actingAs($editor);
+
+        $this->withHtml($this->get('/'))->assertElementNotExists('body[component="shortcuts"]');
+
+        setting()->putUser($editor, 'ui-shortcuts-enabled', 'true');
+        $this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]');
+    }
+
     public function test_update_sort_preference()
     {
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/books', [
+        $updateRequest = $this->patch('/preferences/change-sort/books', [
             'sort'  => 'created_at',
             'order' => 'desc',
         ]);
@@ -34,7 +73,7 @@ class UserPreferencesTest extends TestCase
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/dogs', [
+        $updateRequest = $this->patch('/preferences/change-sort/dogs', [
             'sort'  => 'name',
             'order' => 'asc',
         ]);
@@ -49,7 +88,7 @@ class UserPreferencesTest extends TestCase
         $editor = $this->getEditor();
         $this->actingAs($editor);
 
-        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/update-expansion-preference/home-details', ['expand' => 'true']);
+        $updateRequest = $this->patch('/preferences/change-expansion/home-details', ['expand' => 'true']);
         $updateRequest->assertStatus(204);
 
         $this->assertDatabaseHas('settings', [
@@ -58,7 +97,7 @@ class UserPreferencesTest extends TestCase
         ]);
         $this->assertEquals(true, setting()->getForCurrentUser('section_expansion#home-details'));
 
-        $invalidKeyRequest = $this->patch('/settings/users/' . $editor->id . '/update-expansion-preference/my-home-details', ['expand' => 'true']);
+        $invalidKeyRequest = $this->patch('/preferences/change-expansion/my-home-details', ['expand' => 'true']);
         $invalidKeyRequest->assertStatus(500);
     }
 
@@ -69,7 +108,7 @@ class UserPreferencesTest extends TestCase
         $this->withHtml($home)->assertElementNotExists('.dark-mode');
 
         $this->assertEquals(false, setting()->getForCurrentUser('dark-mode-enabled', false));
-        $prefChange = $this->patch('/settings/users/toggle-dark-mode');
+        $prefChange = $this->patch('/preferences/toggle-dark-mode');
         $prefChange->assertRedirect();
         $this->assertEquals(true, setting()->getForCurrentUser('dark-mode-enabled'));
 
@@ -123,7 +162,7 @@ class UserPreferencesTest extends TestCase
             ->assertElementNotExists('.featured-image-container')
             ->assertElementExists('.content-wrap .entity-list-item');
 
-        $req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']);
+        $req = $this->patch("/preferences/change-view/bookshelf", ['view' => 'grid']);
         $req->assertRedirect($shelf->getUrl());
 
         $resp = $this->actingAs($editor)->get($shelf->getUrl())
@@ -140,14 +179,14 @@ class UserPreferencesTest extends TestCase
         $page = $this->entities->page();
         $this->actingAs($editor);
 
-        $this->patch('/settings/users/update-code-language-favourite', ['language' => 'php', 'active' => true]);
-        $this->patch('/settings/users/update-code-language-favourite', ['language' => 'javascript', 'active' => true]);
+        $this->patch('/preferences/update-code-language-favourite', ['language' => 'php', 'active' => true]);
+        $this->patch('/preferences/update-code-language-favourite', ['language' => 'javascript', 'active' => true]);
 
         $resp = $this->get($page->getUrl('/edit'));
         $resp->assertSee('option:code-editor:favourites="php,javascript"', false);
 
-        $this->patch('/settings/users/update-code-language-favourite', ['language' => 'ruby', 'active' => true]);
-        $this->patch('/settings/users/update-code-language-favourite', ['language' => 'php', 'active' => false]);
+        $this->patch('/preferences/update-code-language-favourite', ['language' => 'ruby', 'active' => true]);
+        $this->patch('/preferences/update-code-language-favourite', ['language' => 'php', 'active' => false]);
 
         $resp = $this->get($page->getUrl('/edit'));
         $resp->assertSee('option:code-editor:favourites="javascript,ruby"', false);