@crudadmin/helpers
| Install | |
|---|---|
npm install @crudadmin/helpers |
|
| Latest Version: | 2.1.0 |
| Vue: | 3 |
| License: | ISC |
| Last Updated: | Oct 6, 2026 |
| Links: | npm |
@crudadmin/helpers
Shared Nuxt layer for CrudAdmin backed frontends: axios wrapper, gettext translator, Pinia stores, modals, toasts, the frontend editor bootstrap and url based localization.
// nuxt.config.ts
export default defineNuxtConfig({
extends: ['@crudadmin/helpers/nuxt'], // or './node_modules/@crudadmin/helpers/nuxt'
});
Upgrading from 2.0: see MIGRATION-2.0-to-2.1.md.
App boot, bootstrap and auth
Off by default. Turn it on and the layer boots the app the same way in every project: axios defaults, the auth token, one bootstrap request and its refresh. A project then only registers its own stores.
export default defineNuxtConfig({
extends: ['@crudadmin/helpers/nuxt'],
crudadmin: {
bootstrap: {
enabled: true,
path: 'api/bootstrap', // relative to baseURL
baseURL: null, // runtime config public.crudBootstrap.baseURL, then VITE_APP_SERVER_URL
only: [], // sections asked for on boot, empty = all
ssr: true, // fetch on the server when the app renders there
blocking: false, // SPA: wait for the bootstrap before mounting
refresh: true, // browser: refresh on reconnect, on return to the tab and every refreshSeconds
refreshSeconds: 600, // backendEnv.APP_REFRESH_SECONDS wins
},
auth: {
storage: 'auto', // auto | cookie | local | preferences | false
cookie: { name: 'auth_token', maxAge: 31536000, sameSite: 'lax', secure: null },
logoutOnUnauthorized: true,
routes: {
login: 'api/auth/login',
logout: 'api/auth/logout',
register: 'api/auth/register/otp-verify',
user: 'api/user',
passwordForgot: 'api/auth/password/forgot',
passwordReset: 'api/auth/password/reset',
},
},
platform: { type: null, version: null }, // app-type header, native version override
capacitor: { enabled: false }, // native boot through @crudadmin/helpers/capacitor
},
});
What happens
| Where | What |
|---|---|
plugin 02.axios (always) |
Axios and Response learn to find the request (tryUseNuxtApp) |
plugin 03.bootstrap |
Axios.configure() with baseURL, Authorization from authStore, app-locale from localeStore; app-platform, app-version, app-type headers; Response.addStores() of the helpers stores; token restored from its storage; 401 forgets the user |
server, app:created |
GET <path>?only=..., sections bound into the stores, which reach the client with the payload |
| client after SSR | nothing is fetched again, crudadmin:bootstrap runs with hydrated: true |
| client SPA | the bootstrap is fetched in app:created, then the refresher starts |
plugin 04.capacitor.client |
new Capacitor({ user, refresh, refreshSeconds }).ready() instead of the browser refresher |
When the bootstrap refreshes
Network (browser) and Capacitor (native app, with or without the Nuxt layer) call
refresh() = useAppStore().refreshApp():
- on start when online, on reconnect (and then sends the queued offline requests again),
- when the app or the tab returns to the foreground (
visibilitychangeand theresumeevent Capacitor fires on thedocument, one refresh per return), - every
refreshSeconds(backendEnv.APP_REFRESH_SECONDSwins).
A refresh is skipped while the last successful one (networkStore.lastUpdateTime, set when
refreshApp() resolves true) is younger than refreshSeconds.
The bootstrap is fetched in app:created, after every plugin, so the stores
and headers registered by the plugins of other layers (eshop 10-19) and of
the project are all in place.
Numbered plugins (NN.name.js) of every layer extending helpers run in the
order of their numbers right after pinia, before the Nuxt core plugins and
before the plugins of the project: helpers 00-09, eshop 10-19. Do not
use the router in them, use a hook. The project's own plugins are never
reordered.
Hooks
nuxtApp.hook('crudadmin:bootstrap:before', (ctx) => {
// { nuxtApp, source, partial, path, only, params, headers }, all mutable
});
nuxtApp.hook('crudadmin:bootstrap', (ctx) => {
// { nuxtApp, source, partial, hydrated, only, response, data }
// source: server | client | hydration | refresh | login | register | logout ...
});
nuxtApp.hook('crudadmin:bootstrap:error', ({ error }) => {});
nuxtApp.hook('crudadmin:auth:login', ({ user, response, source }) => {});
nuxtApp.hook('crudadmin:auth:logout', ({ source }) => {}); // logout | unauthorized
nuxtApp.hook('crudadmin:capacitor:ready', () => {});
Stores
useAppStore() (app) holds booted, loading, backendEnv,
latestVersion, appVersion, platform, the getters updateAvailable and
updateStoreUrl (from backendEnv.APP_STORES) and refreshApp(only).
useAuthStore() (auth) has the shape of the PHP helpers AuthResponse:
token, driver, user, device_tokens, getters loggedIn and bearer,
actions setAuth(), logout(), flushData().
Both are defined from options exported by @crudadmin/helpers/definitions.
A project extends them by defining the store under the same id; its
definition wins the auto-import and the layer uses it too:
import { authStore } from '@crudadmin/helpers/definitions';
export const useAuthStore = defineStore('auth', {
...authStore,
getters: { ...authStore.getters, isCourier: (state) => state.driver === 'couriers' },
});
On the web build the stores are not persisted (the token lives in a cookie,
so the server renders the logged in state, and it is removed from the html
payload). The SPA build persists them and keeps the token in localStorage, or
in Capacitor Preferences with auth.storage: 'preferences'.
Composables
| composable | |
|---|---|
useBootstrap() |
refresh(only), addSections(sections), ready() (starts the first bootstrap when needed, awaitable from route middleware on the server), booted, loading, path |
useAuth() |
login(data), register(data), logout(options), forgotPassword(data), resetPassword(data) / setPassword(data), fetchUser(), handleResponse(response), setToken(token), user, loggedIn, store |
useBackendEnv(key, default) |
a value of app.backendEnv |
usePlatformHeaders() |
{ 'app-platform', 'app-version', 'app-type' } |
usePlatform() |
web, ios, android... |
useAxios(nuxtApp?), useResponse() |
auto-imported now as well |
Every response of useAuth() hydrates the stores like a bootstrap: the
store sections the backend adds to a login (eshop cart after merging the
guest cart) reach their stores, and an AuthResponse in data fills the
auth store. Errors are thrown to the caller.
All auto-imports of the layer have a lower priority than the project, so a project exporting the same name keeps its own.
Extension points for layers
import { Axios, Response } from '@crudadmin/helpers';
// a module level callback, registered under a key: one entry for all requests
const cartHeaders = ({ pinia }) => ({ 'Cart-Token': useCartStore(pinia).token });
export default defineNuxtPlugin(() => {
Response.addStores([useEshopStore, useCartStore]); // definitions, by $id
Axios.addHeaders(cartHeaders, 'eshop.cart');
Axios.onUnauthorized(({ pinia }) => useCartStore(pinia).$reset(), 'eshop.cart');
useBootstrap().addSections(['eshop', 'cart']); // only matters with bootstrap.only
});
Response.addStores(definitions) |
adds stores receiving response sections; setStores() keeps working and is never overwritten by it |
Response.removeStores(ids) |
|
Axios.addHeaders(callback | object, key?) |
headers merged into every request; callback gets { headers, nuxtApp, pinia }; returns a remover |
Axios.onUnauthorized(callback, key?) |
every handler runs on a 401, after options.unauthorized |
Axios.configure(options) |
merges into the options, where setOptions() replaces them |
No request state is kept on the module. A callback registered with a key is
global and replaces the previous one of that key, so it must read the request
through the pinia it is handed. Without a key, a callback registered in a
plugin belongs to that request only (kept per nuxtApp in a WeakMap). Store
definitions are static modules and are resolved with the pinia of each
response.
On a server, useAxios() captures the request when it is created. Create it
in setup, a plugin or a hook, or pass the nuxtApp: useAxios(nuxtApp).
Modals and toasts
Modal and Toast are shared by the Ionic apps and the CrudAdmin administration.
They keep the state only, the application gives them the look. The
@crudadmin/helpers/modal entry depends on Vue alone.
import { Modal, Toast, ModalRenderer, ToastRenderer } from '@crudadmin/helpers/modal';
Opening
// by name, the component is placed in the app template or registered
Modal.open('chat-thread-delete', { thread });
// a component, mounted by <ModalRenderer />
const modal = Modal.open(EditRow, { row }, { title: 'Edit', class: '--wide' });
// messages, rendered by the default component
Modal.success('Saved.');
Modal.info({ title: 'Info', message: '...' });
Modal.warning({ message: '...', success: () => {} });
Modal.danger(); // default title and text
Modal.message(type, options); // type from a server response, error means danger
if (await Modal.confirm({ title: 'Delete?', message: 'This cannot be undone.' })) {
// confirmed
}
// the second argument are the options of the opening itself
Modal.confirm({ title: 'Delete?' }, { replace: true }); // closes the modal below it
Modal.confirm({ title: 'Delete?' }, { replaceAll: true }); // closes every opened modal
Message options: title, text (or message), type, success, cancel,
confirmText, cancelText, actions, and anything else ends in
modal.options (class, key, ...). Every preset takes opening options as its
second argument: replace closes the modal below it, replaceAll closes every
opened modal, the rest joins modal.options. Modal.open() takes them too.
Returning false from success, cancel or an action keeps the modal opened.
Every opening is a separate entry with its own id, the same name or component
can be opened more times at once.
The entry
{
id, name, component, props, data,
message: { type, title, text }, // messages only
actions: [{ type, name, class, callback, enter, close }],
options: { class, key, ... },
state: { visible, openedAt, closedAt },
result, // Promise of { action, value }
close(value, action),
}
action is success, cancel, close (X, escape, backdrop) or the type of a
custom action.
Reading and closing
Every method accepts an entry, an id or a name. A name means the last opened modal with that name.
| method | |
|---|---|
isOpen(target), isActive(target) |
opened / on top |
get(target, fromClosed), getData(target, fromClosed) |
entry / its data |
data(name, default) |
ref refreshed on every opening |
close(target?, value, action) |
without a target closes the top modal |
runAction(target, action) |
runs an action and closes unless it returns false |
replace(...), replaceAll(...), closeAll() |
both take the arguments of open(); a preset replaces through its own { replace: true } or { replaceAll: true } |
onOpen, onClose, onChange(target, callback) |
onOpen of an entry which is already open runs on the next tick |
Setup
Modal.configure({
closeDelay: 500, // close() waits this long, against double clicks
unmountDelay: 800, // a closed modal stays mounted for its leave animation
labels: { close: () => __('Zatvoriť'), confirm: () => __('Potvrdiť') },
titles: { success: () => __('Informácia'), danger: () => __('Upozornenie') },
texts: { danger: () => __('Nastala nečakaná chyba.') },
hooks: { closing(entry) {}, unmounted(entry) {} },
});
Modal.setDefaultComponent(MessageModal); // messages, and components without a modal prop
Modal.register({ 'modal-confirm': ConfirmModal }); // names rendered by <ModalRenderer />
Place <ModalRenderer /> once in the app. A component declaring a modal prop
renders the whole modal and gets the entry in it; any other component is put in
the default slot of the default component. Names that are not registered are
left to the app template, which binds them to Modal.isOpen(name).
Toasts
Toast.success('Saved.');
Toast.error({ title: 'Error', message: '...' });
Toast.open({ type: 'warning', message: '...', duration: 10000 });
Ionic apps hand toasts to toastController through Toast.setOpener(). Without
an opener they go to Toast.toasts and are rendered by <ToastRenderer /> with
the component given to Toast.setComponent(), which receives the entry in the
toast prop.
Localization by url slugs
Off by default. Turn it on and every page gets one route per language:
export default defineNuxtConfig({
extends: ['./node_modules/@crudadmin/helpers/nuxt'],
crudadmin: {
localization: {
enabled: true,
locales: ['sk', 'en'],
},
},
});
| url | language | route name |
|---|---|---|
/ |
sk | index___sk |
/about |
sk | about___sk |
/en |
en | index___en |
/en/about |
en | about___en |
The first locale is the default one and keeps the bare paths. Every other
language is served from its own /<slug> prefix.
All languages live in the route table at the same time, so one build serves all of them and switching a language is a plain navigation. This is the main difference from the old Nuxt 2 implementation, which rebuilt the router for the language of each incoming request.
Options
Everything sits under crudadmin.localization:
| option | default | what it does |
|---|---|---|
enabled |
false |
Turns localized routes on. |
locales |
[] |
Language slugs to build routes for. Required, routes are generated at build time. |
defaultLocale |
first one | Language served from the site root. |
prefixDefault |
false |
Prefix the default language too, and redirect / to it. |
redirect |
true |
Send an unprefixed url to the domain's language, or the one the visitor picked last. |
domains |
{} |
Hostname to language map, eg { 'example.com': 'en' }. false ignores the domain. |
domainTld |
true |
Treat a matching top level domain as its language, so example.sk serves sk. |
cookie |
'locale' |
Cookie remembering the picked language. |
routes |
{} |
Translated paths per route name. May be a function, resolved at build time. |
Translated paths
Paths are keyed by route name, which stays stable no matter how the path is rewritten:
crudadmin: {
localization: {
enabled: true,
locales: ['sk', 'en'],
routes: {
about: { sk: '/o-nas', en: '/about' },
'projects-slug': { sk: '/projekty/:slug?' },
},
},
},
gives /o-nas and /en/about. Child routes are translated as well, but only
the root of a page tree receives the language prefix.
Paths translated in the administration
Set translateRoutes and the paths come from the backend gettext catalog
instead of the config, which is how the Nuxt 2 implementation worked:
crudadmin: {
localization: {
enabled: true,
locales: ['sk', 'en'],
translateRoutes: true,
},
},
Two things happen during a build.
The route paths are written out as gettext calls into
<buildDir>/crudadmin.routes.js. Nothing imports that file, it exists so the
CrudAdmin scanner finds the paths and offers them for translation. Point the
backend at it:
// config/admin.php
'gettext_source_paths' => array_filter(array_merge([
app_path('/Utilities'),
], [
env('APP_SRC_PATH').'/app',
env('APP_SRC_PATH').'/node_modules/.cache/nuxt/.nuxt/crudadmin.routes.js',
])),
APP_SRC_PATH is the Nuxt project root, so the same one value locates both the
sources and the generated route list. The tail of that path is the Nuxt build
directory, which is .nuxt on Nuxt 3 and node_modules/.cache/nuxt/.nuxt on
Nuxt 4.
Then the catalog of every language is read from /api/bootstrap?only=locale
(/<crudadmin.bootstrap.path>?only=locale, from bootstrap.baseURL when set)
and each route path is looked up in it. Translating /about to /o-nas in the
administration moves that page, and every link to it follows, because links
resolve by route name.
Routes are static in Nuxt, so a translated path takes effect on the next build, not immediately. That is the one thing the per request router of the Nuxt 2 version could do and this cannot.
The answer of the last successful build is kept in crudadmin.routes.json at
the project root and used when the backend cannot be reached, so a build never
silently drops every translated path. Commit it. Pointing cacheFile inside
the build directory defeats it, since a deploy wipes that.
Because routes may also be a function, the paths can come from somewhere else
entirely. It is awaited during the build:
routes: async () => {
const response = await fetch(process.env.VITE_APP_SERVER_URL + '/api/route-paths');
return await response.json();
},
How the language is picked
- the
/<slug>prefix of the url, which always wins - the domain, through
domainsor a matching top level domain - the
localecookie, from the visitor's last switch defaultLocale
Steps 2 and 3 only apply to urls with no prefix, and only when redirect is on.
They cause a 302 to the prefixed url rather than rendering another language at
the same address, so the url and the rendered language never disagree.
Links build themselves
The router is taught to resolve in the active language, so ordinary links and ordinary navigation stay in that language on their own. Nothing in an application has to know that prefixed routes exist:
<NuxtLink :to="{ name: 'about' }">{{ __('About') }}</NuxtLink>
<NuxtLink :to="{ name: 'projects-slug', params: { slug } }">…</NuxtLink>
router.push({ name: 'projects-slug', params: { slug } });
On /en these resolve to /en/about and /en/projects/<slug>, on the default
language to /about and /projects/<slug>.
Address routes by name rather than by a written out path. A name survives a
path translation, a hardcoded /about does not. Paths still work and are
localized the same way, but they have to be written the way the default
language spells them.
Pointing at a language other than the active one is what the language
switcher does, through useSwitchLocalePath.
Composables
All auto-imported:
| composable | returns |
|---|---|
useCurrentLocale() |
active language slug |
useLocales() |
every configured slug |
useSwitchLocalePath(locale) |
the current page in another language |
useSetLocale(locale, options?) |
switch language and navigate there |
useLocaleParams(params?) |
route params of this page in the other languages |
useLocalePath(to, locale?) |
path of a route in a language, the active one by default |
useLocaleCookie() |
the language cookie |
useLocalizationConfig() |
resolved options |
A language switcher:
<button v-for="slug in useLocales()" :key="slug" @click="useSetLocale(slug)">
{{ slug.toUpperCase() }}
</button>
useSetLocale reloads the page by default, because the translations and any
content the backend localizes have to be fetched again. Pass
{ reload: false } for a client side navigation instead.
Localized route parameters
A dynamic segment usually differs per language, because the record behind it is localized too. The router cannot know the other language's value, so the page hands it over:
useLocaleParams({
sk: { slug: 'crm-erp-systemy' },
en: { slug: 'crm-erp-systems' },
});
The language switcher merges these over the current parameters. Without them a switch keeps the parameter as it is, which lands on a url carrying the other language's slug. The values are dropped again on the next navigation, so every page sets its own.
Notes
localeshas to be listed in the config. Routes are built ahead of time while the languages themselves live in the database, so the two are kept in sync by hand. A slug missing fromlocalessimply has no routes.- The layer writes the resolved language into both the store and the cookie before the application's own plugins run, so anything fetched on boot is already requested in the right language.
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 2.1.0 | ^3.4 | ISC | |
| 2.0.0 | ^3.4 | ISC | |
| 1.2.0 | ^3.5.20 | ISC | |
| 1.1.10 | ^3.5.20 | ISC | |
| 1.1.9 | ^3.5.20 | ISC | |
| 1.1.8 | ^3.5.20 | ISC | |
| 1.1.7 | ^3.5.20 | ISC | |
| 1.1.6 | ^3.5.20 | ISC | |
| 1.1.5 | ^3.5.20 | ISC | |
| 1.1.4 | ^3.5.20 | ISC | |
| 1.1.3 | ^3.5.20 | ISC | |
| 1.1.2 | ^3.5.20 | ISC | |
| 1.1.1 | ^3.5.20 | ISC | |
| 1.1.0 | ^3.5.20 | ISC | |
| 1.0.8 | ^3.5.20 | ISC | |
| 1.0.7 | ^3.5.20 | ISC | |
| 1.0.6 | ^3.5.20 | ISC | |
| 1.0.5 | ^3.5.20 | ISC | |
| 1.0.4 | ^3.5.20 | ISC | |
| 1.0.3 | ^3.5.20 | ISC | |
| 1.0.2 | ^3.5.20 | ISC | |
| 1.0.1 | ^3.5.20 | ISC | |
| 1.0.0 | ^3.5.20 | ISC |