feat(common): add better ux to profile page by enabling routing for each tab (#5544)

Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
This commit is contained in:
Anwarul Islam 2025-11-25 23:36:56 +06:00 committed by GitHub
parent a5fb7cb0d2
commit 16f08e2a50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 408 additions and 199 deletions

View file

@ -1037,6 +1037,8 @@
"no_collection": "No collection",
"collection_deleted": "associated collection deleted.",
"private_access_hint": "For private mock servers, include the header 'x-api-key' with your Personal Access Token (create one from your profile).",
"private_access_instruction": "To access this private mock server, include the header 'x-api-key' with your Personal Access Token.",
"create_token_here": "Create here",
"status": "Status",
"server_running": "Server is running",
"server_stopped": "Server is stopped",

View file

@ -327,7 +327,6 @@ declare module 'vue' {
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
TabsNav: typeof import('./components/TabsNav.vue')['default']
Teams: typeof import('./components/teams/index.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsEdit: typeof import('./components/teams/Edit.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']

View file

@ -0,0 +1,129 @@
<template>
<div
class="tabs relative border-dividerLight"
:class="[vertical ? 'border-r' : 'border-b', styles]"
>
<div class="flex flex-1">
<div class="flex flex-1">
<router-link
v-for="(item, index) in items"
:key="`nav-${index}`"
:to="item.route"
v-tippy="{
theme: 'tooltip',
placement: 'left',
content: vertical ? item.label : null,
}"
active-class="active"
exact-active-class="active"
:exact="item.exactMatch"
class="tab"
:class="[{ vertical: vertical }]"
:aria-label="item.label || ''"
role="link"
>
<component
:is="item.icon"
v-if="item.icon"
class="svg-icons"
:class="{ 'mr-2': item.label && !vertical }"
/>
<span v-if="item.label && !vertical">{{ item.label }}</span>
</router-link>
</div>
<div class="flex items-center justify-center">
<slot name="actions"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from "vue"
export type NavItem = {
label: string | null
icon: string | Component | null
route: string
exactMatch?: boolean
}
defineProps<{
items: readonly NavItem[]
styles?: string
vertical?: boolean
}>()
</script>
<style lang="scss" scoped>
.tabs {
@apply flex;
@apply whitespace-nowrap;
@apply overflow-auto;
@apply flex-shrink-0;
.tab {
@apply relative;
@apply flex;
@apply flex-shrink-0;
@apply items-center;
@apply justify-center;
@apply px-4 py-2;
@apply text-secondary;
@apply font-semibold;
@apply cursor-pointer;
@apply hover:text-secondaryDark;
@apply focus:outline-none;
@apply focus-visible:text-secondaryDark;
@apply after:absolute;
@apply after:left-4;
@apply after:right-4;
@apply after:bottom-0;
@apply after:bg-transparent;
@apply after:z-[2];
@apply after:h-0.5;
@apply after:content-[''];
@apply focus:after:bg-divider;
.tab-info {
@apply inline-flex;
@apply items-center;
@apply justify-center;
@apply px-1;
@apply min-w-[1rem];
@apply h-4;
@apply ml-2;
@apply text-[8px];
@apply border border-divider;
@apply rounded;
@apply text-secondaryLight;
}
&.active {
@apply text-secondaryDark;
@apply after:bg-accent;
.tab-info {
@apply text-secondary;
@apply border-dividerDark;
}
}
&.vertical {
@apply p-2;
@apply rounded;
@apply focus:after:hidden;
&.active {
@apply text-accent;
@apply after:hidden;
.tab-info {
@apply text-secondary;
@apply border-dividerDark;
}
}
}
}
}
</style>

View file

@ -120,7 +120,15 @@
</div>
<!-- Hint for private mock servers -->
<div v-if="!isPublic" class="w-full mt-2 text-xs text-secondaryLight">
{{ t("mock_server.private_access_hint") }}
{{ t("mock_server.private_access_instruction") }}
<HoppSmartAnchor
class="link"
to="/profile/tokens"
blank
:icon="IconExternalLink"
:label="t('mock_server.create_token_here')"
reverse
/>
</div>
<!-- Set in Environment Toggle -->
@ -202,6 +210,7 @@ import IconCopy from "~icons/lucide/copy"
import IconPlay from "~icons/lucide/play"
import IconServer from "~icons/lucide/server"
import IconSquare from "~icons/lucide/square"
import IconExternalLink from "~icons/lucide/external-link"
const t = useI18n()
const toast = useToast()

View file

@ -157,7 +157,15 @@
</div>
<!-- Hint for private mock servers -->
<div v-if="!isPublic" class="w-full mt-2 text-xs text-secondaryLight">
{{ t("mock_server.private_access_hint") }}
{{ t("mock_server.private_access_instruction") }}
<HoppSmartAnchor
class="link"
to="/profile/tokens"
blank
:icon="IconExternalLink"
:label="t('mock_server.create_token_here')"
reverse
/>
</div>
<!-- Set in Environment Toggle -->
@ -309,6 +317,7 @@ import IconCheck from "~icons/lucide/check"
import IconPlay from "~icons/lucide/play"
import IconServer from "~icons/lucide/server"
import IconSquare from "~icons/lucide/square"
import IconExternalLink from "~icons/lucide/external-link"
const t = useI18n()
const toast = useToast()

View file

@ -118,7 +118,15 @@
</span>
</div>
<div v-if="!isPublic" class="text-xs text-secondaryLight">
{{ t("mock_server.private_access_hint") }}
{{ t("mock_server.private_access_instruction") }}
<HoppSmartAnchor
class="link"
to="/profile/tokens"
blank
:icon="IconExternalLink"
:label="t('mock_server.create_token_here')"
reverse
/>
</div>
</div>
</div>
@ -171,6 +179,7 @@ import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconPlay from "~icons/lucide/play"
import IconSquare from "~icons/lucide/square"
import IconExternalLink from "~icons/lucide/external-link"
interface Props {
show: boolean

View file

@ -0,0 +1,202 @@
<template>
<div class="grid grid-cols-1">
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.profile") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.profile_description") }}
</div>
<div class="py-4">
<label for="displayName">
{{ t("settings.profile_name") }}
</label>
<HoppSmartInput
v-model="displayName"
:autofocus="false"
styles="mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_name')}`"
>
<template #button>
<HoppButtonSecondary
filled
outline
:label="t('action.save')"
class="min-w-[4rem] ml-2"
type="submit"
:loading="updatingDisplayName"
@click="updateDisplayName"
/>
</template>
</HoppSmartInput>
</div>
<div class="py-4">
<label for="emailAddress">
{{ t("settings.profile_email") }}
</label>
<HoppSmartInput
v-model="emailAddress"
:autofocus="false"
styles="flex mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_email')}`"
:disabled="!isEmailEditable"
>
<template #button>
<HoppButtonSecondary
v-if="isEmailEditable"
filled
outline
:label="t('action.save')"
class="min-w-[4rem] ml-2"
type="submit"
:loading="updatingEmailAddress"
@click="updateEmailAddress"
/>
</template>
</HoppSmartInput>
</div>
</section>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.sync_description") }}
</div>
<div class="space-y-4 py-4">
<div class="flex items-center">
<HoppSmartToggle
:on="SYNC_COLLECTIONS"
@change="toggleSetting('syncCollections')"
>
{{ t("settings.sync_collections") }}
</HoppSmartToggle>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="SYNC_ENVIRONMENTS"
@change="toggleSetting('syncEnvironments')"
>
{{ t("settings.sync_environments") }}
</HoppSmartToggle>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="SYNC_HISTORY"
@change="toggleSetting('syncHistory')"
>
{{ t("settings.sync_history") }}
</HoppSmartToggle>
</div>
</div>
</section>
<template v-if="platform.ui?.additionalProfileSections?.length">
<template
v-for="(item, index) in platform.ui?.additionalProfileSections"
:key="index"
>
<component :is="item" />
</template>
</template>
<ProfileUserDelete />
</div>
</template>
<script setup lang="ts">
import * as E from "fp-ts/Either"
import { computed, ref, watchEffect } from "vue"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { toggleSetting } from "~/newstore/settings"
const t = useI18n()
const toast = useToast()
const SYNC_COLLECTIONS = useSetting("syncCollections")
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
const SYNC_HISTORY = useSetting("syncHistory")
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const isEmailEditable = computed(() => {
return platform.auth.isEmailEditable ?? false
})
const displayName = ref(currentUser.value?.displayName || "")
const updatingDisplayName = ref(false)
watchEffect(() => (displayName.value = currentUser.value?.displayName || ""))
const updateDisplayName = async () => {
const inputName = displayName.value.trim()
if (!inputName) {
toast.error(`${t("error.empty_profile_name")}`)
return
}
if (currentUser.value?.displayName === inputName) {
toast.error(`${t("error.same_profile_name")}`)
return
}
updatingDisplayName.value = true
const res = await platform.auth.setDisplayName(inputName)
if (E.isLeft(res)) {
toast.error(t("error.something_went_wrong"))
} else if (E.isRight(res)) {
toast.success(`${t("profile.updated")}`)
}
updatingDisplayName.value = false
}
const emailAddress = ref(currentUser.value?.email || "")
const updatingEmailAddress = ref(false)
watchEffect(() => (emailAddress.value = currentUser.value?.email || ""))
const updateEmailAddress = async () => {
const inputEmailAddress = emailAddress.value.trim()
if (!inputEmailAddress) {
toast.error(`${t("error.empty_email_address")}`)
return
}
if (currentUser.value?.email === inputEmailAddress) {
toast.error(`${t("error.same_email_address")}`)
return
}
updatingEmailAddress.value = true
const result = await platform.auth.setEmailAddress(inputEmailAddress)
if (!result) {
toast.error(`${t("error.something_went_wrong")}`)
updatingEmailAddress.value = false
return
}
if (result.type === "success") {
toast.success(`${t("profile.verified_email_sent")}`)
} else if (result.type === "email-already-in-use") {
toast.error(`${t("error.email_already_exists")}`)
} else if (result.type === "requires-recent-login") {
await result.link()
} else {
toast.error(`${t("error.something_went_wrong")}`)
}
updatingEmailAddress.value = false
}
</script>

View file

@ -6,7 +6,7 @@
@close="hideModal"
>
<template #body>
<Teams :modal="true" />
<TeamsView :modal="true" />
</template>
</HoppSmartModal>
</template>

View file

@ -71,126 +71,14 @@
<FirebaseLogout outline />
</div>
</div>
<HoppSmartTabs
v-model="selectedProfileTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
render-inactive-tabs
>
<HoppSmartTab id="sync" :label="t('settings.account')">
<div class="grid grid-cols-1">
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.profile") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.profile_description") }}
</div>
<div class="py-4">
<label for="displayName">
{{ t("settings.profile_name") }}
</label>
<HoppSmartInput
v-model="displayName"
:autofocus="false"
styles="mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_name')}`"
>
<template #button>
<HoppButtonSecondary
filled
outline
:label="t('action.save')"
class="min-w-[4rem] ml-2"
type="submit"
:loading="updatingDisplayName"
@click="updateDisplayName"
/>
</template>
</HoppSmartInput>
</div>
<div class="py-4">
<label for="emailAddress">
{{ t("settings.profile_email") }}
</label>
<HoppSmartInput
v-model="emailAddress"
:autofocus="false"
styles="flex mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_email')}`"
:disabled="!isEmailEditable"
>
<template #button>
<HoppButtonSecondary
v-if="isEmailEditable"
filled
outline
:label="t('action.save')"
class="min-w-[4rem] ml-2"
type="submit"
:loading="updatingEmailAddress"
@click="updateEmailAddress"
/>
</template>
</HoppSmartInput>
</div>
</section>
<div class="flex flex-col space-y-2">
<TabsNav
:items="PROFILE_NAVIGATION"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
/>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.sync") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.sync_description") }}
</div>
<div class="space-y-4 py-4">
<div class="flex items-center">
<HoppSmartToggle
:on="SYNC_COLLECTIONS"
@change="toggleSetting('syncCollections')"
>
{{ t("settings.sync_collections") }}
</HoppSmartToggle>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="SYNC_ENVIRONMENTS"
@change="toggleSetting('syncEnvironments')"
>
{{ t("settings.sync_environments") }}
</HoppSmartToggle>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="SYNC_HISTORY"
@change="toggleSetting('syncHistory')"
>
{{ t("settings.sync_history") }}
</HoppSmartToggle>
</div>
</div>
</section>
<template v-if="platform.ui?.additionalProfileSections?.length">
<template
v-for="item in platform.ui?.additionalProfileSections"
:key="item.id"
>
<component :is="item" />
</template>
</template>
<ProfileUserDelete />
</div>
</HoppSmartTab>
<HoppSmartTab id="teams" :label="t('team.title')">
<Teams :modal="false" class="p-4" />
</HoppSmartTab>
<HoppSmartTab id="tokens" :label="t('access_tokens.tab_title')">
<AccessTokens />
</HoppSmartTab>
</HoppSmartTabs>
<RouterView />
</div>
</div>
</div>
</div>
@ -198,27 +86,21 @@
</template>
<script setup lang="ts">
import * as E from "fp-ts/Either"
import { computed, ref, watchEffect } from "vue"
import { platform } from "~/platform"
import { usePageHead } from "@composables/head"
import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings"
import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
import { invokeAction } from "~/helpers/actions"
import { toggleSetting } from "~/newstore/settings"
import IconSettings from "~icons/lucide/settings"
import IconVerified from "~icons/lucide/verified"
type ProfileTabs = "sync" | "teams"
const selectedProfileTab = ref<ProfileTabs>("sync")
import TabsNav from "~/components/TabsNav.vue"
const t = useI18n()
const toast = useToast()
@ -228,9 +110,6 @@ usePageHead({
title: computed(() => t("navigation.profile")),
})
const SYNC_COLLECTIONS = useSetting("syncCollections")
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
const SYNC_HISTORY = useSetting("syncHistory")
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
@ -240,10 +119,6 @@ const probableUser = useReadonlyStream(
platform.auth.getProbableUser()
)
const isEmailEditable = computed(() => {
return platform.auth.isEmailEditable ?? false
})
const loadingCurrentUser = computed(() => {
if (!probableUser.value) return false
else if (!currentUser.value) return true
@ -251,72 +126,13 @@ const loadingCurrentUser = computed(() => {
})
const displayName = ref(currentUser.value?.displayName || "")
const updatingDisplayName = ref(false)
watchEffect(() => (displayName.value = currentUser.value?.displayName || ""))
const updateDisplayName = async () => {
const inputName = displayName.value.trim()
if (!inputName) {
toast.error(`${t("error.empty_profile_name")}`)
return
}
if (currentUser.value?.displayName === inputName) {
toast.error(`${t("error.same_profile_name")}`)
return
}
updatingDisplayName.value = true
const res = await platform.auth.setDisplayName(inputName)
if (E.isLeft(res)) {
toast.error(t("error.something_went_wrong"))
} else if (E.isRight(res)) {
toast.success(`${t("profile.updated")}`)
}
updatingDisplayName.value = false
}
const emailAddress = ref(currentUser.value?.email || "")
const updatingEmailAddress = ref(false)
watchEffect(() => (emailAddress.value = currentUser.value?.email || ""))
const updateEmailAddress = async () => {
const inputEmailAddress = emailAddress.value.trim()
if (!inputEmailAddress) {
toast.error(`${t("error.empty_email_address")}`)
return
}
if (currentUser.value?.email === inputEmailAddress) {
toast.error(`${t("error.same_email_address")}`)
return
}
updatingEmailAddress.value = true
const result = await platform.auth.setEmailAddress(inputEmailAddress)
if (!result) {
toast.error(`${t("error.something_went_wrong")}`)
updatingEmailAddress.value = false
return
}
if (result.type === "success") {
toast.success(`${t("profile.verified_email_sent")}`)
} else if (result.type === "email-already-in-use") {
toast.error(`${t("error.email_already_exists")}`)
} else if (result.type === "requires-recent-login") {
await result.link()
} else {
toast.error(`${t("error.something_went_wrong")}`)
}
updatingEmailAddress.value = false
}
const verifyingEmailAddress = ref(false)
const sendEmailVerification = () => {
@ -333,4 +149,23 @@ const sendEmailVerification = () => {
verifyingEmailAddress.value = false
})
}
const PROFILE_NAVIGATION = computed(() => [
{
route: "/profile",
label: t("settings.account"),
icon: null,
exactMatch: true,
},
{
route: "/profile/teams",
label: t("team.title"),
icon: null,
},
{
route: "/profile/tokens",
label: t("access_tokens.tab_title"),
icon: null,
},
])
</script>

View file

@ -0,0 +1,5 @@
<template>
<Profile />
</template>
<script setup lang="ts"></script>

View file

@ -0,0 +1,5 @@
<template>
<TeamsView :modal="false" class="p-4" />
</template>
<script setup lang="ts"></script>

View file

@ -0,0 +1,5 @@
<template>
<AccessTokens />
</template>
<script setup lang="ts"></script>