mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +00:00 
			
		
		
		
	feat: Updates on new components (#10444)
This commit is contained in:
		| @@ -82,9 +82,8 @@ const inboxIcon = computed(() => { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row"> | ||||
|     <template #header> | ||||
|       <div class="flex flex-col items-start gap-2"> | ||||
|   <CardLayout layout="row"> | ||||
|     <div class="flex flex-col items-start justify-between flex-1 min-w-0 gap-2"> | ||||
|       <div class="flex justify-between gap-3 w-fit"> | ||||
|         <span | ||||
|           class="text-base font-medium capitalize text-n-slate-12 line-clamp-1" | ||||
| @@ -117,8 +116,6 @@ const inboxIcon = computed(() => { | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|     </template> | ||||
|     <template #footer> | ||||
|     <div class="flex items-center justify-end w-20 gap-2"> | ||||
|       <Button | ||||
|         v-if="isLiveChatType" | ||||
| @@ -136,6 +133,5 @@ const inboxIcon = computed(() => { | ||||
|         @click="emit('delete')" | ||||
|       /> | ||||
|     </div> | ||||
|     </template> | ||||
|   </CardLayout> | ||||
| </template> | ||||
|   | ||||
| @@ -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 }} | ||||
|   | ||||
| @@ -27,7 +27,6 @@ defineProps({ | ||||
|           :message="campaign.message" | ||||
|           :is-enabled="campaign.enabled" | ||||
|           :status="campaign.campaign_status" | ||||
|           :trigger-rules="campaign.trigger_rules" | ||||
|           :sender="campaign.sender" | ||||
|           :inbox="campaign.inbox" | ||||
|           :scheduled-at="campaign.scheduled_at" | ||||
|   | ||||
| @@ -27,7 +27,6 @@ defineProps({ | ||||
|           :message="campaign.message" | ||||
|           :is-enabled="campaign.enabled" | ||||
|           :status="campaign.campaign_status" | ||||
|           :trigger-rules="campaign.trigger_rules" | ||||
|           :sender="campaign.sender" | ||||
|           :inbox="campaign.inbox" | ||||
|           :scheduled-at="campaign.scheduled_at" | ||||
|   | ||||
| @@ -27,7 +27,6 @@ const handleDelete = campaign => emit('delete', campaign); | ||||
|       :message="campaign.message" | ||||
|       :is-enabled="campaign.enabled" | ||||
|       :status="campaign.campaign_status" | ||||
|       :trigger-rules="campaign.trigger_rules" | ||||
|       :sender="campaign.sender" | ||||
|       :inbox="campaign.inbox" | ||||
|       :scheduled-at="campaign.scheduled_at" | ||||
|   | ||||
| @@ -63,7 +63,6 @@ defineExpose({ dialogRef }); | ||||
|     overflow-y-auto | ||||
|     @confirm="handleSubmit" | ||||
|   > | ||||
|     <template #form> | ||||
|     <LiveChatCampaignForm | ||||
|       ref="liveChatCampaignFormRef" | ||||
|       mode="edit" | ||||
| @@ -71,6 +70,5 @@ defineExpose({ dialogRef }); | ||||
|       :show-action-buttons="false" | ||||
|       @submit="handleSubmit" | ||||
|     /> | ||||
|     </template> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| <script setup> | ||||
| const props = defineProps({ | ||||
| defineProps({ | ||||
|   layout: { | ||||
|     type: String, | ||||
|     default: 'col', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['click']); | ||||
|  | ||||
| const handleClick = () => { | ||||
|   emit('click'); | ||||
| }; | ||||
| @@ -13,11 +15,18 @@ const handleClick = () => { | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2" | ||||
|     :class="props.layout === 'col' ? 'flex-col' : 'flex-row'" | ||||
|     class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2" | ||||
|   > | ||||
|     <div | ||||
|       class="flex w-full gap-3 px-6 py-5" | ||||
|       :class=" | ||||
|         layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center' | ||||
|       " | ||||
|       @click="handleClick" | ||||
|     > | ||||
|     <slot name="header" /> | ||||
|     <slot name="footer" /> | ||||
|       <slot /> | ||||
|     </div> | ||||
|  | ||||
|     <slot name="after" /> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -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(() => { | ||||
| @@ -124,8 +124,7 @@ const handleClick = id => { | ||||
|  | ||||
| <template> | ||||
|   <CardLayout> | ||||
|     <template #header> | ||||
|       <div class="flex justify-between gap-1"> | ||||
|     <div class="flex justify-between w-full gap-1"> | ||||
|       <span | ||||
|         class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1" | ||||
|         @click="handleClick(id)" | ||||
| @@ -159,18 +158,17 @@ const handleClick = id => { | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     </template> | ||||
|     <template #footer> | ||||
|       <div class="flex items-center justify-between gap-4"> | ||||
|     <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 text-n-slate-11"> | ||||
|               {{ authorName }} | ||||
|           <span class="text-sm truncate text-n-slate-11"> | ||||
|             {{ authorName || '-' }} | ||||
|           </span> | ||||
|         </div> | ||||
|         <span class="block text-sm whitespace-nowrap text-n-slate-11"> | ||||
| @@ -193,6 +191,5 @@ const handleClick = id => { | ||||
|         {{ lastUpdatedAt }} | ||||
|       </span> | ||||
|     </div> | ||||
|     </template> | ||||
|   </CardLayout> | ||||
| </template> | ||||
|   | ||||
| @@ -79,8 +79,7 @@ const handleAction = ({ action, value }) => { | ||||
|  | ||||
| <template> | ||||
|   <CardLayout> | ||||
|     <template #header> | ||||
|       <div class="flex gap-2"> | ||||
|     <div class="flex w-full gap-2"> | ||||
|       <div class="flex justify-between w-full gap-2"> | ||||
|         <div class="flex items-center justify-start w-full min-w-0 gap-2"> | ||||
|           <span | ||||
| @@ -120,8 +119,6 @@ const handleAction = ({ action, value }) => { | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     </template> | ||||
|     <template #footer> | ||||
|     <span | ||||
|       class="text-sm line-clamp-3" | ||||
|       :class=" | ||||
| @@ -132,6 +129,5 @@ const handleAction = ({ action, value }) => { | ||||
|     > | ||||
|       {{ description }} | ||||
|     </span> | ||||
|     </template> | ||||
|   </CardLayout> | ||||
| </template> | ||||
|   | ||||
| @@ -53,7 +53,6 @@ const handleAction = ({ action, value }) => { | ||||
|  | ||||
| <template> | ||||
|   <CardLayout> | ||||
|     <template #header> | ||||
|     <div class="flex justify-between gap-2"> | ||||
|       <div class="flex items-center justify-start gap-2"> | ||||
|         <span | ||||
| @@ -113,6 +112,5 @@ const handleAction = ({ action, value }) => { | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     </template> | ||||
|   </CardLayout> | ||||
| </template> | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -97,7 +97,6 @@ defineExpose({ dialogRef }); | ||||
|     :disable-confirm-button="isUpdatingCategory || isInvalidForm" | ||||
|     @confirm="onUpdateCategory" | ||||
|   > | ||||
|     <template #form> | ||||
|     <CategoryForm | ||||
|       ref="categoryFormRef" | ||||
|       mode="edit" | ||||
| @@ -107,6 +106,5 @@ defineExpose({ dialogRef }); | ||||
|       :active-locale-name="activeLocaleName" | ||||
|       :show-action-buttons="false" | ||||
|     /> | ||||
|     </template> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -85,7 +85,6 @@ defineExpose({ dialogRef }); | ||||
|     :description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')" | ||||
|     @confirm="onCreate" | ||||
|   > | ||||
|     <template #form> | ||||
|     <div class="flex flex-col gap-6"> | ||||
|       <ComboBox | ||||
|         v-model="selectedLocale" | ||||
| @@ -96,6 +95,5 @@ defineExpose({ dialogRef }); | ||||
|         class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5" | ||||
|       /> | ||||
|     </div> | ||||
|     </template> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -55,7 +55,6 @@ defineExpose({ dialogRef }); | ||||
|     " | ||||
|     @confirm="handleDialogConfirm" | ||||
|   > | ||||
|     <template #form> | ||||
|     <Input | ||||
|       v-model="formState.customDomain" | ||||
|       :label=" | ||||
| @@ -69,6 +68,5 @@ defineExpose({ dialogRef }); | ||||
|         ) | ||||
|       " | ||||
|     /> | ||||
|     </template> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -59,7 +59,7 @@ defineExpose({ dialogRef }); | ||||
|         }} | ||||
|       </p> | ||||
|     </template> | ||||
|     <template #form> | ||||
|  | ||||
|     <div class="flex flex-col gap-6"> | ||||
|       <span | ||||
|         class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong" | ||||
| @@ -74,6 +74,5 @@ defineExpose({ dialogRef }); | ||||
|         }} | ||||
|       </p> | ||||
|     </div> | ||||
|     </template> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -12,7 +12,7 @@ import { shouldBeUrl } from 'shared/helpers/Validators'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
| import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| import EditableAvatar from 'dashboard/components-next/avatar/EditableAvatar.vue'; | ||||
| import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; | ||||
| import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||
| import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue'; | ||||
|  | ||||
| @@ -187,10 +187,12 @@ const handleAvatarDelete = () => { | ||||
|       <label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"> | ||||
|         {{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }} | ||||
|       </label> | ||||
|       <EditableAvatar | ||||
|         label="Avatar" | ||||
|       <Avatar | ||||
|         :src="state.logoUrl" | ||||
|         :name="state.name" | ||||
|         :size="72" | ||||
|         allow-upload | ||||
|         icon-name="i-lucide-building-2" | ||||
|         @upload="handleAvatarUpload" | ||||
|         @delete="handleAvatarDelete" | ||||
|       /> | ||||
|   | ||||
| @@ -120,7 +120,6 @@ defineExpose({ dialogRef }); | ||||
|     :is-loading="isCreatingPortal" | ||||
|     @confirm="handleDialogConfirm" | ||||
|   > | ||||
|     <template #form> | ||||
|     <div class="flex flex-col gap-6"> | ||||
|       <Input | ||||
|         id="portal-name" | ||||
| @@ -143,6 +142,5 @@ defineExpose({ dialogRef }); | ||||
|         :message="slugError || buildPortalURL(state.slug)" | ||||
|       /> | ||||
|     </div> | ||||
|     </template> | ||||
|   </Dialog> | ||||
| </template> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -5,37 +5,30 @@ import Avatar from './Avatar.vue'; | ||||
| <template> | ||||
|   <Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }"> | ||||
|     <Variant title="Default"> | ||||
|       <div class="p-4 bg-white dark:bg-slate-900"> | ||||
|       <div class="flex p-4 space-x-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar | ||||
|           name="" | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" | ||||
|           class="bg-ruby-300 dark:bg-ruby-900" | ||||
|         /> | ||||
|         <Avatar name="Amaya" src="" /> | ||||
|         <Avatar name="" src="" /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Default with upload"> | ||||
|       <div class="p-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" | ||||
|           class="bg-ruby-300 dark:bg-ruby-900" | ||||
|           allow-upload | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Invalid or empty SRC"> | ||||
|       <div class="p-4 space-x-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar src="https://example.com/ruby.png" name="Ruby" allow-upload /> | ||||
|         <Avatar name="Bruce Wayne" allow-upload /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Rounded Full"> | ||||
|       <div class="p-4 space-x-4 bg-white dark:bg-slate-900"> | ||||
|     <Variant title="Different Shapes"> | ||||
|       <div class="gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" | ||||
|           name="" | ||||
|           allow-upload | ||||
|           rounded-full | ||||
|           :size="48" | ||||
|         /> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Amaya" | ||||
|           name="" | ||||
|           allow-upload | ||||
|           :size="48" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
| @@ -43,24 +36,78 @@ import Avatar from './Avatar.vue'; | ||||
|     <Variant title="Different Sizes"> | ||||
|       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix" | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix" | ||||
|           :size="48" | ||||
|           class="bg-green-300 dark:bg-green-900" | ||||
|           name="" | ||||
|           allow-upload | ||||
|         /> | ||||
|         <Avatar | ||||
|           :size="72" | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade" | ||||
|           class="bg-indigo-300 dark:bg-indigo-900" | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade" | ||||
|           name="" | ||||
|           allow-upload | ||||
|         /> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery" | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery" | ||||
|           name="" | ||||
|           :size="96" | ||||
|           class="bg-woot-300 dark:bg-woot-900" | ||||
|           allow-upload | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="With Status"> | ||||
|       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Felix" | ||||
|           status="online" | ||||
|           name="Felix Online" | ||||
|         /> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Jade" | ||||
|           status="busy" | ||||
|           name="Jade Busy" | ||||
|         /> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Emery" | ||||
|           status="offline" | ||||
|           name="Emery Offline" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="With Custom Icon"> | ||||
|       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar name="Custom Icon" icon-name="i-lucide-user" :size="48" /> | ||||
|         <Avatar | ||||
|           name="Custom Industry" | ||||
|           icon-name="i-lucide-building-2" | ||||
|           :size="48" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Upload States"> | ||||
|       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <!-- Empty state with upload --> | ||||
|         <Avatar name="Upload New" allow-upload :size="48" /> | ||||
|  | ||||
|         <!-- With image and upload --> | ||||
|         <Avatar | ||||
|           src="https://api.dicebear.com/9.x/thumbs/svg?seed=Upload" | ||||
|           name="Replace Image" | ||||
|           allow-upload | ||||
|           :size="48" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Name Initials"> | ||||
|       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <Avatar name="Catherine" :size="48" /> | ||||
|         <Avatar name="John Doe" :size="48" /> | ||||
|         <Avatar name="Rose Doe John" :size="48" /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|   </Story> | ||||
| </template> | ||||
|   | ||||
| @@ -39,11 +39,12 @@ const props = defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['upload']); | ||||
| const emit = defineEmits(['upload', 'delete']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const isImageValid = ref(true); | ||||
| const fileInput = ref(null); | ||||
|  | ||||
| const AVATAR_COLORS = { | ||||
|   dark: [ | ||||
| @@ -131,13 +132,39 @@ const iconStyles = computed(() => ({ | ||||
| })); | ||||
|  | ||||
| const initialsStyles = computed(() => ({ | ||||
|   fontSize: `${props.size / 1.8}px`, | ||||
|   fontSize: `${props.size / 2}px`, | ||||
| })); | ||||
|  | ||||
| const invalidateCurrentImage = () => { | ||||
|   isImageValid.value = false; | ||||
| }; | ||||
|  | ||||
| const handleUploadAvatar = () => { | ||||
|   fileInput.value.click(); | ||||
| }; | ||||
|  | ||||
| const handleImageUpload = event => { | ||||
|   const [file] = event.target.files; | ||||
|   if (file) { | ||||
|     emit('upload', { | ||||
|       file, | ||||
|       url: file ? URL.createObjectURL(file) : null, | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleDeleteAvatar = () => { | ||||
|   if (fileInput.value) { | ||||
|     fileInput.value.value = null; | ||||
|   } | ||||
|   emit('delete'); | ||||
| }; | ||||
|  | ||||
| const handleDismiss = event => { | ||||
|   event.stopPropagation(); | ||||
|   handleDeleteAvatar(); | ||||
| }; | ||||
|  | ||||
| watch( | ||||
|   () => props.src, | ||||
|   () => { | ||||
| @@ -147,7 +174,7 @@ watch( | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <span class="relative inline-flex" :style="containerStyles"> | ||||
|   <span class="relative inline-flex group/avatar" :style="containerStyles"> | ||||
|     <!-- Status Badge --> | ||||
|     <slot name="badge" :size="size"> | ||||
|       <div | ||||
| @@ -158,6 +185,15 @@ watch( | ||||
|       /> | ||||
|     </slot> | ||||
|  | ||||
|     <!-- Delete Avatar Button --> | ||||
|     <div | ||||
|       v-if="src && allowUpload" | ||||
|       class="absolute z-20 flex items-center justify-center invisible w-6 h-6 transition-all duration-300 ease-in-out opacity-0 cursor-pointer outline outline-1 outline-n-container -top-2 -right-2 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100" | ||||
|       @click="handleDismiss" | ||||
|     > | ||||
|       <Icon icon="i-lucide-x" class="text-n-slate-11 size-4" /> | ||||
|     </div> | ||||
|  | ||||
|     <!-- Avatar Container --> | ||||
|     <span | ||||
|       role="img" | ||||
| @@ -202,16 +238,23 @@ watch( | ||||
|         /> | ||||
|       </template> | ||||
|  | ||||
|       <!-- Upload Overlay --> | ||||
|       <!-- Upload Overlay and Input --> | ||||
|       <div | ||||
|         v-if="allowUpload" | ||||
|         role="button" | ||||
|         class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100" | ||||
|         @click="emit('upload')" | ||||
|         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" | ||||
|         @click="handleUploadAvatar" | ||||
|       > | ||||
|         <Icon | ||||
|           icon="i-lucide-upload" | ||||
|           class="text-white dark:text-white size-4" | ||||
|           class="text-white" | ||||
|           :style="{ width: `${size / 2}px`, height: `${size / 2}px` }" | ||||
|         /> | ||||
|         <input | ||||
|           ref="fileInput" | ||||
|           type="file" | ||||
|           accept="image/png, image/jpeg, image/jpg, image/gif, image/webp" | ||||
|           class="hidden" | ||||
|           @change="handleImageUpload" | ||||
|         /> | ||||
|       </div> | ||||
|     </span> | ||||
|   | ||||
| @@ -1,36 +0,0 @@ | ||||
| <script setup> | ||||
| import EditableAvatar from './EditableAvatar.vue'; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }"> | ||||
|     <Variant title="Default"> | ||||
|       <div class="p-4 bg-white dark:bg-slate-900"> | ||||
|         <EditableAvatar | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya" | ||||
|           class="bg-ruby-300 dark:bg-ruby-900" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="Different Sizes"> | ||||
|       <div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900"> | ||||
|         <EditableAvatar | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix" | ||||
|           :size="48" | ||||
|           class="bg-green-300 dark:bg-green-900" | ||||
|         /> | ||||
|         <EditableAvatar | ||||
|           :size="72" | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade" | ||||
|           class="bg-indigo-300 dark:bg-indigo-900" | ||||
|         /> | ||||
|         <EditableAvatar | ||||
|           src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery" | ||||
|           :size="96" | ||||
|           class="bg-woot-300 dark:bg-woot-900" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|   </Story> | ||||
| </template> | ||||
| @@ -1,105 +0,0 @@ | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue'; | ||||
|  | ||||
| import Icon from 'dashboard/components-next/icon/Icon.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   src: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   size: { | ||||
|     type: Number, | ||||
|     default: 72, | ||||
|   }, | ||||
|   name: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['upload', 'delete']); | ||||
|  | ||||
| const avatarSize = computed(() => `${props.size}px`); | ||||
| const iconSize = computed(() => `${props.size / 2}px`); | ||||
|  | ||||
| const fileInput = ref(null); | ||||
| const imgError = ref(false); | ||||
|  | ||||
| const shouldShowImage = computed(() => props.src && !imgError.value); | ||||
|  | ||||
| const handleUploadAvatar = () => { | ||||
|   fileInput.value.click(); | ||||
| }; | ||||
|  | ||||
| const handleImageUpload = event => { | ||||
|   const [file] = event.target.files; | ||||
|   if (file) { | ||||
|     emit('upload', { | ||||
|       file, | ||||
|       url: file ? URL.createObjectURL(file) : null, | ||||
|     }); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleDeleteAvatar = () => { | ||||
|   if (fileInput.value) { | ||||
|     fileInput.value.value = null; | ||||
|   } | ||||
|   emit('delete'); | ||||
| }; | ||||
|  | ||||
| const handleDismiss = event => { | ||||
|   event.stopPropagation(); | ||||
|   handleDeleteAvatar(); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="relative flex flex-col items-center gap-2 select-none rounded-xl outline outline-1 outline-n-container group/avatar" | ||||
|     :style="{ width: avatarSize, height: avatarSize }" | ||||
|   > | ||||
|     <img | ||||
|       v-if="shouldShowImage" | ||||
|       :src="src" | ||||
|       :alt="name || 'avatar'" | ||||
|       class="object-cover w-full h-full shadow-sm rounded-xl" | ||||
|       @error="imgError = true" | ||||
|     /> | ||||
|     <div | ||||
|       v-else | ||||
|       class="flex items-center justify-center w-full h-full rounded-xl bg-n-alpha-2" | ||||
|     > | ||||
|       <Icon | ||||
|         icon="i-lucide-building-2" | ||||
|         class="text-n-brand/50" | ||||
|         :style="{ width: `${iconSize}`, height: `${iconSize}` }" | ||||
|       /> | ||||
|     </div> | ||||
|     <div | ||||
|       v-if="src" | ||||
|       class="absolute z-20 outline outline-1 outline-n-container flex items-center cursor-pointer justify-center w-6 h-6 transition-all invisible opacity-0 duration-500 ease-in-out -top-2.5 -right-2.5 rounded-xl bg-n-solid-3 group-hover/avatar:visible group-hover/avatar:opacity-100" | ||||
|       @click="handleDismiss" | ||||
|     > | ||||
|       <Icon icon="i-lucide-x" class="text-n-slate-11 size-4" /> | ||||
|     </div> | ||||
|     <div | ||||
|       class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100" | ||||
|       @click="handleUploadAvatar" | ||||
|     > | ||||
|       <Icon | ||||
|         icon="i-lucide-upload" | ||||
|         class="text-white" | ||||
|         :style="{ width: `${iconSize}`, height: `${iconSize}` }" | ||||
|       /> | ||||
|       <input | ||||
|         ref="fileInput" | ||||
|         type="file" | ||||
|         accept="image/png, image/jpeg, image/jpg, image/gif, image/webp" | ||||
|         class="hidden" | ||||
|         @change="handleImageUpload" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -6,7 +6,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue'; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   label: { | ||||
|     type: String, | ||||
|     type: [String, Number], | ||||
|     default: '', | ||||
|   }, | ||||
|   variant: { | ||||
| @@ -47,9 +47,9 @@ const STYLE_CONFIG = { | ||||
|     blue: { | ||||
|       solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent', | ||||
|       faded: | ||||
|         'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent', | ||||
|         'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent', | ||||
|       outline: 'text-n-blue-text outline-n-blue-border', | ||||
|       link: 'text-n-brand hover:underline outline-transparent', | ||||
|       link: 'text-n-blue-text hover:underline outline-transparent', | ||||
|     }, | ||||
|     ruby: { | ||||
|       solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent', | ||||
| @@ -161,7 +161,7 @@ const linkButtonClasses = computed(() => { | ||||
|     <Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" /> | ||||
|  | ||||
|     <slot v-if="label || $slots.default" name="default"> | ||||
|       <span class="min-w-0 truncate">{{ label }}</span> | ||||
|       <span v-if="label" class="min-w-0 truncate">{{ label }}</span> | ||||
|     </slot> | ||||
|   </button> | ||||
| </template> | ||||
|   | ||||
| @@ -43,7 +43,7 @@ const props = defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue']); | ||||
| const emit = defineEmits(['update:modelValue', 'search']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| @@ -118,13 +118,13 @@ watch( | ||||
|  | ||||
|       <ComboBoxDropdown | ||||
|         ref="dropdownRef" | ||||
|         v-model:search-value="search" | ||||
|         :open="open" | ||||
|         :options="filteredOptions" | ||||
|         :search-value="search" | ||||
|         :search-placeholder="searchPlaceholder" | ||||
|         :empty-state="emptyState" | ||||
|         :selected-values="selectedValue" | ||||
|         @update:search-value="search = $event" | ||||
|         @search="emit('search', $event)" | ||||
|         @select="selectOption" | ||||
|       /> | ||||
|  | ||||
|   | ||||
| @@ -11,10 +11,6 @@ const props = defineProps({ | ||||
|     type: Array, | ||||
|     required: true, | ||||
|   }, | ||||
|   searchValue: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|   }, | ||||
|   searchPlaceholder: { | ||||
|     type: String, | ||||
|     default: '', | ||||
| @@ -33,10 +29,15 @@ const props = defineProps({ | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:searchValue', 'select']); | ||||
| const emit = defineEmits(['update:searchValue', 'select', 'search']); | ||||
|  | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const searchValue = defineModel('searchValue', { | ||||
|   type: String, | ||||
|   default: '', | ||||
| }); | ||||
|  | ||||
| const searchInput = ref(null); | ||||
|  | ||||
| const isSelected = option => { | ||||
| @@ -46,6 +47,11 @@ const isSelected = option => { | ||||
|   return option.value === props.selectedValues; | ||||
| }; | ||||
|  | ||||
| const onInputSearch = event => { | ||||
|   searchValue.value = event.target.value; | ||||
|   emit('search', event.target.value); | ||||
| }; | ||||
|  | ||||
| defineExpose({ | ||||
|   focus: () => searchInput.value?.focus(), | ||||
| }); | ||||
| @@ -64,7 +70,7 @@ defineExpose({ | ||||
|         type="search" | ||||
|         :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')" | ||||
|         class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50" | ||||
|         @input="emit('update:searchValue', $event.target.value)" | ||||
|         @input="onInputSearch" | ||||
|       /> | ||||
|     </div> | ||||
|     <ul | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import Input from 'dashboard/components-next/input/Input.vue'; | ||||
| const alertDialog = ref(null); | ||||
| const editDialog = ref(null); | ||||
| const confirmDialog = ref(null); | ||||
| const confirmDialogWithCustomFooter = ref(null); | ||||
|  | ||||
| const openAlertDialog = () => { | ||||
|   alertDialog.value.open(); | ||||
| @@ -17,6 +18,9 @@ const openEditDialog = () => { | ||||
| const openConfirmDialog = () => { | ||||
|   confirmDialog.value.open(); | ||||
| }; | ||||
| const openConfirmDialogWithCustomFooter = () => { | ||||
|   confirmDialogWithCustomFooter.value.open(); | ||||
| }; | ||||
|  | ||||
| // eslint-disable-next-line no-unused-vars | ||||
| const onConfirm = dialog => {}; | ||||
| @@ -44,7 +48,6 @@ const onConfirm = dialog => {}; | ||||
|         confirm-button-label="Save" | ||||
|         @confirm="onConfirm()" | ||||
|       > | ||||
|         <template #form> | ||||
|         <div class="flex flex-col gap-6"> | ||||
|           <Input | ||||
|             id="portal-name" | ||||
| @@ -61,7 +64,6 @@ const onConfirm = dialog => {}; | ||||
|             message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug" | ||||
|           /> | ||||
|         </div> | ||||
|         </template> | ||||
|       </Dialog> | ||||
|     </Variant> | ||||
|  | ||||
| @@ -77,5 +79,21 @@ const onConfirm = dialog => {}; | ||||
|         @confirm="onConfirm()" | ||||
|       /> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="With custom footer"> | ||||
|       <Button | ||||
|         label="Open Confirm Dialog with custom footer" | ||||
|         @click="openConfirmDialogWithCustomFooter" | ||||
|       /> | ||||
|       <Dialog | ||||
|         ref="confirmDialogWithCustomFooter" | ||||
|         title="Confirm Action" | ||||
|         description="Are you sure you want to perform this action?" | ||||
|       > | ||||
|         <template #footer> | ||||
|           <Button label="Custom Button" @click="onConfirm()" /> | ||||
|         </template> | ||||
|       </Dialog> | ||||
|     </Variant> | ||||
|   </Story> | ||||
| </template> | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| <script setup> | ||||
| import { ref } from 'vue'; | ||||
| import { ref, computed } from 'vue'; | ||||
| import { OnClickOutside } from '@vueuse/components'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
| import { useMapGetter } from 'dashboard/composables/store.js'; | ||||
|  | ||||
| import Button from 'dashboard/components-next/button/Button.vue'; | ||||
|  | ||||
| defineProps({ | ||||
| const props = defineProps({ | ||||
|   type: { | ||||
|     type: String, | ||||
|     default: 'edit', | ||||
| @@ -14,7 +14,7 @@ defineProps({ | ||||
|   }, | ||||
|   title: { | ||||
|     type: String, | ||||
|     required: true, | ||||
|     default: '', | ||||
|   }, | ||||
|   description: { | ||||
|     type: String, | ||||
| @@ -48,6 +48,11 @@ defineProps({ | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   width: { | ||||
|     type: String, | ||||
|     default: 'lg', | ||||
|     validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value), | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['confirm', 'close']); | ||||
| @@ -59,6 +64,19 @@ const isRTL = useMapGetter('accounts/isRTL'); | ||||
| const dialogRef = ref(null); | ||||
| const dialogContentRef = ref(null); | ||||
|  | ||||
| const maxWidthClass = computed(() => { | ||||
|   const classesMap = { | ||||
|     '3xl': 'max-w-3xl', | ||||
|     '2xl': 'max-w-2xl', | ||||
|     xl: 'max-w-xl', | ||||
|     lg: 'max-w-lg', | ||||
|     md: 'max-w-md', | ||||
|     sm: 'max-w-sm', | ||||
|   }; | ||||
|  | ||||
|   return classesMap[props.width] ?? 'max-w-md'; | ||||
| }); | ||||
|  | ||||
| const open = () => { | ||||
|   dialogRef.value?.showModal(); | ||||
| }; | ||||
| @@ -77,8 +95,11 @@ defineExpose({ open, close }); | ||||
|   <Teleport to="body"> | ||||
|     <dialog | ||||
|       ref="dialogRef" | ||||
|       class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl" | ||||
|       :class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'" | ||||
|       class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl" | ||||
|       :class="[ | ||||
|         maxWidthClass, | ||||
|         overflowYAuto ? 'overflow-y-auto' : 'overflow-visible', | ||||
|       ]" | ||||
|       :dir="isRTL ? 'rtl' : 'ltr'" | ||||
|       @close="close" | ||||
|     > | ||||
| @@ -88,7 +109,7 @@ defineExpose({ open, close }); | ||||
|           class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl" | ||||
|           @click.stop | ||||
|         > | ||||
|           <div class="flex flex-col gap-2"> | ||||
|           <div v-if="title || description" class="flex flex-col gap-2"> | ||||
|             <h3 class="text-base font-medium leading-6 text-n-slate-12"> | ||||
|               {{ title }} | ||||
|             </h3> | ||||
| @@ -98,9 +119,9 @@ defineExpose({ open, close }); | ||||
|               </p> | ||||
|             </slot> | ||||
|           </div> | ||||
|           <slot name="form"> | ||||
|             <!-- Form content will be injected here --> | ||||
|           </slot> | ||||
|           <slot /> | ||||
|           <!-- Dialog content will be injected here --> | ||||
|           <slot name="footer"> | ||||
|             <div class="flex items-center justify-between w-full gap-3"> | ||||
|               <Button | ||||
|                 v-if="showCancelButton" | ||||
| @@ -120,6 +141,7 @@ defineExpose({ open, close }); | ||||
|                 @click="confirm" | ||||
|               /> | ||||
|             </div> | ||||
|           </slot> | ||||
|         </div> | ||||
|       </OnClickOutside> | ||||
|     </dialog> | ||||
|   | ||||
| @@ -51,5 +51,19 @@ const handleAction = () => { | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|  | ||||
|     <Variant title="With search"> | ||||
|       <div class="p-4 bg-white h-72 dark:bg-slate-900"> | ||||
|         <DropdownMenu | ||||
|           :menu-items="[ | ||||
|             { label: 'Custom 1', action: 'custom1', icon: 'file-upload' }, | ||||
|             { label: 'Custom 2', action: 'custom2', icon: 'document' }, | ||||
|             { label: 'Danger', action: 'delete', icon: 'delete' }, | ||||
|           ]" | ||||
|           show-search | ||||
|           @action="handleAction" | ||||
|         /> | ||||
|       </div> | ||||
|     </Variant> | ||||
|   </Story> | ||||
| </template> | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| <script setup> | ||||
| import { defineProps, defineEmits } from 'vue'; | ||||
| import { defineProps, ref, defineEmits, computed, onMounted } from 'vue'; | ||||
| import { useI18n } from 'vue-i18n'; | ||||
|  | ||||
| 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({ | ||||
| const props = defineProps({ | ||||
|   menuItems: { | ||||
|     type: Array, | ||||
|     required: true, | ||||
| @@ -16,21 +17,61 @@ defineProps({ | ||||
|     type: Number, | ||||
|     default: 20, | ||||
|   }, | ||||
|   showSearch: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   searchPlaceholder: { | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['action']); | ||||
|  | ||||
| const handleAction = (action, value) => { | ||||
|   emit('action', { action, value }); | ||||
| const { t } = useI18n(); | ||||
|  | ||||
| const searchInput = ref(null); | ||||
| const searchQuery = ref(''); | ||||
|  | ||||
| const filteredMenuItems = computed(() => { | ||||
|   if (!searchQuery.value) return props.menuItems; | ||||
|  | ||||
|   return props.menuItems.filter(item => | ||||
|     item.label.toLowerCase().includes(searchQuery.value.toLowerCase()) | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| const handleAction = item => { | ||||
|   const { action, value, ...rest } = item; | ||||
|   emit('action', { action, value, ...rest }); | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (searchInput.value && props.showSearch) { | ||||
|     searchInput.value.focus(); | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg" | ||||
|   > | ||||
|     <div v-if="showSearch" class="relative"> | ||||
|       <span class="absolute i-lucide-search size-3.5 top-2 left-3" /> | ||||
|       <input | ||||
|         ref="searchInput" | ||||
|         v-model="searchQuery" | ||||
|         type="search" | ||||
|         :placeholder=" | ||||
|           searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER') | ||||
|         " | ||||
|         class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12" | ||||
|       /> | ||||
|     </div> | ||||
|     <button | ||||
|       v-for="item in menuItems" | ||||
|       v-for="item in filteredMenuItems" | ||||
|       :key="item.action" | ||||
|       class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" | ||||
|       :class="{ | ||||
| @@ -39,20 +80,29 @@ const handleAction = (action, value) => { | ||||
|         'text-n-slate-12': item.action !== 'delete', | ||||
|       }" | ||||
|       :disabled="item.disabled" | ||||
|       @click="handleAction(item.action, item.value)" | ||||
|       @click="handleAction(item)" | ||||
|       @keydown.enter="handleAction(item)" | ||||
|     > | ||||
|       <Thumbnail | ||||
|       <slot name="thumbnail" :item="item"> | ||||
|         <Avatar | ||||
|           v-if="item.thumbnail" | ||||
|         :author="item.thumbnail" | ||||
|           :name="item.thumbnail.name" | ||||
|         :size="thumbnailSize" | ||||
|           :src="item.thumbnail.src" | ||||
|           :size="thumbnailSize" | ||||
|           rounded-full | ||||
|         /> | ||||
|       </slot> | ||||
|       <Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" /> | ||||
|       <span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span> | ||||
|       <span v-if="item.label" class="min-w-0 text-sm truncate">{{ | ||||
|         item.label | ||||
|       }}</span> | ||||
|     </button> | ||||
|     <div | ||||
|       v-if="filteredMenuItems.length === 0" | ||||
|       class="text-sm text-n-slate-11 px-2 py-1.5" | ||||
|     > | ||||
|       {{ t('DROPDOWN_MENU.EMPTY_STATE') }} | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue'; | ||||
| import { computed, ref, onMounted, nextTick } from 'vue'; | ||||
| const props = defineProps({ | ||||
|   modelValue: { | ||||
|     type: [String, Number], | ||||
| @@ -42,9 +42,22 @@ const props = defineProps({ | ||||
|     type: String, | ||||
|     default: '', | ||||
|   }, | ||||
|   autofocus: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue', 'blur', 'input']); | ||||
| const emit = defineEmits([ | ||||
|   'update:modelValue', | ||||
|   'blur', | ||||
|   'input', | ||||
|   'focus', | ||||
|   'enter', | ||||
| ]); | ||||
|  | ||||
| const isFocused = ref(false); | ||||
| const inputRef = ref(null); | ||||
|  | ||||
| const messageClass = computed(() => { | ||||
|   switch (props.messageType) { | ||||
| @@ -62,7 +75,7 @@ const inputBorderClass = computed(() => { | ||||
|     case 'error': | ||||
|       return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8'; | ||||
|     default: | ||||
|       return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak'; | ||||
|       return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak focus:border-n-brand dark:focus:border-n-brand'; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| @@ -70,6 +83,28 @@ const handleInput = event => { | ||||
|   emit('update:modelValue', event.target.value); | ||||
|   emit('input', event); | ||||
| }; | ||||
|  | ||||
| const handleFocus = event => { | ||||
|   emit('focus', event); | ||||
|   isFocused.value = true; | ||||
| }; | ||||
|  | ||||
| const handleBlur = event => { | ||||
|   emit('blur', event); | ||||
|   isFocused.value = false; | ||||
| }; | ||||
|  | ||||
| const handleEnter = event => { | ||||
|   emit('enter', event); | ||||
| }; | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (props.autofocus) { | ||||
|     nextTick(() => { | ||||
|       inputRef.value?.focus(); | ||||
|     }); | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
| @@ -85,15 +120,25 @@ const handleInput = event => { | ||||
|     <slot name="prefix" /> | ||||
|     <input | ||||
|       :id="id" | ||||
|       ref="inputRef" | ||||
|       :value="modelValue" | ||||
|       :class="[customInputClass, inputBorderClass]" | ||||
|       :class="[ | ||||
|         customInputClass, | ||||
|         inputBorderClass, | ||||
|         { | ||||
|           error: messageType === 'error', | ||||
|           focus: isFocused, | ||||
|         }, | ||||
|       ]" | ||||
|       :type="type" | ||||
|       :placeholder="placeholder" | ||||
|       :disabled="disabled" | ||||
|       :min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined" | ||||
|       class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out" | ||||
|       class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out" | ||||
|       @input="handleInput" | ||||
|       @blur="emit('blur')" | ||||
|       @focus="handleFocus" | ||||
|       @blur="handleBlur" | ||||
|       @keyup.enter="handleEnter" | ||||
|     /> | ||||
|     <p | ||||
|       v-if="message" | ||||
|   | ||||
| @@ -46,6 +46,13 @@ const handleClickOutside = () => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const handleKeydown = event => { | ||||
|   if (event.key === ',') { | ||||
|     event.preventDefault(); | ||||
|     addTag(); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| watch( | ||||
|   () => props.modelValue, | ||||
|   newValue => { | ||||
| @@ -81,6 +88,7 @@ watch( | ||||
|         :placeholder="placeholder" | ||||
|         custom-input-class="flex-grow" | ||||
|         @enter-press="addTag" | ||||
|         @keydown="handleKeydown" | ||||
|       /> | ||||
|     </div> | ||||
|   </OnClickOutside> | ||||
|   | ||||
| @@ -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> | ||||
| @@ -8,6 +8,10 @@ | ||||
|     "EMPTY_STATE": "No results found.", | ||||
|     "SEARCH_PLACEHOLDER": "Search..." | ||||
|   }, | ||||
|   "DROPDOWN_MENU": { | ||||
|     "SEARCH_PLACEHOLDER": "Search...", | ||||
|     "EMPTY_STATE": "No results found." | ||||
|   }, | ||||
|   "DIALOG": { | ||||
|     "BUTTONS": { | ||||
|       "CANCEL": "Cancel", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese