feat: Update conversation side panel layout and styling (#12983)

This commit is contained in:
Sivin Varghese
2025-12-04 13:55:07 +05:30
committed by GitHub
parent 832d61c004
commit 604d4a9c30
68 changed files with 2336 additions and 2553 deletions
@@ -48,10 +48,26 @@ onMounted(() => {
</script>
<template>
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<div class="flex flex-col gap-1.5 border-b border-n-strong group/note">
<div class="flex items-center justify-between w-full gap-1.5 min-w-0 h-9">
<div class="min-w-0 truncate text-n-slate-11 text-xs font-440">
{{ dynamicTime(note.createdAt) }}
</div>
<div class="flex items-center gap-1">
<Button
v-if="allowDelete"
ghost
ruby
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
<Avatar
v-tooltip.left="{
content: writtenBy,
delay: { show: 500, hide: 0 },
}"
:name="note?.user?.name || 'Bot'"
:src="
note?.user?.name
@@ -61,30 +77,12 @@ onMounted(() => {
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-12">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
ref="noteContentRef"
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
class="mb-0 font-420 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
:class="{
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
}"
@@ -101,6 +101,7 @@ const onPickInbox = async inbox => {
:label="label"
:icon="icon"
:size="size"
class="!px-2"
@click="onClick"
/>
@@ -44,7 +44,7 @@ const onSelectConversation = checked => {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-all duration-200"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-all duration-150"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
@@ -62,7 +62,7 @@ const selectedModel = computed({
<template>
<div
class="relative cursor-pointer group transition-all duration-200 grid gap-4 items-center px-2 h-12"
class="relative cursor-pointer group transition-all duration-150 grid gap-4 items-center px-2 h-12"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
() => uiSettings.value.is_contact_sidebar_open || false
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
() => uiSettings.value.is_copilot_panel_open || false
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
is_copilot_panel_open: !isCopilotPanelOpen.value,
});
};
@@ -96,7 +96,7 @@ defineExpose({
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
/>
</div>
<template v-else />
@@ -1,5 +1,5 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
@@ -19,28 +19,35 @@ const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const isAttributePresent = computed(() => {
return (
props.attribute.value !== undefined &&
props.attribute.value !== null &&
props.attribute.value !== ''
);
});
const handleChange = value => {
emit('update', value);
};
const handleDelete = () => {
attributeValue.value = false;
emit('delete');
};
</script>
<template>
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full gap-2 justify-end">
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView"
variant="faded"
color="ruby"
v-if="isEditingView && isAttributePresent"
ghost
ruby
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="handleDelete"
/>
</div>
</template>
@@ -75,20 +75,15 @@ const handleInputUpdate = async () => {
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView,
'text-n-slate-12 truncate': isEditingView && attribute.value,
'text-n-slate-10 truncate': isEditingView && !attribute.value,
}"
@click="toggleEditValue(!isEditingView)"
>
@@ -100,19 +95,20 @@ const handleInputUpdate = async () => {
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
ghost
slate
icon="i-lucide-pen-line"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
v-if="attribute.value"
ghost
ruby
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
</div>
@@ -125,7 +121,7 @@ const handleInputUpdate = async () => {
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
class="w-full [&>p]:absolute [&>p]:mt-1 [&>p]:top-6 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
@@ -133,14 +129,15 @@ const handleInputUpdate = async () => {
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
custom-input-class="h-7 py-1 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
type="button"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none !h-7"
@click="handleInputUpdate"
/>
</div>
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
attribute: {
@@ -40,61 +41,55 @@ const handleAttributeAction = async action => {
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center"
class="relative flex items-center min-w-0 flex-1 justify-end gap-2 cursor-pointer"
@click="toggleAttributeListDropdown()"
>
<span
class="min-w-0 text-sm"
v-tooltip.top="{
content: attribute.value,
delay: { show: 1000, hide: 0 },
}"
class="min-w-0 text-sm truncate font-420"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
'text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate flex-1': isEditingView,
'text-n-slate-12': isEditingView && attribute.value,
'text-n-slate-10': isEditingView && !attribute.value,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<Icon
:icon="
showAttributeListDropdown
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
class="text-n-slate-11 flex-shrink-0 size-4"
/>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
class="w-48 mt-2 top-full ltr:right-0 rtl:left-0"
@action="handleAttributeAction($event)"
/>
</div>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
<Button
v-if="isEditingView && attribute.value"
ghost
ruby
icon="i-lucide-trash"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
</div>
</template>
@@ -114,24 +114,24 @@ const handleInputUpdate = async () => {
</script>
<template>
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<span
v-if="!isEditingValue"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
v-tooltip.top="{
content: !isAttributeTypeLink ? attribute.value : '',
delay: { show: 1000, hide: 0 },
}"
@click="toggleEditValue(!isEditingView)"
class="min-w-0 text-sm cursor-pointer font-420"
:class="{
'text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate':
isEditingView && !isAttributeTypeLink && attribute.value,
'text-n-slate-10 truncate': isEditingView && !attribute.value,
'truncate hover:text-n-brand text-n-blue-10':
isEditingView && isAttributeTypeLink && attribute.value,
}"
@click="toggleEditValue(true)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
@@ -156,19 +156,20 @@ const handleInputUpdate = async () => {
class="flex items-center gap-1"
>
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
ghost
slate
icon="i-lucide-pen-line"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="toggleEditValue(true)"
/>
<Button
variant="faded"
color="ruby"
v-if="attribute.value"
ghost
ruby
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
</div>
@@ -182,18 +183,19 @@ const handleInputUpdate = async () => {
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
class="w-full [&>p]:absolute [&>p]:mt-1 [&>p]:top-6 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
custom-input-class="h-7 py-1 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
type="button"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none !h-7"
@click="handleInputUpdate"
/>
</div>
@@ -20,11 +20,11 @@ const handleButtonClick = button => {
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
class="flex flex-col items-start gap-3 justify-between ltr:pl-4 ltr:pr-2 rtl:pl-2 rtl:pr-4 my-2 py-2"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">{{ title }}</span>
<div class="flex items-center">
<div class="flex items-center justify-between gap-2 flex-1 w-full">
<span class="font-medium text-base text-n-slate-12">{{ title }}</span>
<div class="flex items-center h-6">
<Button
v-for="button in buttons"
:key="button.key"
@@ -43,5 +43,6 @@ const handleButtonClick = button => {
/>
</div>
</div>
<slot />
</div>
</template>
@@ -121,8 +121,7 @@ const borderRadiusClass = computed(() => {
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
return 'rounded-lg'; // 16px
});
const avatarStyles = computed(() => ({
@@ -280,7 +279,8 @@ watch(
:handle-image-upload="handleImageUpload"
>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
@click="handleUploadAvatar"
>
<Icon
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
usersList: {
type: Array,
default: () => [],
},
size: {
type: Number,
default: 24,
},
showMoreCount: {
type: Boolean,
default: false,
},
moreCountText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', 'tight'].includes(value);
},
},
});
const gapClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:[&:not(:first-child)]:-ml-2 rtl:[&:not(:first-child)]:-mr-2';
}
return 'ltr:[&:not(:first-child)]:-ml-1 rtl:[&:not(:first-child)]:-mr-1';
});
const morePlaceholderClass = computed(() => {
if (props.gap === 'tight') {
return 'ltr:-ml-2 rtl:-mr-2';
}
return 'ltr:-ml-1 rtl:-mr-1';
});
</script>
<template>
<div class="flex">
<Avatar
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:name="user.name"
:size="size"
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background [&>span]:shadow"
:class="gapClass"
rounded-full
/>
<span
v-if="showMoreCount"
class="text-n-slate-11 bg-n-button-color outline outline-1 outline-n-weak text-xs font-420 rounded-full px-2 inline-flex items-center shadow relative"
:class="morePlaceholderClass"
>
{{ moreCountText }}
</span>
</div>
</template>
@@ -255,7 +255,7 @@ const animationClasses = computed(() => {
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
<slot v-if="label || $slots.default" name="default">
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
<span v-if="label" class="min-w-0 font-420 truncate">{{ label }}</span>
</slot>
</button>
</template>
@@ -11,7 +11,13 @@ const props = defineProps({
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
return value.every(
item =>
item.action &&
item.value !== undefined &&
item.value !== null &&
item.label
);
},
},
menuSections: {
@@ -22,6 +28,10 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -48,7 +58,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action', 'search', 'empty']);
const { t } = useI18n();
@@ -100,9 +110,13 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
};
const handleAction = item => {
@@ -194,22 +208,26 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
@@ -241,22 +259,26 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
</template>