chore: Replace thumbnail component with avatar
This commit is contained in:
+5
-3
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -33,10 +34,11 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Thumbnail
|
||||
:author="sender || { name: senderName }"
|
||||
<Avatar
|
||||
:name="senderName"
|
||||
:src="senderThumbnailSrc"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ senderName }}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { debounce } from '@chatwoot/utils';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
@@ -27,11 +27,6 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const author = computed(() => ({
|
||||
name: props.name,
|
||||
thumbnail: props.thumbnail,
|
||||
}));
|
||||
|
||||
const updateContact = async updatedData => {
|
||||
await store.dispatch('contacts/update', updatedData);
|
||||
await store.dispatch('contacts/fetchContactableInbox', props.id);
|
||||
@@ -58,7 +53,7 @@ const onClickViewDetails = () => {
|
||||
<CardLayout :key="id" layout="row">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<Thumbnail :author="author" :name="name" :src="thumbnail" :size="48" />
|
||||
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">{{
|
||||
|
||||
+15
-14
@@ -7,7 +7,7 @@ import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/Button/Button.vue';
|
||||
|
||||
@@ -84,23 +84,24 @@ const onDelete = noteId => {
|
||||
class="flex flex-col gap-2 px-6 py-2 border-b border-n-strong group/note"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5 py-2.5">
|
||||
<Thumbnail
|
||||
:author="note.user || { name: note.user.name }"
|
||||
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
|
||||
<Avatar
|
||||
:name="note.user.name"
|
||||
:src="note.user.thumbnail"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
|
||||
>
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ getWrittenBy(note) }}
|
||||
<div class="min-w-0 truncate">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
|
||||
>
|
||||
<span class="font-medium">{{ getWrittenBy(note) }}</span>
|
||||
{{ $t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium">{{
|
||||
dynamicTime(note.createdAt)
|
||||
}}</span>
|
||||
</span>
|
||||
{{ $t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="text-sm font-medium text-n-slate-11">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="faded"
|
||||
|
||||
@@ -13,7 +13,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -101,7 +101,7 @@ const categoryName = computed(() => {
|
||||
});
|
||||
|
||||
const authorName = computed(() => {
|
||||
return props.author?.name || props.author?.availableName || '-';
|
||||
return props.author?.name || props.author?.availableName || '';
|
||||
});
|
||||
|
||||
const authorThumbnailSrc = computed(() => {
|
||||
@@ -164,13 +164,14 @@ const handleClick = id => {
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:src="authorThumbnailSrc"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<span class="text-sm truncate text-n-slate-11">
|
||||
{{ authorName }}
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="block text-sm whitespace-nowrap text-n-slate-11">
|
||||
|
||||
+7
-10
@@ -6,7 +6,7 @@ import { OnClickOutside } from '@vueuse/components';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
|
||||
|
||||
@@ -50,7 +50,7 @@ const author = computed(() => {
|
||||
});
|
||||
|
||||
const authorName = computed(
|
||||
() => author.value?.name || author.value?.available_name || '-'
|
||||
() => author.value?.name || author.value?.available_name || ''
|
||||
);
|
||||
const authorThumbnailSrc = computed(() => author.value?.thumbnail);
|
||||
|
||||
@@ -186,17 +186,14 @@ onMounted(() => {
|
||||
text-variant="info"
|
||||
@click="openAgentsList = !openAgentsList"
|
||||
>
|
||||
<Thumbnail
|
||||
:author="author"
|
||||
<Avatar
|
||||
:name="authorName"
|
||||
:size="20"
|
||||
:src="authorThumbnailSrc"
|
||||
:size="20"
|
||||
rounded-full
|
||||
/>
|
||||
<span
|
||||
v-if="author"
|
||||
class="text-sm text-n-slate-12 hover:text-n-slate-11"
|
||||
>
|
||||
{{ author.available_name }}
|
||||
<span class="text-sm text-n-slate-12 hover:text-n-slate-11">
|
||||
{{ authorName || '-' }}
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
|
||||
+4
-5
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createPortal']);
|
||||
|
||||
@@ -148,14 +148,13 @@ const redirectToPortalHomePage = () => {
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ portal.name || '' }}
|
||||
</span>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="portal"
|
||||
:author="portal"
|
||||
:name="portal.name"
|
||||
:size="20"
|
||||
:src="getPortalThumbnailSrc(portal)"
|
||||
:show-author-name="false"
|
||||
:size="20"
|
||||
icon-name="i-lucide-building-2"
|
||||
rounded-full
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -27,34 +30,109 @@ const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => {
|
||||
if (!value) return true;
|
||||
return wootConstants.AVAILABILITY_STATUS_KEYS.includes(value);
|
||||
},
|
||||
validator: value =>
|
||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isImageValid = ref(true);
|
||||
|
||||
function invalidateCurrentImage() {
|
||||
isImageValid.value = false;
|
||||
}
|
||||
const AVATAR_COLORS = {
|
||||
dark: [
|
||||
['#4B143D', '#FF8DCC'],
|
||||
['#3F220D', '#FFA366'],
|
||||
['#2A2A2A', '#ADB1B8'],
|
||||
['#023B37', '#0BD8B6'],
|
||||
['#27264D', '#A19EFF'],
|
||||
['#1D2E62', '#9EB1FF'],
|
||||
],
|
||||
light: [
|
||||
['#FBDCEF', '#C2298A'],
|
||||
['#FFE0BB', '#99543A'],
|
||||
['#E8E8E8', '#60646C'],
|
||||
['#CCF3EA', '#008573'],
|
||||
['#EBEBFE', '#4747C2'],
|
||||
['#E1E9FF', '#3A5BC7'],
|
||||
],
|
||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||
};
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
online: 'bg-n-teal-10',
|
||||
busy: 'bg-n-amber-10',
|
||||
offline: 'bg-n-slate-10',
|
||||
};
|
||||
|
||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||
|
||||
const initials = computed(() => {
|
||||
const splitNames = props.name.split(' ');
|
||||
|
||||
if (splitNames.length > 1) {
|
||||
const firstName = splitNames[0];
|
||||
const lastName = splitNames[splitNames.length - 1];
|
||||
|
||||
return firstName[0] + lastName[0];
|
||||
}
|
||||
|
||||
const firstName = splitNames[0];
|
||||
return firstName[0];
|
||||
if (!props.name) return '';
|
||||
const words = removeEmoji(props.name).split(/\s+/);
|
||||
return words.length === 1
|
||||
? words[0].charAt(0).toUpperCase()
|
||||
: words
|
||||
.slice(0, 2)
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
});
|
||||
|
||||
const getColorsByNameLength = computed(() => {
|
||||
if (!props.name) return AVATAR_COLORS.default;
|
||||
|
||||
const index = props.name.length % AVATAR_COLORS.light.length;
|
||||
return {
|
||||
bg: AVATAR_COLORS.light[index][0],
|
||||
darkBg: AVATAR_COLORS.dark[index][0],
|
||||
text: AVATAR_COLORS.light[index][1],
|
||||
darkText: AVATAR_COLORS.dark[index][1],
|
||||
};
|
||||
});
|
||||
|
||||
const containerStyles = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}));
|
||||
|
||||
const avatarStyles = computed(() => ({
|
||||
...containerStyles.value,
|
||||
backgroundColor:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.bg
|
||||
: undefined,
|
||||
color:
|
||||
!showDefaultAvatar.value && (!props.src || !isImageValid.value)
|
||||
? getColorsByNameLength.value.text
|
||||
: undefined,
|
||||
'--dark-bg': getColorsByNameLength.value.darkBg,
|
||||
'--dark-text': getColorsByNameLength.value.darkText,
|
||||
}));
|
||||
|
||||
const badgeStyles = computed(() => ({
|
||||
top: `${props.size - 10}px`,
|
||||
left: `${props.size - 10}px`,
|
||||
}));
|
||||
|
||||
const iconStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.6}px`,
|
||||
}));
|
||||
|
||||
const initialsStyles = computed(() => ({
|
||||
fontSize: `${props.size / 1.8}px`,
|
||||
}));
|
||||
|
||||
const invalidateCurrentImage = () => {
|
||||
isImageValid.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
@@ -64,48 +142,63 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="relative inline"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
>
|
||||
<slot name="badge" :size>
|
||||
<span class="relative inline-flex" :style="containerStyles">
|
||||
<!-- Status Badge -->
|
||||
<slot name="badge" :size="size">
|
||||
<div
|
||||
v-if="status"
|
||||
class="rounded-full w-2.5 h-2.5 absolute z-20"
|
||||
:style="{
|
||||
top: `${size - 10}px`,
|
||||
left: `${size - 10}px`,
|
||||
}"
|
||||
:class="{
|
||||
'bg-n-teal-10': status === 'online',
|
||||
'bg-n-amber-10': status === 'busy',
|
||||
'bg-n-slate-10': status === 'offline',
|
||||
}"
|
||||
:style="badgeStyles"
|
||||
:class="STATUS_CLASSES[status]"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<!-- Avatar Container -->
|
||||
<span
|
||||
role="img"
|
||||
class="inline-flex relative items-center justify-center object-cover overflow-hidden font-medium bg-woot-50 text-woot-500 group/avatar"
|
||||
:class="{
|
||||
'rounded-full': roundedFull,
|
||||
'rounded-xl': !roundedFull,
|
||||
}"
|
||||
:style="{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}"
|
||||
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
|
||||
:class="[
|
||||
roundedFull ? 'rounded-full' : 'rounded-xl',
|
||||
{
|
||||
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
|
||||
!showDefaultAvatar && (!src || !isImageValid),
|
||||
'bg-n-slate-3 dark:bg-n-slate-4': showDefaultAvatar,
|
||||
},
|
||||
]"
|
||||
:style="avatarStyles"
|
||||
>
|
||||
<img
|
||||
<!-- Avatar Content -->
|
||||
<component
|
||||
:is="src && isImageValid ? 'img' : 'div'"
|
||||
v-if="src && isImageValid"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
@error="invalidateCurrentImage"
|
||||
/>
|
||||
<span v-else>
|
||||
{{ initials }}
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
<!-- Custom Icon -->
|
||||
<Icon v-if="iconName" :icon="iconName" :style="iconStyles" />
|
||||
|
||||
<!-- Initials -->
|
||||
<span
|
||||
v-else-if="!showDefaultAvatar"
|
||||
:style="initialsStyles"
|
||||
class="select-none"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
|
||||
<!-- Fallback Icon if no name or image -->
|
||||
<Icon
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
icon="i-lucide-user"
|
||||
:style="iconStyles"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Upload Overlay -->
|
||||
<div
|
||||
v-if="allowUpload"
|
||||
role="button"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
menuItems: {
|
||||
@@ -41,12 +41,12 @@ const handleAction = (action, value) => {
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item.action, item.value)"
|
||||
>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:author="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:size="thumbnailSize"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
const props = defineProps({
|
||||
author: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 16,
|
||||
},
|
||||
showAuthorName: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
iconName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasImageLoaded = ref(false);
|
||||
const imgError = ref(false);
|
||||
|
||||
const authorInitial = computed(() => {
|
||||
if (!props.name) return '';
|
||||
const name = removeEmoji(props.name);
|
||||
const words = name.split(/\s+/);
|
||||
|
||||
if (words.length === 1) {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map(word => word[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
});
|
||||
|
||||
const fontSize = computed(() => {
|
||||
return props.size / 2;
|
||||
});
|
||||
|
||||
const iconSize = computed(() => {
|
||||
return Math.round(props.size / 1.8);
|
||||
});
|
||||
|
||||
const shouldShowImage = computed(() => {
|
||||
return props.src && !imgError.value;
|
||||
});
|
||||
|
||||
const onImgError = () => {
|
||||
imgError.value = true;
|
||||
};
|
||||
|
||||
const onImgLoad = () => {
|
||||
hasImageLoaded.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center rounded-full bg-n-slate-3 dark:bg-n-slate-4"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
>
|
||||
<div v-if="author" class="flex items-center justify-center">
|
||||
<img
|
||||
v-if="shouldShowImage"
|
||||
:src="src"
|
||||
:alt="name"
|
||||
class="w-full h-full rounded-full"
|
||||
@load="onImgLoad"
|
||||
@error="onImgError"
|
||||
/>
|
||||
<template v-else>
|
||||
<span
|
||||
v-if="showAuthorName"
|
||||
class="flex items-center justify-center font-medium text-n-slate-11"
|
||||
:style="{ fontSize: `${fontSize}px` }"
|
||||
>
|
||||
{{ authorInitial }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center w-full h-full rounded-xl"
|
||||
>
|
||||
<span
|
||||
v-if="iconName"
|
||||
:class="`${iconName} text-n-brand/70`"
|
||||
:style="{ width: `${iconSize}px`, height: `${iconSize}px` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip.top-start="t('THUMBNAIL.AUTHOR.NOT_AVAILABLE')"
|
||||
class="flex items-center justify-center w-4 h-4 rounded-full bg-n-slate-3 dark:bg-n-slate-4"
|
||||
>
|
||||
<span class="i-lucide-user size-2.5 text-n-brand" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user