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;