hesabixCore/webUI/src/views/chat/home.vue
2025-08-04 13:31:07 +00:00

2798 lines
83 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="chat-home">
<!-- Header -->
<v-app-bar color="toolbar" dark elevation="0">
<v-app-bar-title class="d-flex align-center">
<v-icon start class="mr-2">mdi-chat</v-icon>
گفتوگو
</v-app-bar-title>
<v-spacer></v-spacer>
<!-- Actions -->
<div class="d-flex align-center">
<v-btn icon @click="showCreateChannel = true">
<v-icon>mdi-plus</v-icon>
</v-btn>
<v-btn icon @click="showChannelDrawer = !showChannelDrawer">
<v-icon>mdi-menu</v-icon>
</v-btn>
</div>
</v-app-bar>
<!-- Main Content -->
<div class="chat-container">
<!-- Channel Drawer -->
<v-navigation-drawer
v-model="showChannelDrawer"
:temporary="true"
width="320"
class="channel-drawer"
>
<v-card flat class="h-100">
<!-- Drawer Header -->
<div class="drawer-header pa-4">
<h3 class="text-h6 mb-3">گفت‌وگوها</h3>
<v-text-field
v-model="channelSearch"
placeholder="جستجو در کانال‌ها..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
@input="searchChannels"
></v-text-field>
</div>
<!-- Channel Lists -->
<v-card-text class="pa-0">
<!-- My Channels -->
<div class="channel-section">
<div class="section-header pa-4">
<v-icon start class="mr-2">mdi-account-group</v-icon>
<span class="text-subtitle-2">کانال‌های من</span>
<v-chip size="small" color="primary" variant="elevated" class="ml-auto count-chip">
{{ userChannels.length }}
</v-chip>
</div>
<v-list class="pa-0">
<v-list-item
v-for="channel in filteredChannels"
:key="channel.id"
@click="selectChannel(channel)"
:active="currentChannel?.id === channel.id"
class="channel-item"
rounded="lg"
:class="{ 'channel-active': currentChannel?.id === channel.id }"
>
<template v-slot:prepend>
<v-avatar size="40" :color="channel.isPublic ? 'green' : 'orange'" class="mr-3">
<v-icon :color="channel.isPublic ? 'white' : 'white'">
{{ channel.isPublic ? 'mdi-earth' : 'mdi-lock' }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium">
{{ channel.name }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ channel.messageCount }} پیام • {{ channel.memberCount || 0 }} عضو
</v-list-item-subtitle>
<template v-slot:append>
<v-chip
v-if="channel.unreadCount && channel.unreadCount > 0"
size="small"
color="error"
variant="elevated"
class="ml-2 unread-chip"
>
{{ channel.unreadCount }}
</v-chip>
</template>
</v-list-item>
</v-list>
</div>
<!-- Public Channels -->
<v-divider class="my-4"></v-divider>
<div class="channel-section">
<div class="section-header pa-4">
<v-icon start class="mr-2">mdi-earth</v-icon>
<span class="text-subtitle-2">{{ channelSearch ? 'کانال‌های عمومی' : 'کانال‌های محبوب' }}</span>
<v-chip size="small" color="blue" variant="elevated" class="ml-auto count-chip">
{{ publicChannels.length }}
</v-chip>
</div>
<v-list class="pa-0">
<v-list-item
v-for="channel in publicChannels"
:key="channel.id"
class="channel-item"
rounded="lg"
@click="selectChannel(channel)"
:active="currentChannel?.id === channel.id"
:class="{ 'channel-active': currentChannel?.id === channel.id }"
>
<template v-slot:prepend>
<v-avatar size="40" color="blue" class="mr-3">
<v-icon color="white">mdi-earth</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium">
{{ channel.name }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
{{ channel.messageCount }} پیام • {{ channel.memberCount || 0 }} عضو
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
v-if="!isChannelJoined(channel.id)"
size="small"
color="primary"
variant="elevated"
class="join-btn"
@click.stop="joinChannel(channel)"
>
پیوستن
</v-btn>
<v-chip
v-else
size="small"
color="success"
variant="elevated"
class="member-chip"
>
عضو
</v-chip>
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
</v-card>
</v-navigation-drawer>
<!-- Chat Area -->
<div class="chat-area">
<!-- Empty State -->
<div v-if="!currentChannel" class="empty-state">
<div class="empty-content">
<v-icon size="120" color="grey-lighten-1">mdi-chat-outline</v-icon>
<h1 class="text-h4 text-grey-lighten-1 mt-6 mb-4">خوش آمدید!</h1>
<p class="text-body-1 text-grey-lighten-1 mb-8 text-center">
برای شروع گفت‌و‌گو، یک کانال انتخاب کنید یا کانال جدیدی ایجاد کنید
</p>
<div class="action-buttons">
<v-btn
color="primary"
size="large"
class="mr-4"
@click="showChannelDrawer = true"
>
<v-icon start>mdi-format-list-bulleted</v-icon>
انتخاب کانال
</v-btn>
<v-btn
color="secondary"
size="large"
variant="outlined"
@click="showCreateChannel = true"
>
<v-icon start>mdi-plus</v-icon>
ایجاد کانال
</v-btn>
</div>
</div>
</div>
<!-- Chat Content -->
<div v-else class="chat-content">
<!-- Channel Header -->
<div class="channel-header">
<div class="d-flex align-center">
<v-avatar size="48" :color="currentChannel.isPublic ? 'green' : 'orange'" class="mr-4">
<v-icon :color="currentChannel.isPublic ? 'white' : 'white'" size="24">
{{ currentChannel.isPublic ? 'mdi-earth' : 'mdi-lock' }}
</v-icon>
</v-avatar>
<div>
<h2 class="text-h5 mb-1">{{ currentChannel.name }}</h2>
<div class="d-flex align-center">
<v-icon size="16" class="mr-1">mdi-account-group</v-icon>
<span class="text-caption">{{ currentChannel.memberCount || 0 }} عضو</span>
<v-divider vertical class="mx-3"></v-divider>
<span class="text-caption">{{ currentChannel.messageCount }} پیام</span>
<v-divider vertical class="mx-3"></v-divider>
<v-chip
v-if="currentChannel.isPublic"
size="small"
:color="canSendMessage ? 'success' : 'warning'"
variant="tonal"
>
{{ canSendMessage ? 'عضو' : 'مشاهده‌کننده' }}
</v-chip>
</div>
</div>
</div>
<div class="channel-actions">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn icon size="small" v-bind="props">
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="showChannelInfo = !showChannelInfo">
<v-list-item-title>اطلاعات کانال</v-list-item-title>
</v-list-item>
<v-list-item
v-if="canSendMessage"
@click="leaveChannel"
>
<v-list-item-title>ترک کانال</v-list-item-title>
</v-list-item>
<v-list-item
v-else-if="currentChannel.isPublic"
@click="joinChannel(currentChannel)"
>
<v-list-item-title>پیوستن به کانال</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
<!-- Channel Info -->
<v-expand-transition>
<div v-if="showChannelInfo" class="channel-info">
<v-card variant="outlined" class="ma-4">
<v-card-text>
<div class="d-flex align-center mb-3">
<v-icon start class="mr-2">mdi-information</v-icon>
<h3 class="text-h6">اطلاعات کانال</h3>
</div>
<p class="text-body-2 mb-4">{{ getChannelInfo?.description || 'توضیحی برای این کانال وجود ندارد.' }}</p>
<div class="channel-stats">
<div class="stat-item">
<v-icon size="20" class="mr-2">mdi-account-group</v-icon>
<span>{{ getChannelInfo?.memberCount || 0 }} عضو</span>
</div>
<div class="stat-item">
<v-icon size="20" class="mr-2">mdi-message</v-icon>
<span>{{ getChannelInfo?.messageCount || 0 }} پیام</span>
</div>
<div class="stat-item">
<v-icon size="20" class="mr-2">mdi-calendar</v-icon>
<span>{{ getChannelInfo?.createdAt ? formatDate(getChannelInfo.createdAt) : '-' }}</span>
</div>
</div>
<div class="channel-actions mt-4">
<v-btn
v-if="canSendMessage"
color="primary"
variant="outlined"
size="small"
@click="showMemberDialog = true"
>
<v-icon start>mdi-account-group</v-icon>
مشاهده اعضا
</v-btn>
<v-btn
v-if="currentChannel.isAdmin"
color="secondary"
variant="outlined"
size="small"
class="mr-2"
@click="showAddMemberDialog = true"
>
<v-icon start>mdi-account-plus</v-icon>
اضافه کردن عضو
</v-btn>
<v-btn
v-if="currentChannel.isPublic && !canSendMessage"
color="primary"
size="small"
@click="joinChannel(currentChannel)"
>
<v-icon start>mdi-account-plus</v-icon>
پیوستن به کانال
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
</v-expand-transition>
<!-- Messages -->
<div class="messages-container" ref="messagesContainer" @scroll="handleScroll">
<!-- Load more indicator -->
<div v-if="pagination.loadingMore" class="load-more-indicator">
<v-progress-circular indeterminate color="primary" size="30"></v-progress-circular>
<span class="text-caption ml-2">در حال بارگذاری پیام‌های بیشتر...</span>
</div>
<div v-if="loading" class="loading-state">
<v-progress-circular indeterminate color="primary" size="50"></v-progress-circular>
<p class="text-body-1 mt-4">در حال بارگذاری پیام‌ها...</p>
</div>
<div v-else-if="messages.length === 0" class="empty-messages">
<v-icon size="80" color="grey-lighten-1">mdi-chat-outline</v-icon>
<h3 class="text-h6 text-grey-lighten-1 mt-4 mb-2">هنوز پیامی ارسال نشده است</h3>
<p class="text-grey-lighten-1">اولین پیام را ارسال کنید!</p>
</div>
<div v-else class="messages-list">
<div
v-for="message in messages"
:key="message.id"
class="message-item"
:class="{ 'message-own': message.sender.id === currentUser?.id }"
>
<div class="message-avatar">
<v-avatar size="40" color="primary">
<span class="text-white">{{ message.sender.fullName.charAt(0) }}</span>
</v-avatar>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">{{ message.sender.fullName }}</span>
<span class="message-time">{{ formatTime(message.sentAt) }}</span>
</div>
<!-- Quoted Message -->
<div v-if="message.quotedMessage" class="quoted-message">
<div class="quoted-content">
<span class="quoted-sender">{{ message.quotedMessage.sender }}</span>
<span class="quoted-text">{{ message.quotedMessage.content }}</span>
</div>
</div>
<div class="message-bubble">
<div v-if="message.messageType === 'emoji'" class="emoji-message">
{{ message.content }}
</div>
<div v-else class="text-message">
{{ message.content }}
</div>
<!-- Reactions -->
<div v-if="message.reactions && Object.keys(message.reactions).length > 0" class="message-reactions">
<v-chip
v-for="(users, emoji) in message.reactions"
:key="emoji"
size="small"
variant="outlined"
class="mr-1 reaction-chip"
:class="{
'like-chip': emoji === '❤️',
'dislike-chip': emoji === '👎'
}"
>
<v-icon size="14" class="mr-1">
{{ emoji === '❤️' ? 'mdi-heart' : emoji === '👎' ? 'mdi-thumb-down' : emoji }}
</v-icon>
{{ users.length }}
</v-chip>
</div>
</div>
<!-- Message Actions -->
<div class="message-actions">
<v-tooltip text="نقل قول از این پیام" location="top">
<template v-slot:activator="{ props }">
<v-btn
icon
size="x-small"
variant="text"
@click="quoteMessage(message)"
class="action-btn"
v-bind="props"
>
<v-icon size="16">mdi-reply</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="پاسخ به این پیام" location="top">
<template v-slot:activator="{ props }">
<v-btn
icon
size="x-small"
variant="text"
@click="replyToMessage(message)"
class="action-btn"
v-bind="props"
>
<v-icon size="16">mdi-reply-all</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip :text="isLiked(message) ? 'حذف لایک' : 'لایک کردن'" location="top">
<template v-slot:activator="{ props }">
<v-btn
icon
size="x-small"
variant="text"
@click="toggleLike(message)"
class="action-btn"
:class="{ 'liked': isLiked(message) }"
:loading="reactingMessages.has(`like-${message.id}`)"
:disabled="reactingMessages.has(`like-${message.id}`) || reactingMessages.has(`dislike-${message.id}`)"
v-bind="props"
>
<v-icon size="16" :color="isLiked(message) ? 'red' : undefined">
{{ isLiked(message) ? 'mdi-heart' : 'mdi-heart-outline' }}
</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip :text="isDisliked(message) ? 'حذف دیسلایک' : 'دیسلایک کردن'" location="top">
<template v-slot:activator="{ props }">
<v-btn
icon
size="x-small"
variant="text"
@click="toggleDislike(message)"
class="action-btn"
:class="{ 'disliked': isDisliked(message) }"
:loading="reactingMessages.has(`dislike-${message.id}`)"
:disabled="reactingMessages.has(`like-${message.id}`) || reactingMessages.has(`dislike-${message.id}`)"
v-bind="props"
>
<v-icon size="16" :color="isDisliked(message) ? 'orange' : undefined">
{{ isDisliked(message) ? 'mdi-thumb-down' : 'mdi-thumb-down-outline' }}
</v-icon>
</v-btn>
</template>
</v-tooltip>
</div>
</div>
</div>
</div>
</div>
<!-- Message Input -->
<div class="message-input-container">
<!-- Show join button if user is not a member -->
<div v-if="currentChannel && !canSendMessage" class="join-prompt">
<v-alert
type="info"
variant="tonal"
class="mb-4"
>
<div class="d-flex align-center">
<v-icon start class="mr-2">mdi-information</v-icon>
<span>برای ارسال پیام باید عضو این کانال باشید</span>
</div>
</v-alert>
<v-btn
color="primary"
size="large"
block
@click="joinChannel(currentChannel)"
>
<v-icon start>mdi-account-plus</v-icon>
پیوستن به کانال
</v-btn>
</div>
<!-- Show message input if user is a member -->
<div v-else-if="currentChannel && canSendMessage" class="input-wrapper">
<!-- Quoted Message Preview -->
<div v-if="quotedMessage" class="quoted-message-preview">
<div class="quoted-preview-content">
<div class="quoted-preview-header">
<v-icon size="16" class="mr-2">mdi-reply</v-icon>
<span class="text-caption">در پاسخ به {{ quotedMessage.sender.fullName }}</span>
<v-spacer></v-spacer>
<v-tooltip text="لغو نقل قول" location="top">
<template v-slot:activator="{ props }">
<v-btn
icon
size="x-small"
variant="text"
@click="cancelQuote"
v-bind="props"
>
<v-icon size="16">mdi-close</v-icon>
</v-btn>
</template>
</v-tooltip>
</div>
<div class="quoted-preview-text">
{{ quotedMessage.content }}
</div>
</div>
</div>
<v-text-field
v-model="messageText"
:placeholder="quotedMessage ? 'پاسخ خود را بنویسید...' : 'پیام خود را بنویسید...'"
variant="outlined"
density="compact"
hide-details
@keyup.enter="sendMessage"
:disabled="loading"
class="message-input"
>
<template v-slot:prepend>
<v-btn icon size="small" @click="showEmojiPicker = !showEmojiPicker">
<v-icon>mdi-emoticon</v-icon>
</v-btn>
</template>
<template v-slot:append>
<v-btn
icon
color="primary"
@click="sendMessage"
:disabled="!messageText.trim() || loading"
>
<v-icon>mdi-send</v-icon>
</v-btn>
</template>
</v-text-field>
<!-- Emoji Picker -->
<v-expand-transition>
<div v-if="showEmojiPicker" class="emoji-picker">
<div class="emoji-grid">
<v-btn
v-for="emoji in commonEmojis"
:key="emoji"
variant="text"
size="small"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</v-btn>
</div>
</div>
</v-expand-transition>
</div>
</div>
</div>
</div>
</div>
<!-- Create Channel Dialog -->
<v-dialog v-model="showCreateChannel" max-width="600px" persistent>
<v-card class="create-channel-dialog">
<v-card-title class="dialog-header">
<div class="d-flex align-center">
<v-avatar size="48" color="primary" class="mr-4">
<v-icon size="24" color="white">mdi-plus-circle</v-icon>
</v-avatar>
<div>
<h2 class="text-h5 mb-1">ایجاد کانال جدید</h2>
<p class="text-body-2 text-grey-darken-1">کانال جدیدی برای گفت‌وگو ایجاد کنید</p>
</div>
</div>
</v-card-title>
<v-card-text class="dialog-content">
<v-form ref="createChannelForm" class="mt-4">
<v-text-field
v-model="newChannel.name"
label="نام کانال"
placeholder="مثال: گروه عمومی"
required
:rules="[v => !!v || 'نام کانال الزامی است']"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-format-title"
class="mb-4"
></v-text-field>
<v-textarea
v-model="newChannel.description"
label="توضیحات کانال"
placeholder="توضیح کوتاهی درباره هدف و موضوع کانال بنویسید..."
rows="4"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-text"
class="mb-4"
></v-textarea>
<v-card variant="outlined" class="mb-4">
<v-card-text class="pa-4">
<div class="d-flex align-center mb-3">
<v-switch
v-model="newChannel.isPublic"
color="primary"
inset
class="mr-3"
></v-switch>
<div>
<h4 class="text-subtitle-1 font-weight-medium">
{{ newChannel.isPublic ? 'کانال عمومی' : 'کانال خصوصی' }}
</h4>
<p class="text-caption text-grey-darken-1">
{{ newChannel.isPublic
? 'کانال‌های عمومی برای همه قابل مشاهده و پیوستن هستند'
: 'کانال‌های خصوصی فقط برای اعضای دعوت شده قابل دسترسی هستند'
}}
</p>
</div>
</div>
<v-alert
:type="newChannel.isPublic ? 'info' : 'warning'"
variant="tonal"
class="mt-3"
>
<div class="d-flex align-center">
<v-icon start class="mr-2">
{{ newChannel.isPublic ? 'mdi-earth' : 'mdi-lock' }}
</v-icon>
<span>
{{ newChannel.isPublic
? 'این کانال در لیست کانال‌های عمومی نمایش داده می‌شود'
: 'این کانال فقط برای اعضای دعوت شده قابل دسترسی است'
}}
</span>
</div>
</v-alert>
</v-card-text>
</v-card>
</v-form>
</v-card-text>
<v-card-actions class="dialog-actions">
<v-btn
@click="showCreateChannel = false"
variant="outlined"
size="large"
>
لغو
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="createChannel"
:loading="creatingChannel"
size="large"
:disabled="!newChannel.name.trim()"
>
<v-icon start>mdi-plus</v-icon>
ایجاد کانال
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Member Management Dialog -->
<v-dialog v-model="showMemberDialog" max-width="700px" persistent>
<v-card class="member-dialog">
<v-card-title class="dialog-header">
<div class="d-flex align-center">
<v-avatar size="48" color="blue" class="mr-4">
<v-icon size="24" color="white">mdi-account-group</v-icon>
</v-avatar>
<div>
<h2 class="text-h5 mb-1">مدیریت اعضای کانال</h2>
<p class="text-body-2 text-grey-darken-1">
{{ currentChannel?.name }} - {{ channelMembers.length }} عضو
</p>
</div>
<v-spacer></v-spacer>
<v-btn
v-if="currentChannel?.isAdmin"
color="primary"
size="large"
@click="showAddMemberDialog = true"
class="add-member-btn"
>
<v-icon start>mdi-plus</v-icon>
اضافه کردن عضو
</v-btn>
</div>
</v-card-title>
<v-card-text class="dialog-content">
<div v-if="loadingMembers" class="loading-members">
<v-progress-circular indeterminate color="primary" size="60"></v-progress-circular>
<p class="text-body-1 mt-4">در حال بارگذاری اعضا...</p>
</div>
<div v-else>
<div class="member-stats mb-4">
<v-row>
<v-col cols="4">
<v-card variant="outlined" class="stat-card">
<v-card-text class="text-center pa-4">
<v-icon size="32" color="primary" class="mb-2">mdi-account-group</v-icon>
<h3 class="text-h4 font-weight-bold">{{ channelMembers.length }}</h3>
<p class="text-caption text-grey-darken-1">کل اعضا</p>
</v-card-text>
</v-card>
</v-col>
<v-col cols="4">
<v-card variant="outlined" class="stat-card">
<v-card-text class="text-center pa-4">
<v-icon size="32" color="orange" class="mb-2">mdi-crown</v-icon>
<h3 class="text-h4 font-weight-bold">
{{ channelMembers.filter(m => m.isAdmin).length }}
</h3>
<p class="text-caption text-grey-darken-1">مدیر</p>
</v-card-text>
</v-card>
</v-col>
<v-col cols="4">
<v-card variant="outlined" class="stat-card">
<v-card-text class="text-center pa-4">
<v-icon size="32" color="green" class="mb-2">mdi-account</v-icon>
<h3 class="text-h4 font-weight-bold">
{{ channelMembers.filter(m => !m.isAdmin).length }}
</h3>
<p class="text-caption text-grey-darken-1">عضو عادی</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<v-list class="member-list">
<v-list-item
v-for="member in channelMembers"
:key="member.id"
class="member-item"
rounded="lg"
>
<template v-slot:prepend>
<v-avatar size="48" color="primary" class="mr-3">
<span class="text-white text-h6">{{ member.fullName.charAt(0) }}</span>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium text-body-1">
{{ member.fullName }}
<v-chip
v-if="member.isAdmin"
size="small"
color="orange"
variant="elevated"
class="ml-2"
>
<v-icon start size="14">mdi-crown</v-icon>
مدیر
</v-chip>
</v-list-item-title>
<v-list-item-subtitle class="text-caption mt-1">
<v-icon size="14" class="mr-1">mdi-email</v-icon>
{{ maskEmail(member.email) }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
v-if="currentChannel?.isAdmin && !member.isAdmin"
icon
size="small"
color="error"
variant="text"
@click="removeMemberFromChannel(member.id)"
class="remove-btn"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-list-item>
</v-list>
</div>
</v-card-text>
<v-card-actions class="dialog-actions">
<v-btn @click="showMemberDialog = false" variant="outlined" size="large">
بستن
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Add Member Dialog -->
<v-dialog v-model="showAddMemberDialog" max-width="600px" persistent>
<v-card class="add-member-dialog">
<v-card-title class="dialog-header">
<div class="d-flex align-center">
<v-avatar size="48" color="green" class="mr-4">
<v-icon size="24" color="white">mdi-account-plus</v-icon>
</v-avatar>
<div>
<h2 class="text-h5 mb-1">اضافه کردن عضو جدید</h2>
<p class="text-body-2 text-grey-darken-1">
کاربران جدید را به کانال {{ currentChannel?.name }} اضافه کنید
</p>
</div>
</div>
</v-card-title>
<v-card-text class="dialog-content">
<v-text-field
v-model="memberSearch"
label="جستجوی کاربر"
placeholder="نام یا ایمیل کاربر را وارد کنید..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
@input="searchUsersForMember"
class="mb-4"
></v-text-field>
<div v-if="searchingUsers" class="searching-users">
<v-progress-circular indeterminate color="primary" size="40"></v-progress-circular>
<p class="text-body-2 mt-3">در حال جستجو...</p>
</div>
<div v-else-if="searchResults.length > 0" class="search-results">
<h4 class="text-subtitle-1 font-weight-medium mb-3">
نتایج جستجو ({{ searchResults.length }} کاربر)
</h4>
<v-list class="search-result-list">
<v-list-item
v-for="user in searchResults"
:key="user.id"
@click="addMemberToChannel(user.id)"
class="search-result-item"
rounded="lg"
>
<template v-slot:prepend>
<v-avatar size="40" color="primary" class="mr-3">
<span class="text-white">{{ user.fullName.charAt(0) }}</span>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium">
{{ user.fullName }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption">
<v-icon size="14" class="mr-1">mdi-email</v-icon>
{{ maskEmail(user.email) }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon
size="small"
color="primary"
variant="text"
>
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
</v-list-item>
</v-list>
</div>
<div v-else-if="memberSearch && !searchingUsers" class="no-results">
<v-icon size="64" color="grey-lighten-1" class="mb-3">mdi-account-search</v-icon>
<h4 class="text-subtitle-1 text-grey-lighten-1 mb-2">کاربری یافت نشد</h4>
<p class="text-body-2 text-grey-darken-1">
کاربری با این نام یا ایمیل در سیستم وجود ندارد
</p>
</div>
<div v-else class="search-placeholder">
<v-icon size="64" color="grey-lighten-1" class="mb-3">mdi-account-search</v-icon>
<h4 class="text-subtitle-1 text-grey-lighten-1 mb-2">جستجوی کاربر</h4>
<p class="text-body-2 text-grey-darken-1">
نام یا ایمیل کاربر مورد نظر را وارد کنید
</p>
</div>
</v-card-text>
<v-card-actions class="dialog-actions">
<v-btn @click="showAddMemberDialog = false" variant="outlined" size="large">
لغو
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import { defineComponent, ref, watch, nextTick, computed, onMounted, onUnmounted } from 'vue';
import axios from 'axios';
import { useChatStore } from '@/stores/chatStore';
export default defineComponent({
name: "ChatHome",
setup() {
// Chat store
const chatStore = useChatStore();
// Reactive data
const messageText = ref('');
const loading = ref(false);
const messagesContainer = ref(null);
// Channel management
const showChannelDrawer = ref(false);
const showCreateChannel = ref(false);
const showChannelInfo = ref(false);
const userChannels = ref([]);
const publicChannels = ref([]);
const currentChannel = ref(null);
const messages = ref([]);
const creatingChannel = ref(false);
const channelSearch = ref('');
// Pagination state
const pagination = ref({
limit: 30,
offset: 0,
hasMore: true,
loadingMore: false,
totalMessages: 0
});
// Emoji picker
const showEmojiPicker = ref(false);
const commonEmojis = ref([
'😀', '😃', '😄', '😁', '😆', '😅', '😂', '🤣', '😊', '😇',
'🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚',
'😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩',
'🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣',
'😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬',
'🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗',
'🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😯', '😦', '😧',
'😮', '😲', '🥱', '😴', '🤤', '😪', '😵', '🤐', '🥴', '🤢',
'🤮', '🤧', '😷', '🤒', '🤕', '🤑', '🤠', '💩', '👻', '💀',
'☠️', '👽', '👾', '🤖', '😺', '😸', '😹', '😻', '😼', '😽',
'🙀', '😿', '😾', '🙈', '🙉', '🙊', '💌', '💘', '💝', '💖',
'💗', '💙', '💚', '❣️', '💕', '💞', '💓', '💗', '💖', '💘',
'💝', '💟', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
'🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝',
'💟', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎'
]);
// Quote/Reply functionality
const quotedMessage = ref(null);
const showMessageActions = ref(null);
// Like/Dislike functionality
const currentUser = ref({
id: 1,
fullName: 'کاربر فعلی',
email: 'user@example.com'
});
// Loading states for reactions
const reactingMessages = ref(new Set());
// Member management
const showMemberDialog = ref(false);
const showAddMemberDialog = ref(false);
const channelMembers = ref([]);
const loadingMembers = ref(false);
const memberSearch = ref('');
const searchResults = ref([]);
const searchingUsers = ref(false);
// Auto refresh
const autoRefreshInterval = ref(null);
const refreshInterval = 3000; // 3 seconds
// New channel form
const newChannel = ref({
name: '',
description: '',
isPublic: true
});
// Computed properties
const filteredChannels = computed(() => {
if (!channelSearch.value) return userChannels.value;
return userChannels.value.filter(channel =>
channel.name.toLowerCase().includes(channelSearch.value.toLowerCase())
);
});
const canSendMessage = computed(() => {
if (!currentChannel.value) return false;
// User can send message if they are a member of the channel
return userChannels.value.some(channel => channel.id === currentChannel.value.id);
});
const isCurrentChannelMember = computed(() => {
if (!currentChannel.value) return false;
return userChannels.value.some(channel => channel.id === currentChannel.value.id);
});
const isChannelJoined = (channelId) => {
return userChannels.value.some(channel => channel.id === channelId);
};
const getChannelInfo = computed(() => {
if (!currentChannel.value) return null;
return {
name: currentChannel.value.name || '',
description: currentChannel.value.description || '',
isPublic: currentChannel.value.isPublic || false,
messageCount: currentChannel.value.messageCount || 0,
memberCount: currentChannel.value.memberCount || 0,
createdAt: currentChannel.value.createdAt || null,
isAdmin: currentChannel.value.isAdmin || false
};
});
// Methods
const loadUserChannels = async () => {
try {
loading.value = true;
const response = await axios.get('/api/chat/channels');
if (response.data.success) {
userChannels.value = response.data.data;
if (userChannels.value.length > 0) {
selectChannel(userChannels.value[0]);
}
}
} catch (error) {
console.error('Error loading channels:', error);
} finally {
loading.value = false;
}
};
const updateUserChannels = async () => {
try {
const response = await axios.get('/api/chat/channels');
if (response.data.success) {
userChannels.value = response.data.data;
}
} catch (error) {
console.error('Error updating user channels:', error);
}
};
const loadPublicChannels = async () => {
try {
const response = await axios.get('/api/chat/channels/search?q=');
if (response.data.success) {
// Filter out channels that user is already a member of
publicChannels.value = response.data.data.filter(channel =>
!userChannels.value.some(userChannel => userChannel.id === channel.id)
);
}
} catch (error) {
console.error('Error loading public channels:', error);
}
};
const searchChannels = async () => {
if (channelSearch.value.trim()) {
try {
const response = await axios.get(`/api/chat/channels/search?q=${encodeURIComponent(channelSearch.value)}`);
if (response.data.success) {
publicChannels.value = response.data.data.filter(channel =>
!userChannels.value.some(userChannel => userChannel.id === channel.id)
);
}
} catch (error) {
console.error('Error searching channels:', error);
}
} else {
loadPublicChannels();
}
};
const selectChannel = async (channel) => {
currentChannel.value = channel;
showChannelDrawer.value = false;
// Reset pagination when switching channels
pagination.value = {
limit: 30,
offset: 0,
hasMore: true,
loadingMore: false,
totalMessages: 0
};
await loadChannelMessages(channel.channelId);
// Update channel stats when selecting a channel
await updateChannelStats();
};
const joinChannel = async (channel) => {
try {
const response = await axios.post(`/api/chat/channels/${channel.channelId}/join`);
if (response.data.success) {
// Update user channels to get updated membership status
await updateUserChannels();
// Immediately remove the joined channel from public channels list
publicChannels.value = publicChannels.value.filter(c => c.id !== channel.id);
// If we're currently viewing this channel, update the current channel data
if (currentChannel.value && currentChannel.value.id === channel.id) {
// Find the updated channel data from userChannels
const updatedChannel = userChannels.value.find(c => c.id === channel.id);
if (updatedChannel) {
currentChannel.value = updatedChannel;
}
}
// Force reactivity update
await nextTick();
// Update channel stats after joining
await updateChannelStats();
// Show success message (optional)
console.log('Successfully joined channel:', channel.name);
}
} catch (error) {
console.error('Error joining channel:', error);
}
};
const leaveChannel = async () => {
if (!currentChannel.value) return;
try {
const response = await axios.post(`/api/chat/channels/${currentChannel.value.channelId}/leave`);
if (response.data.success) {
await loadUserChannels();
await loadPublicChannels();
currentChannel.value = null;
messages.value = [];
// Update channel stats after leaving
if (currentChannel.value) {
await updateChannelStats();
}
}
} catch (error) {
console.error('Error leaving channel:', error);
}
};
const loadChannelMessages = async (channelId, isAutoRefresh = false, isLoadMore = false) => {
try {
if (!isAutoRefresh && !isLoadMore) {
loading.value = true;
}
if (isLoadMore) {
pagination.value.loadingMore = true;
}
const params = new URLSearchParams({
limit: pagination.value.limit.toString(),
offset: pagination.value.offset.toString()
});
const response = await axios.get(`/api/chat/channels/${channelId}/messages?${params}`);
if (response.data.success) {
const newMessages = response.data.data.reverse(); // Show newest first
const paginationInfo = response.data.pagination;
// For auto-refresh, only update if there are new messages
if (isAutoRefresh) {
const currentMessageIds = messages.value.map(m => m.id);
const newMessageIds = newMessages.map(m => m.id);
const hasNewMessages = newMessageIds.some(id => !currentMessageIds.includes(id));
if (hasNewMessages) {
messages.value = newMessages;
await nextTick();
scrollToBottom();
}
} else if (isLoadMore) {
// For load more, prepend older messages to the beginning
if (newMessages.length > 0) {
const scrollPosition = messagesContainer.value?.scrollTop || 0;
const scrollHeight = messagesContainer.value?.scrollHeight || 0;
messages.value = [...newMessages, ...messages.value];
// Update pagination from server response
pagination.value.offset = paginationInfo.offset + pagination.value.limit;
pagination.value.hasMore = paginationInfo.hasMore;
pagination.value.totalMessages = paginationInfo.total;
// Maintain scroll position after adding older messages
await nextTick();
if (messagesContainer.value) {
const newScrollHeight = messagesContainer.value.scrollHeight;
const scrollDiff = newScrollHeight - scrollHeight;
messagesContainer.value.scrollTop = scrollPosition + scrollDiff;
}
} else {
pagination.value.hasMore = false;
}
} else {
// Initial load
messages.value = newMessages;
pagination.value.offset = paginationInfo.offset + pagination.value.limit;
pagination.value.hasMore = paginationInfo.hasMore;
pagination.value.totalMessages = paginationInfo.total;
await nextTick();
scrollToBottom();
}
}
} catch (error) {
console.error('Error loading messages:', error);
} finally {
if (!isAutoRefresh && !isLoadMore) {
loading.value = false;
}
if (isLoadMore) {
pagination.value.loadingMore = false;
}
}
};
const loadMoreMessages = async () => {
if (!currentChannel.value || pagination.value.loadingMore || !pagination.value.hasMore) {
return;
}
await loadChannelMessages(currentChannel.value.channelId, false, true);
};
const handleScroll = () => {
if (!messagesContainer.value || pagination.value.loadingMore || !pagination.value.hasMore) {
return;
}
const { scrollTop } = messagesContainer.value;
// Load more messages when user scrolls to the top (or near the top)
if (scrollTop <= 100) {
loadMoreMessages();
}
};
const sendMessage = async () => {
if (!messageText.value.trim() || loading.value || !currentChannel.value) return;
// Check if user can send message
if (!canSendMessage.value) {
// Show a notification that user needs to join
console.log('User needs to join channel to send messages');
return;
}
loading.value = true;
try {
const messageData = {
content: messageText.value,
messageType: 'text'
};
// Add quoted message ID if there's a quoted message
if (quotedMessage.value) {
messageData.quotedMessageId = quotedMessage.value.id;
}
const response = await axios.post(`/api/chat/channels/${currentChannel.value.channelId}/messages`, messageData);
if (response.data.success) {
const newMessage = response.data.data;
messages.value.push(newMessage);
messageText.value = '';
quotedMessage.value = null; // Clear quoted message after sending
// Update channel stats after sending message
await updateChannelStats();
await nextTick();
scrollToBottom();
}
} catch (error) {
console.error('Error sending message:', error);
// Handle specific error for non-members
if (error.response?.status === 403) {
console.log('User is not a member of this channel');
}
} finally {
loading.value = false;
}
};
const quoteMessage = (message) => {
quotedMessage.value = message;
// Focus on the input field
nextTick(() => {
const input = document.querySelector('.message-input input');
if (input) {
input.focus();
}
});
};
const replyToMessage = (message) => {
quotedMessage.value = message;
// Focus on the input field
nextTick(() => {
const input = document.querySelector('.message-input input');
if (input) {
input.focus();
}
});
};
const cancelQuote = () => {
quotedMessage.value = null;
};
const isLiked = (message) => {
if (!message.reactions || !message.reactions['❤️']) return false;
return message.reactions['❤️'].includes(currentUser.value.id);
};
const isDisliked = (message) => {
if (!message.reactions || !message.reactions['👎']) return false;
return message.reactions['👎'].includes(currentUser.value.id);
};
const toggleLike = async (message) => {
const loadingKey = `like-${message.id}`;
// Prevent multiple clicks
if (reactingMessages.value.has(loadingKey)) return;
try {
reactingMessages.value.add(loadingKey);
const wasLiked = isLiked(message);
// Store original state for rollback
const originalReactions = JSON.parse(JSON.stringify(message.reactions || {}));
if (wasLiked) {
// Remove like
await axios.delete(`/api/chat/messages/${message.id}/reactions`, {
data: { emoji: '❤️' }
});
// Update local state immediately
if (message.reactions && message.reactions['❤️']) {
message.reactions['❤️'] = message.reactions['❤️'].filter(id => id !== currentUser.value.id);
if (message.reactions['❤️'].length === 0) {
delete message.reactions['❤️'];
}
}
} else {
// Add like
await axios.post(`/api/chat/messages/${message.id}/reactions`, {
emoji: '❤️'
});
// Update local state immediately
if (!message.reactions) {
message.reactions = {};
}
if (!message.reactions['❤️']) {
message.reactions['❤️'] = [];
}
if (!message.reactions['❤️'].includes(currentUser.value.id)) {
message.reactions['❤️'].push(currentUser.value.id);
}
}
// Force reactivity update
messages.value = [...messages.value];
} catch (error) {
console.error('Error toggling like:', error);
// Revert local changes on error
message.reactions = originalReactions;
messages.value = [...messages.value];
// Optionally show error message to user
// You can add a toast notification here
} finally {
reactingMessages.value.delete(loadingKey);
}
};
const toggleDislike = async (message) => {
const loadingKey = `dislike-${message.id}`;
// Prevent multiple clicks
if (reactingMessages.value.has(loadingKey)) return;
try {
reactingMessages.value.add(loadingKey);
const wasDisliked = isDisliked(message);
// Store original state for rollback
const originalReactions = JSON.parse(JSON.stringify(message.reactions || {}));
if (wasDisliked) {
// Remove dislike
await axios.delete(`/api/chat/messages/${message.id}/reactions`, {
data: { emoji: '👎' }
});
// Update local state immediately
if (message.reactions && message.reactions['👎']) {
message.reactions['👎'] = message.reactions['👎'].filter(id => id !== currentUser.value.id);
if (message.reactions['👎'].length === 0) {
delete message.reactions['👎'];
}
}
} else {
// Add dislike
await axios.post(`/api/chat/messages/${message.id}/reactions`, {
emoji: '👎'
});
// Update local state immediately
if (!message.reactions) {
message.reactions = {};
}
if (!message.reactions['👎']) {
message.reactions['👎'] = [];
}
if (!message.reactions['👎'].includes(currentUser.value.id)) {
message.reactions['👎'].push(currentUser.value.id);
}
}
// Force reactivity update
messages.value = [...messages.value];
} catch (error) {
console.error('Error toggling dislike:', error);
// Revert local changes on error
message.reactions = originalReactions;
messages.value = [...messages.value];
// Optionally show error message to user
// You can add a toast notification here
} finally {
reactingMessages.value.delete(loadingKey);
}
};
const createChannel = async () => {
if (!newChannel.value.name.trim()) return;
creatingChannel.value = true;
try {
const response = await axios.post('/api/chat/channels', {
name: newChannel.value.name,
description: newChannel.value.description,
isPublic: newChannel.value.isPublic
});
if (response.data.success) {
const newChannelData = response.data.data;
userChannels.value.push(newChannelData);
selectChannel(newChannelData);
showCreateChannel.value = false;
newChannel.value = { name: '', description: '', isPublic: true };
}
} catch (error) {
console.error('Error creating channel:', error);
} finally {
creatingChannel.value = false;
}
};
const insertEmoji = (emoji) => {
messageText.value += emoji;
showEmojiPicker.value = false;
};
const scrollToBottom = () => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
};
const updateChannelStats = async () => {
if (!currentChannel.value) return;
try {
const response = await axios.get(`/api/chat/channels/${currentChannel.value.channelId}/stats`);
if (response.data.success) {
const stats = response.data.data;
// Update current channel stats
if (currentChannel.value) {
currentChannel.value.messageCount = stats.messageCount;
currentChannel.value.memberCount = stats.memberCount;
}
// Update channel in userChannels list
const channelIndex = userChannels.value.findIndex(c => c.id === currentChannel.value.id);
if (channelIndex !== -1) {
userChannels.value[channelIndex].messageCount = stats.messageCount;
userChannels.value[channelIndex].memberCount = stats.memberCount;
}
}
} catch (error) {
console.error('Error updating channel stats:', error);
}
};
const refreshChannels = async () => {
await loadUserChannels();
await loadPublicChannels();
};
// Member management methods
const loadChannelMembers = async () => {
if (!currentChannel.value) return;
// Only load members if user is a member of the channel
if (!canSendMessage.value) {
channelMembers.value = [];
return;
}
loadingMembers.value = true;
try {
const members = await chatStore.getChannelMembers(currentChannel.value.channelId);
channelMembers.value = members;
} catch (error) {
console.error('Error loading channel members:', error);
channelMembers.value = [];
} finally {
loadingMembers.value = false;
}
};
const searchUsersForMember = async () => {
if (!memberSearch.value.trim()) {
searchResults.value = [];
return;
}
searchingUsers.value = true;
try {
const users = await chatStore.searchUsers(memberSearch.value);
// Filter out users who are already members
const memberIds = channelMembers.value.map(m => m.id);
searchResults.value = users.filter(user => !memberIds.includes(user.id));
} catch (error) {
console.error('Error searching users:', error);
searchResults.value = [];
} finally {
searchingUsers.value = false;
}
};
const addMemberToChannel = async (userId) => {
if (!currentChannel.value) return;
try {
const success = await chatStore.addMember(currentChannel.value.channelId, userId);
if (success) {
await loadChannelMembers();
showAddMemberDialog.value = false;
memberSearch.value = '';
searchResults.value = [];
}
} catch (error) {
console.error('Error adding member:', error);
}
};
const removeMemberFromChannel = async (userId) => {
if (!currentChannel.value) return;
try {
const success = await chatStore.removeMember(currentChannel.value.channelId, userId);
if (success) {
await loadChannelMembers();
}
} catch (error) {
console.error('Error removing member:', error);
}
};
// Watch for channel changes to load members
watch(currentChannel, async (newChannel) => {
if (newChannel && canSendMessage.value) {
await loadChannelMembers();
} else {
channelMembers.value = [];
}
});
// Auto refresh methods
const startAutoRefresh = () => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value);
}
autoRefreshInterval.value = setInterval(async () => {
if (currentChannel.value && !loading.value) {
await loadChannelMessages(currentChannel.value.channelId, true);
}
}, refreshInterval);
};
const stopAutoRefresh = () => {
if (autoRefreshInterval.value) {
clearInterval(autoRefreshInterval.value);
autoRefreshInterval.value = null;
}
};
// Watch current channel for auto refresh
watch(currentChannel, (newChannel) => {
if (newChannel) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
});
const formatTime = (timeString) => {
if (!timeString) return '-';
const date = new Date(timeString);
// Check if date is valid
if (isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
// Check if date is valid
if (isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric'
}).format(date);
};
// Function to mask email for security
const maskEmail = (email) => {
if (!email) return '';
const [username, domain] = email.split('@');
if (!domain) return email;
// Mask username (show first and last character)
let maskedUsername = username;
if (username.length > 2) {
maskedUsername = username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1);
} else if (username.length === 2) {
maskedUsername = username.charAt(0) + '*';
}
// Mask domain (show first character of each part)
const domainParts = domain.split('.');
const maskedDomainParts = domainParts.map(part => {
if (part.length > 1) {
return part.charAt(0) + '*'.repeat(part.length - 1);
}
return part;
});
return `${maskedUsername}@${maskedDomainParts.join('.')}`;
};
// Initialize
onMounted(() => {
loadUserChannels();
loadPublicChannels();
});
// Cleanup on unmount
onUnmounted(() => {
stopAutoRefresh();
});
return {
messageText,
loading,
messagesContainer,
showChannelDrawer,
showCreateChannel,
showChannelInfo,
userChannels,
publicChannels,
currentChannel,
messages,
creatingChannel,
showEmojiPicker,
commonEmojis,
currentUser,
newChannel,
channelSearch,
filteredChannels,
canSendMessage,
isCurrentChannelMember,
isChannelJoined,
getChannelInfo,
showMemberDialog,
showAddMemberDialog,
channelMembers,
loadingMembers,
memberSearch,
searchResults,
searchingUsers,
pagination,
sendMessage,
createChannel,
selectChannel,
joinChannel,
leaveChannel,
searchChannels,
insertEmoji,
refreshChannels,
loadChannelMembers,
searchUsersForMember,
addMemberToChannel,
removeMemberFromChannel,
updateUserChannels,
updateChannelStats,
formatTime,
formatDate,
quotedMessage,
showMessageActions,
quoteMessage,
replyToMessage,
cancelQuote,
isLiked,
isDisliked,
toggleLike,
toggleDislike,
reactingMessages,
handleScroll,
loadMoreMessages,
maskEmail,
};
},
});
</script>
<style scoped>
.chat-home {
height: 100vh;
display: flex;
flex-direction: column;
}
.chat-container {
display: flex;
flex: 1;
overflow: hidden;
}
.channel-drawer {
border-right: 1px solid rgba(0, 0, 0, 0.12);
background-color: rgba(0, 0, 0, 0.02);
}
.drawer-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
background-color: rgba(0, 0, 0, 0.01);
}
.channel-section {
margin-bottom: 16px;
}
.section-header {
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
}
.channel-item {
margin: 4px 8px;
transition: all 0.2s ease;
}
.channel-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.channel-active {
background-color: rgba(25, 118, 210, 0.1) !important;
border-left: 3px solid #1976d2;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.chat-content {
display: flex;
flex-direction: column;
height: 100%;
}
.channel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
background-color: rgba(0, 0, 0, 0.02);
}
.channel-actions {
display: flex;
gap: 8px;
}
.channel-info {
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
background-color: rgba(0, 0, 0, 0.01);
}
.channel-stats {
display: flex;
gap: 24px;
}
.stat-item {
display: flex;
align-items: center;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.7);
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.loading-state,
.empty-messages {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.message-item {
display: flex;
gap: 16px;
}
.message-own {
flex-direction: row-reverse;
}
.message-content {
flex: 1;
max-width: 70%;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.message-sender {
font-weight: 600;
font-size: 0.875rem;
}
.message-time {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.6);
}
.quoted-message {
background-color: rgba(0, 0, 0, 0.05);
border-left: 3px solid #1976d2;
padding: 12px;
margin-bottom: 12px;
border-radius: 8px;
}
.quoted-sender {
font-weight: 600;
font-size: 0.75rem;
color: #1976d2;
}
.quoted-text {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.7);
}
.message-bubble {
background-color: #f5f5f5;
padding: 16px;
border-radius: 16px;
position: relative;
}
.message-own .message-bubble {
background-color: #1976d2;
color: white;
}
.emoji-message {
font-size: 2.5rem;
text-align: center;
}
.text-message {
line-height: 1.6;
font-size: 0.95rem;
}
.message-reactions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.message-input-container {
padding: 20px;
border-top: 1px solid rgba(0, 0, 0, 0.12);
background-color: rgba(0, 0, 0, 0.02);
position: relative;
}
.input-wrapper {
position: relative;
}
.message-input {
background-color: white;
border-radius: 12px;
}
.emoji-picker {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 12px;
padding: 12px;
max-height: 250px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
margin-bottom: 12px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
}
.empty-content {
text-align: center;
max-width: 500px;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 16px;
}
.join-prompt {
text-align: center;
padding: 20px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 12px;
margin: 20px;
}
.v-theme--dark .join-prompt {
background-color: rgba(255, 255, 255, 0.02);
}
/* Custom scrollbar */
.messages-container::-webkit-scrollbar,
.channel-drawer::-webkit-scrollbar,
.channel-menu-card::-webkit-scrollbar {
width: 8px;
}
.messages-container::-webkit-scrollbar-track,
.channel-drawer::-webkit-scrollbar-track,
.channel-menu-card::-webkit-scrollbar-track {
background: transparent;
}
.messages-container::-webkit-scrollbar-thumb,
.channel-drawer::-webkit-scrollbar-thumb,
.channel-menu-card::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.messages-container::-webkit-scrollbar-thumb:hover,
.channel-drawer::-webkit-scrollbar-thumb:hover,
.channel-menu-card::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.4);
}
/* Dark theme support */
.v-theme--dark .channel-drawer {
border-right-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.02);
}
.v-theme--dark .drawer-header {
border-bottom-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.01);
}
.v-theme--dark .section-header {
background-color: rgba(255, 255, 255, 0.02);
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.v-theme--dark .channel-item:hover {
background-color: rgba(255, 255, 255, 0.04);
}
.v-theme--dark .message-input-container {
border-top-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.02);
}
.v-theme--dark .channel-header {
border-bottom-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.02);
}
.v-theme--dark .channel-info {
border-bottom-color: rgba(255, 255, 255, 0.12);
background-color: rgba(255, 255, 255, 0.01);
}
.v-theme--dark .message-bubble {
background-color: rgba(255, 255, 255, 0.1);
}
.v-theme--dark .quoted-message {
background-color: rgba(255, 255, 255, 0.05);
}
.v-theme--dark .message-input {
background-color: rgba(255, 255, 255, 0.1);
}
/* Load more indicator */
.load-more-indicator {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background-color: rgba(0, 0, 0, 0.02);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
color: rgba(0, 0, 0, 0.7);
}
.v-theme--dark .load-more-indicator {
background-color: rgba(255, 255, 255, 0.02);
border-bottom-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
/* Mobile optimizations */
@media (max-width: 768px) {
.chat-container {
flex-direction: column;
}
.channel-drawer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
background: white;
}
.chat-area {
width: 100%;
}
.message-content {
max-width: 85%;
}
.emoji-picker {
position: fixed;
bottom: 100px;
left: 16px;
right: 16px;
max-height: 300px;
}
.channel-header {
padding: 16px;
}
.messages-container {
padding: 16px;
}
.message-input-container {
padding: 16px;
}
.action-buttons {
flex-direction: column;
align-items: center;
}
.channel-stats {
flex-direction: column;
gap: 12px;
}
}
/* Member management styles */
.member-item {
transition: all 0.2s ease;
}
.member-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.search-result-item {
transition: all 0.2s ease;
cursor: pointer;
}
.search-result-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
/* Join button and member chip styles */
.join-btn {
font-weight: 600;
text-transform: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.join-btn:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.member-chip {
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.count-chip {
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.unread-chip {
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.v-theme--dark .member-item:hover,
.v-theme--dark .search-result-item:hover {
background-color: rgba(255, 255, 255, 0.04);
}
.v-theme--dark .join-btn {
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
}
.v-theme--dark .member-chip {
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
}
.v-theme--dark .count-chip {
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
}
.v-theme--dark .unread-chip {
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
}
/* New styles for quoted message preview */
.quoted-message-preview {
background-color: rgba(0, 0, 0, 0.05);
border-left: 3px solid #1976d2;
padding: 12px;
margin-bottom: 12px;
border-radius: 8px;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.quoted-preview-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.quoted-preview-header {
display: flex;
align-items: center;
background-color: rgba(25, 118, 210, 0.1);
padding: 4px 8px;
border-radius: 6px;
font-size: 0.75rem;
color: #1976d2;
}
.quoted-preview-text {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.7);
word-break: break-word;
line-height: 1.4;
}
.v-theme--dark .quoted-message-preview {
background-color: rgba(255, 255, 255, 0.05);
border-left-color: #1976d2;
}
.v-theme--dark .quoted-preview-header {
background-color: rgba(25, 118, 210, 0.2);
color: #64b5f6;
}
.v-theme--dark .quoted-preview-text {
color: rgba(255, 255, 255, 0.7);
}
/* Message actions styles */
.message-actions {
display: flex;
gap: 4px;
margin-top: 8px;
opacity: 0;
transition: opacity 0.2s ease;
justify-content: flex-start;
}
.message-item:hover .message-actions {
opacity: 1;
}
.message-own .message-actions {
justify-content: flex-end;
}
.action-btn {
color: rgba(0, 0, 0, 0.6) !important;
transition: all 0.2s ease;
}
.action-btn:hover {
color: #1976d2 !important;
background-color: rgba(25, 118, 210, 0.1) !important;
}
.action-btn.liked {
color: #f44336 !important;
}
.action-btn.liked:hover {
color: #d32f2f !important;
background-color: rgba(244, 67, 54, 0.1) !important;
}
.action-btn.disliked {
color: #ff9800 !important;
}
.action-btn.disliked:hover {
color: #f57c00 !important;
background-color: rgba(255, 152, 0, 0.1) !important;
}
.v-theme--dark .action-btn {
color: rgba(255, 255, 255, 0.6) !important;
}
.v-theme--dark .action-btn:hover {
color: #64b5f6 !important;
background-color: rgba(100, 181, 246, 0.1) !important;
}
.v-theme--dark .action-btn.liked {
color: #ef5350 !important;
}
.v-theme--dark .action-btn.liked:hover {
color: #e53935 !important;
background-color: rgba(239, 83, 80, 0.1) !important;
}
.v-theme--dark .action-btn.disliked {
color: #ffb74d !important;
}
.v-theme--dark .action-btn.disliked:hover {
color: #ffa726 !important;
background-color: rgba(255, 183, 77, 0.1) !important;
}
/* Improve quoted message styling */
.quoted-message {
background-color: rgba(0, 0, 0, 0.05);
border-left: 3px solid #1976d2;
padding: 12px;
margin-bottom: 12px;
border-radius: 8px;
position: relative;
}
.quoted-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.quoted-sender {
font-weight: 600;
font-size: 0.75rem;
color: #1976d2;
}
.quoted-text {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.7);
line-height: 1.4;
word-break: break-word;
}
.v-theme--dark .quoted-message {
background-color: rgba(255, 255, 255, 0.05);
border-left-color: #1976d2;
}
.v-theme--dark .quoted-sender {
color: #64b5f6;
}
.v-theme--dark .quoted-text {
color: rgba(255, 255, 255, 0.7);
}
/* Reaction chip styles */
.reaction-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 12px;
font-weight: 600;
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.8);
background-color: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.reaction-chip:hover {
background-color: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.2);
}
.reaction-chip.like-chip {
color: #f44336; /* Red for like */
background-color: rgba(244, 67, 54, 0.1);
border-color: rgba(244, 67, 54, 0.2);
}
.reaction-chip.like-chip:hover {
background-color: rgba(244, 67, 54, 0.2);
border-color: rgba(244, 67, 54, 0.3);
}
.reaction-chip.dislike-chip {
color: #ff9800; /* Orange for dislike */
background-color: rgba(255, 152, 0, 0.1);
border-color: rgba(255, 152, 0, 0.2);
}
.reaction-chip.dislike-chip:hover {
background-color: rgba(255, 152, 0, 0.2);
border-color: rgba(255, 152, 0, 0.3);
}
.v-theme--dark .reaction-chip {
color: rgba(255, 255, 255, 0.8);
background-color: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.v-theme--dark .reaction-chip:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.v-theme--dark .reaction-chip.like-chip {
color: #ef5350; /* Darker red for dark mode */
background-color: rgba(239, 83, 80, 0.1);
border-color: rgba(239, 83, 80, 0.2);
}
.v-theme--dark .reaction-chip.like-chip:hover {
background-color: rgba(239, 83, 80, 0.2);
border-color: rgba(239, 83, 80, 0.3);
}
.v-theme--dark .reaction-chip.dislike-chip {
color: #ffa726; /* Darker orange for dark mode */
background-color: rgba(255, 167, 38, 0.1);
border-color: rgba(255, 167, 38, 0.2);
}
.v-theme--dark .reaction-chip.dislike-chip:hover {
background-color: rgba(255, 167, 38, 0.2);
border-color: rgba(255, 167, 38, 0.3);
}
/* Dialog Styles */
.create-channel-dialog,
.member-dialog,
.add-member-dialog {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.dialog-header {
background: linear-gradient(135deg, #1976d2 0%, #1565c0 100%);
color: white;
padding: 24px;
position: relative;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.dialog-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(5px);
}
.dialog-header > div {
position: relative;
z-index: 1;
}
.dialog-header h2 {
color: white !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
font-weight: 600;
}
.dialog-header p {
color: rgba(255, 255, 255, 0.9) !important;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Dark theme support for dialog headers */
.v-theme--dark .dialog-header {
background: linear-gradient(135deg, #1565c0 0%, #0d47a1 100%);
}
.v-theme--dark .dialog-header h2 {
color: white !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.v-theme--dark .dialog-header p {
color: rgba(255, 255, 255, 0.95) !important;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dialog-content {
padding: 24px;
max-height: 60vh;
overflow-y: auto;
}
.dialog-actions {
padding: 16px 24px;
background-color: rgba(0, 0, 0, 0.02);
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.add-member-btn {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
.add-member-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
/* Member Stats Cards */
.member-stats {
margin-bottom: 24px;
}
.stat-card {
border-radius: 12px;
transition: all 0.3s ease;
cursor: pointer;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.stat-card .v-card-text {
padding: 20px;
}
/* Member List */
.member-list {
background: transparent;
}
.member-item {
margin-bottom: 8px;
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.member-item:hover {
background-color: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.08);
transform: translateX(4px);
}
.remove-btn {
opacity: 0;
transition: all 0.3s ease;
}
.member-item:hover .remove-btn {
opacity: 1;
}
/* Search Results */
.search-results {
margin-top: 16px;
}
.search-result-list {
background: transparent;
}
.search-result-item {
margin-bottom: 8px;
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid transparent;
cursor: pointer;
}
.search-result-item:hover {
background-color: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.08);
transform: translateX(4px);
}
/* Loading and Empty States */
.loading-members,
.searching-users,
.no-results,
.search-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.loading-members p,
.searching-users p {
color: rgba(0, 0, 0, 0.7);
margin-top: 16px;
}
.no-results h4,
.search-placeholder h4 {
color: rgba(0, 0, 0, 0.6);
margin-bottom: 8px;
}
.no-results p,
.search-placeholder p {
color: rgba(0, 0, 0, 0.5);
max-width: 300px;
}
/* Dark Theme Support for Dialogs */
.v-theme--dark .dialog-actions {
background-color: rgba(255, 255, 255, 0.02);
border-top-color: rgba(255, 255, 255, 0.08);
}
.v-theme--dark .stat-card:hover {
box-shadow: 0 8px 25px rgba(255, 255, 255, 0.1);
}
.v-theme--dark .member-item:hover {
background-color: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
.v-theme--dark .search-result-item:hover {
background-color: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
.v-theme--dark .loading-members p,
.v-theme--dark .searching-users p {
color: rgba(255, 255, 255, 0.7);
}
.v-theme--dark .no-results h4,
.v-theme--dark .search-placeholder h4 {
color: rgba(255, 255, 255, 0.6);
}
.v-theme--dark .no-results p,
.v-theme--dark .search-placeholder p {
color: rgba(255, 255, 255, 0.5);
}
/* Mobile Responsive for Dialogs */
@media (max-width: 768px) {
.create-channel-dialog,
.member-dialog,
.add-member-dialog {
margin: 16px;
border-radius: 12px;
}
.dialog-header {
padding: 20px;
}
.dialog-content {
padding: 20px;
max-height: 50vh;
}
.dialog-actions {
padding: 12px 20px;
}
.member-stats .v-row {
margin: 0;
}
.member-stats .v-col {
padding: 8px;
}
.stat-card .v-card-text {
padding: 16px;
}
.add-member-btn {
font-size: 0.875rem;
padding: 8px 16px;
}
}
/* Animation for dialog entrance */
.v-dialog .v-card {
animation: dialogSlideIn 0.3s ease-out;
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Hover effects for interactive elements */
.v-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.v-chip:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Custom scrollbar for dialog content */
.dialog-content::-webkit-scrollbar {
width: 6px;
}
.dialog-content::-webkit-scrollbar-track {
background: transparent;
}
.dialog-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.dialog-content::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.v-theme--dark .dialog-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
.v-theme--dark .dialog-content::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
</style>