chore: split app to commons and web (squash commit)
This commit is contained in:
61
packages/hoppscotch-common/src/pages/_.vue
Normal file
61
packages/hoppscotch-common/src/pages/_.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- The Catch-All Page -->
|
||||
<!-- Reserved for Critical Errors and 404 ONLY -->
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center"
|
||||
:class="{ 'min-h-screen': statusCode !== 404 }"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/youre_lost.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center mb-2 h-46 w-46"
|
||||
:alt="message"
|
||||
/>
|
||||
<h1 class="mb-2 text-4xl heading">
|
||||
{{ statusCode }}
|
||||
</h1>
|
||||
<p class="mb-4 text-secondaryLight">{{ message }}</p>
|
||||
<p class="mt-4 space-x-2">
|
||||
<ButtonSecondary to="/" :icon="IconHome" filled :label="t('app.home')" />
|
||||
<ButtonSecondary
|
||||
:icon="IconRefreshCW"
|
||||
:label="t('app.reload')"
|
||||
filled
|
||||
@click="reloadApplication"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconRefreshCW from "~icons/lucide/refresh-cw"
|
||||
import IconHome from "~icons/lucide/home"
|
||||
import { PropType, computed } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
|
||||
export type ErrorPageData = {
|
||||
message: string
|
||||
statusCode: number
|
||||
}
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
error: {
|
||||
type: Object as PropType<ErrorPageData | null>,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const statusCode = computed(() => props.error?.statusCode ?? 404)
|
||||
|
||||
const message = computed(
|
||||
() => props.error?.message ?? t("error.page_not_found")
|
||||
)
|
||||
|
||||
const reloadApplication = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
63
packages/hoppscotch-common/src/pages/enter.vue
Normal file
63
packages/hoppscotch-common/src/pages/enter.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen">
|
||||
<SmartSpinner v-if="signingInWithEmail" />
|
||||
<AppLogo v-else class="w-16 h-16 rounded" />
|
||||
<pre v-if="error" class="mt-4 text-secondaryLight">{{ error }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { initializeFirebase } from "~/helpers/fb"
|
||||
import { isSignInWithEmailLink, signInWithEmailLink } from "~/helpers/fb/auth"
|
||||
import { getLocalConfig, removeLocalConfig } from "~/newstore/localpersistence"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
signingInWithEmail: false,
|
||||
error: null,
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
initializeFirebase()
|
||||
},
|
||||
async mounted() {
|
||||
if (isSignInWithEmailLink(window.location.href)) {
|
||||
this.signingInWithEmail = true
|
||||
|
||||
let email = getLocalConfig("emailForSignIn")
|
||||
|
||||
if (!email) {
|
||||
email = window.prompt(
|
||||
"Please provide your email for confirmation"
|
||||
) as string
|
||||
}
|
||||
|
||||
await signInWithEmailLink(email, window.location.href)
|
||||
.then(() => {
|
||||
removeLocalConfig("emailForSignIn")
|
||||
this.$router.push({ path: "/" })
|
||||
})
|
||||
.catch((e) => {
|
||||
this.signingInWithEmail = false
|
||||
this.error = e.message
|
||||
})
|
||||
.finally(() => {
|
||||
this.signingInWithEmail = false
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: empty
|
||||
</route>
|
||||
43
packages/hoppscotch-common/src/pages/graphql.vue
Normal file
43
packages/hoppscotch-common/src/pages/graphql.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="graphql">
|
||||
<template #primary>
|
||||
<GraphqlRequest :conn="gqlConn" />
|
||||
<GraphqlRequestOptions :conn="gqlConn" />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<GraphqlResponse :conn="gqlConn" />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<GraphqlSidebar :conn="gqlConn" />
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, watch } from "vue"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { usePageHead } from "@composables/head"
|
||||
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
|
||||
import { GQLConnection } from "@helpers/GQLConnection"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
usePageHead({
|
||||
title: computed(() => t("navigation.graphql")),
|
||||
})
|
||||
|
||||
const gqlConn = new GQLConnection()
|
||||
const isLoading = useReadonlyStream(gqlConn.isLoading$, false)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (gqlConn.connected$.value) {
|
||||
gqlConn.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isLoading, () => {
|
||||
if (isLoading.value) startPageProgress()
|
||||
else completePageProgress()
|
||||
})
|
||||
</script>
|
||||
104
packages/hoppscotch-common/src/pages/import.vue
Normal file
104
packages/hoppscotch-common/src/pages/import.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import axios from "axios"
|
||||
import * as TO from "fp-ts/TaskOption"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
|
||||
import { onMounted } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
|
||||
import { appendRESTCollections } from "~/newstore/collections"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { URLImporters } from "~/helpers/import-export/import/importers"
|
||||
import { IMPORTER_INVALID_FILE_FORMAT } from "~/helpers/import-export/import"
|
||||
import { OPENAPI_DEREF_ERROR } from "~/helpers/import-export/import/openapi"
|
||||
import { isOfType } from "~/helpers/functional/primtive"
|
||||
import { TELeftType } from "~/helpers/functional/taskEither"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const IMPORTER_INVALID_TYPE = "importer_invalid_type" as const
|
||||
const IMPORTER_INVALID_FETCH = "importer_invalid_fetch" as const
|
||||
|
||||
const importCollections = (url: unknown, type: unknown) =>
|
||||
pipe(
|
||||
TE.Do,
|
||||
TE.bind("importer", () =>
|
||||
pipe(
|
||||
URLImporters,
|
||||
RA.findFirst(
|
||||
(importer) =>
|
||||
importer.applicableTo.includes("url-import") && importer.id === type
|
||||
),
|
||||
TE.fromOption(() => IMPORTER_INVALID_TYPE)
|
||||
)
|
||||
),
|
||||
TE.bindW("content", () =>
|
||||
pipe(
|
||||
url,
|
||||
TO.fromPredicate(isOfType("string")),
|
||||
TO.chain(fetchUrlData),
|
||||
TE.fromTaskOption(() => IMPORTER_INVALID_FETCH)
|
||||
)
|
||||
),
|
||||
TE.chainW(({ importer, content }) =>
|
||||
pipe(
|
||||
content.data,
|
||||
TO.fromPredicate(isOfType("string")),
|
||||
TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT),
|
||||
TE.chain((data) => importer.importer([data]))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
type ImportCollectionsError = TELeftType<ReturnType<typeof importCollections>>
|
||||
|
||||
onMounted(async () => {
|
||||
const { query } = route
|
||||
|
||||
const url = query.url
|
||||
const type = query.type
|
||||
|
||||
const result = await importCollections(url, type)()
|
||||
|
||||
pipe(result, E.fold(handleImportFailure, handleImportSuccess))
|
||||
|
||||
router.replace("/")
|
||||
})
|
||||
|
||||
const IMPORT_ERROR_MAP: Record<ImportCollectionsError, string> = {
|
||||
[IMPORTER_INVALID_TYPE]: "import.import_from_url_invalid_type",
|
||||
[IMPORTER_INVALID_FETCH]: "import.import_from_url_invalid_fetch",
|
||||
[IMPORTER_INVALID_FILE_FORMAT]: "import.import_from_url_invalid_file_format",
|
||||
[OPENAPI_DEREF_ERROR]: "import.import_from_url_invalid_file_format",
|
||||
} as const
|
||||
|
||||
const handleImportFailure = (error: ImportCollectionsError) => {
|
||||
toast.error(t(IMPORT_ERROR_MAP[error]).toString())
|
||||
}
|
||||
|
||||
const handleImportSuccess = (
|
||||
collections: HoppCollection<HoppRESTRequest>[]
|
||||
) => {
|
||||
appendRESTCollections(collections)
|
||||
toast.success(t("import.import_from_url_success").toString())
|
||||
}
|
||||
|
||||
const fetchUrlData = (url: string) =>
|
||||
TO.tryCatch(() =>
|
||||
axios.get(url, {
|
||||
responseType: "text",
|
||||
transitional: { forcedJSONParsing: false },
|
||||
})
|
||||
)
|
||||
</script>
|
||||
173
packages/hoppscotch-common/src/pages/index.vue
Normal file
173
packages/hoppscotch-common/src/pages/index.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="http">
|
||||
<template #primary>
|
||||
<HttpRequest />
|
||||
<HttpRequestOptions />
|
||||
</template>
|
||||
<template #secondary>
|
||||
<HttpResponse />
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<HttpSidebar />
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
onBeforeMount,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
Ref,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue"
|
||||
import type { Subscription } from "rxjs"
|
||||
import {
|
||||
HoppRESTRequest,
|
||||
HoppRESTAuthOAuth2,
|
||||
safelyExtractRESTRequest,
|
||||
isEqualHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import {
|
||||
getRESTRequest,
|
||||
setRESTRequest,
|
||||
setRESTAuth,
|
||||
restAuth$,
|
||||
getDefaultRESTRequest,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
||||
import { pluckRef } from "@composables/ref"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { loadRequestFromSync, startRequestSync } from "~/helpers/fb/request"
|
||||
import { oauthRedirect } from "~/helpers/oauth"
|
||||
import { useRoute } from "vue-router"
|
||||
|
||||
function bindRequestToURLParams() {
|
||||
const route = useRoute()
|
||||
// Get URL parameters and set that as the request
|
||||
onMounted(() => {
|
||||
const query = route.query
|
||||
// If query params are empty, or contains code or error param (these are from Oauth Redirect)
|
||||
// We skip URL params parsing
|
||||
if (Object.keys(query).length === 0 || query.code || query.error) return
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(
|
||||
translateExtURLParams(query),
|
||||
getDefaultRESTRequest()
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function oAuthURL() {
|
||||
const auth = useStream(
|
||||
restAuth$,
|
||||
{ authType: "none", authActive: true },
|
||||
setRESTAuth
|
||||
)
|
||||
|
||||
const oauth2Token = pluckRef(auth as Ref<HoppRESTAuthOAuth2>, "token")
|
||||
|
||||
onBeforeMount(async () => {
|
||||
try {
|
||||
const tokenInfo = await oauthRedirect()
|
||||
if (Object.prototype.hasOwnProperty.call(tokenInfo, "access_token")) {
|
||||
if (typeof tokenInfo === "object") {
|
||||
oauth2Token.value = tokenInfo.access_token
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (_) {}
|
||||
})
|
||||
}
|
||||
|
||||
function setupRequestSync(
|
||||
confirmSync: Ref<boolean>,
|
||||
requestForSync: Ref<HoppRESTRequest | null>
|
||||
) {
|
||||
const route = useRoute()
|
||||
|
||||
// Subscription to request sync
|
||||
let sub: Subscription | null = null
|
||||
|
||||
// Load request on login resolve and start sync
|
||||
onLoggedIn(async () => {
|
||||
if (
|
||||
Object.keys(route.query).length === 0 &&
|
||||
!(route.query.code || route.query.error)
|
||||
) {
|
||||
const request = await loadRequestFromSync()
|
||||
if (request) {
|
||||
if (!isEqualHoppRESTRequest(request, getRESTRequest())) {
|
||||
requestForSync.value = request
|
||||
confirmSync.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sub = startRequestSync()
|
||||
})
|
||||
|
||||
// Stop subscription to stop syncing
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
})
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const requestForSync = ref<HoppRESTRequest | null>(null)
|
||||
|
||||
const confirmSync = ref(false)
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
watch(confirmSync, (newValue) => {
|
||||
if (newValue) {
|
||||
toast.show(`${t("confirm.sync")}`, {
|
||||
duration: 0,
|
||||
action: [
|
||||
{
|
||||
text: `${t("action.yes")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
syncRequest()
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: `${t("action.no")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const syncRequest = () => {
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(requestForSync.value!, getDefaultRESTRequest())
|
||||
)
|
||||
}
|
||||
|
||||
setupRequestSync(confirmSync, requestForSync)
|
||||
bindRequestToURLParams()
|
||||
oAuthURL()
|
||||
|
||||
return {
|
||||
confirmSync,
|
||||
syncRequest,
|
||||
oAuthURL,
|
||||
requestForSync,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
279
packages/hoppscotch-common/src/pages/join-team.vue
Normal file
279
packages/hoppscotch-common/src/pages/join-team.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-between min-h-screen">
|
||||
<div
|
||||
v-if="invalidLink"
|
||||
class="flex flex-col items-center justify-center flex-1"
|
||||
>
|
||||
<i class="pb-2 opacity-75 material-icons">error_outline</i>
|
||||
<h1 class="text-center heading">
|
||||
{{ t("team.invalid_invite_link") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-center">
|
||||
{{ t("team.invalid_invite_link_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="loadingCurrentUser"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<SmartSpinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentUser === null"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<h1 class="heading">{{ t("team.login_to_continue") }}</h1>
|
||||
<p class="mt-2">{{ t("team.login_to_continue_description") }}</p>
|
||||
<ButtonPrimary
|
||||
:label="t('auth.login_to_hoppscotch')"
|
||||
class="mt-8"
|
||||
@click="showLogin = true"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<div
|
||||
v-if="inviteDetails.loading"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<SmartSpinner />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="!inviteDetails.loading && E.isLeft(inviteDetails.data)"
|
||||
class="flex flex-col items-center p-4"
|
||||
>
|
||||
<i class="mb-4 material-icons">error_outline</i>
|
||||
<p>
|
||||
{{ getErrorMessage(inviteDetails.data.left) }}
|
||||
</p>
|
||||
<p
|
||||
class="flex flex-col items-center p-4 mt-8 border rounded border-dividerLight"
|
||||
>
|
||||
<span class="mb-4">
|
||||
{{ t("team.logout_and_try_again") }}
|
||||
</span>
|
||||
<span class="flex">
|
||||
<FirebaseLogout
|
||||
v-if="inviteDetails.data.left.type === 'gql_error'"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!inviteDetails.loading &&
|
||||
E.isRight(inviteDetails.data) &&
|
||||
!joinTeamSuccess
|
||||
"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<h1 class="heading">
|
||||
{{
|
||||
t("team.join_team", {
|
||||
team: inviteDetails.data.right.teamInvitation.team.name,
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="mt-2 text-secondaryLight">
|
||||
{{
|
||||
t("team.invited_to_team", {
|
||||
owner:
|
||||
inviteDetails.data.right.teamInvitation.creator.displayName ??
|
||||
inviteDetails.data.right.teamInvitation.creator.email,
|
||||
team: inviteDetails.data.right.teamInvitation.team.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<ButtonPrimary
|
||||
:label="
|
||||
t('team.join_team', {
|
||||
team: inviteDetails.data.right.teamInvitation.team.name,
|
||||
})
|
||||
"
|
||||
:loading="loading"
|
||||
:disabled="revokedLink"
|
||||
@click="joinTeam"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!inviteDetails.loading &&
|
||||
E.isRight(inviteDetails.data) &&
|
||||
joinTeamSuccess
|
||||
"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<h1 class="heading">
|
||||
{{
|
||||
t("team.joined_team", {
|
||||
team: inviteDetails.data.right.teamInvitation.team.name,
|
||||
})
|
||||
}}
|
||||
</h1>
|
||||
<p class="mt-2 text-secondaryLight">
|
||||
{{
|
||||
t("team.joined_team_description", {
|
||||
team: inviteDetails.data.right.teamInvitation.team.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<ButtonSecondary
|
||||
to="/"
|
||||
:icon="IconHome"
|
||||
filled
|
||||
:label="t('app.home')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<ButtonSecondary
|
||||
class="tracking-wide !font-bold !text-secondaryDark"
|
||||
label="HOPPSCOTCH"
|
||||
to="/"
|
||||
/>
|
||||
</div>
|
||||
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import * as E from "fp-ts/Either"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { useGQLQuery } from "@composables/graphql"
|
||||
import {
|
||||
GetInviteDetailsDocument,
|
||||
GetInviteDetailsQuery,
|
||||
GetInviteDetailsQueryVariables,
|
||||
} from "~/helpers/backend/graphql"
|
||||
import { acceptTeamInvitation } from "~/helpers/backend/mutations/TeamInvitation"
|
||||
import { initializeFirebase } from "~/helpers/fb"
|
||||
import { currentUser$, probableUser$ } from "~/helpers/fb/auth"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import IconHome from "~icons/lucide/home"
|
||||
|
||||
type GetInviteDetailsError =
|
||||
| "team_invite/not_valid_viewer"
|
||||
| "team_invite/not_found"
|
||||
| "team_invite/no_invite_found"
|
||||
| "team_invite/email_do_not_match"
|
||||
| "team_invite/already_member"
|
||||
|
||||
export default defineComponent({
|
||||
layout: "empty",
|
||||
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
|
||||
const inviteDetails = useGQLQuery<
|
||||
GetInviteDetailsQuery,
|
||||
GetInviteDetailsQueryVariables,
|
||||
GetInviteDetailsError
|
||||
>({
|
||||
query: GetInviteDetailsDocument,
|
||||
variables: {
|
||||
inviteID: route.query.id as string,
|
||||
},
|
||||
defer: true,
|
||||
})
|
||||
|
||||
onLoggedIn(() => {
|
||||
if (typeof route.query.id === "string") {
|
||||
inviteDetails.execute({
|
||||
inviteID: route.query.id,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const probableUser = useReadonlyStream(probableUser$, null)
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
|
||||
const loadingCurrentUser = computed(() => {
|
||||
if (!probableUser.value) return false
|
||||
else if (!currentUser.value) return true
|
||||
else return false
|
||||
})
|
||||
|
||||
return {
|
||||
E,
|
||||
inviteDetails,
|
||||
loadingCurrentUser,
|
||||
currentUser,
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
IconHome,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
invalidLink: false,
|
||||
showLogin: false,
|
||||
loading: false,
|
||||
revokedLink: false,
|
||||
inviteID: "",
|
||||
joinTeamSuccess: false,
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
initializeFirebase()
|
||||
},
|
||||
mounted() {
|
||||
if (typeof this.$route.query.id === "string") {
|
||||
this.inviteID = this.$route.query.id
|
||||
}
|
||||
this.invalidLink = !this.inviteID
|
||||
// TODO: check revokeTeamInvitation
|
||||
// TODO: check login user already a member
|
||||
},
|
||||
methods: {
|
||||
joinTeam() {
|
||||
this.loading = true
|
||||
pipe(
|
||||
acceptTeamInvitation(this.inviteID),
|
||||
TE.matchW(
|
||||
() => {
|
||||
this.loading = false
|
||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||
},
|
||||
() => {
|
||||
this.joinTeamSuccess = true
|
||||
this.loading = false
|
||||
}
|
||||
)
|
||||
)()
|
||||
},
|
||||
getErrorMessage(error: GQLError<GetInviteDetailsError>) {
|
||||
if (error.type === "network_error") {
|
||||
return this.t("error.network_error")
|
||||
} else {
|
||||
switch (error.error) {
|
||||
case "team_invite/not_valid_viewer":
|
||||
return this.t("team.not_valid_viewer")
|
||||
case "team_invite/not_found":
|
||||
return this.t("team.not_found")
|
||||
case "team_invite/no_invite_found":
|
||||
return this.t("team.no_invite_found")
|
||||
case "team_invite/already_member":
|
||||
return this.t("team.already_member")
|
||||
case "team_invite/email_do_not_match":
|
||||
return this.t("team.email_do_not_match")
|
||||
default:
|
||||
return this.t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
456
packages/hoppscotch-common/src/pages/profile.vue
Normal file
456
packages/hoppscotch-common/src/pages/profile.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="container">
|
||||
<div class="p-4">
|
||||
<div
|
||||
v-if="loadingCurrentUser"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<SmartSpinner class="mb-4" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentUser === null"
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-24 h-24 my-4"
|
||||
:alt="`${t('empty.parameters')}`"
|
||||
/>
|
||||
<p class="pb-4 text-center text-secondaryLight">
|
||||
{{ t("empty.profile") }}
|
||||
</p>
|
||||
<ButtonPrimary
|
||||
:label="t('auth.login')"
|
||||
class="mb-4"
|
||||
@click="showLogin = true"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="space-y-8">
|
||||
<div
|
||||
class="h-24 rounded bg-primaryLight -mb-11 md:h-32"
|
||||
style="background-image: url('/images/cover.svg')"
|
||||
></div>
|
||||
<div class="flex flex-col justify-between px-4 space-y-8 md:flex-row">
|
||||
<div class="flex items-end">
|
||||
<ProfilePicture
|
||||
v-if="currentUser.photoURL"
|
||||
:url="currentUser.photoURL"
|
||||
:alt="
|
||||
currentUser.displayName || t('profile.default_displayname')
|
||||
"
|
||||
class="ring-primary ring-4"
|
||||
size="16"
|
||||
rounded="lg"
|
||||
/>
|
||||
<ProfilePicture
|
||||
v-else
|
||||
:initial="currentUser.displayName || currentUser.email"
|
||||
rounded="lg"
|
||||
size="16"
|
||||
class="ring-primary ring-4"
|
||||
/>
|
||||
<div class="ml-4">
|
||||
<label class="heading">
|
||||
{{
|
||||
currentUser.displayName ||
|
||||
t("profile.default_hopp_displayname")
|
||||
}}
|
||||
</label>
|
||||
<p class="flex items-center text-secondaryLight">
|
||||
{{ currentUser.email }}
|
||||
<icon-lucide-verified
|
||||
v-if="currentUser.emailVerified"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('settings.verified_email')"
|
||||
class="ml-2 text-green-500 svg-icons cursor-help"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-else
|
||||
:label="t('settings.verify_email')"
|
||||
:icon="IconVerified"
|
||||
class="px-1 py-0 ml-2"
|
||||
:loading="verifyingEmailAddress"
|
||||
@click="sendEmailVerification"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<div>
|
||||
<SmartItem
|
||||
to="/settings"
|
||||
:icon="IconSettings"
|
||||
:label="t('profile.app_settings')"
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
<FirebaseLogout outline />
|
||||
</div>
|
||||
</div>
|
||||
<SmartTabs
|
||||
v-model="selectedProfileTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab :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>
|
||||
<form
|
||||
class="flex mt-2 md:max-w-sm"
|
||||
@submit.prevent="updateDisplayName"
|
||||
>
|
||||
<input
|
||||
id="displayName"
|
||||
v-model="displayName"
|
||||
class="input"
|
||||
:placeholder="`${t('settings.profile_name')}`"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<ButtonSecondary
|
||||
filled
|
||||
outline
|
||||
:label="t('action.save')"
|
||||
class="ml-2 min-w-16"
|
||||
type="submit"
|
||||
:loading="updatingDisplayName"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="py-4">
|
||||
<label for="emailAddress">
|
||||
{{ t("settings.profile_email") }}
|
||||
</label>
|
||||
<form
|
||||
class="flex mt-2 md:max-w-sm"
|
||||
@submit.prevent="updateEmailAddress"
|
||||
>
|
||||
<input
|
||||
id="emailAddress"
|
||||
v-model="emailAddress"
|
||||
class="input"
|
||||
:placeholder="`${t('settings.profile_name')}`"
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
<ButtonSecondary
|
||||
filled
|
||||
outline
|
||||
:label="t('action.save')"
|
||||
class="ml-2 min-w-16"
|
||||
type="submit"
|
||||
:loading="updatingEmailAddress"
|
||||
/>
|
||||
</form>
|
||||
</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="py-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="SYNC_COLLECTIONS"
|
||||
@change="toggleSetting('syncCollections')"
|
||||
>
|
||||
{{ t("settings.sync_collections") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="SYNC_ENVIRONMENTS"
|
||||
@change="toggleSetting('syncEnvironments')"
|
||||
>
|
||||
{{ t("settings.sync_environments") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="SYNC_HISTORY"
|
||||
@change="toggleSetting('syncHistory')"
|
||||
>
|
||||
{{ t("settings.sync_history") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="p-4">
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.short_codes") }}
|
||||
</h4>
|
||||
<div class="my-1 text-secondaryLight">
|
||||
{{ t("settings.short_codes_description") }}
|
||||
</div>
|
||||
<div class="relative flex-shrink-0 py-4 overflow-x-auto">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center"
|
||||
>
|
||||
<SmartSpinner class="mb-4" />
|
||||
<span class="text-secondaryLight">{{
|
||||
t("state.loading")
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && myShortcodes.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_files.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
|
||||
:alt="`${t('empty.shortcodes')}`"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.shortcodes") }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="!loading">
|
||||
<div
|
||||
class="hidden w-full border-t rounded-t bg-primaryLight lg:flex border-x border-dividerLight"
|
||||
>
|
||||
<div class="flex w-full overflow-y-scroll">
|
||||
<div class="table-box">
|
||||
{{ t("shortcodes.short_code") }}
|
||||
</div>
|
||||
<div class="table-box">
|
||||
{{ t("shortcodes.method") }}
|
||||
</div>
|
||||
<div class="table-box">
|
||||
{{ t("shortcodes.url") }}
|
||||
</div>
|
||||
<div class="table-box">
|
||||
{{ t("shortcodes.created_on") }}
|
||||
</div>
|
||||
<div class="justify-center table-box">
|
||||
{{ t("shortcodes.actions") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-between w-full overflow-y-scroll border rounded max-h-sm lg:rounded-t-none lg:divide-y border-dividerLight divide-dividerLight"
|
||||
>
|
||||
<ProfileShortcode
|
||||
v-for="(shortcode, shortcodeIndex) in myShortcodes"
|
||||
:key="`shortcode-${shortcodeIndex}`"
|
||||
:shortcode="shortcode"
|
||||
@delete-shortcode="deleteShortcode"
|
||||
/>
|
||||
<SmartIntersection
|
||||
v-if="hasMoreShortcodes && myShortcodes.length > 0"
|
||||
@intersecting="loadMoreShortcodes()"
|
||||
>
|
||||
<div
|
||||
v-if="adapterLoading"
|
||||
class="flex flex-col items-center py-3"
|
||||
>
|
||||
<SmartSpinner />
|
||||
</div>
|
||||
</SmartIntersection>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loading && adapterError"
|
||||
class="flex flex-col items-center py-4"
|
||||
>
|
||||
<component :is="IconHelpCircle" class="mb-4 svg-icons" />
|
||||
{{ getErrorMessage(adapterError) }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'teams'" :label="t('team.title')">
|
||||
<Teams :modal="false" class="p-4" />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</div>
|
||||
</div>
|
||||
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, computed } from "vue"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
currentUser$,
|
||||
probableUser$,
|
||||
setDisplayName,
|
||||
setEmailAddress,
|
||||
verifyEmailAddress,
|
||||
} from "~/helpers/fb/auth"
|
||||
|
||||
import { onAuthEvent, onLoggedIn } from "@composables/auth"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { usePageHead } from "@composables/head"
|
||||
|
||||
import { toggleSetting } from "~/newstore/settings"
|
||||
import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
|
||||
import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
|
||||
|
||||
import IconVerified from "~icons/lucide/verified"
|
||||
import IconSettings from "~icons/lucide/settings"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
|
||||
type ProfileTabs = "sync" | "teams"
|
||||
|
||||
const selectedProfileTab = ref<ProfileTabs>("sync")
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
usePageHead({
|
||||
title: computed(() => t("navigation.profile")),
|
||||
})
|
||||
|
||||
const showLogin = ref(false)
|
||||
|
||||
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
||||
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
||||
const SYNC_HISTORY = useSetting("syncHistory")
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const probableUser = useReadonlyStream(probableUser$, null)
|
||||
|
||||
const loadingCurrentUser = computed(() => {
|
||||
if (!probableUser.value) return false
|
||||
else if (!currentUser.value) return true
|
||||
else return false
|
||||
})
|
||||
|
||||
const displayName = ref(currentUser.value?.displayName)
|
||||
const updatingDisplayName = ref(false)
|
||||
watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
||||
|
||||
const updateDisplayName = () => {
|
||||
updatingDisplayName.value = true
|
||||
setDisplayName(displayName.value as string)
|
||||
.then(() => {
|
||||
toast.success(`${t("profile.updated")}`)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
})
|
||||
.finally(() => {
|
||||
updatingDisplayName.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const emailAddress = ref(currentUser.value?.email)
|
||||
const updatingEmailAddress = ref(false)
|
||||
watchEffect(() => (emailAddress.value = currentUser.value?.email))
|
||||
|
||||
const updateEmailAddress = () => {
|
||||
updatingEmailAddress.value = true
|
||||
setEmailAddress(emailAddress.value as string)
|
||||
.then(() => {
|
||||
toast.success(`${t("profile.updated")}`)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
})
|
||||
.finally(() => {
|
||||
updatingEmailAddress.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const verifyingEmailAddress = ref(false)
|
||||
|
||||
const sendEmailVerification = () => {
|
||||
verifyingEmailAddress.value = true
|
||||
verifyEmailAddress()
|
||||
.then(() => {
|
||||
toast.success(`${t("profile.email_verification_mail")}`)
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(`${t("error.something_went_wrong")}`)
|
||||
})
|
||||
.finally(() => {
|
||||
verifyingEmailAddress.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const adapter = new ShortcodeListAdapter(true)
|
||||
const adapterLoading = useReadonlyStream(adapter.loading$, false)
|
||||
const adapterError = useReadonlyStream(adapter.error$, null)
|
||||
const myShortcodes = useReadonlyStream(adapter.shortcodes$, [])
|
||||
const hasMoreShortcodes = useReadonlyStream(adapter.hasMoreShortcodes$, true)
|
||||
|
||||
const loading = computed(
|
||||
() => adapterLoading.value && myShortcodes.value.length === 0
|
||||
)
|
||||
|
||||
onLoggedIn(() => {
|
||||
adapter.initialize()
|
||||
})
|
||||
|
||||
onAuthEvent((ev) => {
|
||||
if (ev.event === "logout" && adapter.isInitialized()) {
|
||||
adapter.dispose()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const deleteShortcode = (codeID: string) => {
|
||||
pipe(
|
||||
backendDeleteShortcode(codeID),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
toast.success(`${t("shortcodes.deleted")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
|
||||
const loadMoreShortcodes = () => {
|
||||
adapter.loadMore()
|
||||
}
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "shortcode/not_found":
|
||||
return t("shortcodes.not_found")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.table-box {
|
||||
@apply flex flex-1 items-center px-4 py-2 truncate;
|
||||
}
|
||||
</style>
|
||||
145
packages/hoppscotch-common/src/pages/r/_id.vue
Normal file
145
packages/hoppscotch-common/src/pages/r/_id.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<div
|
||||
v-if="invalidLink"
|
||||
class="flex flex-col items-center justify-center flex-1"
|
||||
>
|
||||
<i class="pb-2 opacity-75 material-icons">error_outline</i>
|
||||
<h1 class="text-center heading">
|
||||
{{ t("error.invalid_link") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-center">
|
||||
{{ t("error.invalid_link_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center flex-1 p-4">
|
||||
<div
|
||||
v-if="shortcodeDetails.loading"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<SmartSpinner />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="!shortcodeDetails.loading && E.isLeft(shortcodeDetails.data)"
|
||||
class="flex flex-col items-center p-4"
|
||||
>
|
||||
<i class="pb-2 opacity-75 material-icons">error_outline</i>
|
||||
<h1 class="text-center heading">
|
||||
{{ t("error.invalid_link") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-center">
|
||||
{{ t("error.invalid_link_description") }}
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
<ButtonSecondary
|
||||
to="/"
|
||||
:icon="IconHome"
|
||||
filled
|
||||
:label="t('app.home')"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:icon="IconRefreshCW"
|
||||
:label="t('app.reload')"
|
||||
filled
|
||||
@click="reloadApplication"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="!shortcodeDetails.loading && E.isRight(shortcodeDetails.data)"
|
||||
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||
>
|
||||
<h1 class="heading">
|
||||
{{ t("state.loading") }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
||||
import { useGQLQuery } from "@composables/graphql"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
ResolveShortcodeDocument,
|
||||
ResolveShortcodeQuery,
|
||||
ResolveShortcodeQueryVariables,
|
||||
} from "~/helpers/backend/graphql"
|
||||
import { getDefaultRESTRequest, setRESTRequest } from "~/newstore/RESTSession"
|
||||
|
||||
import IconHome from "~icons/lucide/home"
|
||||
import IconRefreshCW from "~icons/lucide/refresh-cw"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const shortcodeDetails = useGQLQuery<
|
||||
ResolveShortcodeQuery,
|
||||
ResolveShortcodeQueryVariables,
|
||||
""
|
||||
>({
|
||||
query: ResolveShortcodeDocument,
|
||||
variables: {
|
||||
code: route.params.id as string,
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => shortcodeDetails.data,
|
||||
() => {
|
||||
if (shortcodeDetails.loading) return
|
||||
|
||||
const data = shortcodeDetails.data
|
||||
|
||||
if (E.isRight(data)) {
|
||||
const request: unknown = JSON.parse(
|
||||
data.right.shortcode?.request as string
|
||||
)
|
||||
|
||||
setRESTRequest(
|
||||
safelyExtractRESTRequest(request, getDefaultRESTRequest())
|
||||
)
|
||||
|
||||
router.push({ path: "/" })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const reloadApplication = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return {
|
||||
E,
|
||||
shortcodeDetails,
|
||||
reloadApplication,
|
||||
t,
|
||||
route,
|
||||
IconHome,
|
||||
IconRefreshCW,
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
invalidLink: false,
|
||||
shortcodeID: "",
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (typeof this.route.params.id === "string") {
|
||||
this.shortcodeID = this.route.params.id
|
||||
}
|
||||
this.invalidLink = !this.shortcodeID
|
||||
},
|
||||
})
|
||||
</script>
|
||||
81
packages/hoppscotch-common/src/pages/realtime.vue
Normal file
81
packages/hoppscotch-common/src/pages/realtime.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<SmartTabs
|
||||
v-model="currentTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
|
||||
content-styles="h-[calc(100%-var(--sidebar-primary-sticky-fold)-1px)] !flex"
|
||||
>
|
||||
<SmartTab
|
||||
v-for="{ target, title } in REALTIME_NAVIGATION"
|
||||
:id="target"
|
||||
:key="target"
|
||||
:label="title"
|
||||
>
|
||||
<RouterView />
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, ref, computed } from "vue"
|
||||
import { RouterView, useRouter, useRoute } from "vue-router"
|
||||
import { usePageHead } from "~/composables/head"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const REALTIME_NAVIGATION = [
|
||||
{
|
||||
target: "websocket",
|
||||
title: t("tab.websocket"),
|
||||
},
|
||||
{
|
||||
target: "sse",
|
||||
title: t("tab.sse"),
|
||||
},
|
||||
{
|
||||
target: "socketio",
|
||||
title: t("tab.socketio"),
|
||||
},
|
||||
{
|
||||
target: "mqtt",
|
||||
title: t("tab.mqtt"),
|
||||
},
|
||||
] as const
|
||||
|
||||
type RealtimeNavTab = typeof REALTIME_NAVIGATION[number]["target"]
|
||||
|
||||
const currentTab = ref<RealtimeNavTab>("websocket")
|
||||
|
||||
usePageHead({
|
||||
title: computed(() => t(`tab.${currentTab.value}`)),
|
||||
})
|
||||
|
||||
// Update the router when the tab is updated
|
||||
watch(currentTab, (newTab) => {
|
||||
router.push(`/realtime/${newTab}`)
|
||||
})
|
||||
|
||||
// Update the tab when router is upgrad
|
||||
watch(
|
||||
route,
|
||||
(updateRoute) => {
|
||||
const path = updateRoute.path
|
||||
|
||||
if (updateRoute.name?.toString() === "realtime") {
|
||||
router.replace(`/realtime/websocket`)
|
||||
return
|
||||
}
|
||||
|
||||
const destination: string | undefined = path.split("realtime/")[1]
|
||||
|
||||
const target = REALTIME_NAVIGATION.find(
|
||||
({ target }) => target === destination
|
||||
)?.target
|
||||
|
||||
if (target) currentTab.value = target
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
476
packages/hoppscotch-common/src/pages/realtime/mqtt.vue
Normal file
476
packages/hoppscotch-common/src/pages/realtime/mqtt.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="mqtt">
|
||||
<template #primary>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
|
||||
>
|
||||
<div class="inline-flex flex-1 space-x-2">
|
||||
<div class="flex flex-1">
|
||||
<input
|
||||
id="mqtt-url"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
:class="{ error: !isUrlValid }"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
|
||||
:placeholder="`${t('mqtt.url')}`"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<label
|
||||
for="client-id"
|
||||
class="px-4 py-2 font-semibold truncate border-t border-b bg-primaryLight border-divider text-secondaryLight"
|
||||
>
|
||||
{{ t("mqtt.client_id") }}
|
||||
</label>
|
||||
<input
|
||||
id="client-id"
|
||||
v-model="clientID"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
|
||||
spellcheck="false"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!isUrlValid"
|
||||
class="w-32"
|
||||
:label="
|
||||
connectionState === 'CONNECTING'
|
||||
? t('action.connecting')
|
||||
: connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col flex-1"
|
||||
:class="{ '!hidden': connectionState === 'CONNECTED' }"
|
||||
>
|
||||
<RealtimeConnectionConfig @change="onChangeConfig" />
|
||||
</div>
|
||||
<RealtimeCommunication
|
||||
v-if="connectionState === 'CONNECTED'"
|
||||
:show-event-field="currentTabId === 'all'"
|
||||
:is-connected="connectionState === 'CONNECTED'"
|
||||
event-field-styles="top-upperPrimaryStickyFold"
|
||||
:sticky-header-styles="
|
||||
currentTabId === 'all'
|
||||
? 'top-upperSecondaryStickyFold'
|
||||
: 'top-upperPrimaryStickyFold'
|
||||
"
|
||||
@send-message="
|
||||
publish(
|
||||
currentTabId === 'all'
|
||||
? $event
|
||||
: {
|
||||
message: $event.message,
|
||||
eventName: currentTabId,
|
||||
}
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<SmartWindows
|
||||
:id="'communication_tab'"
|
||||
v-model="currentTabId"
|
||||
:can-add-new-tab="false"
|
||||
@remove-tab="removeTab"
|
||||
@sort="sortTabs"
|
||||
>
|
||||
<SmartWindow
|
||||
v-for="tab in tabs"
|
||||
:id="tab.id"
|
||||
:key="'removable_tab_' + tab.id"
|
||||
:label="tab.name"
|
||||
:is-removable="tab.removable"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-rss
|
||||
:style="{
|
||||
color: tab.color,
|
||||
}"
|
||||
class="w-4 h-4 svg-icons"
|
||||
/>
|
||||
</template>
|
||||
<RealtimeLog
|
||||
:title="t('mqtt.log')"
|
||||
:log="((tab.id === 'all' ? logs : tab.logs) as LogEntryData[])"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</SmartWindow>
|
||||
</SmartWindows>
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<div
|
||||
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b divide-y rounded-t divide-dividerLight bg-primary border-dividerLight"
|
||||
>
|
||||
<div class="flex justify-between flex-1">
|
||||
<ButtonSecondary
|
||||
:icon="IconPlus"
|
||||
:label="t('mqtt.new')"
|
||||
class="!rounded-none"
|
||||
@click="showSubscriptionModal(true)"
|
||||
/>
|
||||
<span class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/mqtt"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="topics.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.subscription')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.subscription") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="t('mqtt.new')"
|
||||
filled
|
||||
outline
|
||||
@click="showSubscriptionModal(true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(topic, index) in topics"
|
||||
:key="`subscription-${index}`"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div class="flex items-stretch group">
|
||||
<span class="flex items-center justify-center px-4 cursor-pointer">
|
||||
<icon-lucide-rss
|
||||
:style="{
|
||||
color: topic.color,
|
||||
}"
|
||||
class="w-4 h-4 svg-icons"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
|
||||
@click="openTopicAsTab(topic)"
|
||||
>
|
||||
<span class="truncate">
|
||||
{{ topic.name }}
|
||||
</span>
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
:title="t('mqtt.unsubscribe')"
|
||||
class="hidden group-hover:inline-flex"
|
||||
data-testid="unsubscribe_mqtt_subscription"
|
||||
@click="unsubscribeFromTopic(topic.name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RealtimeSubscription
|
||||
:show="subscriptionModalShown"
|
||||
:loading-state="subscribing"
|
||||
@submit="subscribeToTopic"
|
||||
@hide-modal="showSubscriptionModal(false)"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue"
|
||||
import debounce from "lodash-es/debounce"
|
||||
import {
|
||||
MQTTConnection,
|
||||
MQTTConnectionConfig,
|
||||
MQTTError,
|
||||
MQTTTopic,
|
||||
} from "~/helpers/realtime/MQTTConnection"
|
||||
import { HoppRealtimeLogLine } from "~/helpers/types/HoppRealtimeLog"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import {
|
||||
useReadonlyStream,
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
} from "@composables/stream"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
addMQTTLogLine,
|
||||
MQTTConn$,
|
||||
MQTTEndpoint$,
|
||||
MQTTClientID$,
|
||||
MQTTLog$,
|
||||
setMQTTConn,
|
||||
setMQTTEndpoint,
|
||||
setMQTTClientID,
|
||||
setMQTTLog,
|
||||
MQTTTabs$,
|
||||
setMQTTTabs,
|
||||
MQTTCurrentTab$,
|
||||
setCurrentTab,
|
||||
addMQTTCurrentTabLogLine,
|
||||
} from "~/newstore/MQTTSession"
|
||||
import RegexWorker from "@workers/regex?worker"
|
||||
import { LogEntryData } from "~/components/realtime/Log.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
const url = useStream(MQTTEndpoint$, "", setMQTTEndpoint)
|
||||
const clientID = useStream(MQTTClientID$, "", setMQTTClientID)
|
||||
const config = ref<MQTTConnectionConfig>({
|
||||
username: "",
|
||||
password: "",
|
||||
keepAlive: "60",
|
||||
cleanSession: true,
|
||||
lwTopic: "",
|
||||
lwMessage: "",
|
||||
lwQos: 0,
|
||||
lwRetain: false,
|
||||
})
|
||||
const logs = useStream(MQTTLog$, [], setMQTTLog)
|
||||
const socket = useStream(MQTTConn$, new MQTTConnection(), setMQTTConn)
|
||||
const connectionState = useReadonlyStream(
|
||||
socket.value.connectionState$,
|
||||
"DISCONNECTED"
|
||||
)
|
||||
const subscriptionState = useReadonlyStream(
|
||||
socket.value.subscriptionState$,
|
||||
false
|
||||
)
|
||||
const subscribing = useReadonlyStream(socket.value.subscribing$, false)
|
||||
const isUrlValid = ref(true)
|
||||
const subTopic = ref("")
|
||||
let worker: Worker
|
||||
const subscriptionModalShown = ref(false)
|
||||
const canSubscribe = computed(() => connectionState.value === "CONNECTED")
|
||||
const topics = useReadonlyStream(socket.value.subscribedTopics$, [])
|
||||
|
||||
const currentTabId = useStream(MQTTCurrentTab$, "", setCurrentTab)
|
||||
const tabs = useStream(MQTTTabs$, [], setMQTTTabs)
|
||||
|
||||
const onChangeConfig = (e: MQTTConnectionConfig) => {
|
||||
config.value = e
|
||||
}
|
||||
|
||||
const showSubscriptionModal = (show: boolean) => {
|
||||
subscriptionModalShown.value = show
|
||||
}
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === url.value) isUrlValid.value = data.result
|
||||
}
|
||||
onMounted(() => {
|
||||
worker = new RegexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
subscribeToStream(socket.value.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "CONNECTING":
|
||||
logs.value = [
|
||||
{
|
||||
payload: `${t("state.connecting_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
case "CONNECTED":
|
||||
logs.value = [
|
||||
{
|
||||
payload: `${t("state.connected_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: Date.now(),
|
||||
},
|
||||
]
|
||||
toast.success(`${t("state.connected")}`)
|
||||
break
|
||||
case "MESSAGE_SENT":
|
||||
addLog(
|
||||
{
|
||||
prefix: `${event.message.topic}`,
|
||||
payload: event.message.message,
|
||||
source: "client",
|
||||
ts: Date.now(),
|
||||
},
|
||||
event.message.topic
|
||||
)
|
||||
break
|
||||
case "MESSAGE_RECEIVED":
|
||||
addLog(
|
||||
{
|
||||
prefix: `${event.message.topic}`,
|
||||
payload: event.message.message,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
},
|
||||
event.message.topic
|
||||
)
|
||||
break
|
||||
case "SUBSCRIBED":
|
||||
showSubscriptionModal(false)
|
||||
addMQTTLogLine({
|
||||
payload: subscriptionState.value
|
||||
? `${t("state.subscribed_success", { topic: event.topic })}`
|
||||
: `${t("state.unsubscribed_success", { topic: event.topic })}`,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
case "SUBSCRIPTION_FAILED":
|
||||
addMQTTLogLine({
|
||||
payload: subscriptionState.value
|
||||
? `${t("state.subscribed_failed", { topic: subTopic.value })}`
|
||||
: `${t("state.unsubscribed_failed", { topic: subTopic.value })}`,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
case "ERROR":
|
||||
addMQTTLogLine({
|
||||
payload: getI18nError(event.error),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
case "DISCONNECTED":
|
||||
addMQTTLogLine({
|
||||
payload: t("state.disconnected_from", { name: url.value }).toString(),
|
||||
source: "disconnected",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
const addLog = (line: HoppRealtimeLogLine, topic: string | undefined) => {
|
||||
if (topic) addMQTTCurrentTabLogLine(topic, line)
|
||||
addMQTTLogLine(line)
|
||||
}
|
||||
const debouncer = debounce(function () {
|
||||
worker.postMessage({ type: "ws", url: url.value })
|
||||
}, 1000)
|
||||
watch(url, (newUrl) => {
|
||||
if (newUrl) debouncer()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
worker.terminate()
|
||||
})
|
||||
// METHODS
|
||||
const toggleConnection = () => {
|
||||
// If it is connecting:
|
||||
if (connectionState.value === "DISCONNECTED") {
|
||||
return socket.value.connect(url.value, clientID.value, config.value)
|
||||
}
|
||||
// Otherwise, it's disconnecting.
|
||||
socket.value.disconnect()
|
||||
}
|
||||
const publish = (event: { message: string; eventName: string }) => {
|
||||
socket.value?.publish(event.eventName, event.message)
|
||||
}
|
||||
const subscribeToTopic = (topic: MQTTTopic) => {
|
||||
if (canSubscribe.value) {
|
||||
if (topics.value.some((t) => t.name === topic.name)) {
|
||||
return toast.error(t("mqtt.already_subscribed").toString())
|
||||
}
|
||||
socket.value.subscribe(topic)
|
||||
} else {
|
||||
subscriptionModalShown.value = false
|
||||
toast.error(t("mqtt.not_connected").toString())
|
||||
}
|
||||
}
|
||||
const unsubscribeFromTopic = (topic: string) => {
|
||||
socket.value.unsubscribe(topic)
|
||||
removeTab(topic)
|
||||
}
|
||||
const getI18nError = (error: MQTTError): string => {
|
||||
if (typeof error === "string") return error
|
||||
switch (error.type) {
|
||||
case "CONNECTION_NOT_ESTABLISHED":
|
||||
return t("state.connection_lost").toString()
|
||||
case "SUBSCRIPTION_FAILED":
|
||||
return t("state.mqtt_subscription_failed", {
|
||||
topic: error.topic,
|
||||
}).toString()
|
||||
case "PUBLISH_ERROR":
|
||||
return t("state.publish_error", { topic: error.topic }).toString()
|
||||
case "CONNECTION_LOST":
|
||||
return t("state.connection_lost").toString()
|
||||
case "CONNECTION_FAILED":
|
||||
return t("state.connection_failed").toString()
|
||||
default:
|
||||
return t("state.disconnected_from", { name: url.value }).toString()
|
||||
}
|
||||
}
|
||||
const clearLogEntries = () => {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
const openTopicAsTab = (topic: MQTTTopic) => {
|
||||
const { name, color } = topic
|
||||
if (tabs.value.some((tab) => tab.id === topic.name)) {
|
||||
return (currentTabId.value = topic.name)
|
||||
}
|
||||
tabs.value = [
|
||||
...tabs.value,
|
||||
{
|
||||
id: name,
|
||||
name,
|
||||
color,
|
||||
removable: true,
|
||||
logs: [],
|
||||
},
|
||||
]
|
||||
currentTabId.value = name
|
||||
}
|
||||
|
||||
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
|
||||
const newTabs = [...tabs.value]
|
||||
newTabs.splice(e.newIndex, 0, newTabs.splice(e.oldIndex, 1)[0])
|
||||
tabs.value = newTabs
|
||||
}
|
||||
|
||||
const removeTab = (tabID: string) => {
|
||||
tabs.value = tabs.value.filter((tab) => tab.id !== tabID)
|
||||
}
|
||||
</script>
|
||||
460
packages/hoppscotch-common/src/pages/realtime/socketio.vue
Normal file
460
packages/hoppscotch-common/src/pages/realtime/socketio.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="socketio">
|
||||
<template #primary>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
|
||||
>
|
||||
<div class="inline-flex flex-1 space-x-2">
|
||||
<div class="flex flex-1">
|
||||
<label for="client-version">
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => tippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<input
|
||||
id="client-version"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
title="socket.io-client version"
|
||||
class="flex px-4 py-2 font-semibold border rounded-l cursor-pointer bg-primaryLight border-divider text-secondaryDark w-26"
|
||||
:value="`Client ${clientVersion}`"
|
||||
readonly
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
v-for="version in SIOVersions"
|
||||
:key="`client-${version}`"
|
||||
:label="`Client ${version}`"
|
||||
@click="
|
||||
() => {
|
||||
onSelectVersion(version as SIOClientVersion)
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</label>
|
||||
<input
|
||||
id="socketio-url"
|
||||
v-model="url"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:class="{ error: !isUrlValid }"
|
||||
class="flex flex-1 w-full px-4 py-2 border bg-primaryLight border-divider text-secondaryDark"
|
||||
:placeholder="`${t('socketio.url')}`"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<input
|
||||
id="socketio-path"
|
||||
v-model="path"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
|
||||
spellcheck="false"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!isUrlValid"
|
||||
name="connect"
|
||||
class="w-32"
|
||||
:label="
|
||||
connectionState === 'CONNECTING'
|
||||
? t('action.connecting')
|
||||
: connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SmartTabs
|
||||
v-model="selectedTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'communication'"
|
||||
:label="`${t('websocket.communication')}`"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<RealtimeCommunication
|
||||
:show-event-field="true"
|
||||
:is-connected="connectionState === 'CONNECTED'"
|
||||
event-field-styles="top-upperSecondaryStickyFold"
|
||||
sticky-header-styles="top-upperTertiaryStickyFold"
|
||||
@send-message="sendMessage($event)"
|
||||
/>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'protocols'" :label="`${t('request.authorization')}`">
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("authorization.type") }}
|
||||
</label>
|
||||
<tippy
|
||||
interactive
|
||||
trigger="click"
|
||||
theme="popover"
|
||||
:on-shown="() => authTippyActions.focus()"
|
||||
>
|
||||
<span class="select-wrapper">
|
||||
<ButtonSecondary
|
||||
class="pr-8 ml-2 rounded-none"
|
||||
:label="authType"
|
||||
/>
|
||||
</span>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="authTippyActions"
|
||||
class="flex flex-col focus:outline-none"
|
||||
tabindex="0"
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<SmartItem
|
||||
label="None"
|
||||
:icon="authType === 'None' ? IconCircleDot : IconCircle"
|
||||
:active="authType === 'None'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'None'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<SmartItem
|
||||
label="Bearer Token"
|
||||
:icon="authType === 'Bearer' ? IconCircleDot : IconCircle"
|
||||
:active="authType === 'Bearer'"
|
||||
@click="
|
||||
() => {
|
||||
authType = 'Bearer'
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</tippy>
|
||||
</span>
|
||||
<div class="flex">
|
||||
<SmartCheckbox
|
||||
:on="authActive"
|
||||
class="px-2"
|
||||
@change="authActive = !authActive"
|
||||
>
|
||||
{{ t("state.enabled") }}
|
||||
</SmartCheckbox>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:title="t('app.wiki')"
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'None'"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/login.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.authorization')}`"
|
||||
/>
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("socketio.connection_not_authorized") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
outline
|
||||
:label="t('app.documentation')"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
:icon="IconExternalLink"
|
||||
reverse
|
||||
class="mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="authType === 'Bearer'"
|
||||
class="flex flex-1 border-b border-dividerLight"
|
||||
>
|
||||
<div class="w-2/3 border-r border-dividerLight">
|
||||
<div class="flex flex-1 border-b border-dividerLight">
|
||||
<SmartEnvInput v-model="bearerToken" placeholder="Token" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="sticky flex-shrink-0 h-full p-4 overflow-auto overflow-x-auto bg-primary top-upperTertiaryStickyFold min-w-46 max-w-1/3 z-9"
|
||||
>
|
||||
<div class="p-2">
|
||||
<div class="pb-2 text-secondaryLight">
|
||||
{{ t("helpers.authorization") }}
|
||||
</div>
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
:label="t('authorization.learn')"
|
||||
:icon="IconExternalLink"
|
||||
to="https://docs.hoppscotch.io/features/authorization"
|
||||
blank
|
||||
reverse
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog
|
||||
:title="t('socketio.log')"
|
||||
:log="(log as LogEntryData[])"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue"
|
||||
|
||||
import IconCircleDot from "~icons/lucide/circle-dot"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconExternalLink from "~icons/lucide/external-link"
|
||||
|
||||
import { debounce } from "lodash-es"
|
||||
import {
|
||||
SIOConnection,
|
||||
SIOError,
|
||||
SIOMessage,
|
||||
SOCKET_CLIENTS,
|
||||
} from "~/helpers/realtime/SIOConnection"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
useReadonlyStream,
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
} from "@composables/stream"
|
||||
import {
|
||||
addSIOLogLine,
|
||||
setSIOEndpoint,
|
||||
setSIOLog,
|
||||
setSIOPath,
|
||||
setSIOVersion,
|
||||
SIOClientVersion,
|
||||
SIOEndpoint$,
|
||||
SIOLog$,
|
||||
SIOPath$,
|
||||
SIOVersion$,
|
||||
} from "~/newstore/SocketIOSession"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import RegexWorker from "@workers/regex?worker"
|
||||
import { LogEntryData } from "~/components/realtime/Log.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const colorMode = useColorMode()
|
||||
const toast = useToast()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
type SIOTab = "communication" | "protocols"
|
||||
const selectedTab = ref<SIOTab>("communication")
|
||||
|
||||
const SIOVersions = Object.keys(SOCKET_CLIENTS)
|
||||
const url = useStream(SIOEndpoint$, "", setSIOEndpoint)
|
||||
const clientVersion = useStream(SIOVersion$, "v4", setSIOVersion)
|
||||
const path = useStream(SIOPath$, "", setSIOPath)
|
||||
const socket = new SIOConnection()
|
||||
const connectionState = useReadonlyStream(
|
||||
socket.connectionState$,
|
||||
"DISCONNECTED"
|
||||
)
|
||||
const log = useStream(SIOLog$, [], setSIOLog)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
const authTippyActions = ref<any | null>(null)
|
||||
const isUrlValid = ref(true)
|
||||
const authType = ref<"None" | "Bearer">("None")
|
||||
const bearerToken = ref("")
|
||||
const authActive = ref(true)
|
||||
|
||||
let worker: Worker
|
||||
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === url.value) isUrlValid.value = data.result
|
||||
}
|
||||
|
||||
const getMessagePayload = (data: SIOMessage): string =>
|
||||
typeof data.value === "object" ? JSON.stringify(data.value) : `${data.value}`
|
||||
|
||||
const getErrorPayload = (error: SIOError): string => {
|
||||
switch (error.type) {
|
||||
case "CONNECTION":
|
||||
return t("state.connection_error").toString()
|
||||
case "RECONNECT_ERROR":
|
||||
return t("state.reconnection_error").toString()
|
||||
default:
|
||||
return t("state.disconnected_from", { name: url.value }).toString()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = new RegexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
|
||||
subscribeToStream(socket.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "CONNECTING":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connecting_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
case "CONNECTED":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connected_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: event.time,
|
||||
},
|
||||
]
|
||||
toast.success(`${t("state.connected")}`)
|
||||
break
|
||||
|
||||
case "MESSAGE_SENT":
|
||||
addSIOLogLine({
|
||||
prefix: `[${event.message.eventName}]`,
|
||||
payload: getMessagePayload(event.message),
|
||||
source: "client",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "MESSAGE_RECEIVED":
|
||||
addSIOLogLine({
|
||||
prefix: `[${event.message.eventName}]`,
|
||||
payload: getMessagePayload(event.message),
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "ERROR":
|
||||
addSIOLogLine({
|
||||
payload: getErrorPayload(event.error),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "DISCONNECTED":
|
||||
addSIOLogLine({
|
||||
payload: t("state.disconnected_from", { name: url.value }).toString(),
|
||||
source: "disconnected",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(url, (newUrl) => {
|
||||
if (newUrl) debouncer()
|
||||
})
|
||||
|
||||
// TODO: How important is this ?
|
||||
// watch(connectionState, (connected) => {
|
||||
// if (connected) versionOptions.value.tippy().disable()
|
||||
// else versionOptions.value.tippy().enable()
|
||||
// })
|
||||
|
||||
onUnmounted(() => {
|
||||
worker.terminate()
|
||||
})
|
||||
|
||||
const debouncer = debounce(function () {
|
||||
worker.postMessage({ type: "socketio", url: url.value })
|
||||
}, 1000)
|
||||
|
||||
const toggleConnection = () => {
|
||||
// If it is connecting:
|
||||
if (connectionState.value === "DISCONNECTED") {
|
||||
return socket.connect({
|
||||
url: url.value,
|
||||
path: path.value || "/socket.io",
|
||||
clientVersion: clientVersion.value,
|
||||
auth: authActive.value
|
||||
? {
|
||||
type: authType.value,
|
||||
token: bearerToken.value,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
// Otherwise, it's disconnecting.
|
||||
socket.disconnect()
|
||||
}
|
||||
const sendMessage = (event: { message: string; eventName: string }) => {
|
||||
socket.sendMessage(event)
|
||||
}
|
||||
const onSelectVersion = (version: SIOClientVersion) => {
|
||||
clientVersion.value = version
|
||||
}
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
const clearContent = () => {
|
||||
// TODO: Implementation ?
|
||||
}
|
||||
</script>
|
||||
201
packages/hoppscotch-common/src/pages/realtime/sse.vue
Normal file
201
packages/hoppscotch-common/src/pages/realtime/sse.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="sse">
|
||||
<template #primary>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 overflow-x-auto space-x-2 bg-primary"
|
||||
>
|
||||
<div class="inline-flex flex-1 space-x-2">
|
||||
<div class="flex flex-1">
|
||||
<input
|
||||
id="server"
|
||||
v-model="server"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
:class="{ error: !isUrlValid }"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-l bg-primaryLight border-divider text-secondaryDark"
|
||||
:placeholder="t('sse.url')"
|
||||
:disabled="
|
||||
connectionState === 'STARTED' || connectionState === 'STARTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
|
||||
/>
|
||||
<label
|
||||
for="event-type"
|
||||
class="px-4 py-2 font-semibold truncate border-t border-b bg-primaryLight border-divider text-secondaryLight"
|
||||
>
|
||||
{{ t("sse.event_type") }}
|
||||
</label>
|
||||
<input
|
||||
id="event-type"
|
||||
v-model="eventType"
|
||||
class="flex flex-1 w-full px-4 py-2 border rounded-r bg-primaryLight border-divider text-secondaryDark"
|
||||
spellcheck="false"
|
||||
:disabled="
|
||||
connectionState === 'STARTED' || connectionState === 'STARTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleSSEConnection() : null"
|
||||
/>
|
||||
</div>
|
||||
<ButtonPrimary
|
||||
id="start"
|
||||
:disabled="!isUrlValid"
|
||||
name="start"
|
||||
class="w-32"
|
||||
:label="
|
||||
connectionState === 'STARTING'
|
||||
? t('action.starting')
|
||||
: connectionState === 'STOPPED'
|
||||
? t('action.start')
|
||||
: t('action.stop')
|
||||
"
|
||||
:loading="connectionState === 'STARTING'"
|
||||
@click="toggleSSEConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog
|
||||
:title="t('sse.log')"
|
||||
:log="(log as LogEntryData[])"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted, onMounted } from "vue"
|
||||
import "splitpanes/dist/splitpanes.css"
|
||||
import { debounce } from "lodash-es"
|
||||
import {
|
||||
SSEEndpoint$,
|
||||
setSSEEndpoint,
|
||||
SSEEventType$,
|
||||
setSSEEventType,
|
||||
SSESocket$,
|
||||
setSSESocket,
|
||||
SSELog$,
|
||||
setSSELog,
|
||||
addSSELogLine,
|
||||
} from "~/newstore/SSESession"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
useReadonlyStream,
|
||||
} from "@composables/stream"
|
||||
import { SSEConnection } from "@helpers/realtime/SSEConnection"
|
||||
import RegexWorker from "@workers/regex?worker"
|
||||
import { LogEntryData } from "~/components/realtime/Log.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const sse = useStream(SSESocket$, new SSEConnection(), setSSESocket)
|
||||
const connectionState = useReadonlyStream(sse.value.connectionState$, "STOPPED")
|
||||
const server = useStream(SSEEndpoint$, "", setSSEEndpoint)
|
||||
const eventType = useStream(SSEEventType$, "", setSSEEventType)
|
||||
const log = useStream(SSELog$, [], setSSELog)
|
||||
|
||||
const isUrlValid = ref(true)
|
||||
|
||||
let worker: Worker
|
||||
|
||||
const debouncer = debounce(function () {
|
||||
worker.postMessage({ type: "sse", url: server.value })
|
||||
}, 1000)
|
||||
|
||||
watch(server, (url) => {
|
||||
if (url) debouncer()
|
||||
})
|
||||
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === server.value) isUrlValid.value = data.result
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = new RegexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
|
||||
subscribeToStream(sse.value.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "STARTING":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connecting_to", { name: server.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
case "STARTED":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connected_to", { name: server.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: Date.now(),
|
||||
},
|
||||
]
|
||||
toast.success(`${t("state.connected")}`)
|
||||
break
|
||||
|
||||
case "MESSAGE_RECEIVED":
|
||||
addSSELogLine({
|
||||
payload: event.message,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "ERROR":
|
||||
addSSELogLine({
|
||||
payload: t("error.browser_support_sse").toString(),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "STOPPED":
|
||||
addSSELogLine({
|
||||
payload: t("state.disconnected_from", {
|
||||
name: server.value,
|
||||
}).toString(),
|
||||
source: "disconnected",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
const toggleSSEConnection = () => {
|
||||
// If it is connecting:
|
||||
if (connectionState.value === "STOPPED") {
|
||||
return sse.value.start(server.value, eventType.value)
|
||||
}
|
||||
// Otherwise, it's disconnecting.
|
||||
sse.value.stop()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
worker.terminate()
|
||||
})
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
</script>
|
||||
409
packages/hoppscotch-common/src/pages/realtime/websocket.vue
Normal file
409
packages/hoppscotch-common/src/pages/realtime/websocket.vue
Normal file
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<AppPaneLayout layout-id="websocket">
|
||||
<template #primary>
|
||||
<div
|
||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
|
||||
>
|
||||
<div class="inline-flex flex-1 space-x-2">
|
||||
<input
|
||||
id="websocket-url"
|
||||
v-model="url"
|
||||
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:class="{ error: !isUrlValid }"
|
||||
:placeholder="`${t('websocket.url')}`"
|
||||
:disabled="
|
||||
connectionState === 'CONNECTED' ||
|
||||
connectionState === 'CONNECTING'
|
||||
"
|
||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
||||
/>
|
||||
<ButtonPrimary
|
||||
id="connect"
|
||||
:disabled="!isUrlValid"
|
||||
class="w-32"
|
||||
name="connect"
|
||||
:label="
|
||||
connectionState === 'CONNECTING'
|
||||
? t('action.connecting')
|
||||
: connectionState === 'DISCONNECTED'
|
||||
? t('action.connect')
|
||||
: t('action.disconnect')
|
||||
"
|
||||
:loading="connectionState === 'CONNECTING'"
|
||||
@click="toggleConnection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SmartTabs
|
||||
v-model="selectedTab"
|
||||
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
|
||||
render-inactive-tabs
|
||||
>
|
||||
<SmartTab
|
||||
:id="'communication'"
|
||||
:label="`${t('websocket.communication')}`"
|
||||
>
|
||||
<RealtimeCommunication
|
||||
:is-connected="connectionState === 'CONNECTED'"
|
||||
sticky-header-styles="top-upperSecondaryStickyFold"
|
||||
@send-message="sendMessage($event)"
|
||||
/>
|
||||
</SmartTab>
|
||||
<SmartTab :id="'protocols'" :label="`${t('websocket.protocols')}`">
|
||||
<div
|
||||
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
|
||||
>
|
||||
<label class="font-semibold truncate text-secondaryLight">
|
||||
{{ t("websocket.protocols") }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.clear_all')"
|
||||
:icon="IconTrash2"
|
||||
@click="clearContent"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('add.new')"
|
||||
:icon="IconPlus"
|
||||
@click="addProtocol"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<draggable
|
||||
v-model="protocolsWithID"
|
||||
item-key="id"
|
||||
animation="250"
|
||||
handle=".draggable-handle"
|
||||
draggable=".draggable-content"
|
||||
ghost-class="cursor-move"
|
||||
chosen-class="bg-primaryLight"
|
||||
drag-class="cursor-grabbing"
|
||||
>
|
||||
<template #item="{ element: { protocol }, index }">
|
||||
<div
|
||||
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
|
||||
>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{
|
||||
theme: 'tooltip',
|
||||
delay: [500, 20],
|
||||
content:
|
||||
index !== protocols?.length - 1
|
||||
? t('action.drag_to_reorder')
|
||||
: null,
|
||||
}"
|
||||
:icon="IconGripVertical"
|
||||
class="cursor-auto text-primary hover:text-primary"
|
||||
:class="{
|
||||
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
|
||||
index !== protocols?.length - 1,
|
||||
}"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
v-model="protocol.value"
|
||||
class="flex flex-1 px-4 py-2 bg-transparent"
|
||||
:placeholder="`${t('count.protocol', { count: index + 1 })}`"
|
||||
name="message"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@change="
|
||||
updateProtocol(index, {
|
||||
value: ($event.target as HTMLInputElement).value,
|
||||
active: protocol.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
protocol.hasOwnProperty('active')
|
||||
? protocol.active
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
: t('action.turn_off')
|
||||
"
|
||||
:icon="
|
||||
protocol.hasOwnProperty('active')
|
||||
? protocol.active
|
||||
? IconCheckCircle
|
||||
: IconCircle
|
||||
: IconCheckCircle
|
||||
"
|
||||
color="green"
|
||||
@click="
|
||||
updateProtocol(index, {
|
||||
value: protocol.value,
|
||||
active: !protocol.active,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
:icon="IconTrash"
|
||||
color="red"
|
||||
@click="deleteProtocol(index)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<div
|
||||
v-if="protocols.length === 0"
|
||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||
>
|
||||
<img
|
||||
:src="`/images/states/${colorMode.value}/add_category.svg`"
|
||||
loading="lazy"
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4"
|
||||
:alt="`${t('empty.protocols')}`"
|
||||
/>
|
||||
<span class="mb-4 text-center">
|
||||
{{ t("empty.protocols") }}
|
||||
</span>
|
||||
</div>
|
||||
</SmartTab>
|
||||
</SmartTabs>
|
||||
</template>
|
||||
<template #secondary>
|
||||
<RealtimeLog
|
||||
:title="t('websocket.log')"
|
||||
:log="(log as LogEntryData[])"
|
||||
@delete="clearLogEntries()"
|
||||
/>
|
||||
</template>
|
||||
</AppPaneLayout>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted, onMounted, computed } from "vue"
|
||||
import { debounce } from "lodash-es"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconGripVertical from "~icons/lucide/grip-vertical"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import draggable from "vuedraggable"
|
||||
import {
|
||||
setWSEndpoint,
|
||||
WSEndpoint$,
|
||||
WSProtocols$,
|
||||
setWSProtocols,
|
||||
addWSProtocol,
|
||||
deleteWSProtocol,
|
||||
updateWSProtocol,
|
||||
deleteAllWSProtocols,
|
||||
addWSLogLine,
|
||||
WSLog$,
|
||||
setWSLog,
|
||||
HoppWSProtocol,
|
||||
setWSSocket,
|
||||
WSSocket$,
|
||||
} from "~/newstore/WebSocketSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
useStream,
|
||||
useStreamSubscriber,
|
||||
useReadonlyStream,
|
||||
} from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { WSConnection, WSErrorMessage } from "@helpers/realtime/WSConnection"
|
||||
import RegexWorker from "@workers/regex?worker"
|
||||
import { LogEntryData } from "~/components/realtime/Log.vue"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
const { subscribeToStream } = useStreamSubscriber()
|
||||
|
||||
const selectedTab = ref<"communication" | "protocols">("communication")
|
||||
const url = useStream(WSEndpoint$, "", setWSEndpoint)
|
||||
const protocols = useStream(WSProtocols$, [], setWSProtocols)
|
||||
|
||||
/**
|
||||
* Protocols but with ID inbuilt
|
||||
*/
|
||||
const protocolsWithID = computed({
|
||||
get() {
|
||||
return protocols.value.map((protocol, index) => ({
|
||||
id: `protocol-${index}-${protocol.value}`,
|
||||
protocol,
|
||||
}))
|
||||
},
|
||||
set(newData) {
|
||||
protocols.value = newData.map(({ protocol }) => protocol)
|
||||
},
|
||||
})
|
||||
|
||||
const socket = useStream(WSSocket$, new WSConnection(), setWSSocket)
|
||||
|
||||
const connectionState = useReadonlyStream(
|
||||
socket.value.connectionState$,
|
||||
"DISCONNECTED"
|
||||
)
|
||||
|
||||
const log = useStream(WSLog$, [], setWSLog)
|
||||
// DATA
|
||||
const isUrlValid = ref(true)
|
||||
|
||||
const activeProtocols = ref<string[]>([])
|
||||
|
||||
let worker: Worker
|
||||
|
||||
watch(url, (newUrl) => {
|
||||
if (newUrl) debouncer()
|
||||
})
|
||||
|
||||
watch(
|
||||
protocols,
|
||||
(newProtocols) => {
|
||||
activeProtocols.value = newProtocols
|
||||
.filter((item) =>
|
||||
Object.prototype.hasOwnProperty.call(item, "active")
|
||||
? item.active === true
|
||||
: true
|
||||
)
|
||||
.map(({ value }) => value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
const workerResponseHandler = ({
|
||||
data,
|
||||
}: {
|
||||
data: { url: string; result: boolean }
|
||||
}) => {
|
||||
if (data.url === url.value) isUrlValid.value = data.result
|
||||
}
|
||||
|
||||
const getErrorPayload = (error: WSErrorMessage): string => {
|
||||
if (error instanceof SyntaxError) {
|
||||
return error.message
|
||||
}
|
||||
return t("error.something_went_wrong").toString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
worker = new RegexWorker()
|
||||
worker.addEventListener("message", workerResponseHandler)
|
||||
|
||||
subscribeToStream(socket.value.event$, (event) => {
|
||||
switch (event?.type) {
|
||||
case "CONNECTING":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connecting_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: undefined,
|
||||
},
|
||||
]
|
||||
break
|
||||
|
||||
case "CONNECTED":
|
||||
log.value = [
|
||||
{
|
||||
payload: `${t("state.connected_to", { name: url.value })}`,
|
||||
source: "info",
|
||||
color: "var(--accent-color)",
|
||||
ts: Date.now(),
|
||||
},
|
||||
]
|
||||
toast.success(`${t("state.connected")}`)
|
||||
break
|
||||
|
||||
case "MESSAGE_SENT":
|
||||
addWSLogLine({
|
||||
payload: event.message,
|
||||
source: "client",
|
||||
ts: Date.now(),
|
||||
})
|
||||
break
|
||||
|
||||
case "MESSAGE_RECEIVED":
|
||||
addWSLogLine({
|
||||
payload: event.message,
|
||||
source: "server",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "ERROR":
|
||||
addWSLogLine({
|
||||
payload: getErrorPayload(event.error),
|
||||
source: "info",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
break
|
||||
|
||||
case "DISCONNECTED":
|
||||
addWSLogLine({
|
||||
payload: t("state.disconnected_from", { name: url.value }).toString(),
|
||||
source: "disconnected",
|
||||
color: "#ff5555",
|
||||
ts: event.time,
|
||||
})
|
||||
toast.error(`${t("state.disconnected")}`)
|
||||
break
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (worker) worker.terminate()
|
||||
})
|
||||
const clearContent = () => {
|
||||
deleteAllWSProtocols()
|
||||
}
|
||||
const debouncer = debounce(function () {
|
||||
worker.postMessage({ type: "ws", url: url.value })
|
||||
}, 1000)
|
||||
|
||||
const toggleConnection = () => {
|
||||
// If it is connecting:
|
||||
if (connectionState.value === "DISCONNECTED") {
|
||||
return socket.value.connect(url.value, activeProtocols.value)
|
||||
}
|
||||
// Otherwise, it's disconnecting.
|
||||
socket.value.disconnect()
|
||||
}
|
||||
|
||||
const sendMessage = (event: { message: string; eventName: string }) => {
|
||||
socket.value.sendMessage(event)
|
||||
}
|
||||
const addProtocol = () => {
|
||||
addWSProtocol({ value: "", active: true })
|
||||
}
|
||||
const deleteProtocol = (index: number) => {
|
||||
const oldProtocols = protocols.value.slice()
|
||||
deleteWSProtocol(index)
|
||||
toast.success(`${t("state.deleted")}`, {
|
||||
duration: 4000,
|
||||
action: {
|
||||
text: `${t("action.undo")}`,
|
||||
onClick: (_, toastObject) => {
|
||||
protocols.value = oldProtocols
|
||||
toastObject.goAway()
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const updateProtocol = (index: number, updated: HoppWSProtocol) => {
|
||||
updateWSProtocol(index, updated)
|
||||
}
|
||||
const clearLogEntries = () => {
|
||||
log.value = []
|
||||
}
|
||||
</script>
|
||||
350
packages/hoppscotch-common/src/pages/settings.vue
Normal file
350
packages/hoppscotch-common/src/pages/settings.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="container space-y-8 divide-y divide-dividerLight">
|
||||
<div class="md:grid md:gap-4 md:grid-cols-3">
|
||||
<div class="p-8 md:col-span-1">
|
||||
<h3 class="heading">
|
||||
{{ t("settings.theme") }}
|
||||
</h3>
|
||||
<p class="my-1 text-secondaryLight">
|
||||
{{ t("settings.theme_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-8 space-y-8 md:col-span-2">
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.background") }}
|
||||
</h4>
|
||||
<div class="my-1 text-secondaryLight">
|
||||
{{ t(getColorModeName(colorMode.preference)) }}
|
||||
<span v-if="colorMode.preference === 'system'">
|
||||
({{ t(getColorModeName(colorMode.value)) }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<SmartColorModePicker />
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.accent_color") }}
|
||||
</h4>
|
||||
<div class="my-1 text-secondaryLight">
|
||||
{{ ACCENT_COLOR.charAt(0).toUpperCase() + ACCENT_COLOR.slice(1) }}
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<SmartAccentModePicker />
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.font_size") }}
|
||||
</h4>
|
||||
<div class="mt-4">
|
||||
<SmartFontSizePicker />
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.language") }}
|
||||
</h4>
|
||||
<div class="mt-4">
|
||||
<SmartChangeLanguage />
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.experiments") }}
|
||||
</h4>
|
||||
<div class="my-1 text-secondaryLight">
|
||||
{{ t("settings.experiments_notice") }}
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
to="https://github.com/hoppscotch/hoppscotch/issues/new/choose"
|
||||
blank
|
||||
:label="t('app.contact_us')"
|
||||
/>.
|
||||
</div>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<SmartToggle :on="TELEMETRY_ENABLED" @change="showConfirmModal">
|
||||
{{ t("settings.telemetry") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="EXPAND_NAVIGATION"
|
||||
@change="toggleSetting('EXPAND_NAVIGATION')"
|
||||
>
|
||||
{{ t("settings.expand_navigation") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="SIDEBAR_ON_LEFT"
|
||||
@change="toggleSetting('SIDEBAR_ON_LEFT')"
|
||||
>
|
||||
{{ t("settings.sidebar_on_left") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<SmartToggle :on="ZEN_MODE" @change="toggleSetting('ZEN_MODE')">
|
||||
{{ t("layout.zen_mode") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:grid md:gap-4 md:grid-cols-3">
|
||||
<div class="p-8 md:col-span-1">
|
||||
<h3 class="heading">
|
||||
{{ t("settings.interceptor") }}
|
||||
</h3>
|
||||
<p class="my-1 text-secondaryLight">
|
||||
{{ t("settings.interceptor_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-8 space-y-8 md:col-span-2">
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.extensions") }}
|
||||
</h4>
|
||||
<div class="my-1 text-secondaryLight">
|
||||
<span v-if="extensionVersion != null">
|
||||
{{
|
||||
`${t("settings.extension_version")}: v${
|
||||
extensionVersion.major
|
||||
}.${extensionVersion.minor}`
|
||||
}}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t("settings.extension_version") }}:
|
||||
{{ t("settings.extension_ver_not_reported") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col py-4 space-y-2">
|
||||
<span>
|
||||
<SmartItem
|
||||
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
||||
blank
|
||||
:icon="IconChrome"
|
||||
label="Chrome"
|
||||
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
|
||||
:active-info-icon="hasChromeExtInstalled"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
<SmartItem
|
||||
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
||||
blank
|
||||
:icon="IconFirefox"
|
||||
label="Firefox"
|
||||
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
|
||||
:active-info-icon="hasFirefoxExtInstalled"
|
||||
outline
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="EXTENSIONS_ENABLED"
|
||||
@change="toggleInterceptor('extension')"
|
||||
>
|
||||
{{ t("settings.extensions_use_toggle") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.proxy") }}
|
||||
</h4>
|
||||
<div class="my-1 text-secondaryLight">
|
||||
{{
|
||||
`${t("settings.official_proxy_hosting")} ${t(
|
||||
"settings.read_the"
|
||||
)}`
|
||||
}}
|
||||
<SmartAnchor
|
||||
class="link"
|
||||
to="https://docs.hoppscotch.io/privacy"
|
||||
blank
|
||||
:label="t('app.proxy_privacy_policy')"
|
||||
/>.
|
||||
</div>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<SmartToggle
|
||||
:on="PROXY_ENABLED"
|
||||
@change="toggleInterceptor('proxy')"
|
||||
>
|
||||
{{ t("settings.proxy_use_toggle") }}
|
||||
</SmartToggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center py-4 space-x-2">
|
||||
<div class="relative flex flex-col flex-1">
|
||||
<input
|
||||
id="url"
|
||||
v-model="PROXY_URL"
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="url"
|
||||
autocomplete="off"
|
||||
:disabled="!PROXY_ENABLED"
|
||||
/>
|
||||
<label for="url">
|
||||
{{ t("settings.proxy_url") }}
|
||||
</label>
|
||||
</div>
|
||||
<ButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('settings.reset_default')"
|
||||
:icon="clearIcon"
|
||||
outline
|
||||
class="rounded"
|
||||
@click="resetProxy"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SmartConfirmModal
|
||||
:show="confirmRemove"
|
||||
:title="`${t('confirm.remove_telemetry')} ${t(
|
||||
'settings.telemetry_helps_us'
|
||||
)}`"
|
||||
@hide-modal="confirmRemove = false"
|
||||
@resolve="
|
||||
() => {
|
||||
toggleSetting('TELEMETRY_ENABLED')
|
||||
confirmRemove = false
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconChrome from "~icons/brands/chrome"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconFirefox from "~icons/brands/firefox"
|
||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { refAutoReset } from "@vueuse/core"
|
||||
import { applySetting, toggleSetting } from "~/newstore/settings"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
|
||||
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
|
||||
import { extensionStatus$ } from "~/newstore/HoppExtension"
|
||||
import { usePageHead } from "@composables/head"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
usePageHead({
|
||||
title: computed(() => t("navigation.settings")),
|
||||
})
|
||||
|
||||
const ACCENT_COLOR = useSetting("THEME_COLOR")
|
||||
const PROXY_ENABLED = useSetting("PROXY_ENABLED")
|
||||
const PROXY_URL = useSetting("PROXY_URL")
|
||||
const EXTENSIONS_ENABLED = useSetting("EXTENSIONS_ENABLED")
|
||||
const TELEMETRY_ENABLED = useSetting("TELEMETRY_ENABLED")
|
||||
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
|
||||
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||
const ZEN_MODE = useSetting("ZEN_MODE")
|
||||
|
||||
const currentExtensionStatus = useReadonlyStream(extensionStatus$, null)
|
||||
|
||||
const extensionVersion = computed(() => {
|
||||
return currentExtensionStatus.value === "available"
|
||||
? window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion() ?? null
|
||||
: null
|
||||
})
|
||||
|
||||
const hasChromeExtInstalled = computed(
|
||||
() => browserIsChrome() && currentExtensionStatus.value === "available"
|
||||
)
|
||||
|
||||
const hasFirefoxExtInstalled = computed(
|
||||
() => browserIsFirefox() && currentExtensionStatus.value === "available"
|
||||
)
|
||||
|
||||
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
|
||||
IconRotateCCW,
|
||||
1000
|
||||
)
|
||||
|
||||
const confirmRemove = ref(false)
|
||||
|
||||
const proxySettings = computed(() => ({
|
||||
url: PROXY_URL.value,
|
||||
}))
|
||||
|
||||
watch(ZEN_MODE, (mode) => {
|
||||
applySetting("EXPAND_NAVIGATION", !mode)
|
||||
})
|
||||
|
||||
watch(
|
||||
proxySettings,
|
||||
({ url }) => {
|
||||
applySetting("PROXY_URL", url)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Extensions and proxy should not be enabled at the same time
|
||||
const toggleInterceptor = (interceptor: "extension" | "proxy") => {
|
||||
if (interceptor === "extension") {
|
||||
EXTENSIONS_ENABLED.value = !EXTENSIONS_ENABLED.value
|
||||
|
||||
if (EXTENSIONS_ENABLED.value) {
|
||||
PROXY_ENABLED.value = false
|
||||
}
|
||||
} else {
|
||||
PROXY_ENABLED.value = !PROXY_ENABLED.value
|
||||
|
||||
if (PROXY_ENABLED.value) {
|
||||
EXTENSIONS_ENABLED.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showConfirmModal = () => {
|
||||
if (TELEMETRY_ENABLED.value) confirmRemove.value = true
|
||||
else toggleSetting("TELEMETRY_ENABLED")
|
||||
}
|
||||
|
||||
const resetProxy = () => {
|
||||
applySetting("PROXY_URL", `https://proxy.hoppscotch.io/`)
|
||||
clearIcon.value = IconCheck
|
||||
toast.success(`${t("state.cleared")}`)
|
||||
}
|
||||
|
||||
const getColorModeName = (colorMode: string) => {
|
||||
switch (colorMode) {
|
||||
case "system":
|
||||
return "settings.system_mode"
|
||||
case "light":
|
||||
return "settings.light_mode"
|
||||
case "dark":
|
||||
return "settings.dark_mode"
|
||||
case "black":
|
||||
return "settings.black_mode"
|
||||
default:
|
||||
return "settings.system_mode"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user