diff --git a/frontend/index.html b/frontend/index.html index 1e112f4..5761634 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,7 +8,7 @@ - Vite App + Freifunk Knotenverwaltung
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 795f79f..b028e36 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,13 +2,12 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; -import router from "./router"; +import router, { initTitleSync } from "./router"; import { useConfigStore } from "@/stores/config"; import { useVersionStore } from "@/stores/version"; async function main() { const app = createApp(App); - app.use(createPinia()); app.use(router); @@ -18,6 +17,8 @@ async function main() { await configLoaded; await versionLoaded; + initTitleSync(); + app.mount("#app"); } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bd67321..112d516 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,7 @@ import { createWebHistory, type LocationQueryRaw, type RouteLocationNormalized, + type RouteRecordRaw, } from "vue-router"; import AdminDashboardView from "@/views/AdminDashboardView.vue"; import AdminNodesView from "@/views/AdminNodesView.vue"; @@ -23,12 +24,26 @@ import { type TypeGuard, } from "@/types"; import { parseJSON } from "@/shared/utils/json"; +import { useConfigStore } from "@/stores/config"; +/** + * Route to use for navigation. + */ export interface Route { + /** + * Name of the route to navigate to. + */ name: RouteName; + + /** + * Optional query parameters. This is a flat mapping from key to value. Nested values must be stringified already. + */ query?: LocationQueryRaw; } +/** + * Enum used to identify each route. Each field corresponds to `name` field of one of the routes below. + */ export enum RouteName { HOME = "home", NODE_CREATE = "node-create", @@ -37,6 +52,13 @@ export enum RouteName { 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 { return { 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 ``. + * @returns The query parameter of type ``. If the query parameter is not set or has an unexpected type `undefined` + * will be returned. + */ function getQueryField( route: RouteLocationNormalized, field: string, @@ -56,6 +87,15 @@ function getQueryField( 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 ``. + * @returns The query parameter of type ``. 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( route: RouteLocationNormalized, field: string, @@ -77,46 +117,110 @@ function getJSONQueryField( 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({ history: createWebHistory(import.meta.env.BASE_URL), - 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), - }), - }, - ], + routes, }); +/** + * 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;