148 lines
3.9 KiB
Vue
148 lines
3.9 KiB
Vue
|
|
<template>
|
||
|
|
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" maximized>
|
||
|
|
<q-card class="column no-wrap">
|
||
|
|
<q-toolbar class="bg-primary text-white">
|
||
|
|
<q-icon name="chat" class="q-mr-sm" />
|
||
|
|
<q-toolbar-title>{{ spotName }} 채팅</q-toolbar-title>
|
||
|
|
<q-chip dense :color="connected ? 'positive' : 'grey-5'" text-color="white">
|
||
|
|
{{ connected ? '실시간' : '연결 중…' }}
|
||
|
|
</q-chip>
|
||
|
|
<q-btn flat round dense icon="close" @click="close" />
|
||
|
|
</q-toolbar>
|
||
|
|
|
||
|
|
<!-- 메시지 목록 -->
|
||
|
|
<q-scroll-area ref="scrollArea" class="col q-pa-md" style="background: #f4faff">
|
||
|
|
<div v-if="!messages.length" class="text-center text-grey-5 q-mt-lg">
|
||
|
|
첫 메시지를 남겨보세요. 이 스팟에 체크인한 이웃들과 대화해요.
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
v-for="m in messages"
|
||
|
|
:key="m.id"
|
||
|
|
class="q-mb-sm row"
|
||
|
|
:class="isMine(m) ? 'justify-end' : 'justify-start'"
|
||
|
|
>
|
||
|
|
<div :class="isMine(m) ? 'msg mine' : 'msg'">
|
||
|
|
<div v-if="!isMine(m)" class="text-caption text-weight-medium text-accent">
|
||
|
|
{{ m.senderNickname }}
|
||
|
|
</div>
|
||
|
|
<div>{{ m.content }}</div>
|
||
|
|
<div class="text-caption text-grey-6 text-right">{{ time(m.createdAt) }}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</q-scroll-area>
|
||
|
|
|
||
|
|
<!-- 입력 -->
|
||
|
|
<div class="row q-pa-sm q-gutter-sm bg-white items-center">
|
||
|
|
<q-input
|
||
|
|
v-model="draft"
|
||
|
|
class="col"
|
||
|
|
dense
|
||
|
|
outlined
|
||
|
|
placeholder="메시지 입력…"
|
||
|
|
maxlength="1000"
|
||
|
|
:disable="!connected"
|
||
|
|
@keyup.enter="send"
|
||
|
|
/>
|
||
|
|
<q-btn round color="primary" icon="send" :disable="!connected || !draft.trim()" @click="send" />
|
||
|
|
</div>
|
||
|
|
</q-card>
|
||
|
|
</q-dialog>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
|
|
import { QScrollArea, useQuasar } from 'quasar'
|
||
|
|
import { chatApi, type ChatMessage } from '@/api/chat'
|
||
|
|
import { createSpotChat } from '@/lib/chatSocket'
|
||
|
|
import { useAuthStore } from '@/stores/auth'
|
||
|
|
|
||
|
|
const props = defineProps<{ modelValue: boolean; spotId: number; spotName: string }>()
|
||
|
|
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
|
||
|
|
|
||
|
|
const $q = useQuasar()
|
||
|
|
const auth = useAuthStore()
|
||
|
|
|
||
|
|
const messages = ref<ChatMessage[]>([])
|
||
|
|
const draft = ref('')
|
||
|
|
const connected = ref(false)
|
||
|
|
const scrollArea = ref<QScrollArea | null>(null)
|
||
|
|
let chat: ReturnType<typeof createSpotChat> | null = null
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => props.modelValue,
|
||
|
|
async (open) => {
|
||
|
|
if (open) await start()
|
||
|
|
else stop()
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
onBeforeUnmount(stop)
|
||
|
|
|
||
|
|
async function start() {
|
||
|
|
messages.value = []
|
||
|
|
try {
|
||
|
|
messages.value = await chatApi.history(props.spotId)
|
||
|
|
scrollToBottom()
|
||
|
|
} catch {
|
||
|
|
// 히스토리 실패해도 실시간 연결은 시도
|
||
|
|
}
|
||
|
|
chat = createSpotChat(
|
||
|
|
props.spotId,
|
||
|
|
(m) => {
|
||
|
|
messages.value.push(m)
|
||
|
|
scrollToBottom()
|
||
|
|
},
|
||
|
|
(c) => (connected.value = c),
|
||
|
|
)
|
||
|
|
chat.activate()
|
||
|
|
}
|
||
|
|
|
||
|
|
function stop() {
|
||
|
|
chat?.deactivate()
|
||
|
|
chat = null
|
||
|
|
connected.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function send() {
|
||
|
|
const text = draft.value.trim()
|
||
|
|
if (!text || !chat) return
|
||
|
|
chat.send(text)
|
||
|
|
draft.value = ''
|
||
|
|
}
|
||
|
|
|
||
|
|
function close() {
|
||
|
|
emit('update:modelValue', false)
|
||
|
|
}
|
||
|
|
|
||
|
|
function isMine(m: ChatMessage) {
|
||
|
|
return m.senderId === auth.member?.id
|
||
|
|
}
|
||
|
|
|
||
|
|
function time(iso: string) {
|
||
|
|
const d = new Date(iso)
|
||
|
|
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||
|
|
}
|
||
|
|
|
||
|
|
function scrollToBottom() {
|
||
|
|
nextTick(() => {
|
||
|
|
const el = scrollArea.value
|
||
|
|
if (el) el.setScrollPercentage('vertical', 1, 150)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.msg {
|
||
|
|
max-width: 76%;
|
||
|
|
padding: 8px 12px;
|
||
|
|
border-radius: 12px;
|
||
|
|
background: #ffffff;
|
||
|
|
border: 1px solid #e3eef7;
|
||
|
|
word-break: break-word;
|
||
|
|
}
|
||
|
|
.msg.mine {
|
||
|
|
background: #d7ecfb;
|
||
|
|
border-color: #cfe8fb;
|
||
|
|
}
|
||
|
|
</style>
|