diff --git a/admin/src/components/account/ApiTokenCreateDialog.vue b/admin/src/components/account/ApiTokenCreateDialog.vue index 0ce0f252..a4b13e22 100644 --- a/admin/src/components/account/ApiTokenCreateDialog.vue +++ b/admin/src/components/account/ApiTokenCreateDialog.vue @@ -13,6 +13,7 @@ import { import { Check, CircleAlert, Copy, Info, LoaderCircle, TriangleAlert, X } from '@lucide/vue' import { api } from '../../api/client' import type { AdminAPITokenScope } from '../../api/types' +import { shortcutHint } from '../../app/hotkeys' import { t } from '../../app/i18n' import { controlClass } from '../form/control' import { fieldErrors, focusField } from '../form/formState' @@ -553,6 +554,8 @@ onBeforeUnmount(() => { type="submit" class="flex-1" data-action="create-token-submit" + hotkey="mod+s mod+enter" + :title="shortcutHint(t('backend::lang.api_tokens.create_submit'), 'mod+s')" :disabled="!canSubmit" > diff --git a/admin/src/components/form/fields/FileCaptionModal.vue b/admin/src/components/form/fields/FileCaptionModal.vue index 7284fda3..9b882249 100644 --- a/admin/src/components/form/fields/FileCaptionModal.vue +++ b/admin/src/components/form/fields/FileCaptionModal.vue @@ -12,6 +12,7 @@ import { import { CircleAlert, FileText, X } from '@lucide/vue' import { errorMessage, type FileRoutes } from '../../../api/files' import type { FileItem } from '../../../api/types' +import { ariaKeyshortcuts, shortcutHint } from '../../../app/hotkeys' import { t } from '../../../app/i18n' import { showToast } from '../../../state/useToasts' import ConfirmDialog from '../../ui/ConfirmDialog.vue' @@ -219,6 +220,9 @@ function onCloseAutoFocus(event: Event): void { diff --git a/admin/src/components/list/ListToolbar.vue b/admin/src/components/list/ListToolbar.vue index 4f44275f..7ac82ff1 100644 --- a/admin/src/components/list/ListToolbar.vue +++ b/admin/src/components/list/ListToolbar.vue @@ -15,6 +15,7 @@ import BulkActionsMenu from './BulkActionsMenu.vue' // Declared bulk actions (D-09) sit in one menu directly after the selection // pill; the menu is not rendered without a permitted action, and its trigger // is disabled while nothing is selected or a bulk action runs. +// The slash key focuses the search input (app/hotkeys.ts). const props = withDefaults( defineProps<{ showSearch: boolean @@ -53,6 +54,8 @@ defineExpose({ focusBulk }) diff --git a/admin/src/components/relation/RelationPickerModal.vue b/admin/src/components/relation/RelationPickerModal.vue index ed099ee9..1f48d746 100644 --- a/admin/src/components/relation/RelationPickerModal.vue +++ b/admin/src/components/relation/RelationPickerModal.vue @@ -12,6 +12,7 @@ import { import { Check, ChevronLeft, ChevronRight, Search, X } from '@lucide/vue' import { api } from '../../api/client' import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types' +import { ariaKeyshortcuts, shortcutHint } from '../../app/hotkeys' import { message, t } from '../../app/i18n' import { SESSION_HEADER } from '../../app/sessionKey' import { showToast } from '../../state/useToasts' @@ -545,6 +546,9 @@ const pagerButton = v-else type="button" data-action="confirm-link" + data-hotkey="mod+s mod+enter" + :aria-keyshortcuts="ariaKeyshortcuts('mod+s mod+enter')" + :title="shortcutHint(single ? message(schema.messages.linkSubmit) : t('backend::lang.relation.add_count', { count }), 'mod+s')" :disabled="busy || count === 0" :aria-busy="busy ? 'true' : undefined" class="inline-flex h-button flex-1 items-center justify-center rounded-control bg-primary font-semibold text-on-primary hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60" diff --git a/admin/src/components/relation/RelationPivotModal.vue b/admin/src/components/relation/RelationPivotModal.vue index 1a2021f9..cc9c2df7 100644 --- a/admin/src/components/relation/RelationPivotModal.vue +++ b/admin/src/components/relation/RelationPivotModal.vue @@ -12,6 +12,7 @@ import { import { X } from '@lucide/vue' import { api } from '../../api/client' import type { AdminRecord, ControllerParams, RelationSchema } from '../../api/types' +import { ariaKeyshortcuts, shortcutHint } from '../../app/hotkeys' import { message, t } from '../../app/i18n' import { SESSION_HEADER } from '../../app/sessionKey' import FormErrorBanner from '../form/FormErrorBanner.vue' @@ -266,6 +267,9 @@ function onCloseAutoFocus(event: Event): void { + diff --git a/admin/src/components/shell/ShortcutHelpDialog.vue b/admin/src/components/shell/ShortcutHelpDialog.vue new file mode 100644 index 00000000..0b1d9f32 --- /dev/null +++ b/admin/src/components/shell/ShortcutHelpDialog.vue @@ -0,0 +1,70 @@ + + + + + + + + + + {{ t('backend::lang.shortcuts.title') }} + {{ t('backend::lang.shortcuts.description') }} + + + + + + + + + + {{ part }} + + {{ t(entry.labelKey) }} + + + + + + diff --git a/admin/src/views/AccountView.vue b/admin/src/views/AccountView.vue index 4e71b872..bd089bea 100644 --- a/admin/src/views/AccountView.vue +++ b/admin/src/views/AccountView.vue @@ -3,6 +3,7 @@ import { computed, nextTick, ref } from 'vue' import { CircleAlert, ImageUp, Trash2 } from '@lucide/vue' import { api } from '../api/client' import type { AdminAccountRequest } from '../api/types' +import { shortcutHint } from '../app/hotkeys' import { t } from '../app/i18n' import FormErrorBanner from '../components/form/FormErrorBanner.vue' import { controlClass } from '../components/form/control' @@ -22,7 +23,7 @@ import { showToast } from '../state/useToasts' // typed. The avatar is uploaded (POST /account/avatar, one file_data part) // and removed (DELETE /account/avatar, after a confirmation) on its own. // Every success passes the returned profile to setCurrentUser, so the top -// bar updates without a reload. +// bar updates without a reload. Save answers Ctrl/Cmd+S (app/hotkeys.ts). const ID_PREFIX = 'account' type ProfileKey = 'first_name' | 'last_name' | 'email' | 'subtitle' @@ -427,7 +428,14 @@ async function onSave(): Promise { diff --git a/admin/src/views/FormView.vue b/admin/src/views/FormView.vue index 1200ae6f..7c6ae79b 100644 --- a/admin/src/views/FormView.vue +++ b/admin/src/views/FormView.vue @@ -691,7 +691,7 @@ void load() {{ t('backend::lang.form.delete') }} - {{ t('backend::lang.form.cancel') }} + {{ t('backend::lang.form.cancel') }} import { computed, nextTick, provide, ref } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import { ArrowLeft } from '@lucide/vue' import { api } from '../api/client' import type { AdminRecord, FormField, FormView } from '../api/types' +import { shortcutHint } from '../app/hotkeys' import { t } from '../app/i18n' import FormErrorBanner from '../components/form/FormErrorBanner.vue' import FormGrid from '../components/form/FormGrid.vue' @@ -34,7 +35,9 @@ import { showToast } from '../state/useToasts' // empty state from meta.secrets (F2) and are sent only when the // administrator typed a value (a string) or removed the stored one (null); // richeditor save notices from meta.notices render under their field (F3). +// Save and Save and close answer Ctrl/Cmd+S and Ctrl/Cmd+Enter (app/hotkeys.ts). const route = useRoute() +const router = useRouter() const code = String(route.params.code ?? '') const ID_PREFIX = 'setting' @@ -132,9 +135,10 @@ function settingsPayload(editable: FormField[], current: AdminRecord): AdminReco return out } -async function onSave(): Promise { +/** Saves the values; true when the PUT succeeded. */ +async function save(): Promise { if (busy.value || !schema.value) { - return + return false } busy.value = true try { @@ -148,7 +152,7 @@ async function onSave(): Promise { secrets.value = { ...(result.data.data.meta?.secrets ?? {}) } notices.value = noticeLines(result.data.data.meta?.notices) showToast(t('backend::lang.settings.saved')) - return + return true } if (result.response.status === 422) { errors.value = fieldErrors(result.error?.error.details) @@ -161,16 +165,29 @@ async function onSave(): Promise { if (first) { focusField(ID_PREFIX, first.name) } - return + return false } showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger') + return false } catch { showToast(t('backend::lang.form.error_generic'), 'danger') + return false } finally { busy.value = false } } +async function onSave(): Promise { + await save() +} + +/** Zapisz i zamknij: saves, then returns to the settings index (the Cancel target). */ +async function onSaveAndClose(): Promise { + if (await save()) { + await router.push('/settings') + } +} + void load() @@ -220,8 +237,27 @@ void load() diff --git a/docs/backend/admin-spa.md b/docs/backend/admin-spa.md index 287e8c94..7b8f5814 100644 --- a/docs/backend/admin-spa.md +++ b/docs/backend/admin-spa.md @@ -39,6 +39,23 @@ The button at the top of the plugin rail switches the theme: each click moves fr When the server offers more than one admin language (`meta.locales` of `GET .../lang`), the user menu lists them under Language, each named in its own language. Choosing one saves it as the administrator's `locale` preference, caches it in `localStorage` (`summer-admin.locale`) and reloads the page, so the strings, navigation, settings, schemas and validation messages all come back in that language, in any browser and whatever `Accept-Language` says. A form with unsaved changes asks before the reload. +## Keyboard shortcuts + +As in WinterCMS, a shortcut presses a button on the screen. Ctrl and Cmd are both accepted on every platform; the tooltips name Cmd (⌘) on macOS. + +| Key | Where | What | +|-----|-------|------| +| Ctrl+S | Record forms, settings forms, My account | Save; on a create form, create | +| Ctrl+Enter | Record forms, settings forms | Save and close; on a create form, create and close | +| Ctrl+Shift+S | Create forms | Create and new (see [Forms](forms.md)) | +| Esc | Record forms | Cancel; asks first when there are unsaved changes | +| / | Lists with a search box | Go to the search box | +| ? | Every screen | Show the shortcuts of the current screen | + +A shortcut does what its button does, and nothing while the button is disabled (while saving, while loading, with an upload in progress). The save keys also work while typing in a field, a code field or the rich editor. There, on a record form, Ctrl+Enter saves and closes instead of inserting a line break (Shift+Enter still does), and on a create form Ctrl+Shift+S creates and starts a new record instead of toggling strikethrough; this is a known trade-off. Ctrl+S never opens the browser's Save page dialog. + +Esc, / and ? do nothing while typing in a field, or while a dialog, popover or menu is open; those close on Esc themselves. When a dialog with a form is open (a related record, a pivot, the record picker, file details, a new API token), Ctrl+S and Ctrl+Enter submit that dialog and never the form underneath. Settings forms and My account have no unsaved-changes question, so Esc does not leave them. Save buttons show their shortcut in their tooltip. + ## Types from OpenAPI The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. Type overrides for swag live in the root `.swaggo` (it documents `json.RawMessage` as an untyped value, which swag cannot resolve on its own). `--check` fails when either committed file is out of date: