refactor: organize frontend project structure

- feat: add toggle for conversation sidebar
- save sidebar state in localstorage.
This commit is contained in:
Abhinav Raut
2025-02-06 03:33:13 +05:30
parent c5cf62573e
commit ceb076bbba
162 changed files with 608 additions and 1040 deletions
+5 -3
View File
@@ -143,7 +143,7 @@ func main() {
messageIncomingQWorkers = ko.MustDuration("message.incoming_queue_workers")
messageOutgoingScanInterval = ko.MustDuration("message.message_outoing_scan_interval")
slaEvaluationInterval = ko.MustDuration("sla.evaluation_interval")
lo = initLogger("libredesk")
lo = initLogger(appName)
wsHub = ws.NewHub()
rdb = initRedis()
constants = initConstants()
@@ -221,13 +221,15 @@ func main() {
}
go func() {
colorlog.Green("Server started at %s", ko.String("app.server.address"))
if ko.String("server.socket") != "" {
colorlog.Green("Unix socket created at %s", ko.String("server.socket"))
}
if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil {
log.Fatalf("error starting server: %v", err)
}
}()
colorlog.Green("🚀 listening on %s %s", ko.String("app.server.address"), ko.String("app.server.socket"))
// Wait for shutdown signal.
<-ctx.Done()
colorlog.Red("Shutting down HTTP server...")
+21 -20
View File
@@ -1,20 +1,22 @@
<template>
<Sidebar
:isLoading="false"
:open="sidebarOpen"
:userTeams="userStore.teams"
:userViews="userViews"
@update:open="sidebarOpen = $event"
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
>
<div class="w-full h-screen border-l">
<PageHeader />
<RouterView />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</Sidebar>
<div class="flex">
<!-- Main sidebar -->
<Sidebar
class="w-full"
:userTeams="userStore.teams"
:userViews="userViews"
@create-view="openCreateViewForm = true"
@edit-view="editView"
@delete-view="deleteView"
>
<div class="h-screen border-l">
<PageHeader />
<RouterView />
</div>
<ViewForm v-model:openDialog="openCreateViewForm" v-model:view="view" />
</Sidebar>
</div>
<!-- Command box -->
<Command />
</template>
@@ -34,15 +36,14 @@ import { useTeamStore } from '@/stores/team'
import { useSlaStore } from '@/stores/sla'
import { useMacroStore } from '@/stores/macro'
import { useTagStore } from '@/stores/tag'
import PageHeader from './components/common/PageHeader.vue'
import ViewForm from '@/components/ViewForm.vue'
import PageHeader from './components/layout/PageHeader.vue'
import ViewForm from '@/features/view/ViewForm.vue'
import api from '@/api'
import Sidebar from '@/components/sidebar/Sidebar.vue'
import Command from '@/components/command/CommandBox.vue'
import Command from '@/features/command/CommandBox.vue'
const { toast } = useToast()
const emitter = useEmitter()
const sidebarOpen = ref(true)
const userStore = useUserStore()
const conversationStore = useConversationStore()
const usersStore = useUsersStore()
@@ -1,5 +0,0 @@
<template>
<div>
<slot></slot>
</div>
</template>
@@ -1,104 +0,0 @@
<template>
<form @submit="onLocalFsSubmit" class="space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
v-bind="componentField"
v-model="componentField.modelValue"
@update:modelValue="handleProviderUpdate"
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="s3"> S3 </SelectItem>
<SelectItem value="localfs"> Local filesystem </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="upload_path">
<FormItem v-auto-animate>
<FormLabel>Upload path</FormLabel>
<FormControl>
<Input type="text" placeholder="/home/ubuntu/uploads" v-bind="componentField" />
</FormControl>
<FormDescription> Path to the directory where files will be uploaded. </FormDescription>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch } from 'vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { localFsFormSchema } from './formSchema.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
}
})
const emit = defineEmits(['provider-update'])
const localFsForm = useForm({
validationSchema: toTypedSchema(localFsFormSchema)
})
const onLocalFsSubmit = localFsForm.handleSubmit((values) => {
props.submitForm(values)
})
const handleProviderUpdate = (value) => {
emit('provider-update', value)
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
localFsForm.setValues(newValues)
},
{ deep: true, immediate: true }
)
</script>
@@ -1,191 +0,0 @@
<template>
<form @submit="onS3FormSubmit" class="space-y-6">
<FormField v-slot="{ componentField }" name="provider">
<FormItem>
<FormLabel>Provider</FormLabel>
<FormControl>
<Select
v-bind="componentField"
v-model="componentField.modelValue"
@update:modelValue="handleProviderUpdate"
>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="s3"> S3 </SelectItem>
<SelectItem value="localfs"> Local filesystem </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="region">
<FormItem v-auto-animate>
<FormLabel>Region</FormLabel>
<FormControl>
<Input type="text" placeholder="ap-south-1" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="access_key">
<FormItem v-auto-animate>
<FormLabel>AWS access key</FormLabel>
<FormControl>
<Input type="text" placeholder="AWS access key" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="access_secret">
<FormItem v-auto-animate>
<FormLabel>AWS access secret</FormLabel>
<FormControl>
<Input type="password" placeholder="AWS access secret" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket_type">
<FormItem>
<FormLabel>Bucket type</FormLabel>
<FormControl>
<Select v-bind="componentField" v-model="componentField.modelValue">
<SelectTrigger>
<SelectValue placeholder="Select bucket type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="public"> Public </SelectItem>
<SelectItem value="private"> Private </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket">
<FormItem v-auto-animate>
<FormLabel>Bucket</FormLabel>
<FormControl>
<Input type="text" placeholder="Bucket" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="bucket_path">
<FormItem v-auto-animate>
<FormLabel>Bucket path</FormLabel>
<FormControl>
<Input type="text" placeholder="Bucket path" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="upload_expiry">
<FormItem v-auto-animate>
<FormLabel>Upload expiry</FormLabel>
<FormControl>
<Input type="text" placeholder="24h" v-bind="componentField" />
</FormControl>
<FormDescription>Only applicable for private buckets.</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="url">
<FormItem v-auto-animate>
<FormLabel>S3 backend URL</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://ap-south-1.s3.amazonaws.com"
v-bind="componentField"
/>
</FormControl>
<FormDescription
>Only change if using a custom S3 compatible backend like Minio.</FormDescription
>
<FormMessage />
</FormItem>
</FormField>
<Button type="submit"> {{ submitLabel }} </Button>
</form>
</template>
<script setup>
import { watch, defineEmits } from 'vue'
import { Button } from '@/components/ui/button'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { s3FormSchema } from './formSchema.js'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
const props = defineProps({
initialValues: {
type: Object,
required: false
},
submitForm: {
type: Function,
required: true
},
submitLabel: {
type: String,
required: false,
default: () => 'Submit'
}
})
const emit = defineEmits(['provider-update'])
const s3Form = useForm({
validationSchema: toTypedSchema(s3FormSchema)
})
const onS3FormSubmit = s3Form.handleSubmit((values) => {
props.submitForm(values)
})
const handleProviderUpdate = (value) => {
emit('provider-update', value)
}
// Watch for changes in initialValues and update the form.
watch(
() => props.initialValues,
(newValues) => {
s3Form.setValues(newValues)
},
{ deep: true, immediate: true }
)
</script>
@@ -1,64 +0,0 @@
<template>
<div>
</div>
<div>
<component
:is="formProvider === 's3' ? S3Form : LocalFsForm"
:submitForm="submitForm"
:initialValues="initialValues"
submitLabel="Save"
@provider-update="handleProviderUpdate"
/>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import S3Form from './S3Form.vue'
import LocalFsForm from './LocalFsForm.vue'
import api from '@/api'
const initialValues = ref({})
onMounted(async () => {
const resp = await api.getSettings('upload')
const config = resp.data.data
const modifiedConfig = {}
for (const key in config) {
if (key.startsWith('upload.localfs.')) {
const newKey = key.replace('upload.localfs.', '')
modifiedConfig[newKey] = config[key]
} else if (key.startsWith('upload.s3')) {
const newKey = key.replace('upload.s3.', '')
modifiedConfig[newKey] = config[key]
} else if (key.startsWith('upload.')) {
const newKey = key.replace('upload.', '')
modifiedConfig[newKey] = config[key]
}
}
initialValues.value = modifiedConfig
})
const submitForm = async (values) => {
const prefixedValues = {}
for (const key in values) {
if (values.provider === 'localfs') {
prefixedValues[`upload.localfs.${key}`] = values[key]
} else if (values.provider === 's3') {
prefixedValues[`upload.s3.${key}`] = values[key]
}
}
prefixedValues['upload.provider'] = values.provider
await api.updateSettings('upload', prefixedValues)
}
const formProvider = computed(() => {
return initialValues.value.provider
})
const handleProviderUpdate = (value) => {
initialValues.value.provider = value
}
</script>
@@ -1,44 +0,0 @@
import * as z from 'zod'
export const s3FormSchema = z.object({
provider: z.string({
required_error: 'Provider is required.'
}),
region: z.string({
required_error: 'Region is required.'
}),
access_key: z.string({
required_error: 'AWS access key is required.'
}),
access_secret: z.string({
required_error: 'AWS access secret is required.'
}),
bucket_type: z.string({
required_error: 'Bucket type is required.'
}),
bucket: z.string({
required_error: 'Bucket is required.'
}),
bucket_path: z.string({
required_error: 'Bucket path is required.'
}),
upload_expiry: z.string({
required_error: 'Upload expiry is required.'
}),
url: z
.string({
required_error: 'S3 backend URL is required.'
})
.url({
message: 'S3 backend URL must be a valid URL.'
})
})
export const localFsFormSchema = z.object({
provider: z.string({
required_error: 'Provider is required.'
}),
upload_path: z.string({
required_error: 'Upload path is required.'
})
})
@@ -1,74 +0,0 @@
<template>
<div v-if="conversationStore.messages.data">
<!-- Header -->
<div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm">
<div class="font-medium">
{{ conversationStore.current?.subject }}
</div>
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger>
<div
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
>
<span
class="text-secondary font-medium inline-block"
v-if="conversationStore.current?.status"
>
{{ conversationStore.current?.status }}
</span>
<span v-else class="text-secondary font-medium inline-block"> Loading... </span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="status in conversationStore.statusOptions"
:key="status.value"
@click="handleUpdateStatus(status.label)"
>
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<!-- Messages & reply box -->
<div>
<div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0 bg-white">
<ReplyBox class="h-max" />
</div>
</div>
</div>
</template>
<script setup>
import { useConversationStore } from '@/stores/conversation'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import MessageList from '@/components/message/MessageList.vue'
import ReplyBox from './ReplyBox.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { useEmitter } from '@/composables/useEmitter'
const conversationStore = useConversationStore()
const emitter = useEmitter()
const handleUpdateStatus = (status) => {
if (status === CONVERSATION_DEFAULT_STATUSES.SNOOZED) {
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, 'snooze')
return
}
conversationStore.updateStatus(status)
}
</script>
@@ -1,7 +0,0 @@
<template>
<h3 class="h-screen flex items-center justify-center">
<div class="flex flex-row items-center justify-center">
<p>Select a conversation from the left panel.</p>
</div>
</h3>
</template>
@@ -1,90 +0,0 @@
<template>
<div class="flex justify-end px-2 py-2 border-b w-full">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<div class="flex items-center mr-2 relative">
<span class="absolute inline-flex h-2 w-2 rounded-full bg-primary opacity-75 right-0 bottom-5 z-20"
v-if="conversationStore.conversations.filters.length > 0" />
<ListFilter size="27"
class="mx-auto cursor-pointer transition-all transform hover:scale-110 hover:bg-secondary hover:bg-opacity-80 p-1 rounded-md z-10" />
</div>
</PopoverTrigger>
<PopoverContent class="w-[450px]">
<Filter v-model:modelValue="localFilters" :fields="fields" @apply="handleApply" @clear="handleClear" />
</PopoverContent>
</Popover>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ListFilter } from 'lucide-vue-next'
import { useConversationStore } from '@/stores/conversation'
import Filter from '@/components/common/FilterBuilder.vue'
import api from '@/api'
const conversationStore = useConversationStore()
const open = ref(false)
const localFilters = ref([])
const statuses = ref([])
const priorities = ref([])
const emit = defineEmits(['updateFilters'])
onMounted(() => {
fetchInitialData()
localFilters.value = [...conversationStore.conversations.filters]
})
const fetchInitialData = async () => {
const [statusesResp, prioritiesResp] = await Promise.all([
api.getStatuses(),
api.getPriorities()
])
statuses.value = statusesResp.data.data.map(status => ({
label: status.name,
value: status.id.toString(),
}))
priorities.value = prioritiesResp.data.data.map(priority => ({
label: priority.name,
value: priority.id.toString(),
}))
}
const fields = ref([
{
model: 'conversations',
label: 'Status',
value: 'status_id',
type: 'select',
options: statuses,
},
{
model: 'conversations',
label: 'Priority',
value: 'priority_id',
type: 'select',
options: priorities,
},
{
model: 'conversations',
label: 'Reference number',
value: 'reference_number',
type: 'text',
}
])
const handleApply = (filters) => {
emit('updateFilters', filters)
open.value = false
}
const handleClear = () => {
localFilters.value = []
emit('updateFilters', [])
open.value = false
}
</script>
@@ -1,36 +0,0 @@
<template>
<div>
<Avatar class="size-20">
<AvatarImage
:src="conversation?.contact?.avatar_url"
v-if="conversation?.contact?.avatar_url"
/>
<AvatarFallback>
{{ conversation?.contact.first_name.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<h4 class="mt-3">
{{ conversation?.contact.first_name + ' ' + conversation?.contact.last_name }}
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact.email">
<Mail class="size-3 mt-1"></Mail>
{{ conversation.contact.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Phone class="size-3 mt-1"></Phone>
<span>
{{
conversation?.contact?.phone_number ? conversation.contact.phone_number : 'Not available'
}}
</span>
</p>
</div>
</template>
<script setup>
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone } from 'lucide-vue-next'
defineProps({
conversation: Object
})
</script>
@@ -1,7 +1,7 @@
<template>
<div v-if="!isHidden">
<div class="flex items-center space-x-4 p-4">
<SidebarTrigger class="cursor-pointer w-5 h-5" />
<SidebarTrigger class="cursor-pointer w-4 h-4" />
<span class="text-2xl font-semibold">
{{ title }}
</span>
+9 -43
View File
@@ -40,26 +40,20 @@ import {
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { useConversationStore } from '@/stores/conversation'
import ConversationSideBar from '@/components/conversation/sidebar/ConversationSideBar.vue'
const route = useRoute()
defineProps({
isLoading: Boolean,
open: Boolean,
activeItem: { type: Object, default: () => {} },
activeGroup: { type: Object, default: () => {} },
userTeams: { type: Array, default: () => [] },
userViews: { type: Array, default: () => [] }
})
const conversationStore = useConversationStore()
const userStore = useUserStore()
const emit = defineEmits(['createView', 'editView', 'deleteView', 'update:open'])
const emit = defineEmits(['createView', 'editView', 'deleteView'])
const openCreateViewDialog = () => {
emit('createView')
@@ -153,7 +147,6 @@ const adminNavItems = [
},
{
title: 'Conversations',
description: 'Manage tags, macros, and statuses.',
children: [
{
title: 'Tags',
@@ -174,7 +167,6 @@ const adminNavItems = [
},
{
title: 'Inboxes',
description: 'Manage inboxes.',
children: [
{
title: 'Inboxes',
@@ -185,7 +177,6 @@ const adminNavItems = [
},
{
title: 'Teammates',
description: 'Manage users, teams, and roles.',
children: [
{
title: 'Users',
@@ -206,7 +197,6 @@ const adminNavItems = [
},
{
title: 'Automations',
description: 'Manage automation rules.',
children: [
{
title: 'Automations',
@@ -217,7 +207,6 @@ const adminNavItems = [
},
{
title: 'Notifications',
description: 'Manage email notifications.',
children: [
{
title: 'Email',
@@ -228,7 +217,6 @@ const adminNavItems = [
},
{
title: 'Templates',
description: 'Manage email templates.',
children: [
{
title: 'Templates',
@@ -239,7 +227,6 @@ const adminNavItems = [
},
{
title: 'Security',
description: 'Configure SSO and security.',
children: [
{
title: 'OpenID Connect SSO',
@@ -263,23 +250,21 @@ const isActiveParent = (parentHref) => {
}
const isInboxRoute = (path) => {
return path.startsWith('/inboxes') || path.startsWith('/teams') || path.startsWith('/views')
return path.startsWith('/inboxes')
}
const hasConversationOpen = computed(() => {
return conversationStore.current || conversationStore.conversation.loading
})
const sidebarOpen = useStorage('sidebarOpen', true)
</script>
<template>
<div class="flex flex-row justify-between h-full">
<div class="flex-1">
<SidebarProvider
:open="open"
@update:open="($event) => emit('update:open', $event)"
style="--sidebar-width: 16rem"
:default-open="sidebarOpen"
v-on:update:open="sidebarOpen = $event"
>
<!-- Flex Container that holds all the sidebar components -->
<Sidebar
collapsible="icon"
class="overflow-hidden [&>[data-sidebar=sidebar]]:flex-row !border-r-0"
@@ -591,7 +576,7 @@ const hasConversationOpen = computed(() => {
<SidebarMenuSubItem>
<SidebarMenuSubButton
size="sm"
:isActive="isActiveParent(`/teams/${team.id}`)"
:isActive="isActiveParent(`/inboxes/teams/${team.id}`)"
asChild
>
<router-link
@@ -637,7 +622,7 @@ const hasConversationOpen = computed(() => {
<SidebarMenuSubItem>
<SidebarMenuSubButton
size="sm"
:isActive="isActiveParent(`/views/${view.id}`)"
:isActive="isActiveParent(`/inboxes/views/${view.id}`)"
asChild
>
<router-link
@@ -680,24 +665,5 @@ const hasConversationOpen = computed(() => {
</SidebarInset>
</SidebarProvider>
</div>
<!-- Right Sidebar with conversation details -->
<div v-if="hasConversationOpen">
<SidebarProvider :open="true" style="--sidebar-width: 20rem">
<Sidebar collapsible="none" side="right">
<SidebarSeparator />
<SidebarContent>
<SidebarGroup style="padding: 0">
<SidebarMenu>
<SidebarMenuItem>
<ConversationSideBar />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarRail />
</Sidebar>
</SidebarProvider>
</div>
</div>
</template>
-5
View File
@@ -1,11 +1,8 @@
// composables/useSla.js
import { ref, onMounted, onUnmounted } from 'vue'
import { calculateSla } from '@/utils/sla'
export function useSla (dueAt, actualAt) {
const sla = ref(null)
function updateSla () {
if (!dueAt.value) {
sla.value = null
@@ -13,7 +10,6 @@ export function useSla (dueAt, actualAt) {
}
sla.value = calculateSla(dueAt.value, actualAt.value)
}
onMounted(() => {
updateSla()
// Update the SLA every 30 seconds.
@@ -22,6 +18,5 @@ export function useSla (dueAt, actualAt) {
clearInterval(intervalId)
})
})
return { sla, updateSla }
}
@@ -2,7 +2,6 @@ import { ref, onUnmounted, nextTick } from 'vue'
export function useTemporaryClass(containerID, className, timeMs = 300) {
const container = ref(null)
const applyClass = async () => {
await nextTick()
container.value = document.getElementById(containerID)
@@ -13,16 +12,13 @@ export function useTemporaryClass(containerID, className, timeMs = 300) {
}, timeMs)
}
}
applyClass()
onUnmounted(() => {
if (container.value) {
container.value.classList.remove(className)
container.value = null
}
})
return {
container
}
+1
View File
@@ -4,4 +4,5 @@ export const EMITTER_EVENTS = {
SHOW_SOONER: 'show-sooner',
NEW_MESSAGE: 'new-message',
SET_NESTED_COMMAND: 'set-nested-command',
CONVERSATION_SIDEBAR_TOGGLE: 'conversation-sidebar-toggle'
}
+1 -2
View File
@@ -1,6 +1,5 @@
export const WS_EVENT = {
NEW_MESSAGE: 'new_message',
MESSAGE_PROP_UPDATE: 'message_prop_update',
CONVERSATION_PROP_UPDATE: 'conversation_prop_update',
}
}
@@ -141,7 +141,7 @@ import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import { WEEKDAYS } from '@/constants/date'
import { Calendar as CalendarIcon } from 'lucide-vue-next'
import SimpleTable from '@/components/common/SimpleTable.vue'
import SimpleTable from '@/components/table/SimpleTable.vue'
import {
Dialog,
DialogContent,
@@ -129,7 +129,7 @@ import { Spinner } from '@/components/ui/spinner'
import { Input } from '@/components/ui/input'
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import ActionBuilder from '@/components/common/ActionBuilder.vue'
import ActionBuilder from '@/features/admin/macros/ActionBuilder.vue'
import { useConversationFilters } from '@/composables/useConversationFilters'
import { useUsersStore } from '@/stores/users'
import { useTeamStore } from '@/stores/team'
@@ -62,9 +62,9 @@
<FormField v-slot="{ componentField }" name="redirect_uri" v-if="!isNewForm">
<FormItem v-auto-animate>
<FormLabel>Redirect URI</FormLabel>
<FormLabel>Callback URL</FormLabel>
<FormControl>
<Input type="text" placeholder="Redirect URI" v-bind="componentField" readonly />
<Input type="text" placeholder="Redirect URL" v-bind="componentField" readonly />
</FormControl>
<FormDescription>Set this URI for callback.</FormDescription>
<FormMessage />
@@ -135,7 +135,7 @@ const props = defineProps({
isLoading: {
type: Boolean,
required: false
},
}
})
const form = useForm({
@@ -1,5 +1,5 @@
import { h } from 'vue'
import TeamDataTableDropdown from '@/components/admin/team/teams/TeamDataTableDropdown.vue'
import TeamDataTableDropdown from '@/features/admin/teams/TeamDataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
@@ -74,7 +74,7 @@ import {
FormDescription
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import CodeEditor from '@/components/common/CodeEditor.vue'
import CodeEditor from '@/components/editor/CodeEditor.vue'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
const props = defineProps({
@@ -1,5 +1,5 @@
import { h } from 'vue'
import UserDataTableDropDown from '@/components/admin/team/users/dataTableDropdown.vue'
import UserDataTableDropDown from '@/features/admin/users/dataTableDropdown.vue'
import { format } from 'date-fns'
export const columns = [
@@ -0,0 +1,77 @@
<template>
<div class="flex flex-col w-full">
<div>
<!-- Header -->
<div class="p-2 border-b flex items-center justify-between">
<div class="flex items-center space-x-3 text-sm">
<div class="font-medium">
{{ conversationStore.current?.subject }}
</div>
</div>
<div class="flex items-center space-x-2">
<div>
<DropdownMenu>
<DropdownMenuTrigger>
<div
class="flex items-center space-x-1 cursor-pointer bg-primary px-2 py-1 rounded-md text-sm"
>
<span
class="text-secondary font-medium inline-block"
v-if="conversationStore.current?.status"
>
{{ conversationStore.current?.status }}
</span>
<span v-else class="text-secondary font-medium inline-block"> Loading... </span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="status in conversationStore.statusOptions"
:key="status.value"
@click="handleUpdateStatus(status.label)"
>
{{ status.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</div>
<!-- Messages & reply box -->
<div>
<div class="flex flex-col h-[calc(100vh-theme(spacing.10))]">
<MessageList class="flex-1 overflow-y-auto" />
<div class="sticky bottom-0 bg-white">
<ReplyBox class="h-max" />
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useConversationStore } from '@/stores/conversation'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import MessageList from '@/features/conversation/message/MessageList.vue'
import ReplyBox from './ReplyBox.vue'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
import { CONVERSATION_DEFAULT_STATUSES } from '@/constants/conversation'
import { useEmitter } from '@/composables/useEmitter'
const conversationStore = useConversationStore()
const emitter = useEmitter()
const handleUpdateStatus = (status) => {
if (status === CONVERSATION_DEFAULT_STATUSES.SNOOZED) {
emitter.emit(EMITTER_EVENTS.SET_NESTED_COMMAND, 'snooze')
return
}
conversationStore.updateStatus(status)
}
</script>
@@ -0,0 +1,5 @@
<template>
<div class="h-screen w-full flex items-center justify-center">
<p>Select a conversation from the left panel.</p>
</div>
</template>
@@ -263,9 +263,9 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useEmitter } from '@/composables/useEmitter'
import AttachmentsPreview from '@/components/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '../macro/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/components/conversation/ReplyBoxMenuBar.vue'
import AttachmentsPreview from '@/features/conversation/message/attachment/AttachmentsPreview.vue'
import MacroActionsPreview from '@/features/conversation/MacroActionsPreview.vue'
import ReplyBoxBottomMenuBar from '@/features/conversation/ReplyBoxMenuBar.vue'
const conversationStore = useConversationStore()
const emitter = useEmitter()
@@ -9,7 +9,7 @@
</header>
<!-- Filters -->
<div class="bg-white px-4 py-3 flex justify-between items-center">
<div class="bg-white px-2 py-3 flex justify-between items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" class="w-30">
@@ -144,10 +144,10 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { SidebarTrigger } from '@/components/ui/sidebar'
import EmptyList from '@/components/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/components/conversation/list/ConversationListItem.vue'
import EmptyList from '@/features/conversation/list/ConversationEmptyList.vue'
import ConversationListItem from '@/features/conversation/list/ConversationListItem.vue'
import { useRoute } from 'vue-router'
import ConversationListItemSkeleton from '@/components/conversation/list/ConversationListItemSkeleton.vue'
import ConversationListItemSkeleton from '@/features/conversation/list/ConversationListItemSkeleton.vue'
const conversationStore = useConversationStore()
const route = useRoute()
@@ -65,7 +65,7 @@ import { useRouter, useRoute } from 'vue-router'
import { formatTime } from '@/utils/datetime'
import { Mail, CheckCheck } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
import SlaDisplay from '@/features/sla/SlaDisplay.vue'
const router = useRouter()
const route = useRoute()
@@ -82,7 +82,6 @@ const navigateToConversation = (uuid) => {
: route.name.includes('view')
? 'view-inbox-conversation'
: 'inbox-conversation'
router.push({
name: baseRoute,
params: {
@@ -74,7 +74,7 @@ import { revertCIDToImageSrc } from '@/utils/strings'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Spinner } from '@/components/ui/spinner'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import MessageAttachmentPreview from '@/components/attachment/MessageAttachmentPreview.vue'
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
import api from '@/api'
const props = defineProps({
@@ -73,7 +73,7 @@ import { useConversationStore } from '@/stores/conversation'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Letter } from 'vue-letter'
import MessageAttachmentPreview from '@/components/attachment/MessageAttachmentPreview.vue'
import MessageAttachmentPreview from '@/features/conversation/message/attachment/MessageAttachmentPreview.vue'
const props = defineProps({
message: Object
@@ -14,8 +14,8 @@
</template>
<script setup>
import ImageAttachmentPreview from '@/components/attachment/ImageAttachmentPreview.vue'
import FileAttachmentPreview from '@/components/attachment/FileAttachmentPreview.vue'
import ImageAttachmentPreview from '@/features/conversation/message/attachment/ImageAttachmentPreview.vue'
import FileAttachmentPreview from '@/features/conversation/message/attachment/FileAttachmentPreview.vue'
defineProps({
attachments: {
@@ -53,7 +53,7 @@
<script setup>
import { format } from 'date-fns'
import SlaDisplay from '@/components/sla/SlaDisplay.vue'
import SlaDisplay from '@/features/sla/SlaDisplay.vue'
defineProps({
conversation: Object
})
@@ -1,11 +1,10 @@
<template>
<div v-if="conversationStore.current" class="h-screen overflow-y-auto">
<div>
<ConversationSideBarContact :conversation="conversationStore.current" class="p-4 border-b" />
<Accordion
type="multiple"
collapsible
:default-value="['Actions', 'Information', 'Previous conversations']"
class="border-t"
>
<AccordionItem value="Actions">
<AccordionTrigger class="bg-muted px-4 py-3 text-sm font-medium">
@@ -191,12 +190,13 @@ import {
AccordionTrigger
} from '@/components/ui/accordion'
import ConversationInfo from './ConversationInfo.vue'
import ConversationSideBarContact from '@/components/conversation/sidebar/ConversationSideBarContact.vue'
import ConversationSideBarContact from '@/features/conversation/sidebar/ConversationSideBarContact.vue'
import ComboBox from '@/components/ui/combobox/ComboBox.vue'
import { SelectTag } from '@/components/ui/select'
import { useToast } from '@/components/ui/toast/use-toast'
import { handleHTTPError } from '@/utils/http'
import api from '@/api'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { CircleAlert, SignalLow, SignalMedium, SignalHigh, Users } from 'lucide-vue-next'
const { toast } = useToast()
@@ -219,9 +219,9 @@ watch(
}
)
const assignedUserID = computed(() => String(conversationStore.current.assigned_user_id))
const assignedTeamID = computed(() => String(conversationStore.current.assigned_team_id))
const priorityID = computed(() => String(conversationStore.current.priority_id))
const assignedUserID = computed(() => String(conversationStore.current?.assigned_user_id))
const assignedTeamID = computed(() => String(conversationStore.current?.assigned_team_id))
const priorityID = computed(() => String(conversationStore.current?.priority_id))
const priorityOptions = computed(() => conversationStore.priorityOptions)
const fetchTags = async () => {
@@ -285,7 +285,6 @@ const selectPriority = (priority) => {
}
const getPriorityIcon = (value) => {
console.log(value)
switch (value) {
case '1':
return SignalLow
@@ -0,0 +1,51 @@
<template>
<div>
<div class="flex justify-between items-start">
<Avatar class="size-20">
<AvatarImage
:src="conversation?.contact?.avatar_url"
v-if="conversation?.contact?.avatar_url"
/>
<AvatarFallback>
{{ conversation?.contact?.first_name?.toUpperCase().substring(0, 2) }}
</AvatarFallback>
</Avatar>
<div class="mt-2">
<PanelLeft
class="cursor-pointer"
@click="emitter.emit(EMITTER_EVENTS.CONVERSATION_SIDEBAR_TOGGLE)"
size="16"
/>
</div>
</div>
<div>
<h4 class="mt-3">
{{ conversation?.contact?.first_name + ' ' + conversation?.contact?.last_name }}
</h4>
<p class="text-sm text-muted-foreground flex gap-2 mt-1" v-if="conversation?.contact?.email">
<Mail class="size-3 mt-1" />
{{ conversation.contact.email }}
</p>
<p class="text-sm text-muted-foreground flex gap-2 mt-1">
<Phone class="size-3 mt-1" />
<span>
{{ conversation?.contact?.phone_number || 'Not available' }}
</span>
</p>
</div>
</div>
</template>
<script setup>
import { PanelLeft } from 'lucide-vue-next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Mail, Phone } from 'lucide-vue-next'
import { useEmitter } from '@/composables/useEmitter'
import { EMITTER_EVENTS } from '@/constants/emitterEvents.js'
const emitter = useEmitter()
defineProps({
conversation: Object
})
</script>

Some files were not shown because too many files have changed in this diff Show More