diff --git a/web-app/packages/admin-app/src/App.vue b/web-app/packages/admin-app/src/App.vue index 63d31509..cc7dff16 100644 --- a/web-app/packages/admin-app/src/App.vue +++ b/web-app/packages/admin-app/src/App.vue @@ -55,11 +55,12 @@ import { useNotificationStore, useUserStore, useLayoutStore, - useProjectStore + useProjectStore, + useRouterTitle } from '@mergin/lib' import { mapActions, mapState } from 'pinia' import { useToast } from 'primevue/usetoast' -import { defineComponent } from 'vue' +import { defineComponent, watchEffect } from 'vue' import { useMeta } from 'vue-meta' export default defineComponent({ @@ -120,8 +121,11 @@ export default defineComponent({ } }, setup() { + const { title } = useRouterTitle({ + defaultTitle: 'Mergin Maps Admin Panel' + }) useMeta({ - title: 'Mergin Maps', + title: 'Mergin Maps Admin Panel', meta: [ { name: 'description', @@ -144,6 +148,9 @@ export default defineComponent({ notificationStore.init(toast) layoutStore.init() projectStore.filterPermissions(['editor'], ['edit']) + watchEffect(() => { + document.title = title.value + }) }, async created() { await this.fetchConfig() diff --git a/web-app/packages/admin-app/src/router.ts b/web-app/packages/admin-app/src/router.ts index df1a3152..b30fe77a 100644 --- a/web-app/packages/admin-app/src/router.ts +++ b/web-app/packages/admin-app/src/router.ts @@ -13,7 +13,8 @@ import { ProjectSettingsView, ProjectVersionView, ProjectVersionsView, - OverviewView + OverviewView, + getAdminTitle } from '@mergin/admin-lib' import { NotFoundView, @@ -165,5 +166,10 @@ export const createRouter = (pinia: Pinia) => { routeUtils.isSuperUser(to, from, next, userStore) }) + router.beforeEach((to, from, next) => { + // Set the page title based on the route's meta title + to.meta.title = getAdminTitle + next() + }) return router } diff --git a/web-app/packages/admin-app/src/shims-vue-router.d.ts b/web-app/packages/admin-app/src/shims-vue-router.d.ts index 9cbc6fc4..1a925be0 100644 --- a/web-app/packages/admin-app/src/shims-vue-router.d.ts +++ b/web-app/packages/admin-app/src/shims-vue-router.d.ts @@ -2,8 +2,6 @@ import 'vue-router' declare module 'vue-router' { interface RouteMeta { - public?: boolean - allowedForNoWorkspace?: boolean - breadcrump?: { title: string; path: string }[] + title?: string | string[] | ((route, extened) => string | string[]) } } diff --git a/web-app/packages/admin-lib/src/modules/admin/routes.ts b/web-app/packages/admin-lib/src/modules/admin/routes.ts index c4835066..3439a561 100644 --- a/web-app/packages/admin-lib/src/modules/admin/routes.ts +++ b/web-app/packages/admin-lib/src/modules/admin/routes.ts @@ -2,7 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -import { RouteRecord } from 'vue-router' +import { RouteLocationNormalizedLoaded, RouteRecord } from 'vue-router' + +import { AdminRouteParams } from './types' export enum AdminRoutes { ACCOUNTS = 'accounts', @@ -15,7 +17,27 @@ export enum AdminRoutes { ProjectHistory = 'project-versions', ProjectSettings = 'project-settings', ProjectVersion = 'project-version', - FileVersionDetail = 'file-version-detail' + FileVersionDetail = 'file-version-detail', + Login = 'login' +} + +export const getAdminTitle = (route: RouteLocationNormalizedLoaded) => { + const params = route.params as AdminRouteParams + const titles: Record = { + [AdminRoutes.Login]: ['Sign in', 'Mergin Maps Admin Panel'], + [AdminRoutes.ACCOUNTS]: 'Accounts', + [AdminRoutes.ACCOUNT]: 'Account details', + [AdminRoutes.OVERVIEW]: 'Admin overview', + [AdminRoutes.PROJECTS]: 'Projects', + [AdminRoutes.PROJECT]: ['Details', params.projectName], + [AdminRoutes.SETTINGS]: 'Settings', + [AdminRoutes.ProjectTree]: ['Files', params.projectName], + [AdminRoutes.ProjectHistory]: ['History', params.projectName], + [AdminRoutes.ProjectSettings]: ['Settings', params.projectName], + [AdminRoutes.ProjectVersion]: [params.version_id, params.projectName], + [AdminRoutes.FileVersionDetail]: [params.path, params.version_id] + } + return titles[route.name as AdminRoutes] } export const getRoutes = (): RouteRecord[] => [] diff --git a/web-app/packages/admin-lib/src/modules/admin/types.ts b/web-app/packages/admin-lib/src/modules/admin/types.ts index ce706c09..353d94df 100644 --- a/web-app/packages/admin-lib/src/modules/admin/types.ts +++ b/web-app/packages/admin-lib/src/modules/admin/types.ts @@ -84,4 +84,12 @@ export interface DownloadReportParams { date_to: string } +export interface AdminRouteParams { + namespace?: string + projectName?: string + version_id?: string + path?: string + username?: string +} + /* eslint-enable camelcase */ diff --git a/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue b/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue index ae739a43..f4642a93 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/ProjectVersionView.vue @@ -62,6 +62,8 @@ + + @@ -74,7 +76,9 @@ import { useNotificationStore, ProjectVersion, errorUtils, - ProjectVersionChanges + ProjectVersionChanges, + DownloadProgress, + DownloadFileLarge } from '@mergin/lib' import { computed, ref, watch } from 'vue' import { useRoute } from 'vue-router' diff --git a/web-app/packages/admin-lib/src/shims-vue-router.d.ts b/web-app/packages/admin-lib/src/shims-vue-router.d.ts new file mode 100644 index 00000000..a7a591d5 --- /dev/null +++ b/web-app/packages/admin-lib/src/shims-vue-router.d.ts @@ -0,0 +1,7 @@ +import 'vue-router' + +declare module 'vue-router' { + interface RouteMeta { + title?: string | string[] | ((route, extended) => string | string[]) + } +} diff --git a/web-app/packages/app/shims-vue-router.d.ts b/web-app/packages/app/shims-vue-router.d.ts index 9cbc6fc4..6c1bdb35 100644 --- a/web-app/packages/app/shims-vue-router.d.ts +++ b/web-app/packages/app/shims-vue-router.d.ts @@ -4,6 +4,9 @@ declare module 'vue-router' { interface RouteMeta { public?: boolean allowedForNoWorkspace?: boolean - breadcrump?: { title: string; path: string }[] + breadcrump?: + | { title: string; path: string }[] + | ((route) => { title: string; path: string }[]) + title?: string | string[] | ((route, extended) => string | string[]) } } diff --git a/web-app/packages/app/src/App.vue b/web-app/packages/app/src/App.vue index 1e9da4bc..fe7593d2 100644 --- a/web-app/packages/app/src/App.vue +++ b/web-app/packages/app/src/App.vue @@ -53,11 +53,13 @@ import { useNotificationStore, useUserStore, InstanceMaintenanceMessage, - useProjectStore + useProjectStore, + useRouterTitle, + routeUtils } from '@mergin/lib' import { mapActions, mapState } from 'pinia' import { useToast } from 'primevue/usetoast' -import { defineComponent } from 'vue' +import { defineComponent, watchEffect } from 'vue' import { useMeta } from 'vue-meta' export default defineComponent({ @@ -101,8 +103,11 @@ export default defineComponent({ } }, setup() { + const { title } = useRouterTitle({ + defaultTitle: routeUtils.DEFAULT_PAGE_TITLE + }) useMeta({ - title: 'Mergin Maps', + title: routeUtils.DEFAULT_PAGE_TITLE, meta: [ { name: 'description', @@ -125,6 +130,9 @@ export default defineComponent({ notificationStore.init(toast) layoutStore.init() projectStore.filterPermissions(['editor'], ['edit']) + watchEffect(() => { + document.title = title.value + }) }, async created() { await this.fetchConfig() @@ -152,7 +160,7 @@ export default defineComponent({ }, methods: { ...mapActions(useAppStore, ['setServerError']), - ...mapActions(useInstanceStore, ['fetchPing', 'fetchConfig', 'initApp']), + ...mapActions(useInstanceStore, ['fetchPing', 'fetchConfig']), ...mapActions(useNotificationStore, { notificationError: 'error' }), diff --git a/web-app/packages/app/src/router.ts b/web-app/packages/app/src/router.ts index 6dbfdc22..4fb00555 100644 --- a/web-app/packages/app/src/router.ts +++ b/web-app/packages/app/src/router.ts @@ -12,7 +12,12 @@ import { routeUtils, useUserStore, SideBarTemplate as SideBar, - ProjectRouteName + ProjectRouteName, + UserRouteName, + DashboardRouteName, + getDashboardTitle, + getUserTitle, + getProjectTitle } from '@mergin/lib' import { Pinia } from 'pinia' import { @@ -57,28 +62,28 @@ export const createRouter = (pinia: Pinia) => { } }, path: '/login/:reset?', - name: 'login', + name: UserRouteName.Login, component: LoginView, props: true, meta: { public: true } }, { path: '/confirm-email/:token', - name: 'confirm_email', + name: UserRouteName.ConfirmEmail, component: VerifyEmailView, props: true, meta: { public: true } }, { path: '/change-password/:token', - name: 'change_password', + name: UserRouteName.ChangePassword, component: ChangePasswordView, props: true, meta: { public: true } }, { path: '/dashboard', - name: 'dashboard', + name: DashboardRouteName.Dashboard, components: { default: DashboardView, header: AppHeader, @@ -93,7 +98,7 @@ export const createRouter = (pinia: Pinia) => { }, { path: '/profile', - name: 'user_profile', + name: UserRouteName.UserProfile, meta: { allowedForNoWorkspace: true, breadcrump: [{ title: 'Profile', path: '/profile' }] @@ -107,7 +112,7 @@ export const createRouter = (pinia: Pinia) => { }, { path: '/projects', - name: 'projects', + name: ProjectRouteName.Projects, components: { default: ProjectsListView, header: AppHeader, @@ -118,13 +123,12 @@ export const createRouter = (pinia: Pinia) => { }, meta: { public: true, - title: 'Projects', breadcrump: [{ title: 'Projects', path: '/projects' }] }, children: [ { path: 'explore', - name: 'explore', + name: ProjectRouteName.ProjectsExplore, component: ProjectsListView, props: true, meta: { @@ -248,7 +252,7 @@ export const createRouter = (pinia: Pinia) => { }, { path: 'history/:version_id/:path', - name: 'file-version-detail', + name: ProjectRouteName.FileVersionDetail, component: FileVersionDetailView, props: true, meta: { @@ -284,5 +288,18 @@ export const createRouter = (pinia: Pinia) => { const userStore = useUserStore(pinia) routeUtils.isAuthenticatedGuard(to, from, next, userStore) }) + + router.beforeEach(async (to, from, next) => { + const titleResolvers = [getProjectTitle, getUserTitle, getDashboardTitle] + + // Use `find` to get the first resolved title + to.meta.title = (route) => { + return titleResolvers + .map((resolver) => resolver(route)) + .find((title) => title) + } + + next() + }) return router } diff --git a/web-app/packages/lib/src/common/composables/index.ts b/web-app/packages/lib/src/common/composables/index.ts new file mode 100644 index 00000000..dd4fe405 --- /dev/null +++ b/web-app/packages/lib/src/common/composables/index.ts @@ -0,0 +1,5 @@ +// Copyright (C) Lutra Consulting Limited +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +export { default as useRouterTitle } from './use_router_title' diff --git a/web-app/packages/lib/src/common/composables/use_router_title.ts b/web-app/packages/lib/src/common/composables/use_router_title.ts new file mode 100644 index 00000000..89936854 --- /dev/null +++ b/web-app/packages/lib/src/common/composables/use_router_title.ts @@ -0,0 +1,43 @@ +// Copyright (C) Lutra Consulting Limited +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +import { computed, toValue } from 'vue' +import { useRoute } from 'vue-router' + +interface RouterTitleConfig { + defaultTitle?: string | string[] +} + +const SEPARATOR = ' \u2022 ' // Unicode for bullet character + +const useRouterTitle = (config: RouterTitleConfig = {}, extended = {}) => { + const route = useRoute() + + const metaTitle = computed(() => { + const metaTitle = route.meta.title + const defaultTitle = config.defaultTitle + if (!metaTitle) { + return defaultTitle + } + if (typeof metaTitle === 'function') { + return metaTitle(route, toValue(extended)) || defaultTitle + } + return metaTitle + }) + + const title = computed(() => { + const result = metaTitle.value + if (Array.isArray(result)) { + return result.filter(Boolean).join(` ${SEPARATOR} `) + } + return result + }) + + return { + metaTitle, + title + } +} + +export default useRouterTitle diff --git a/web-app/packages/lib/src/common/index.ts b/web-app/packages/lib/src/common/index.ts index 4e3f31f8..87759529 100644 --- a/web-app/packages/lib/src/common/index.ts +++ b/web-app/packages/lib/src/common/index.ts @@ -18,6 +18,7 @@ export * from './errors' export * from './mixins' export * from './http' export * from './types' +export * from './composables' // export * from './router_without_navigation_failure' export * as dateUtils from './date_utils' export * as htmlUtils from './html_utils' diff --git a/web-app/packages/lib/src/common/route_utils.ts b/web-app/packages/lib/src/common/route_utils.ts index d9866194..1c516eb8 100644 --- a/web-app/packages/lib/src/common/route_utils.ts +++ b/web-app/packages/lib/src/common/route_utils.ts @@ -6,6 +6,8 @@ import isEqual from 'lodash/isEqual' import pick from 'lodash/pick' import { NavigationGuardNext, RouteLocationNormalized } from 'vue-router' +export const DEFAULT_PAGE_TITLE = 'Mergin Maps' + export type IsAuthenticatedGuardOptions = { notAuthenticatedRedirectPath?: string } diff --git a/web-app/packages/lib/src/modules/dashboard/routes.ts b/web-app/packages/lib/src/modules/dashboard/routes.ts index 055037c7..22b965e2 100644 --- a/web-app/packages/lib/src/modules/dashboard/routes.ts +++ b/web-app/packages/lib/src/modules/dashboard/routes.ts @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial +import { RouteLocationNormalizedLoaded } from 'vue-router' + /** * Enum for route names in the app's router. * Defines string constants for each route path used by project module. @@ -10,3 +12,17 @@ export enum DashboardRouteName { Dashboard = 'dashboard' } + +export const getDashboardTitle = ( + route: RouteLocationNormalizedLoaded, + extended?: { workspaceName: string } +) => { + const name = route.name as DashboardRouteName + const titles: Record = { + [DashboardRouteName.Dashboard]: [ + 'Dashboard', + extended?.workspaceName + ].filter(Boolean) + } + return titles[name] +} diff --git a/web-app/packages/lib/src/modules/project/routes.ts b/web-app/packages/lib/src/modules/project/routes.ts index a07b601b..a3322b1b 100644 --- a/web-app/packages/lib/src/modules/project/routes.ts +++ b/web-app/packages/lib/src/modules/project/routes.ts @@ -2,7 +2,11 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -import { RouteRecord } from 'vue-router' +import { RouteLocationNormalizedLoaded, RouteRecord } from 'vue-router' + +import { ProjectRouteParams, ProjectRouteQuery } from './types' + +import { DEFAULT_PAGE_TITLE } from '@/common/route_utils' /** * Enum for route names in the app's router. @@ -17,7 +21,6 @@ export enum ProjectRouteName { Projects = 'projects', /** Public projects */ ProjectsExplore = 'explore', - Projectsnamespace = 'namespace-projects', /** @deprecated */ Blob = 'blob', /** @@ -38,4 +41,33 @@ export enum ProjectRouteName { FileVersionDetail = 'file-version-detail' } +export const getProjectTitle = (route: RouteLocationNormalizedLoaded) => { + const name = route.name as ProjectRouteName + const params = route.params as ProjectRouteParams + const query = route.query as ProjectRouteQuery + const { projectName, path, version_id } = params as ProjectRouteParams + const titles: Record = { + [ProjectRouteName.Projects]: ['Projects'], + [ProjectRouteName.ProjectsExplore]: ['Public projects'], + [ProjectRouteName.Project]: ['Project details'], + [ProjectRouteName.ProjectTree]: [ + query.file_path || 'Files', + route.params.projectName as string + ], + [ProjectRouteName.ProjectSettings]: ['Settings', projectName], + [ProjectRouteName.ProjectHistory]: [ + query.version_id || 'History', + projectName + ].filter(Boolean), + [ProjectRouteName.ProjectCollaborators]: ['Collaborators', projectName], + [ProjectRouteName.FileVersionDetail]: [ + path, + `Version ${version_id as string}` + ], + [ProjectRouteName.VersionDetail]: DEFAULT_PAGE_TITLE, + [ProjectRouteName.Blob]: DEFAULT_PAGE_TITLE + } + return titles[name] +} + export default (): RouteRecord[] => [] diff --git a/web-app/packages/lib/src/modules/project/types.ts b/web-app/packages/lib/src/modules/project/types.ts index 844e776c..46e738b9 100644 --- a/web-app/packages/lib/src/modules/project/types.ts +++ b/web-app/packages/lib/src/modules/project/types.ts @@ -376,3 +376,17 @@ export interface ProjectCollaborator { project_role: ProjectRoleName | null role: ProjectRoleName } + + +// router related types +export interface ProjectRouteParams { + namespace?: string + projectName?: string + version_id?: string + path?: string +} + +export interface ProjectRouteQuery { + version_id?: string + file_path?: string +} diff --git a/web-app/packages/lib/src/modules/user/routes.ts b/web-app/packages/lib/src/modules/user/routes.ts index 67cc2247..d2a52edd 100644 --- a/web-app/packages/lib/src/modules/user/routes.ts +++ b/web-app/packages/lib/src/modules/user/routes.ts @@ -2,7 +2,11 @@ // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -import { RouteRecord } from 'vue-router' +import { RouteLocationNormalizedLoaded, RouteRecord } from 'vue-router' + +import { UserRouteParams } from './types' + +import { DEFAULT_PAGE_TITLE } from '@/common/route_utils' /** * Enum for user routes names @@ -15,4 +19,19 @@ export enum UserRouteName { UserProfile = 'user_profile' } +export const getUserTitle = (route: RouteLocationNormalizedLoaded) => { + const name = route.name as UserRouteName + const params = route.params as UserRouteParams + const titles: Record = { + [UserRouteName.Login]: [ + params.reset ? 'Reset password' : 'Sign in', + DEFAULT_PAGE_TITLE + ], + [UserRouteName.ConfirmEmail]: ['Confirm email address', DEFAULT_PAGE_TITLE], + [UserRouteName.ChangePassword]: ['Change password', DEFAULT_PAGE_TITLE], + [UserRouteName.UserProfile]: ['Your profile'] + } + return titles[name] +} + export default (): RouteRecord[] => [] diff --git a/web-app/packages/lib/src/modules/user/types.ts b/web-app/packages/lib/src/modules/user/types.ts index bb1f0b29..2990212a 100644 --- a/web-app/packages/lib/src/modules/user/types.ts +++ b/web-app/packages/lib/src/modules/user/types.ts @@ -147,4 +147,8 @@ export interface LastSeenWorkspace { lastSeen: number } +export interface UserRouteParams { + reset?: string +} + /* eslint-enable camelcase */ diff --git a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue index 12358d8c..0a670892 100644 --- a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue +++ b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial class="flex flex-column lg:flex-row lg:align-items-center row-gap-3" > -

Account details

+

Profile

@@ -81,7 +81,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial { title: string; path: string }[]) + title?: string | string[] | ((route, extended) => string | string[]) } }