Update the document title when navigating to a new route

This commit is contained in:
baldo 2022-09-27 15:52:12 +02:00
parent 4a0b6d80e4
commit 32803e0ea1
3 changed files with 146 additions and 41 deletions

View file

@ -8,7 +8,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<title>Vite App</title> <title>Freifunk Knotenverwaltung</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View file

@ -2,13 +2,12 @@ import { createApp } from "vue";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router, { initTitleSync } from "./router";
import { useConfigStore } from "@/stores/config"; import { useConfigStore } from "@/stores/config";
import { useVersionStore } from "@/stores/version"; import { useVersionStore } from "@/stores/version";
async function main() { async function main() {
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
@ -18,6 +17,8 @@ async function main() {
await configLoaded; await configLoaded;
await versionLoaded; await versionLoaded;
initTitleSync();
app.mount("#app"); app.mount("#app");
} }

View file

@ -3,6 +3,7 @@ import {
createWebHistory, createWebHistory,
type LocationQueryRaw, type LocationQueryRaw,
type RouteLocationNormalized, type RouteLocationNormalized,
type RouteRecordRaw,
} from "vue-router"; } from "vue-router";
import AdminDashboardView from "@/views/AdminDashboardView.vue"; import AdminDashboardView from "@/views/AdminDashboardView.vue";
import AdminNodesView from "@/views/AdminNodesView.vue"; import AdminNodesView from "@/views/AdminNodesView.vue";
@ -23,12 +24,26 @@ import {
type TypeGuard, type TypeGuard,
} from "@/types"; } from "@/types";
import { parseJSON } from "@/shared/utils/json"; import { parseJSON } from "@/shared/utils/json";
import { useConfigStore } from "@/stores/config";
/**
* Route to use for navigation.
*/
export interface Route { export interface Route {
/**
* Name of the route to navigate to.
*/
name: RouteName; name: RouteName;
/**
* Optional query parameters. This is a flat mapping from key to value. Nested values must be stringified already.
*/
query?: LocationQueryRaw; query?: LocationQueryRaw;
} }
/**
* Enum used to identify each route. Each field corresponds to `name` field of one of the routes below.
*/
export enum RouteName { export enum RouteName {
HOME = "home", HOME = "home",
NODE_CREATE = "node-create", NODE_CREATE = "node-create",
@ -37,6 +52,13 @@ export enum RouteName {
ADMIN_NODES = "admin-nodes", ADMIN_NODES = "admin-nodes",
} }
/**
* Helper function to construct a {@link Route} object for navigation.
*
* @param name - Name of the route to navigate to.
* @param query - Optional query parameters. This is a flat mapping from key to value. Nested values must be
* stringified already.
*/
export function route(name: RouteName, query?: LocationQueryRaw): Route { export function route(name: RouteName, query?: LocationQueryRaw): Route {
return { return {
name, name,
@ -44,6 +66,15 @@ export function route(name: RouteName, query?: LocationQueryRaw): Route {
}; };
} }
/**
* Helper function to get the query parameter `field` in a typesafe manner.
*
* @param route - The route object to get the query parameter from.
* @param field - The name of the query parameter to get.
* @param isT - Type guard to check the query parameter has the expected type `<T>`.
* @returns The query parameter of type `<T>`. If the query parameter is not set or has an unexpected type `undefined`
* will be returned.
*/
function getQueryField<T>( function getQueryField<T>(
route: RouteLocationNormalized, route: RouteLocationNormalized,
field: string, field: string,
@ -56,6 +87,15 @@ function getQueryField<T>(
return isT(value) ? value : undefined; return isT(value) ? value : undefined;
} }
/**
* Helper function to get JSON from the query parameter `field` in a typesafe manner.
*
* @param route - The route object to get the query parameter from.
* @param field - The name of the query parameter to get.
* @param isT - Type guard to check the JSON for the query parameter has the expected type `<T>`.
* @returns The query parameter of type `<T>`. If the query parameter is not set, the value cannot be parsed a JSON the
* JSON does not match the expected type `undefined` will be returned.
*/
function getJSONQueryField<T>( function getJSONQueryField<T>(
route: RouteLocationNormalized, route: RouteLocationNormalized,
field: string, field: string,
@ -77,46 +117,110 @@ function getJSONQueryField<T>(
return isT(json) ? json : undefined; return isT(json) ? json : undefined;
} }
/**
* Helper type to make the compiler enforce setting a title for each route.
*/
type RouteWithTitle = RouteRecordRaw & {
meta: {
/**
* Title to set for the HTML document for the route.
*/
title: string;
};
};
/**
* All route definitions go here.
*/
const routes: RouteWithTitle[] = [
{
path: "/",
name: RouteName.HOME,
meta: {
title: "Willkommen",
},
component: HomeView,
},
{
path: "/node/create",
name: RouteName.NODE_CREATE,
meta: {
title: "Neuen Knoten anmelden",
},
component: NodeCreateView,
props: (route) => ({
hostname: getQueryField(route, "hostname", isHostname),
fastdKey: getQueryField(route, "key", isFastdKey),
mac: getQueryField(route, "mac", isMAC),
}),
},
{
path: "/node/delete",
name: RouteName.NODE_DELETE,
meta: {
title: "Knoten löschen",
},
component: NodeDeleteView,
},
{
path: "/admin",
name: RouteName.ADMIN,
meta: {
title: "Admin - Dashboard",
},
component: AdminDashboardView,
},
{
path: "/admin/nodes",
name: RouteName.ADMIN_NODES,
meta: {
title: "Admin - Knoten",
},
component: AdminNodesView,
props: (route) => ({
filter: getJSONQueryField(route, "filter", isNodesFilter) || {},
searchTerm: getQueryField(route, "q", isSearchTerm),
sortDirection: getQueryField(route, "sortDir", isSortDirection),
sortField: getQueryField(route, "sortField", isNodeSortField),
}),
},
];
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes,
{
path: "/",
name: RouteName.HOME,
component: HomeView,
},
{
path: "/node/create",
name: RouteName.NODE_CREATE,
component: NodeCreateView,
props: (route) => ({
hostname: getQueryField(route, "hostname", isHostname),
fastdKey: getQueryField(route, "key", isFastdKey),
mac: getQueryField(route, "mac", isMAC),
}),
},
{
path: "/node/delete",
name: RouteName.NODE_DELETE,
component: NodeDeleteView,
},
{
path: "/admin",
name: RouteName.ADMIN,
component: AdminDashboardView,
},
{
path: "/admin/nodes",
name: RouteName.ADMIN_NODES,
component: AdminNodesView,
props: (route) => ({
filter: getJSONQueryField(route, "filter", isNodesFilter) || {},
searchTerm: getQueryField(route, "q", isSearchTerm),
sortDirection: getQueryField(route, "sortDir", isSortDirection),
sortField: getQueryField(route, "sortField", isNodeSortField),
}),
},
],
}); });
/**
* Update the HTML documents title for the given route.
*
* @param route - Route to set the title for.
*/
function updateTitle(route: RouteLocationNormalized) {
const baseTitle = `${
useConfigStore().getConfig.community.name
} - Knotenverwaltung`;
let title: string;
if (hasOwnProperty(route.meta, "title") && isString(route.meta.title)) {
title = `${baseTitle} - ${route.meta.title}`;
} else {
console.error(`Missing title for route: ${route.path}`);
title = baseTitle;
}
document.title = title;
}
/**
* Initialize keeping the HTML document's title in sync with the current route.
*
* Note: This must be called after the config store is available.
*/
export function initTitleSync() {
router.beforeEach(updateTitle);
// Make sure the title is up-to-date after page load.
updateTitle(router.currentRoute.value);
}
export default router; export default router;