chore: split app to commons and web (squash commit)

This commit is contained in:
Andrew Bastin
2022-12-02 02:57:46 -05:00
parent fb827e3586
commit 3d004f2322
535 changed files with 1487 additions and 501 deletions

View File

@@ -0,0 +1,273 @@
<template>
<div class="flex flex-col flex-1">
<div
v-if="showEventField"
class="sticky z-10 flex items-center justify-center flex-shrink-0 overflow-x-auto border-b bg-primary border-dividerLight"
:class="eventFieldStyles"
>
<icon-lucide-rss class="mx-4 svg-icons text-accentLight" />
<input
id="event_name"
v-model="eventName"
class="w-full py-2 pr-4 truncate bg-primary"
name="event_name"
:placeholder="`${t('socketio.event_name')}`"
type="text"
autocomplete="off"
/>
</div>
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
:class="stickyHeaderStyles"
>
<span class="flex items-center">
<label class="font-semibold truncate text-secondaryLight">
{{ t("websocket.message") }}
</label>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<span class="select-wrapper">
<ButtonSecondary
:label="contentType || t('state.none').toLowerCase()"
class="pr-8 ml-2 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
v-for="(contentTypeItem, index) in validContentTypes"
:key="`contentTypeItem-${index}`"
:label="contentTypeItem"
:info-icon="
contentTypeItem === contentType ? IconDone : undefined
"
:active-info-icon="contentTypeItem === contentType"
@click="
() => {
contentType = contentTypeItem
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'request.run'
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
:label="`${t('action.send')}`"
:disabled="!communicationBody || !isConnected"
:icon="IconSend"
class="rounded-none !text-accent !hover:text-accentDark"
@click="sendMessage()"
/>
<SmartCheckbox
v-tippy="{ theme: 'tooltip' }"
:on="clearInputOnSend"
class="px-2"
:title="`${t('mqtt.clear_input_on_send')}`"
@change="clearInputOnSend = !clearInputOnSend"
>
{{ t("mqtt.clear_input") }}
</SmartCheckbox>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/realtime"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear')"
:icon="IconTrash2"
@click="clearContent"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
v-if="contentType && contentType == 'JSON'"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:icon="prettifyIcon"
@click="prettifyRequestBody"
/>
<label for="payload">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('import.title')"
:icon="IconFilePlus"
@click="payload!.click()"
/>
</label>
<input
ref="payload"
class="input"
name="payload"
type="file"
@change="uploadPayload"
/>
</div>
</div>
<div ref="wsCommunicationBody" class="flex flex-col flex-1"></div>
</div>
</template>
<script setup lang="ts">
import { Component, computed, reactive, ref } from "vue"
import IconSend from "~icons/lucide/send"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconWrapText from "~icons/lucide/wrap-text"
import IconTrash2 from "~icons/lucide/trash-2"
import IconWand2 from "~icons/lucide/wand-2"
import IconCheck from "~icons/lucide/check"
import IconInfo from "~icons/lucide/info"
import IconDone from "~icons/lucide/check"
import IconFilePlus from "~icons/lucide/file-plus"
import { pipe } from "fp-ts/function"
import * as TO from "fp-ts/TaskOption"
import * as O from "fp-ts/Option"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "@composables/codemirror"
import jsonLinter from "@helpers/editor/linting/json"
import { readFileAsText } from "@functional/files"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { isJSONContentType } from "@helpers/utils/contenttypes"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
defineProps({
showEventField: {
type: Boolean,
default: false,
},
eventFieldStyles: {
type: String,
default: "",
},
stickyHeaderStyles: {
type: String,
default: "",
},
isConnected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(
e: "send-message",
body: {
eventName: string
message: string
}
): void
}>()
const t = useI18n()
const toast = useToast()
// Template refs
const tippyActions = ref<any | null>(null)
const linewrapEnabled = ref(true)
const wsCommunicationBody = ref<HTMLElement>()
const payload = ref<HTMLInputElement>()
const prettifyIcon = refAutoReset<Component>(IconWand2, 1000)
const clearInputOnSend = ref(false)
const knownContentTypes = {
JSON: "application/ld+json",
Raw: "text/plain",
} as const
const validContentTypes = Object.keys(knownContentTypes) as ["JSON", "Raw"]
const contentType = ref<keyof typeof knownContentTypes>("JSON")
const eventName = ref("")
const communicationBody = ref("")
const rawInputEditorLang = computed(() => knownContentTypes[contentType.value])
const langLinter = computed(() =>
isJSONContentType(contentType.value) ? jsonLinter : null
)
useCodemirror(
wsCommunicationBody,
communicationBody,
reactive({
extendedEditorConfig: {
lineWrapping: linewrapEnabled,
mode: rawInputEditorLang,
placeholder: t("websocket.message").toString(),
},
linter: langLinter,
completer: null,
environmentHighlights: true,
})
)
const clearContent = () => {
if (clearInputOnSend.value) {
communicationBody.value = ""
eventName.value = ""
}
}
const sendMessage = () => {
if (!communicationBody.value) return
emit("send-message", {
eventName: eventName.value,
message: communicationBody.value,
})
clearContent()
}
const uploadPayload = async (e: Event) => {
const result = await pipe(
(e.target as HTMLInputElement).files?.[0],
TO.fromNullable,
TO.chain(readFileAsText)
)()
if (O.isSome(result)) {
communicationBody.value = result.value
toast.success(`${t("state.file_imported")}`)
} else {
toast.error(`${t("action.choose_file")}`)
}
}
const prettifyRequestBody = () => {
try {
const jsonObj = JSON.parse(communicationBody.value)
communicationBody.value = JSON.stringify(jsonObj, null, 2)
prettifyIcon.value = IconCheck
} catch (e) {
console.error(e)
prettifyIcon.value = IconInfo
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
}
defineActionHandler("request.send-cancel", sendMessage)
</script>

View File

@@ -0,0 +1,135 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 py-2 pl-4 pr-2 overflow-x-auto border-b bg-primary border-dividerLight top-upperPrimaryStickyFold"
>
<span class="flex items-center">
<label class="font-semibold truncate text-secondaryLight">
{{ t("mqtt.connection_config") }}
</label>
</span>
<div class="flex">
<SmartCheckbox
:on="config.cleanSession"
class="px-2"
@change="config.cleanSession = !config.cleanSession"
>{{ t("mqtt.clean_session") }}
</SmartCheckbox>
</div>
</div>
<div class="flex flex-1 h-full border-dividerLight">
<div class="w-1/3 border-r border-dividerLight">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="config.username"
:placeholder="t('authorization.username')"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="config.password"
:placeholder="t('authorization.password')"
/>
</div>
<div class="flex items-center border-b border-dividerLight">
<label class="ml-4 text-secondaryLight">
{{ t("mqtt.keep_alive") }}
</label>
<SmartEnvInput
v-model="config.keepAlive"
:placeholder="t('mqtt.keep_alive')"
/>
</div>
</div>
<div class="w-2/3">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="config.lwTopic"
:placeholder="t('mqtt.lw_topic')"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="config.lwMessage"
:placeholder="t('mqtt.lw_message')"
/>
</div>
<div
class="flex items-center justify-between px-4 border-b border-dividerLight"
>
<div class="flex items-center">
<label class="font-semibold truncate text-secondaryLight">
{{ t("mqtt.lw_qos") }}
</label>
<tippy interactive trigger="click" theme="popover">
<span class="select-wrapper">
<ButtonSecondary
class="pr-8 ml-2 rounded-none"
:label="`${config.lwQos}`"
/>
</span>
<template #content="{ hide }">
<div class="flex flex-col" role="menu">
<SmartItem
v-for="item in QOS_VALUES"
:key="`qos-${item}`"
:label="`${item}`"
:icon="config.lwQos === item ? IconCheckCircle : IconCircle"
:active="config.lwQos === item"
@click="
() => {
config.lwQos = item
hide()
}
"
/>
</div>
</template>
</tippy>
</div>
<SmartCheckbox
:on="config.lwRetain"
class="py-2"
@change="config.lwRetain = !config.lwRetain"
>{{ t("mqtt.lw_retain") }}
</SmartCheckbox>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import {
MQTTConnectionConfig,
QOS_VALUES,
} from "~/helpers/realtime/MQTTConnection"
const t = useI18n()
const emit = defineEmits<{
(e: "change", body: MQTTConnectionConfig): void
}>()
const config = ref<MQTTConnectionConfig>({
username: "",
password: "",
keepAlive: "60",
cleanSession: true,
lwTopic: "",
lwMessage: "",
lwQos: 0,
lwRetain: false,
})
watch(
config,
(newVal) => {
emit("change", newVal)
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,134 @@
<template>
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
<div
v-if="log.length !== 0"
class="sticky top-0 z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
>
<label for="log" class="font-semibold truncate text-secondaryLight">
{{ title }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
:icon="IconTrash"
@click="emit('delete')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_top')"
:icon="IconArrowUp"
@click="scrollTo('top')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_bottom')"
:icon="IconArrowDown"
@click="scrollTo('bottom')"
/>
<ButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="`${t('action.autoscroll')}: ${
autoScrollEnabled ? t('action.turn_off') : t('action.turn_on')
}`"
:icon="IconChevronsDown"
:class="toggleAutoscrollColor"
@click="toggleAutoscroll()"
/>
</div>
</div>
<div
v-if="log.length !== 0"
ref="logs"
class="flex flex-col flex-1 overflow-y-auto"
>
<div class="border-b border-dividerLight">
<div class="flex flex-col divide-y divide-dividerLight">
<RealtimeLogEntry
v-for="(entry, index) in log"
:key="`entry-${index}`"
:entry="entry"
/>
</div>
</div>
</div>
<AppShortcutsPrompt v-else class="p-4" />
</div>
</template>
<script setup lang="ts">
import { ref, PropType, computed, watch } from "vue"
import IconTrash from "~icons/lucide/trash"
import IconArrowUp from "~icons/lucide/arrow-up"
import IconArrowDown from "~icons/lucide/arrow-down"
import IconChevronsDown from "~icons/lucide/chevron-down"
import { useThrottleFn, useScroll } from "@vueuse/core"
import { useI18n } from "@composables/i18n"
export type LogEntryData = {
prefix?: string
ts: number | undefined
source: "info" | "client" | "server" | "disconnected"
payload: string
event: "connecting" | "connected" | "disconnected" | "error"
}
const props = defineProps({
log: { type: Array as PropType<LogEntryData[]>, default: () => [] },
title: {
type: String,
default: "",
},
})
const emit = defineEmits<{
(e: "delete"): void
}>()
const t = useI18n()
const logs = ref<HTMLElement>()
const autoScrollEnabled = ref(true)
const logListScroll = useScroll(logs)
// Disable autoscroll when scrolling to top
watch(logListScroll.isScrolling, (isScrolling) => {
if (isScrolling && logListScroll.directions.top)
autoScrollEnabled.value = false
})
const scrollTo = (position: "top" | "bottom") => {
if (position === "top") {
logs.value?.scroll({
behavior: "smooth",
top: 0,
})
} else if (position === "bottom") {
logs.value?.scroll({
behavior: "smooth",
top: logs.value?.scrollHeight,
})
}
}
watch(
() => props.log,
useThrottleFn(() => {
if (autoScrollEnabled.value) scrollTo("bottom")
}, 200),
{ flush: "post" }
)
const toggleAutoscroll = () => {
autoScrollEnabled.value = !autoScrollEnabled.value
}
const toggleAutoscrollColor = computed(() =>
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
)
</script>

View File

@@ -0,0 +1,402 @@
<template>
<div v-if="entry" class="divide-y divide-dividerLight">
<div :style="{ color: entryColor }" class="realtime-log">
<div class="flex group">
<div class="flex flex-1 divide-x divide-dividerLight">
<div class="inline-flex items-center p-2">
<component
:is="icon"
:style="{ color: iconColor }"
@click="copyQuery(entry.payload)"
/>
</div>
<div
v-if="entry.ts !== undefined"
class="items-center hidden px-1 w-34 sm:inline-flex"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="relativeTime"
class="mx-auto truncate ts-font text-secondaryLight hover:text-secondary hover:text-center"
>
{{ shortDateTime(entry.ts) }}
</span>
</div>
<div
class="inline-grid items-center flex-1 min-w-0 p-2"
@click="toggleExpandPayload()"
>
<div class="truncate">
<span v-if="entry.prefix !== undefined" class="!inline">{{
entry.prefix
}}</span>
{{ entry.payload }}
</div>
</div>
</div>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyQueryIcon"
class="hidden group-hover:inline-flex"
@click="copyQuery(entry.payload)"
/>
<ButtonSecondary
:icon="IconChevronDown"
class="transform"
:class="{ 'rotate-180': !minimized }"
@click="toggleExpandPayload()"
/>
</div>
</div>
<div v-if="!minimized" class="overflow-hidden bg-primaryContrast">
<SmartTabs
v-model="selectedTab"
styles="bg-primaryLight"
render-inactive-tabs
>
<SmartTab v-if="isJSON(entry.payload)" id="json" label="JSON" />
<SmartTab id="raw" label="Raw" />
</SmartTabs>
<div
class="z-10 flex items-center justify-between pl-4 border-b border-dividerLight top-lowerSecondaryStickyFold"
>
<label class="font-semibold truncate text-secondaryLight">
{{ t("response.body") }}
</label>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="downloadIcon"
@click="downloadResponse"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyResponse"
/>
</div>
</div>
<div ref="editor"></div>
<div
v-if="outlinePath && selectedTab === 'json'"
class="sticky bottom-0 z-10 flex flex-shrink-0 px-2 overflow-auto overflow-x-auto border-t bg-primaryLight border-dividerLight flex-nowrap"
>
<div
v-for="(item, index) in outlinePath"
:key="`item-${index}`"
class="flex items-center"
>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<div v-if="item.kind === 'RootObject'" class="outline-item">{}</div>
<div v-if="item.kind === 'RootArray'" class="outline-item">[]</div>
<div v-if="item.kind === 'ArrayMember'" class="outline-item">
{{ item.index }}
</div>
<div v-if="item.kind === 'ObjectMember'" class="outline-item">
{{ item.name }}
</div>
<template #content="{ hide }">
<div
v-if="
item.kind === 'ArrayMember' || item.kind === 'ObjectMember'
"
>
<div
v-if="item.kind === 'ArrayMember'"
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
v-for="(arrayMember, astIndex) in item.astParent.values"
:key="`ast-${astIndex}`"
:label="`${astIndex}`"
@click="
() => {
jumpCursor(arrayMember)
hide()
}
"
/>
</div>
<div
v-if="item.kind === 'ObjectMember'"
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
v-for="(objectMember, astIndex) in item.astParent.members"
:key="`ast-${astIndex}`"
:label="objectMember.key.value"
@click="
() => {
jumpCursor(objectMember)
hide()
}
"
/>
</div>
</div>
<div
v-if="item.kind === 'RootObject'"
ref="tippyActions"
class="flex flex-col focus:outline-none"
>
<SmartItem
label="{}"
@click="
() => {
jumpCursor(item.astValue)
hide()
}
"
/>
</div>
<div
v-if="item.kind === 'RootArray'"
ref="tippyActions"
class="flex flex-col focus:outline-none"
>
<SmartItem
label="[]"
@click="
() => {
jumpCursor(item.astValue)
hide()
}
"
/>
</div>
</template>
</tippy>
<icon-lucide-chevron-right
v-if="index + 1 !== outlinePath.length"
class="opacity-50 text-secondaryLight svg-icons"
/>
</div>
</div>
</div>
</div>
<div v-else>{{ t("response.waiting_for_connection") }}</div>
</template>
<script setup lang="ts">
import IconInfo from "~icons/lucide/info"
import IconUpRight from "~icons/lucide/arrow-up-right"
import IconDownLeft from "~icons/lucide/arrow-down-left"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconChevronDown from "~icons/lucide/chevron-down"
import IconWrapText from "~icons/lucide/wrap-text"
import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { ref, computed, reactive, watch, markRaw } from "vue"
import { refAutoReset, useTimeAgo } from "@vueuse/core"
import { LogEntryData } from "./Log.vue"
import { useI18n } from "@composables/i18n"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { isJSON } from "~/helpers/functional/json"
import { useCopyResponse, useDownloadResponse } from "@composables/lens-actions"
import { useCodemirror } from "@composables/codemirror"
import jsonParse, { JSONObjectMember, JSONValue } from "~/helpers/jsonParse"
import { getJSONOutlineAtPos } from "~/helpers/newOutline"
import {
convertIndexToLineCh,
convertLineChToIndex,
} from "~/helpers/editor/utils"
import { shortDateTime } from "~/helpers/utils/date"
const t = useI18n()
const props = defineProps<{ entry: LogEntryData }>()
// Template refs
const tippyActions = ref<any | null>(null)
const editor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const logPayload = computed(() => props.entry.payload)
const selectedTab = ref<"json" | "raw">(
isJSON(props.entry.payload) ? "json" : "raw"
)
// CodeMirror Implementation
const jsonBodyText = computed(() =>
pipe(
logPayload.value,
O.tryCatchK(LJSON.parse),
O.map((val) => LJSON.stringify(val, undefined, 2)),
O.getOrElse(() => logPayload.value)
)
)
const ast = computed(() =>
pipe(
jsonBodyText.value,
O.tryCatchK(jsonParse),
O.getOrElseW(() => null)
)
)
const editorText = computed(() => {
if (selectedTab.value === "json") return jsonBodyText.value
else return logPayload.value
})
const editorMode = computed(() => {
if (selectedTab.value === "json") return "application/ld+json"
else return "text/plain"
})
const { cursor } = useCodemirror(
editor,
editorText,
reactive({
extendedEditorConfig: {
mode: editorMode,
readOnly: true,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
const jumpCursor = (ast: JSONValue | JSONObjectMember) => {
const pos = convertIndexToLineCh(jsonBodyText.value, ast.start)
pos.line--
cursor.value = pos
}
const outlinePath = computed(() =>
pipe(
ast.value,
O.fromNullable,
O.map((ast) =>
getJSONOutlineAtPos(
ast,
convertLineChToIndex(jsonBodyText.value, cursor.value)
)
),
O.getOrElseW(() => null)
)
)
// Code for UI Changes
const minimized = ref(true)
watch(minimized, () => {
selectedTab.value = isJSON(props.entry.payload) ? "json" : "raw"
})
const toggleExpandPayload = () => {
minimized.value = !minimized.value
}
const { copyIcon, copyResponse } = useCopyResponse(logPayload)
const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json",
logPayload
)
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const copyQuery = (entry: string) => {
copyToClipboard(entry)
copyQueryIcon.value = IconCheck
}
// Relative Time
// TS could be undefined here. We're just assigning a default value to 0 because we're not showing it in the UI
const relativeTime = useTimeAgo(computed(() => props.entry.ts ?? 0))
const ENTRY_COLORS = {
connected: "#10b981",
connecting: "#10b981",
error: "#ff5555",
disconnected: "#ff5555",
} as const
// Assigns color based on entry event
const entryColor = computed(() => ENTRY_COLORS[props.entry.event])
const ICONS = {
info: {
icon: IconInfo,
iconColor: "#10b981",
},
client: {
icon: IconUpRight,
iconColor: "#eaaa45",
},
server: {
icon: IconDownLeft,
iconColor: "#38d4ea",
},
disconnected: {
icon: IconInfo,
iconColor: "#ff5555",
},
} as const
const iconColor = computed(() => ICONS[props.entry.source].iconColor)
const icon = computed(() => markRaw(ICONS[props.entry.source].icon))
</script>
<style lang="scss" scoped>
.realtime-log {
@apply text-secondary;
@apply overflow-hidden;
@apply hover:cursor-nsResize;
&,
span {
@apply select-text;
}
span {
@apply block;
@apply break-words break-all;
}
}
.outline-item {
@apply cursor-pointer;
@apply flex-grow-0 flex-shrink-0;
@apply text-secondaryLight;
@apply inline-flex;
@apply items-center;
@apply px-2;
@apply py-1;
@apply transition;
@apply hover: text-secondary;
}
.ts-font {
font-size: 0.6rem;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<SmartModal v-if="show" dialog :title="t('mqtt.new')" @close="hideModal">
<template #body>
<div class="flex justify-between mb-4">
<div
class="flex items-center border rounded divide-x border-divider divide-divider"
>
<label class="mx-4">
{{ t("mqtt.qos") }}
</label>
<tippy interactive trigger="click" theme="popover">
<span class="select-wrapper">
<ButtonSecondary class="pr-8" :label="`${QoS}`" />
</span>
<template #content="{ hide }">
<div class="flex flex-col" role="menu">
<SmartItem
v-for="item in QOS_VALUES"
:key="`qos-${item}`"
:label="`${item}`"
:icon="QoS === item ? IconCheckCircle : IconCircle"
:active="QoS === item"
@click="
() => {
QoS = item
hide()
}
"
/>
</div>
</template>
</tippy>
</div>
</div>
<div class="relative flex flex-col">
<input
id="selectLabelAdd"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="addNewSubscription"
/>
<label for="selectLabelAdd">
{{ t("action.label") }}
</label>
<span class="end-actions">
<label
v-tippy="{ theme: 'tooltip' }"
:title="t('mqtt.color')"
for="select-color"
class="absolute inset-0 flex items-center justify-center group hover:cursor-pointer"
>
<icon-lucide-brush
class="transition opacity-80 svg-icons group-hover:opacity-100 text-accentContrast"
/>
</label>
<input
id="select-color"
v-model="color"
type="color"
class="w-8 h-8 p-1 rounded bg-primary"
/>
</span>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="t('mqtt.subscribe')"
:loading="loadingState"
outline
@click="addNewSubscription"
/>
<ButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</SmartModal>
</template>
<script lang="ts" setup>
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import { ref, watch } from "vue"
import { MQTTTopic, QOS_VALUES } from "~/helpers/realtime/MQTTConnection"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
const toastr = useToast()
const t = useI18n()
const props = defineProps({
show: {
type: Boolean,
default: false,
},
loadingState: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "submit", body: MQTTTopic): void
}>()
const QoS = ref<typeof QOS_VALUES[number]>(2)
const name = ref("")
const color = ref("#f58290")
watch(
() => props.show,
() => {
name.value = ""
QoS.value = 2
const randomColor = Math.floor(Math.random() * 16777215).toString(16)
color.value = `#${randomColor}`
}
)
const addNewSubscription = () => {
if (!name.value) {
toastr.error(t("mqtt.invalid_topic").toString())
return
}
emit("submit", {
name: name.value,
qos: QoS.value,
color: color.value,
})
}
const hideModal = () => {
name.value = ""
QoS.value = 2
emit("hide-modal")
}
</script>