This commit is contained in:
2026-02-23 23:56:39 +03:00
parent 25750fee51
commit 2cc21bb284
15 changed files with 340 additions and 94 deletions

View File

@@ -1,20 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import ChatView from '@/views/ChatView.vue' import ChatView from '@/views/ChatView.vue'
import LoginView from '@/views/LoginView.vue'
import { useAuthStore } from '@/stores/auth.ts' import { useAuthStore } from '@/stores/auth.ts'
import { computed } from 'vue' import SignInView from '@/views/SignInView.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const isAuth = computed(() => !!authStore.token)
</script> </script>
<template> <template>
<v-app> <v-app>
<v-main class="chat-fullscreen"> <v-main class="chat-fullscreen">
<v-container fluid class="h-100 pa-0"> <v-container fluid class="h-100 pa-0">
<chat-view v-if="isAuth" /> <chat-view v-if="authStore.isAuth" />
<login-view v-else /> <sign-in-view v-else />
<!-- <sign-in-view />-->
</v-container> </v-container>
</v-main> </v-main>
</v-app> </v-app>
@@ -24,7 +22,4 @@ const isAuth = computed(() => !!authStore.token)
.chat-fullscreen { .chat-fullscreen {
height: 100vh; height: 100vh;
} }
.message-shaped {
border-radius: 0 24px;
}
</style> </style>

View File

@@ -1,27 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { useSharedWebSocket } from '@/composables/useSharedWebSocket.ts' import { type Chat, useChatsStore } from '@/stores/chats.ts'
import { SocketDataReq, useSocketsStore } from '@/stores/sockets.ts'
import { useAuthStore } from '@/stores/auth.ts'
const { messages, chats, isConnected, error, send } = useSharedWebSocket() const authStore = useAuthStore()
const chatsStore = useChatsStore()
const socketsStore = useSocketsStore()
function onSelect(value: { id: unknown }) {
console.log('onSelectChat', value.id)
const chatId = value.id as string
chatsStore.selected = chatId
socketsStore.send({
type: SocketDataReq.GET_MESSAGES,
data: { chat_id: chatId },
})
}
function getChatName(chat: Chat) {
if (chat.type_id === 2) {
return chat.name
} else if (chat.type_id === 1) {
const otherUsers = chat.users.filter((user) => user.id !== authStore.me?.id)
return otherUsers[0]?.name ?? otherUsers[0]?.email ?? 'unknown'
}
return 'unknown ID'
}
</script> </script>
<template> <template>
<v-list theme="dark" density="compact" lines="two"> <v-list theme="dark" density="compact" lines="two" @click:select="onSelect">
<v-list-subheader>Чаты</v-list-subheader> <v-list-subheader>Чаты</v-list-subheader>
<div>{{ chats }}</div>
<v-list-item <v-list-item
v-for="n in 5" v-for="chat in chatsStore.chats"
:key="n" :key="chat.id"
:title="'Контакт ' + n" :value="chat.id"
:subtitle="'Последнее сообщение...'" subtitle="last message..."
> >
<template v-slot:title>{{ getChatName(chat) }} </template>
<template v-slot:prepend> <template v-slot:prepend>
<v-avatar color="grey-lighten-1" text="V"> </v-avatar> <v-avatar color="grey-lighten-1" text="V"> </v-avatar>
</template> </template>
<template v-slot:append> <template v-slot:append>
<div class="d-flex flex-column align-end"> <div class="d-flex flex-column align-end">
<v-chip density="compact" size="small">2</v-chip> <v-chip density="compact" size="small">0</v-chip>
<div class="text-caption">22:22</div> <div class="text-caption">00:00</div>
</div> </div>
</template> </template>
</v-list-item> </v-list-item>

View File

@@ -2,8 +2,9 @@
import { computed } from 'vue' import { computed } from 'vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ createdAt?: string; username?: string; side?: 'right'; text?: string }>(), defineProps<{ createdAt?: string; username?: string; my?: boolean; text?: string }>(),
{ {
my: false,
text: 'foobar', text: 'foobar',
username: 'robot', username: 'robot',
}, },
@@ -34,19 +35,13 @@ const avatarLetter = computed(() => {
<!-- </div>--> <!-- </div>-->
<!-- </div>--> <!-- </div>-->
<v-sheet <v-sheet color="transparent" class="d-flex ga-2" :class="{ 'flex-row-reverse': props.my }">
color="transparent" <!-- <v-avatar size="36" color="deep-purple-lighten-4">-->
class="d-flex ga-2" <!-- <span class="text-deep-purple-darken-2">{{ avatarLetter }}</span>-->
:class="{ 'flex-row-reverse': props.side === 'right' }" <!-- </v-avatar>-->
>
<v-avatar size="36" color="deep-purple-lighten-4"> <!-- :class="props.my ? 'message-shaped-right' : 'message-shaped'"-->
<span class="text-deep-purple-darken-2">{{ avatarLetter }}</span> <v-sheet class="pa-4 message-width rounded-lg" :color="props.my ? 'primary' : 'white'">
</v-avatar>
<v-sheet
class="pa-4 message-width"
:color="props.side === 'right' ? 'primary' : 'white'"
:class="props.side === 'right' ? 'message-shaped-right' : 'message-shaped'"
>
<span class="text-body-1">{{ props.text }}</span> <span class="text-body-1">{{ props.text }}</span>
<span class="text-caption ml-2">{{ createdAt }}</span> <span class="text-caption ml-2">{{ createdAt }}</span>
</v-sheet> </v-sheet>

View File

@@ -1,9 +1,43 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { computed, ref } from 'vue'
import { SocketDataReq, useSocketsStore } from '@/stores/sockets.ts'
import { useChatsStore } from '@/stores/chats.ts'
const socketsStore = useSocketsStore()
const chatsStore = useChatsStore()
const text = ref('')
const sendMessage = () => {
if (text.value.trim()) {
socketsStore.send({
type: SocketDataReq.CREATE_MESSAGE,
data: {
chat_id: chatsStore.selected,
text: text.value,
},
})
}
text.value = ''
}
const isEmptyText = computed(() => {
return !text.value
})
</script>
<template> <template>
<v-row dense align="center"> <v-row dense align="center">
<v-col> <v-col>
<v-text-field rounded="xl" bg-color="white" variant="outlined" hide-details label="Message"> <v-text-field
rounded="lg"
bg-color="white"
variant="outlined"
hide-details
label="Message"
v-model="text"
@keyup.enter="sendMessage"
>
<template v-slot:prepend-inner> <template v-slot:prepend-inner>
<v-btn icon variant="text" density="compact" color="grey-darken-1"> <v-btn icon variant="text" density="compact" color="grey-darken-1">
<v-icon>mdi-emoticon-outline</v-icon> <v-icon>mdi-emoticon-outline</v-icon>
@@ -12,14 +46,14 @@
</v-text-field> </v-text-field>
</v-col> </v-col>
<v-col cols="auto"> <v-col v-if="false" cols="auto">
<v-btn icon variant="text" color="grey-darken-1"> <v-btn icon variant="text" color="grey-darken-1">
<v-icon>mdi-paperclip</v-icon> <v-icon>mdi-paperclip</v-icon>
</v-btn> </v-btn>
</v-col> </v-col>
<v-col cols="auto"> <v-col cols="auto">
<v-btn icon variant="flat" color="primary"> <v-btn :disabled="isEmptyText" icon variant="flat" color="primary" @click="sendMessage">
<v-icon>mdi-send</v-icon> <v-icon>mdi-send</v-icon>
</v-btn> </v-btn>
</v-col> </v-col>

View File

@@ -2,6 +2,16 @@
import MessageToolbar from '@/components/MessageToolbar.vue' import MessageToolbar from '@/components/MessageToolbar.vue'
import MessageForm from '@/components/MessageForm.vue' import MessageForm from '@/components/MessageForm.vue'
import MessageData from '@/components/MessageData.vue' import MessageData from '@/components/MessageData.vue'
import { useMessagesStore } from '@/stores/messages.ts'
import { useAuthStore } from '@/stores/auth.ts'
import { computed } from 'vue'
const authStore = useAuthStore()
const messagesStore = useMessagesStore()
const messages = computed(() => {
return [...messagesStore.messages]
})
</script> </script>
<template> <template>
@@ -14,8 +24,14 @@ import MessageData from '@/components/MessageData.vue'
color="grey-lighten-3" color="grey-lighten-3"
class="flex-grow-1 d-flex flex-column overflow-hidden" class="flex-grow-1 d-flex flex-column overflow-hidden"
> >
<v-card-text class="flex-grow-1 d-flex ga-4 flex-column flex-column-reverse overflow-y-auto"> <v-card-text class="flex-grow-1 d-flex ga-1 flex-column overflow-y-auto justify-end">
<message-data /> <message-data
v-for="message in messages"
:key="message.id"
:text="message.text"
:my="authStore.me?.id === message.user_id"
:created-at="message.created_at"
/>
</v-card-text> </v-card-text>
<v-card-actions class="flex-grow-0 bg-white pa-4"> <v-card-actions class="flex-grow-0 bg-white pa-4">

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useSharedWebSocket, WsDataType } from '../composables/useSharedWebSocket'
const message = ref('') const message = ref('')
const selectedChat = ref('') const selectedChat = ref('')

View File

@@ -19,12 +19,12 @@ export interface User {
} }
export interface WsData { export interface WsData {
type: WsDataType type: WsDataType2
// data: Chat | Message | User | User[] | Message[] | Chat[] // data: Chat | Message | User | User[] | Message[] | Chat[]
data: unknown data: unknown
} }
export enum WsDataType { export enum WsDataType2 {
GET_CHATS = 'GET_CHATS', GET_CHATS = 'GET_CHATS',
GET_USERS = 'GET_USERS', GET_USERS = 'GET_USERS',
GET_MESSAGES = 'GET_MESSAGES', GET_MESSAGES = 'GET_MESSAGES',
@@ -36,7 +36,7 @@ export enum WsDataType {
ERROR = 'ERROR', ERROR = 'ERROR',
} }
export enum COMMAND { export enum COMMAND2 {
CONNECT = 'CONNECT', CONNECT = 'CONNECT',
SEND = 'SEND', SEND = 'SEND',
CLOSE = 'CLOSE', CLOSE = 'CLOSE',
@@ -68,24 +68,24 @@ export function useSharedWebSocket(options?: { url?: string; autoConnect?: true
const { type, data, connected, message } = event.data const { type, data, connected, message } = event.data
switch (type) { switch (type) {
case WsDataType.USER: case WsDataType2.USER:
break break
case WsDataType.CHATS: case WsDataType2.CHATS:
chats.value = data chats.value = data
break break
case WsDataType.MESSAGES: case WsDataType2.MESSAGES:
messages.value = data messages.value = data
// if (options.onMessage) { // if (options.onMessage) {
// options.onMessage(data) // options.onMessage(data)
// } // }
break break
case WsDataType.CREATE_MESSAGE: case WsDataType2.CREATE_MESSAGE:
messages.value.push(data) messages.value.push(data)
break break
case WsDataType.STATUS: case WsDataType2.STATUS:
isConnected.value = connected isConnected.value = connected
break break
case WsDataType.ERROR: case WsDataType2.ERROR:
error.value = message error.value = message
logout() logout()
break break
@@ -93,7 +93,7 @@ export function useSharedWebSocket(options?: { url?: string; autoConnect?: true
} }
worker.value.port.postMessage({ worker.value.port.postMessage({
command: COMMAND.CONNECT, command: COMMAND2.CONNECT,
data: { url: url, token: getToken() }, data: { url: url, token: getToken() },
}) })
} }
@@ -109,7 +109,7 @@ export function useSharedWebSocket(options?: { url?: string; autoConnect?: true
const send = (data: WsData) => { const send = (data: WsData) => {
if (worker.value) { if (worker.value) {
worker.value.port.postMessage({ worker.value.port.postMessage({
command: COMMAND.SEND, command: COMMAND2.SEND,
data: data, data: data,
}) })
} }
@@ -117,7 +117,7 @@ export function useSharedWebSocket(options?: { url?: string; autoConnect?: true
const close = () => { const close = () => {
if (worker.value) { if (worker.value) {
worker.value.port.postMessage({ command: COMMAND.CLOSE }) worker.value.port.postMessage({ command: COMMAND2.CLOSE })
} }
} }

View File

@@ -1,8 +1,11 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { computed, ref } from 'vue'
import type { WsData } from '@/composables/useSharedWebSocket.ts'
import type { User } from '@/stores/users.ts'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token')) const token = ref(localStorage.getItem('token'))
const me = ref<User>()
async function login(email: string) { async function login(email: string) {
try { try {
@@ -25,5 +28,16 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
return { token, login } const isAuth = computed(() => !!token.value)
function getToken() {
return localStorage.getItem('token')
}
function logout() {
token.value = ''
return localStorage.removeItem('token')
}
return { me, token, isAuth, login, logout, getToken }
}) })

View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { User } from '@/stores/users.ts'
export interface Chat {
id: string
type_id: number
name: string
users: User[]
}
export const useChatsStore = defineStore('chats', () => {
const chats = ref<Chat[]>([])
const selected = ref<string>()
return { chats, selected }
})

View File

@@ -0,0 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Message {
id: number
text: string
user_id: number
created_at: string
}
export const useMessagesStore = defineStore('messages', () => {
const messages = ref<Message[]>([])
return { messages }
})

View File

@@ -1,5 +1,122 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { onMounted, onUnmounted, ref } from 'vue'
import { type Chat, useChatsStore } from '@/stores/chats.ts'
import { useAuthStore } from '@/stores/auth.ts'
import { type Message, useMessagesStore } from '@/stores/messages.ts'
export const useSockets = defineStore('sockets', () => { export enum SocketDataReq {
return {} GET_CHATS = 'GET_CHATS',
CREATE_CHAT = 'CREATE_CHAT',
GET_USERS = 'GET_USERS',
GET_USER = 'GET_USER',
GET_MESSAGES = 'GET_MESSAGES',
CREATE_MESSAGE = 'CREATE_MESSAGE',
}
export enum SocketDataRes {
USERS = 'USERS',
CHATS = 'CHATS',
MESSAGES = 'MESSAGES',
MESSAGE = 'MESSAGE',
USER = 'USER',
STATUS = 'STATUS',
ERROR = 'ERROR',
}
export enum COMMAND {
CONNECT = 'CONNECT',
SEND = 'SEND',
CLOSE = 'CLOSE',
}
export const useSocketsStore = defineStore('sockets', () => {
const url = 'ws://localhost:3000/ws'
const authStore = useAuthStore()
const chatsStore = useChatsStore()
const messagesStore = useMessagesStore()
const isConnected = ref(false)
const error = ref<string>()
const worker = ref<SharedWorker>()
function init() {
console.log('INIT SHARED WORKER')
if (!window.SharedWorker) {
console.log('SharedWorker not supported')
error.value = 'SharedWorker not supported'
}
worker.value = new SharedWorker(new URL('@/workers/worker.js', import.meta.url), {
type: 'module',
})
worker.value.port.onmessage = (event) => {
const { type, data, connected, message } = event.data
switch (type) {
case SocketDataRes.USERS:
console.log('USERS_LIST', data)
break
case SocketDataRes.USER:
console.log('USER', data)
authStore.me = data
break
case SocketDataRes.CHATS:
console.log('CHATS', data)
chatsStore.chats = data
break
case SocketDataRes.MESSAGES:
console.log('MESSAGES', data)
messagesStore.messages = data.reverse()
// if (options.onMessage) {
// options.onMessage(data)
// }
break
case SocketDataRes.MESSAGE:
console.log('MESSAGE', data)
messagesStore.messages.push(data)
break
case SocketDataRes.STATUS:
isConnected.value = connected
break
case SocketDataRes.ERROR:
error.value = message
authStore.logout()
break
}
}
connect()
}
function postMessage(command: COMMAND, data?: unknown) {
if (worker.value) worker.value.port.postMessage({ command, data })
}
function connect() {
postMessage(COMMAND.CONNECT, { url: url, token: authStore.getToken() })
}
const send = (data: unknown) => {
postMessage(COMMAND.SEND, data)
}
const close = () => {
postMessage(COMMAND.CLOSE)
}
onUnmounted(() => {
if (worker.value) close()
})
return {
isConnected,
error,
send,
close,
init,
}
}) })

View File

@@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface User {
id: number
email: string
name: string
}
export const useUsersStore = defineStore('users', () => {
const users = ref<User[]>([])
return { users }
})

View File

@@ -1,47 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, ref } from 'vue'
import { useSharedWebSocket, WsDataType } from '@/composables/useSharedWebSocket.ts'
import ChatsPane from '@/components/ChatsPane.vue' import ChatsPane from '@/components/ChatsPane.vue'
import MessagesPane from '@/components/MessagesPane.vue' import MessagesPane from '@/components/MessagesPane.vue'
import { useSocketsStore } from '@/stores/sockets.ts'
import { useChatsStore } from '@/stores/chats.ts'
const { messages, chats, isConnected, error, send } = useSharedWebSocket({ autoConnect: true }) const socketsStore = useSocketsStore()
// const { messages, chats, isConnected, error, send } = useSharedWebSocket({ autoConnect: true })
// const socketsStore = useSocketsStore()
const chatsStore = useChatsStore()
const message = ref('') const message = ref('')
const selectedChat = ref('') const selectedChat = ref('')
const onChatClick = (id: string) => { // const onChatClick = (id: string) => {
console.log(id) // console.log(id)
selectedChat.value = id // selectedChat.value = id
send({ // send({
type: WsDataType.GET_MESSAGES, // type: WsDataType.GET_MESSAGES,
data: { chat_id: id }, // data: { chat_id: id },
}) // })
} // }
const sendMessage = () => { // const sendMessage = () => {
if (message.value.trim()) { // if (message.value.trim()) {
send({ // send({
type: WsDataType.CREATE_MESSAGE, // type: WsDataType.CREATE_MESSAGE,
data: { // data: {
chat_id: selectedChat.value, // chat_id: selectedChat.value,
text: message.value, // text: message.value,
}, // },
}) // })
message.value = '' // message.value = ''
} // }
} // }
onMounted(() => {
socketsStore.init()
})
</script> </script>
<template> <template>
<v-row no-gutters class="h-100"> <v-row no-gutters class="h-100">
<v-col cols="12" md="4" lg="3"> <v-col cols="12" md="4" lg="3" sm="5">
<chats-pane /> <chats-pane />
</v-col> </v-col>
<v-col cols="12" md="8" lg="9" class="h-100"> <v-col cols="12" md="8" lg="9" sm="7" class="h-100">
<!-- <messages-pane />--> <messages-pane v-if="chatsStore.selected" />
<div>error:{{ error }}</div>
<div>is connected:{{ isConnected }}</div>
<div>chats {{ chats }}</div>
</v-col> </v-col>
</v-row> </v-row>
</template> </template>

View File

@@ -6,18 +6,15 @@ const authStore = useAuthStore()
const email = ref('vadim.olonin@gmail.com') const email = ref('vadim.olonin@gmail.com')
async function onSubmit() { async function onSubmit() {
console.log('onSubmit')
await authStore.login(email.value) await authStore.login(email.value)
} }
</script> </script>
<template> <template>
<v-row justify="center" class="h-100 mt-10"> <v-row justify="center" align="center" class="h-100">
<v-col cols="12" md="4" lg="4"> <v-col cols="12" md="4" lg="4">
<v-card elevation="0"> <v-card elevation="0" border rounded="lg">
<v-card-item class="justify-center mb-4"> <v-card-title class="mb-2">Sign in</v-card-title>
<v-img rounded="lg" src="img.png" width="160" height="160" />
</v-card-item>
<v-card-text> <v-card-text>
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit">
<div class="d-flex flex-column ga-4"> <div class="d-flex flex-column ga-4">
@@ -27,11 +24,11 @@ async function onSubmit() {
v-model="email" v-model="email"
placeholder="email" placeholder="email"
type="email" type="email"
rounded="xl" rounded="lg"
/> />
<div class="d-flex ga-4 align-center justify-space-between"> <div class="d-flex ga-4 align-center justify-space-between">
<v-switch color="black" label="Keep me signed in" hide-details></v-switch> <v-switch color="black" label="Keep me signed in" hide-details></v-switch>
<v-btn variant="flat" rounded="xl" type="submit" color="black">login</v-btn> <v-btn variant="flat" rounded="lg" type="submit" color="black">login</v-btn>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -16,6 +16,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/login': 'http://localhost:3000', '/login': 'http://localhost:3000',
}, },