Files
summercms/admin/src/app/router.ts
Jakub Zych 872fd6535f feat(17-02): tree reorder page and singleton form in the admin
- ReorderView: indented tree with drag handle, move up/down, outdent and
  indent (buttons and arrow keys on the handle), pointer drag before, after
  or inside a row and never into its own subtree; one POST with the whole
  node set 400 ms after the last move, flushed on leave; a failed save
  restores the saved tree, a 409 reloads; loading, empty, load-failure and
  403 states; partition selector
- ListView: outline Reorder button before New record, passing the filtered
  partition
- SettingsIndexView opens a singleton controller's record through
  GET …/singleton; FormView in singleton mode has no Delete and returns to
  the settings index
- reorder.* and messages.list.reorder phrase keys (en, pl), smoke spec, docs
2026-10-12 16:18:31 +02:00

111 lines
4.3 KiB
TypeScript

import { createRouter, createWebHistory, type RouteLocationNormalized, type RouterHistory } from 'vue-router'
import { controllerIdFromParams } from './controllerRoutes'
import { activateStyles } from './pluginAssets'
import { runtime } from './runtime'
import { currentUser } from '../state/useAuth'
import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import FormView from '../views/FormView.vue'
import PreviewView from '../views/PreviewView.vue'
import ReorderView from '../views/ReorderView.vue'
import SettingsFormView from '../views/SettingsFormView.vue'
import SettingsIndexView from '../views/SettingsIndexView.vue'
import AccountView from '../views/AccountView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
interface RouteMeta {
/** Reachable without a session. */
public?: boolean
/** Rendered inside the navigation shell. */
shell?: boolean
}
}
/**
* Accepts a post-login redirect only when it is an in-app path: it must start
* with exactly one slash, which rejects absolute and protocol-relative URLs.
*/
export function safeRedirect(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0) {
return null
}
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return null
}
return value
}
const CONTROLLER_ROUTES = new Set(['list', 'create', 'record', 'preview', 'reorder'])
/**
* The controller a route shows, or '' for every other screen (settings,
* login, not found). Plugin stylesheets follow it (D-16, T-10.1-16).
*/
export function routeControllerId(route: RouteLocationNormalized): string {
if (typeof route.name !== 'string' || !CONTROLLER_ROUTES.has(route.name)) {
return ''
}
const { vendor, plugin, controller } = route.params
if (typeof vendor !== 'string' || typeof plugin !== 'string' || typeof controller !== 'string') {
return ''
}
return controllerIdFromParams({ vendor, plugin, controller })
}
export function createAdminRouter(history: RouterHistory = createWebHistory(runtime.base)) {
const router = createRouter({
history,
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
name: 'home',
component: NotFoundView,
props: { home: true },
meta: { shell: true },
beforeEnter: () => homePath() ?? true,
},
{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } },
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
// My account: the signed-in admin's own profile and password.
{ path: '/account', name: 'account', component: AccountView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
// The tree reorder page of a list with config_reorder.yaml (D-09).
{ path: '/:vendor/:plugin/:controller/reorder', name: 'reorder', component: ReorderView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
// The read-only record screen of a form with a preview block (D-11).
{
path: '/:vendor/:plugin/:controller/:id(\\d+)/preview',
name: 'preview',
component: PreviewView,
meta: { shell: true },
},
{ path: '/:pathMatch(.*)*', name: 'not-found', component: NotFoundView, meta: { shell: true } },
],
})
router.beforeEach((to) => {
const signedIn = currentUser.value !== null
if (to.meta.public) {
if (signedIn && to.name === 'login') {
return safeRedirect(to.query.redirect) ?? '/'
}
return true
}
if (!signedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
return true
})
// Plugin CSS is global: enable only the links of the controller on screen,
// and none on any other screen (D-16, T-10.1-16).
router.afterEach((to, _from, failure) => {
if (!failure) {
activateStyles(routeControllerId(to))
}
})
return router
}