Initial commit

This commit is contained in:
2025-08-04 16:33:07 +03:30
commit f798e8e35c
9595 changed files with 1208683 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useChat } from './useChat'
import { useChatStore } from '@/views/apps/chat/useChatStore'
const emit = defineEmits(['close'])
const store = useChatStore()
const { resolveAvatarBadgeVariant } = useChat()
</script>
<template>
<template v-if="store.activeChat">
<!-- Close Button -->
<div
class="pt-6 px-6"
:class="$vuetify.locale.isRtl ? 'text-left' : 'text-right'"
>
<IconBtn @click="$emit('close')">
<VIcon
icon="tabler-x"
class="text-medium-emphasis"
/>
</IconBtn>
</div>
<!-- User Avatar + Name + Role -->
<div class="text-center px-6">
<VBadge
location="bottom right"
offset-x="7"
offset-y="4"
bordered
:color="resolveAvatarBadgeVariant(store.activeChat.contact.status)"
class="chat-user-profile-badge mb-5"
>
<VAvatar
size="84"
:variant="!store.activeChat.contact.avatar ? 'tonal' : undefined"
:color="!store.activeChat.contact.avatar ? resolveAvatarBadgeVariant(store.activeChat.contact.status) : undefined"
>
<VImg
v-if="store.activeChat.contact.avatar"
:src="store.activeChat.contact.avatar"
/>
<span
v-else
class="text-3xl"
>{{ avatarText(store.activeChat.contact.fullName) }}</span>
</VAvatar>
</VBadge>
<h5 class="text-h5">
{{ store.activeChat.contact.fullName }}
</h5>
<p class="text-capitalize text-body-1 mb-0">
{{ store.activeChat.contact.role }}
</p>
</div>
<!-- User Data -->
<PerfectScrollbar
class="ps-chat-user-profile-sidebar-content text-medium-emphasis pb-6 px-6"
:options="{ wheelPropagation: false }"
>
<!-- About -->
<div class="my-6">
<div class="text-sm text-disabled">
ABOUT
</div>
<p class="mt-1 mb-6">
{{ store.activeChat.contact.about }}
</p>
</div>
<!-- Personal Information -->
<div class="mb-6">
<div class="text-sm text-disabled mb-1">
PERSONAL INFORMATION
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-mail"
size="22"
/>
<div class="text-base">
lucifer@email.com
</div>
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-phone"
size="22"
/>
<div class="text-base">
+1(123) 456 - 7890
</div>
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-clock"
size="22"
/>
<div class="text-base">
Mon - Fri 10AM - 8PM
</div>
</div>
</div>
<!-- Options -->
<div>
<div class="text-sm text-disabled mb-1">
OPTIONS
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-badge"
size="22"
/>
<div class="text-base">
Add Tag
</div>
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-star"
size="22"
/>
<div class="text-base">
Important Contact
</div>
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-photo"
size="22"
/>
<div class="text-base">
Shared Media
</div>
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
class="me-2"
icon="tabler-trash"
size="22"
/>
<div class="text-base">
Delete Contact
</div>
</div>
<div class="d-flex align-center text-high-emphasis pa-2">
<VIcon
icon="tabler-ban"
class="me-2"
size="22"
/>
<div class="text-base">
Block Contact
</div>
</div>
<VBtn
block
color="error"
append-icon="tabler-trash"
class="mt-6"
>
Delete Contact
</VBtn>
</div>
</PerfectScrollbar>
</template>
</template>

View File

@@ -0,0 +1,110 @@
<script setup>
import { useChat } from '@/views/apps/chat/useChat'
import { useChatStore } from '@/views/apps/chat/useChatStore'
const props = defineProps({
isChatContact: {
type: Boolean,
required: false,
default: false,
},
user: {
type: null,
required: true,
},
})
const store = useChatStore()
const { resolveAvatarBadgeVariant } = useChat()
const isChatContactActive = computed(() => {
const isActive = store.activeChat?.contact.id === props.user.id
if (!props.isChatContact)
return !store.activeChat?.chat && isActive
return isActive
})
</script>
<template>
<li
:key="store.chatsContacts.length"
class="chat-contact cursor-pointer d-flex align-center"
:class="{ 'chat-contact-active': isChatContactActive }"
>
<VBadge
dot
location="bottom right"
offset-x="3"
offset-y="0"
:color="resolveAvatarBadgeVariant(props.user.status)"
bordered
:model-value="props.isChatContact"
>
<VAvatar
size="40"
:variant="!props.user.avatar ? 'tonal' : undefined"
:color="!props.user.avatar ? resolveAvatarBadgeVariant(props.user.status) : undefined"
>
<VImg
v-if="props.user.avatar"
:src="props.user.avatar"
alt="John Doe"
/>
<span v-else>{{ avatarText(user.fullName) }}</span>
</VAvatar>
</VBadge>
<div class="flex-grow-1 ms-4 overflow-hidden">
<p class="text-base text-high-emphasis mb-0">
{{ props.user.fullName }}
</p>
<p class="mb-0 text-truncate text-body-2">
{{ props.isChatContact && 'chat' in props.user ? props.user.chat.lastMessage.message : props.user.about }}
</p>
</div>
<div
v-if="props.isChatContact && 'chat' in props.user"
class="d-flex flex-column align-self-start"
>
<div class="text-body-2 text-disabled whitespace-no-wrap">
{{ formatDateToMonthShort(props.user.chat.lastMessage.time) }}
</div>
<VBadge
v-if="props.user.chat.unseenMsgs"
color="error"
inline
:content="props.user.chat.unseenMsgs"
class="ms-auto"
/>
</div>
</li>
</template>
<style lang="scss">
@use "@core-scss/template/mixins" as templateMixins;
@use "@styles/variables/vuetify.scss";
@use "@core-scss/base/mixins";
@use "vuetify/lib/styles/tools/states" as vuetifyStates;
.chat-contact {
border-radius: vuetify.$border-radius-root;
padding-block: 8px;
padding-inline: 12px;
@include mixins.before-pseudo;
@include vuetifyStates.states($active: false);
&.chat-contact-active {
@include templateMixins.custom-elevation(var(--v-theme-primary), "sm");
background: rgb(var(--v-theme-primary));
color: #fff;
--v-theme-on-background: #fff;
}
.v-badge--bordered .v-badge__badge::after {
color: #fff;
}
}
</style>

View File

@@ -0,0 +1,140 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useChat } from './useChat'
import ChatContact from '@/views/apps/chat/ChatContact.vue'
import { useChatStore } from '@/views/apps/chat/useChatStore'
const props = defineProps({
search: {
type: String,
required: true,
},
isDrawerOpen: {
type: Boolean,
required: true,
},
})
const emit = defineEmits([
'openChatOfContact',
'showUserProfile',
'close',
'update:search',
])
const { resolveAvatarBadgeVariant } = useChat()
const search = useVModel(props, 'search', emit)
const store = useChatStore()
</script>
<template>
<!-- 👉 Chat list header -->
<div
v-if="store.profileUser"
class="chat-list-header"
>
<VBadge
dot
location="bottom right"
offset-x="3"
offset-y="3"
:color="resolveAvatarBadgeVariant(store.profileUser.status)"
bordered
>
<VAvatar
size="40"
class="cursor-pointer"
@click="$emit('showUserProfile')"
>
<VImg
:src="store.profileUser.avatar"
alt="John Doe"
/>
</VAvatar>
</VBadge>
<AppTextField
id="search"
v-model="search"
placeholder="Search..."
prepend-inner-icon="tabler-search"
class="ms-4 me-1 chat-list-search"
/>
<IconBtn
v-if="$vuetify.display.smAndDown"
@click="$emit('close')"
>
<VIcon
icon="tabler-x"
class="text-medium-emphasis"
/>
</IconBtn>
</div>
<VDivider />
<PerfectScrollbar
tag="ul"
class="d-flex flex-column gap-y-1 chat-contacts-list px-3 py-2 list-none"
:options="{ wheelPropagation: false }"
>
<li class="list-none">
<h5 class="chat-contact-header text-primary text-h5">
Chats
</h5>
</li>
<ChatContact
v-for="contact in store.chatsContacts"
:key="`chat-${contact.id}`"
:user="contact"
is-chat-contact
@click="$emit('openChatOfContact', contact.id)"
/>
<span
v-show="!store.chatsContacts.length"
class="no-chat-items-text text-disabled"
>No chats found</span>
<li class="list-none pt-2">
<h5 class="chat-contact-header text-primary text-h5">
Contacts
</h5>
</li>
<ChatContact
v-for="contact in store.contacts"
:key="`chat-${contact.id}`"
:user="contact"
@click="$emit('openChatOfContact', contact.id)"
/>
<span
v-show="!store.contacts.length"
class="no-chat-items-text text-disabled"
>No contacts found</span>
</PerfectScrollbar>
</template>
<style lang="scss">
.chat-contacts-list {
--chat-content-spacing-x: 16px;
padding-block-end: 0.75rem;
.chat-contact-header {
margin-block: 0.5rem 0.25rem;
}
.chat-contact-header,
.no-chat-items-text {
margin-inline: var(--chat-content-spacing-x);
}
}
.chat-list-search {
.v-field--focused {
box-shadow: none !important;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<script setup>
import { useChatStore } from '@/views/apps/chat/useChatStore'
const store = useChatStore()
const contact = computed(() => ({
id: store.activeChat?.contact.id,
avatar: store.activeChat?.contact.avatar,
}))
const resolveFeedbackIcon = feedback => {
if (feedback.isSeen)
return {
icon: 'tabler-checks',
color: 'success',
}
else if (feedback.isDelivered)
return {
icon: 'tabler-checks',
color: undefined,
}
else
return {
icon: 'tabler-check',
color: undefined,
}
}
const msgGroups = computed(() => {
let messages = []
const _msgGroups = []
if (store.activeChat.chat) {
messages = store.activeChat.chat.messages
let msgSenderId = messages[0].senderId
let msgGroup = {
senderId: msgSenderId,
messages: [],
}
messages.forEach((msg, index) => {
if (msgSenderId === msg.senderId) {
msgGroup.messages.push({
message: msg.message,
time: msg.time,
feedback: msg.feedback,
})
} else {
msgSenderId = msg.senderId
_msgGroups.push(msgGroup)
msgGroup = {
senderId: msg.senderId,
messages: [{
message: msg.message,
time: msg.time,
feedback: msg.feedback,
}],
}
}
if (index === messages.length - 1)
_msgGroups.push(msgGroup)
})
}
return _msgGroups
})
</script>
<template>
<div class="chat-log pa-6">
<div
v-for="(msgGrp, index) in msgGroups"
:key="msgGrp.senderId + String(index)"
class="chat-group d-flex align-start"
:class="[{
'flex-row-reverse': msgGrp.senderId !== contact.id,
'mb-6': msgGroups.length - 1 !== index,
}]"
>
<div
class="chat-avatar"
:class="msgGrp.senderId !== contact.id ? 'ms-4' : 'me-4'"
>
<VAvatar size="32">
<VImg :src="msgGrp.senderId === contact.id ? contact.avatar : store.profileUser?.avatar" />
</VAvatar>
</div>
<div
class="chat-body d-inline-flex flex-column"
:class="msgGrp.senderId !== contact.id ? 'align-end' : 'align-start'"
>
<div
v-for="(msgData, msgIndex) in msgGrp.messages"
:key="msgData.time"
class="chat-content py-2 px-4 elevation-2"
style="background-color: rgb(var(--v-theme-surface));"
:class="[
msgGrp.senderId === contact.id ? 'chat-left' : 'bg-primary text-white chat-right',
msgGrp.messages.length - 1 !== msgIndex ? 'mb-2' : 'mb-1',
]"
>
<p class="mb-0 text-base">
{{ msgData.message }}
</p>
</div>
<div :class="{ 'text-right': msgGrp.senderId !== contact.id }">
<VIcon
v-if="msgGrp.senderId !== contact.id"
size="16"
:color="resolveFeedbackIcon(msgGrp.messages[msgGrp.messages.length - 1].feedback).color"
>
{{ resolveFeedbackIcon(msgGrp.messages[msgGrp.messages.length - 1].feedback).icon }}
</VIcon>
<span class="text-sm ms-2 text-disabled">{{ formatDate(msgGrp.messages[msgGrp.messages.length - 1].time, { hour: 'numeric', minute: 'numeric' }) }}</span>
</div>
</div>
</div>
</div>
</template>
<style lang=scss>
.chat-log {
.chat-body {
max-inline-size: calc(100% - 6.75rem);
.chat-content {
border-end-end-radius: 6px;
border-end-start-radius: 6px;
p {
overflow-wrap: anywhere;
}
&.chat-left {
border-start-end-radius: 6px;
}
&.chat-right {
border-start-start-radius: 6px;
}
}
}
}
</style>

View File

@@ -0,0 +1,214 @@
<script setup>
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
import { useChat } from './useChat'
import { useChatStore } from '@/views/apps/chat/useChatStore'
const emit = defineEmits(['close'])
// composables
const store = useChatStore()
const { resolveAvatarBadgeVariant } = useChat()
const userStatusRadioOptions = [
{
title: 'Online',
value: 'online',
color: 'success',
},
{
title: 'Away',
value: 'away',
color: 'warning',
},
{
title: 'Do not disturb',
value: 'busy',
color: 'error',
},
{
title: 'Offline',
value: 'offline',
color: 'secondary',
},
]
const isAuthenticationEnabled = ref(true)
const isNotificationEnabled = ref(false)
</script>
<template>
<template v-if="store.profileUser">
<!-- Close Button -->
<div class="pt-2 me-2 text-end">
<IconBtn @click="$emit('close')">
<VIcon
class="text-medium-emphasis"
color="disabled"
icon="tabler-x"
/>
</IconBtn>
</div>
<!-- User Avatar + Name + Role -->
<div class="text-center px-6">
<VBadge
location="bottom right"
offset-x="7"
offset-y="4"
bordered
:color="resolveAvatarBadgeVariant(store.profileUser.status)"
class="chat-user-profile-badge mb-3"
>
<VAvatar
size="84"
:variant="!store.profileUser.avatar ? 'tonal' : undefined"
:color="!store.profileUser.avatar ? resolveAvatarBadgeVariant(store.profileUser.status) : undefined"
>
<VImg
v-if="store.profileUser.avatar"
:src="store.profileUser.avatar"
/>
<span
v-else
class="text-3xl"
>{{ avatarText(store.profileUser.fullName) }}</span>
</VAvatar>
</VBadge>
<h5 class="text-h5">
{{ store.profileUser.fullName }}
</h5>
<p class="text-capitalize text-medium-emphasis mb-0">
{{ store.profileUser.role }}
</p>
</div>
<!-- User Data -->
<PerfectScrollbar
class="ps-chat-user-profile-sidebar-content pb-5 px-6"
:options="{ wheelPropagation: false }"
>
<!-- About -->
<div class="my-6 text-medium-emphasis">
<div
for="textarea-user-about"
class="text-base text-disabled"
>
ABOUT
</div>
<AppTextarea
id="textarea-user-about"
v-model="store.profileUser.about"
auto-grow
class="mt-1"
rows="3"
/>
</div>
<!-- Status -->
<div class="mb-6">
<div class="text-base text-disabled">
STATUS
</div>
<VRadioGroup
v-model="store.profileUser.status"
class="mt-1"
>
<VRadio
v-for="(radioOption, index) in userStatusRadioOptions"
:id="`${index}`"
:key="radioOption.title"
:name="radioOption.title"
:label="radioOption.title"
:value="radioOption.value"
:color="radioOption.color"
/>
</VRadioGroup>
</div>
<!-- Settings -->
<div class="text-medium-emphasis chat-settings-section">
<div class="text-base text-disabled">
SETTINGS
</div>
<div class="d-flex align-center pa-2">
<VIcon
class="me-2 text-high-emphasis"
icon="tabler-lock"
size="22"
/>
<div class="text-high-emphasis d-flex align-center justify-space-between flex-grow-1">
<div class="text-body-1 text-high-emphasis">
Two-step Verification
</div>
<VSwitch
id="two-step-verification"
v-model="isAuthenticationEnabled"
density="compact"
/>
</div>
</div>
<div class="d-flex align-center pa-2">
<VIcon
class="me-2 text-high-emphasis"
icon="tabler-bell"
size="22"
/>
<div class="text-high-emphasis d-flex align-center justify-space-between flex-grow-1">
<div class="text-body-1 text-high-emphasis">
Notification
</div>
<VSwitch
id="chat-notification"
v-model="isNotificationEnabled"
density="compact"
/>
</div>
</div>
<div class="d-flex align-center pa-2">
<VIcon
class="me-2 text-high-emphasis"
icon="tabler-user-plus"
size="22"
/>
<div class="text-body-1 text-high-emphasis">
Invite Friends
</div>
</div>
<div class="d-flex align-center pa-2">
<VIcon
class="me-2 text-high-emphasis"
icon="tabler-trash"
size="22"
/>
<div class="text-body-1 text-high-emphasis">
Delete Account
</div>
</div>
</div>
<!-- Logout Button -->
<VBtn
color="primary"
class="mt-12"
block
append-icon="tabler-logout"
>
Logout
</VBtn>
</PerfectScrollbar>
</template>
</template>
<style lang="scss">
.chat-settings-section {
.v-switch {
.v-input__control {
.v-selection-control__wrapper {
block-size: 18px;
}
}
}
}
</style>

View File

@@ -0,0 +1,16 @@
export const useChat = () => {
const resolveAvatarBadgeVariant = status => {
if (status === 'online')
return 'success'
if (status === 'busy')
return 'error'
if (status === 'away')
return 'warning'
return 'secondary'
}
return {
resolveAvatarBadgeVariant,
}
}

View File

@@ -0,0 +1,80 @@
export const useChatStore = defineStore('chat', {
// arrow function recommended for full type inference
state: () => ({
contacts: [],
chatsContacts: [],
profileUser: undefined,
activeChat: null,
}),
actions: {
async fetchChatsAndContacts(q) {
const { data, error } = await useApi(createUrl('/apps/chat/chats-and-contacts', {
query: {
q,
},
}))
if (error.value) {
console.log(error.value)
}
else {
const { chatsContacts, contacts, profileUser } = data.value
this.chatsContacts = chatsContacts
this.contacts = contacts
this.profileUser = profileUser
}
},
async getChat(userId) {
const res = await $api(`/apps/chat/chats/${userId}`)
this.activeChat = res
},
async sendMsg(message) {
const senderId = this.profileUser?.id
const response = await $api(`apps/chat/chats/${this.activeChat?.contact.id}`, {
method: 'POST',
body: { message, senderId },
})
const { msg, chat } = response
// ? If it's not undefined => New chat is created (Contact is not in list of chats)
if (chat !== undefined) {
const activeChat = this.activeChat
this.chatsContacts.push({
...activeChat.contact,
chat: {
id: chat.id,
lastMessage: [],
unseenMsgs: 0,
messages: [msg],
},
})
if (this.activeChat) {
this.activeChat.chat = {
id: chat.id,
messages: [msg],
unseenMsgs: 0,
userId: this.activeChat?.contact.id,
}
}
}
else {
this.activeChat?.chat?.messages.push(msg)
}
// Set Last Message for active contact
const contact = this.chatsContacts.find(c => {
if (this.activeChat)
return c.id === this.activeChat.contact.id
return false
})
contact.chat.lastMessage = msg
},
},
})