Files
summercms/docs/backend/admin-spa.md
Jakub Zych 101cad0365 feat(quick-261022-k9k): Esc, slash and help shortcuts, settings, account and modal save keys
- '?' help dialog (ShortcutHelpDialog) listing the current screen's shortcuts
- Esc = Cancel on record forms; '/' focuses the list search
- settings forms: Save and close (Ctrl/Cmd+Enter, then /settings) and Ctrl/Cmd+S
- My account: Ctrl/Cmd+S
- modal forms: Ctrl/Cmd+S and Ctrl/Cmd+Enter submit the innermost dialog only
- docs: Keyboard shortcuts section in the admin SPA page
2026-10-22 21:03:30 +02:00

8.8 KiB

title, description, section, order
title description section order
Admin SPA How boardwalk serves the embedded Vue admin SPA under backend.uri, how the SPA talks to the admin API, and how its TypeScript types come from OpenAPI. backend 80

Admin SPA

WinterCMS renders its backend on the server with layouts, partials and the AJAX framework. SummerCMS replaces that with one Vue 3 single-page app, built once and embedded in the binary by boardwalk. Plugins do not ship admin pages: they ship YAML and, when they need them, partials and scripts, and the SPA renders every controller from the schemas the admin API serves.

Serving

cabana mounts the SPA under the admin prefix, backend.uri (/backend by default; one or more lowercase path segments). boardwalk.Handler serves the build:

  • Any path under the prefix that is not a file and not under api/ returns index.html, so the SPA's own routes work on reload.
  • Paths under api/ that no API route matches return the admin API's JSON not_found error, never the SPA.
  • Hashed files under assets/ are cached for a long time; index.html is never cached.
  • Every response carries a restrictive Content-Security-Policy (frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; form-action 'self'; frame-src 'none': scripts only from the admin origin, no native form post to another origin, no framed content), frame denial, nosniff, a same-origin referrer policy and noindex, nofollow.

The build is path-agnostic: index.html holds a placeholder (boardwalk.BaseToken) that boardwalk.RewriteIndex replaces with the prefix once, when the handler is built. A build without the placeholder fails the start-up.

A plugin route under the admin prefix also fails the start-up: the SPA and the admin API own that whole path.

How the SPA talks to the server

The SPA signs in through the admin API and keeps the token in the HttpOnly cookie described on Users and permissions. For each screen it loads the controller's localized schema (schema/list, schema/form), then the records, and renders the fields and columns the schema names. Strings come from GET <prefix>/api/v1/lang, the backend::lang bundle with CLDR plural forms, in the administrator's chosen language (sent as ?locale=) or else the request locale.

Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the X-Session-Key header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id 0. The form will not save while an upload is still in flight. Uploads use XMLHttpRequest for progress events and carry the same X-Requested-With header and cookie as every other call, plus an X-Upload-Id so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs, and so is the preview of a public image whose public URL fails to load. The key travels only in headers, never in a URL. See File uploads for the fileupload field.

A list whose schema carries declared bulk actions shows a "Bulk actions" menu after the selection count. The menu lists only the actions the server offered to this administrator, and its button stays disabled until a row is selected. Choosing an action always asks for confirmation; the dialog stays open while the request runs, and the list reloads afterwards. See Bulk actions.

A list row that carries a state (deleted, negative or disabled) shows a text badge for each state after its first cell, together with a text style; the row background is never changed. See Row state.

A form whose schema carries preview has a read-only record screen at <controller>/<id>/preview, next to the list (<controller>), the create form (<controller>/create) and the update form (<controller>/<id>). It renders the fields whose context allows preview as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See Preview screen.

The user menu in the top bar has a My account item that opens <prefix>/account. That screen edits the signed-in administrator's own first name, last name, email and subtitle, uploads and removes their avatar, shows the login read-only, changes the password when a new one is typed together with the current one, and manages personal API tokens (list, create with a show-once plaintext, revoke, show revoked). Server validation errors show on their fields, and a successful save updates the name and the avatar in the top bar without a reload. See My account and API tokens.

The button at the top of the plugin rail switches the theme: each click moves from system (follow the browser's prefers-color-scheme) to light, to dark and back to system, and its icon and label name the current choice. The choice is saved for the signed-in administrator through PUT <prefix>/api/v1/account/preferences and comes back as preferences.theme in GET .../auth/me, so another browser shows the same theme after sign-in. The SPA also caches the last choice in localStorage (summer-admin.theme) and applies it before anything renders. When the save fails the previous theme returns with an error message. See My account.

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)
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:

scripts/check-admin-openapi.sh --check

Building the SPA

The SPA's source is the admin/ Vite project. npm --prefix admin run build type-checks it and writes the build to modules/boardwalk/dist, which the next go build embeds. An application that only uses the framework never builds the SPA: the build is committed with the framework.