Skip to content

La aplicación base con Vue ​

php artisan app:setup sin --react monta la interfaz con Vue 3, vue-router 4 y Pinia 3. Esta página es el mapa del código: dónde está cada cosa, cómo arranca y dónde se cambia. Lo que debe cumplir está en El contrato de las interfaces.

Los archivos ​

text
package.json
vite.config.js
resources/views/app.blade.php
resources/vue/
├── routes.json                 lo reescribe php artisan route:json
├── index.js                    el módulo de LaraPack (generado)
├── src/                        sus modelos (generados)
└── app/                        la aplicación base: tuya
    ├── main.js                 arranque
    ├── App.vue                 RouterView, ToastRegion y ConfirmHost
    ├── module.js               carga el módulo de LaraPack con un glob
    ├── config.js               adminOnly
    ├── http.js                 axios, apiUrl, interceptores y mensajes de error
    ├── i18n.js                 setupI18n
    ├── lang/es.json            traducciones de la aplicación
    ├── router/
    │   ├── index.js            rutas, SITE_PAGES y títulos
    │   ├── guards.js           guardas
    │   └── menu.js             buildMenu y menuShortcuts
    ├── stores/
    │   ├── auth.js             sesión (laravel-auth)
    │   ├── options.js          opciones (laravel-options)
    │   ├── notifications.js    campana (laravel-notifications)
    │   └── ui.js               modo oscuro y barra lateral
    ├── auth/                   AuthLayout, LoginView, RegisterView,
    │                           ForgotPasswordView, ResetPasswordView
    ├── admin/
    │   ├── AdminLayout.vue
    │   ├── DashboardView.vue
    │   ├── ProfileView.vue
    │   ├── SiteEditorView.vue
    │   ├── site-editor.js      lógica del editor, sin Vue
    │   ├── user.js             avatarOf, initialsOf, firstNameOf
    │   └── components/         AdminBanners, AdminSidebar, NotificationsBell,
    │                           ThemeToggle, UserMenu
    ├── components/
    │   ├── FormField.vue       campo con etiqueta enlazada
    │   └── useValidatedForm.js validación con js-validator
    ├── errors/NotFoundView.vue
    ├── site/
    │   ├── SitePage.vue        página del sitio desde la opción theme
    │   ├── ThemeManager.vue    pinta las secciones
    │   ├── render.js           qué secciones se pintan
    │   ├── links.js            isInternalLink, safeHref
    │   ├── SiteLink.vue        enlace interno o externo
    │   ├── cookies.js          cookie de consentimiento
    │   └── sections/
    │       ├── index.js        el registro de secciones
    │       ├── props.js        normalizar props
    │       ├── social.js       redes sociales
    │       └── legacy/         header, hero, section, footer, cookie-consent
    └── styles/
        ├── app.css             administrador y acceso
        └── site.css            sitio público

Dependencias y compilación ​

package.json trae axios, vue, vue-router, pinia, innoboxrr-form-core, innoboxrr-form-elements, innoboxrr-vue-datatable, innoboxrr-http-request, innoboxrr-i18n, innoboxrr-js-validator e innoboxrr-route-resolver. En desarrollo trae vite 8, @vitejs/plugin-vue 6, laravel-vite-plugin 3 y concurrently. Los scripts son dev (vite) y build (vite build).

vite.config.js:

  • Entrada resources/vue/app/main.js.
  • Alias @app → resources/vue/app.
  • transformAssetUrls desactivado: las imágenes del sitio llegan de las opciones, y Vite no debe tratar un src como un import.
  • resolve.dedupe de axios, pinia, vue, vue-router y los paquetes innoboxrr-*. El módulo de LaraPack y la aplicación tienen que compartir una sola copia: form-core guarda avisos y tema en su módulo, y dos copias de axios no comparten interceptores ni la cabecera XSRF.
  • storage/framework/views fuera del watcher.

resources/views/app.blade.php pone lang desde app()->getLocale() y el csrf-token. También lleva un script en línea que aplica el modo oscuro guardado antes de pintar, para que la página no parpadee en claro:

blade
<script>
    try {
        var theme = localStorage.getItem('theme');
        if (theme === 'dark' || theme === 'light') {
            document.documentElement.dataset.theme = theme;
        }
    } catch (e) {}
</script>

@vite('resources/vue/app/main.js')

El arranque ​

main.js importa innoboxrr-form-core/styles, styles/app.css y styles/site.css, y carga ../src/theme.js con import.meta.glob si existe. Después, en boot():

  1. configureAxios(axios): withCredentials, withXSRFToken, Accept: application/json y X-Requested-With: XMLHttpRequest.
  2. setRoutes(routes) con resources/vue/routes.json.
  3. setupI18n(moduleTranslations): primero las traducciones del módulo, luego app/lang/*.json (ganan las de la aplicación) y setLocale(document.documentElement.lang || 'en').
  4. createPinia(), createApp(App), app.use(pinia) y, si el módulo exporta un plugin con install, app.use(modulePlugin).
  5. useUiStore(pinia).init(): aplica el tema guardado y escucha el del sistema.
  6. await Promise.allSettled([auth.load(), options.load()]). Las guardas y la primera página necesitan saber quién es y qué sitio pintar. Que falle una de las dos no impide arrancar.
  7. createAppRouter({ moduleRoutes }).
  8. installGuards(router, { getSession, adminOnly, onDenied }). onDenied enseña el aviso «Only an administrator can open that page.».
  9. router.afterEach: document.title = documentTitle(to, options.option).
  10. installInterceptors({ onUnauthorized }): ante un 401 limpia la sesión y, si la ruta actual no es de invitado, va a auth.login con redirect.
  11. app.use(router) y app.mount('#app').

module.js carga el módulo de LaraPack con un glob, así que la aplicación compila aunque todavía no se haya generado ningún modelo:

js
const found = Object.values(import.meta.glob('../index.js', { eager: true }))[0] ?? {}

export const moduleRoutes = Array.isArray(found.routes) ? found.routes : []
export const moduleTranslations = found.translations ?? {}
export const modulePlugin = found.default ?? null

App.vue monta RouterView, ToastRegionComponent y ConfirmHostComponent una sola vez. Ahí pintan sus avisos y confirmaciones tanto las pantallas propias como las generadas.

El router ​

router/index.js exporta SITE_PAGES, buildRoutes(moduleRoutes), createAppRouter({ moduleRoutes, history }), formatTitle y documentTitle.

RutaNombremetaComponente
/, /privacy, /terms, /contact, /joinsite.home, site.privacy, …{ page }SitePage.vue (carga inmediata)
/auth—{ guest: true }AuthLayout.vue; '' redirige a auth.login
/auth/loginauth.logintitle: 'Sign in'LoginView.vue
/auth/registerauth.registertitle: 'Create account'RegisterView.vue
/auth/forgot-passwordauth.forgot-passwordtitle: 'Forgot your password?'ForgotPasswordView.vue
/auth/reset-password/:token/:emailauth.reset-passwordtitle: 'Choose a new password'ResetPasswordView.vue
/admin—{ auth: true }AdminLayout.vue
/admin (hija '')admin.dashboardtitle: 'Home'DashboardView.vue
/admin/profileadmin.profiletitle: 'Profile'ProfileView.vue
/admin/siteadmin.site{ admin: true, title: 'Site' }SiteEditorView.vue
hijas de /adminlas del módulolas del módulo (auth: true, title)las del módulo
/:pathMatch(.*)*not-foundtitle: 'Page not found'NotFoundView.vue
  • Los títulos son getters (get title() { return t('Home') }): se traducen al leerse, no al importar el archivo, que se carga antes de setLocale().
  • Salvo SitePage, todas las vistas se cargan con import() al entrar.
  • scrollBehavior respeta la posición guardada, baja a #hash si lo hay y, si no, sube arriba.
  • documentTitle(to, option) devuelve <título> · <site_name>. En una página del sitio el título sale de theme.<page>.title; en el resto, de la ruta más profunda que tenga meta.title. Si las dos partes son iguales, se escribe una sola.

Las guardas ​

router/guards.js son funciones puras. Reciben la ruta de destino y la sesión, y miran toda la cadena to.matched: una hija hereda auth de /admin, y el detalle de un usuario hereda que su listado esté en adminOnly.

FunciónDevuelve
requiresGuest(to)Alguna ruta de la cadena tiene meta.guest === true.
requiresAuth(to)Alguna tiene meta.auth === true.
requiresAdmin(to, adminOnly)Alguna tiene meta.admin === true o su name está en adminOnly.
safeRedirect(value)El valor si empieza por / y no por // ni /\; si no, null.
resolveNavigation(to, { authenticated, isAdmin, adminOnly }){ redirect, reason } según la tabla de abajo.
installGuards(router, { getSession, adminOnly, onDenied })Registra el beforeEach. Llama a onDenied sólo con motivo admin.

resolveNavigation decide en este orden:

CasoRedirige areason
Ruta de invitado y hay sesión{ name: 'admin.dashboard' }guest
Pide sesión o administrador y no hay sesión{ name: 'auth.login', query: { redirect: to.fullPath } }auth
Pide administrador y no lo es{ name: 'admin.dashboard' }admin
Cualquier otro—null

Las guardas no son seguridad

Deciden qué pantallas se enseñan. Lo que protege los datos es el backend: políticas, ManagedFilter y el middleware admin.

El menú ​

router/menu.js:

  • readTitle(record) lee meta.title, sea texto o función.
  • isMenuRoute(record) acepta una ruta con name, path sin : y título.
  • buildMenu(moduleRoutes, { isAdmin, adminOnly, t }) devuelve grupos:
js
[
    { id: 'main', label: null, items: [
        { id: 'admin.dashboard', label: 'Inicio', icon: 'home', to: { name: 'admin.dashboard' } },
        // rutas del módulo que no están en adminOnly
    ] },
    // sólo si isAdmin:
    { id: 'administration', label: 'Administración', items: [
        // rutas del módulo que están en adminOnly
        { id: 'admin.site', label: 'Sitio', icon: 'mdi:web', to: { name: 'admin.site' } },
        { id: 'log-viewer', label: 'Registros', icon: 'mdi:text-box-search-outline', href: '/log-viewer', external: true },
        { id: 'env-editor', label: 'Entorno', icon: 'mdi:tune-variant', href: '/env-editor', external: true },
    ] },
]
  • Una entrada del módulo es { id: name, label, icon: meta.icon ?? 'box', to: { name } }.
  • menuShortcuts(groups) junta todas las entradas menos el inicio: son las tarjetas del dashboard.

Los estados (Pinia) ​

stores/auth.js (app.auth) ​

Estado: session ({ user, authenticated, is_admin, verified, impersonating }) y loaded. Computados: user, authenticated, isAdmin, verified, impersonating.

AcciónPeticiónDespués
load()GET auth.get.auth con skipAuthHandlingnormalizeSession(data); si falla, clear()
login({ email, password, remember })GET cookie CSRF, POST auth.loginload()
register(data)GET cookie CSRF, POST auth.registerload()
logout()POST auth.logout con skipAuthHandlingclear() siempre
forgotPassword({ email })POST auth.forgot.passworddevuelve data
resetPassword({ token, email, password, password_confirmation })POST auth.reset.passworddevuelve data
updatePassword({ old_password, password, password_confirmation })POST auth.update.passworddevuelve data
resendVerification()POST auth.email.verification.notificationdevuelve data
revertImpersonation()POST auth.revert.impersonateload()
setUser(next)—mezcla next en el usuario de la sesión
clear()—sesión vacía

normalizeSession sólo da la sesión por abierta si user es un objeto y authenticated no es false. Los nombres de ruta están en AUTH_ROUTES. El perfil (nombre, correo y foto) no está en este estado: lo hace admin/ProfileView.vue.

stores/options.js (app.options) ​

Estado: values (clave → valor decodificado), records (clave → { id, name }) y loaded.

FunciónQué hace
load()GET api.laravel-options.option.index con paginate: 0. No captura el error: lo absorbe Promise.allSettled en el arranque.
option(path, fallback)option('site_name'), option('theme.home.title'). Primero busca la clave entera y, si no existe, entra en el JSON por puntos.
save(key, value)Con id, PUT api.laravel-options.option.update con { option_id, value }. Sin él, POST api.laravel-options.option.create con { key, name, value }. Actualiza records y values.

Sólo se decodifica un objeto o un arreglo JSON (decodeValue): un site_name "2024" sigue siendo texto. Un valor que no es texto se guarda serializado a JSON (serializeValue). También se exportan decodeOptions, readOption y buildSaveRequest.

stores/notifications.js (app.notifications) ​

Estado: unread, items y loading. Constantes: LATEST_LIMIT = 10 y POLL_INTERVAL = 60_000.

FunciónPetición
fetchUnreadCount()GET innoboxrr.notifications.index.unread.count
fetchLatest(limit = 10)GET innoboxrr.notifications.index con limit
markAsRead(notification)POST innoboxrr.notifications.mark.as.read con notificationId. Devuelve la acción.
markAllAsRead()POST innoboxrr.notifications.mark.all.as.read
reset()—

startUnreadPolling(store, { interval, doc }) pide el contador al llamarse, cada interval y al volver a la pestaña. Devuelve la función que lo detiene. resolveAction(action) devuelve { type: 'router', to } para una ruta interna, { type: 'location', href } para http(s)://… y null para cualquier otra cosa. notificationMessage lee data.message o data.title.

stores/ui.js (app.ui) ​

Estado: themeChoice ('dark', 'light' o null), theme, isDark y sidebarOpen. Acciones: init(), toggleTheme(), openSidebar(), closeSidebar() y toggleSidebar(). La elección se guarda en localStorage.theme. Sin elección no se escribe data-theme, y form-core sigue al sistema.

Peticiones: http.js ​

ExportQué hace
httpLa única copia de axios. El módulo de LaraPack llega a ella a través de innoboxrr-http-request, así que los interceptores cubren también sus peticiones.
configureAxios(instance)Los valores por omisión del arranque.
apiUrl(name, params)route(name, params), pero lanza Unknown backend route "x". Run php artisan route:json. si la ruta no está en routes.json. Con undefined, axios pediría la página actual y el fallo sería silencioso.
csrfCookieUrl(), requestCsrfCookie()route('sanctum.csrf-cookie') si existe; si no, /sanctum/csrf-cookie.
createErrorHandler({ instance, onUnauthorized })Ante un 419 pide otra cookie CSRF y repite la petición una vez (csrfRetried). Ante un 401 llama a onUnauthorized, salvo que la petición lleve skipAuthHandling: true.
installInterceptors({ instance, onUnauthorized })Registra el manejador.
errorMessage(error, t)El texto para el usuario: sin respuesta, «Could not connect to the server.»; 429; 403 con el mensaje del backend salvo el genérico de Laravel; si no, data.message o uno genérico.
validationErrors(error)Los errores { campo: [mensajes] } de un 422, o null.
js
import { apiUrl, http } from '@app/http.js'

await http.post(apiUrl('api.app.user.update'), { user_id: 1, name: 'Ana' })

// Una petición que ya espera no tener sesión:
await http.get(apiUrl('auth.get.auth'), { skipAuthHandling: true })

Formularios ​

components/FormField.vue es un campo con su etiqueta enlazada por for e id. Existe porque el TextInputComponent de form-elements no da id al control y su etiqueta no lo nombra para un lector de pantalla. Admite v-model, label, name, type, autocomplete, validators y hint; el resto de atributos va al <input>.

components/useValidatedForm.js valida con js-validator antes de enviar y coloca los errores de un 422 bajo cada campo:

vue
<template>
    <form ref="form" novalidate @submit.prevent="handleSubmit">
        <p v-if="error" class="app-alert" role="alert">{{ error }}</p>
        <FormField v-model="email" name="email" type="email" :label="t('Email')" validators="required email" />
        <button type="submit" class="fe-button" :disabled="busy">{{ t('Save') }}</button>
    </form>
</template>

<script setup>
    import { ref } from 'vue'
    import t from 'innoboxrr-i18n'
    import FormField from '@app/components/FormField.vue'
    import { useValidatedForm } from '@app/components/useValidatedForm.js'

    const email = ref('')

    const { form, busy, error, handleSubmit } = useValidatedForm(async ({ setError }) => {
        // tu petición; un 422 se pinta junto a cada campo
    }, {
        statusMessages: { 403: t('You are not allowed to do this.') },
    })
</script>

useValidatedForm(submit, { statusMessages }) devuelve { form, busy, error, handleSubmit, resetErrors }. submit recibe { setError }. Ante un error que no es un 422, error toma statusMessages[status] o errorMessage(error, t). Los mensajes del validador (required, email, password_mismatch, password_missing) pasan por t().

Dónde se cambia cada cosa ​

En Vue, estos ajustes están escritos en su archivo. En React viven en config.js (Con React).

QuéDónde
Rutas del módulo sólo para administradoresconfig.js → adminOnly (nombres de ruta)
Páginas del sitiorouter/index.js → SITE_PAGES, y admin/site-editor.js → DEFAULT_PAGES
Entradas fijas del grupo «Administración» (Sitio, Registros, Entorno)router/menu.js → buildMenu
Cada cuánto se consulta el contador de notificacionesstores/notifications.js → POLL_INTERVAL; cuántas se listan, LATEST_LIMIT
Ruta del update del usuario y de la subida de la fotoadmin/ProfileView.vue → USER_UPDATE y UPLOAD
Prefijo del administradorrouter/index.js (path: '/admin'). El menú usa nombres de ruta.
A dónde va quien entraauth/LoginView.vue
Aviso de ruta denegadamain.js → onDenied
Secciones del sitiosite/sections/index.js
Textoslang/es.json
Estilosstyles/app.css, styles/site.css

Las recetas completas están en Personalizar y ampliar.

Tests ​

Los tests de esta interfaz viven en el paquete innoboxrr/laravel-setup, en tests/Frontend/vue, y prueban directamente stubs/app/vue/resources/vue/app. No se copian a tu aplicación.

ArchivoQué prueba
tests/auth-store.test.jsSesión, login, logout, volver de una suplantación
tests/guards.test.jsresolveNavigation, safeRedirect
tests/menu.test.jsbuildMenu, adminOnly, menuShortcuts
tests/notifications-bell.test.jsCampana, contador, acciones
tests/options-store.test.jsDecodificar, leer por puntos, guardar
tests/sections.test.jsLas secciones y sus props
tests/site-editor.test.jsBorrador, JSON inválido, guardar
tests/theme-manager.test.jsQué secciones se pintan y dónde
bash
cd tests/Frontend/vue
npm install
npx vitest run
  • Vitest 3 con jsdom.
  • @app/ apunta a los stubs, y resolve.dedupe resuelve sus imports desde esta carpeta.
  • innoboxrr-form-elements se sustituye por support/form-elements.js: el paquete entero arrastra TinyMCE, CodeMirror y vue-tel-input, que no arrancan en jsdom.

La CI del paquete corre esta suite y la de React en Node 22 antes de publicar.