added room actions: leave and delete, and improved connection error handling
This commit is contained in:
74
src/App.vue
74
src/App.vue
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div id="page">
|
||||
<main id="content">
|
||||
<router-view />
|
||||
</main>
|
||||
<div id="page">
|
||||
<main id="content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
||||
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
||||
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
||||
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
||||
|
||||
<footer v-if="!$route.meta.hideNavbar">
|
||||
<Navbar />
|
||||
</footer>
|
||||
</div>
|
||||
<footer v-if="!$route.meta.hideNavbar">
|
||||
<Navbar />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -27,51 +27,51 @@ const appVersion = __APP_VERSION__
|
||||
const expectedBackendVersion = __BACKEND_VERSION__
|
||||
|
||||
onMounted(async () => {
|
||||
backendVersion.value = (await getBackendVersion()).version
|
||||
if (backendVersion.value !== expectedBackendVersion) {
|
||||
showVersionWarningModal.value = true
|
||||
}
|
||||
backendVersion.value = (await getBackendVersion()).version
|
||||
if (backendVersion.value !== expectedBackendVersion) {
|
||||
showVersionWarningModal.value = true
|
||||
}
|
||||
})
|
||||
|
||||
async function getBackendVersion() {
|
||||
return await apiFetch<VersionResponse>('/version')
|
||||
return await apiFetch<VersionResponse>('/version')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#page {
|
||||
background: var(--bg);
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#content {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
padding: 2rem;
|
||||
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 24px;
|
||||
background: var(--bg);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-bottom: 24px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#content {
|
||||
padding: 12px;
|
||||
padding-top: 30px;
|
||||
}
|
||||
#content {
|
||||
padding: 12px;
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
footer {
|
||||
padding-bottom: 56px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +1,65 @@
|
||||
import { apiFetch } from './client'
|
||||
import type { Room, RoomInvite } from '../types'
|
||||
import { UserProfile, type Room, type RoomInvite } from '../types'
|
||||
|
||||
export function fetchRooms() {
|
||||
return apiFetch<Room[]>(`/rooms`)
|
||||
return apiFetch<Room[]>(`/rooms`)
|
||||
}
|
||||
|
||||
export function fetchRoomInfo(uuid: string) {
|
||||
return apiFetch<Room>(`/rooms/${uuid}`)
|
||||
return apiFetch<Room>(`/rooms/${uuid}`)
|
||||
}
|
||||
|
||||
export function createRoom(name: string, global: boolean) {
|
||||
return apiFetch<Room>('/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, global }),
|
||||
})
|
||||
return apiFetch<Room>('/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, global }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchRoomInvites() {
|
||||
return apiFetch<RoomInvite[]>('/rooms/invites')
|
||||
return apiFetch<RoomInvite[]>('/rooms/invites')
|
||||
}
|
||||
|
||||
export function sendRoomInvite(receiverUsername: string, roomUuid: string) {
|
||||
return apiFetch<void>('/rooms/invite', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ receiver_username: receiverUsername, room_uuid: roomUuid }),
|
||||
});
|
||||
return apiFetch<void>('/rooms/invite', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ receiver_username: receiverUsername, room_uuid: roomUuid }),
|
||||
});
|
||||
}
|
||||
|
||||
export function acceptRoomInvite(senderUuid: string, roomUuid: string) {
|
||||
return apiFetch<void>('/rooms/join', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
||||
})
|
||||
return apiFetch<void>('/rooms/join', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
||||
})
|
||||
}
|
||||
|
||||
export function declineRoomInvite(senderUuid: string, roomUuid: string) {
|
||||
return apiFetch<void>('/rooms/decline', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
||||
})
|
||||
return apiFetch<void>('/rooms/decline', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
||||
})
|
||||
}
|
||||
|
||||
export function leaveRoom(roomUuid: string) {
|
||||
return apiFetch<void>(`/rooms/${roomUuid}/leave`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteRoom(roomUuid: string) {
|
||||
return apiFetch<void>(`/rooms/${roomUuid}/delete`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function transferOwnership(roomUuid: string, newOwnerUuid: string) {
|
||||
return apiFetch<void>('/rooms/transfer-ownership', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ room_uuid: roomUuid, new_owner_uuid: newOwnerUuid }),
|
||||
})
|
||||
}
|
||||
|
||||
export function listMembers(roomUuid: string) {
|
||||
return apiFetch<UserProfile[]>(`/rooms/${roomUuid}/members`)
|
||||
}
|
||||
|
||||
10
src/base.css
10
src/base.css
@@ -24,6 +24,7 @@ body,
|
||||
--muted: #9aa0aa;
|
||||
--accent: #f27aa3;
|
||||
--accent-hover: #ff91b3;
|
||||
--accent-second: #96CDFB;
|
||||
--border: #2a2f3b;
|
||||
--error: #ff5050;
|
||||
--radius: 8px;
|
||||
@@ -174,6 +175,15 @@ i:hover {
|
||||
margin: 30px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
i:hover {
|
||||
color: var(--text);
|
||||
|
||||
@@ -1,39 +1,55 @@
|
||||
<template>
|
||||
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid="props.uuid"
|
||||
@close="showInviteModal = false" />
|
||||
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid="props.uuid"
|
||||
@close="showInviteModal = false" @room-changed="handleRoomChanged" />
|
||||
|
||||
<div v-if="uuid === 'none'" class="no-room">
|
||||
<div class="empty-state">
|
||||
<i class="fa-solid fa-comments"></i>
|
||||
<p>{{ $t('chat-no-room') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid"
|
||||
:roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false"
|
||||
@close="showDetailsModal = false" @room-changed="handleRoomChanged" />
|
||||
|
||||
<div v-else class="chat-container">
|
||||
<h2 class="room-name">{{ currentRoom?.name }}</h2>
|
||||
|
||||
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
||||
<MessageList v-if="messages.length > 0 || isSocketConnected" :messages="messages" />
|
||||
<div v-if="uuid === 'none'" class="no-room">
|
||||
<div class="empty-state">
|
||||
<i class="fa-solid fa-comments"></i>
|
||||
<p>{{ $t('chat-no-room') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="messages.length === 0" class="wait-msg">{{ $t('chat-connecting') }}</p>
|
||||
<div v-else class="chat-container">
|
||||
<button class="room-name" @click="showDetailsModal = true">{{ currentRoom?.name }}</button>
|
||||
|
||||
<div class="input-container">
|
||||
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
|
||||
:title="$t('chat-invite-title')">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
</button>
|
||||
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
||||
<MessageList v-if="messages.length > 0 || isSocketConnected" :messages="messages" />
|
||||
</div>
|
||||
|
||||
<div v-if="connectionError" class="connection-error">
|
||||
<p>{{ connectionError }}</p>
|
||||
<button class="retry-btn" @click="retryConnection">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="messages.length == 0" class="center-status-container">
|
||||
<p v-if="isInitialLoad" class="wait-msg">{{ $t('chat-connecting') }}</p>
|
||||
|
||||
<MessageInput v-if="isSocketConnected" ref="messageInputRef" @send="onSend" />
|
||||
<div v-else-if="connectionError" class="wait-msg">
|
||||
<p>{{ $t('chat-connecting-failed') }}</p>
|
||||
|
||||
<button class="retry-btn" @click="retryConnection">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!connectionError && isSocketConnected" class="empty-room-state">
|
||||
<i class="fa-regular fa-paper-plane"></i>
|
||||
<p>{{ $t('chat-no-messages') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isSocketConnected" class="input-container">
|
||||
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
|
||||
:title="$t('chat-invite-title')">
|
||||
<i class="fa-solid fa-users"></i>
|
||||
</button>
|
||||
|
||||
<div v-if="connectionError" class="connection-error">
|
||||
<p>{{ connectionError }}</p>
|
||||
</div>
|
||||
|
||||
<MessageInput v-if="isSocketConnected" ref="messageInputRef" @send="onSend" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -45,6 +61,7 @@ import { apiFetch } from '../api/client';
|
||||
import MessageList from "./MessageList.vue";
|
||||
import MessageInput from "./MessageInput.vue";
|
||||
import InvitePeopleModal from './InvitePeopleModal.vue';
|
||||
import RoomDetailsModal from "./RoomDetailsModal.vue";
|
||||
import WebSocket from '@tauri-apps/plugin-websocket';
|
||||
import { getAuthData } from "../store.ts";
|
||||
import { fetchRoomInfo } from "../api/rooms.ts";
|
||||
@@ -53,10 +70,11 @@ import { useFluent } from 'fluent-vue';
|
||||
const { $t } = useFluent();
|
||||
|
||||
const emit = defineEmits<{
|
||||
// (e: 'send', content: string): void
|
||||
(e: 'notification', roomUuid: string): void
|
||||
(e: 'notification', roomUuid: string): void
|
||||
(e: 'room-action'): void
|
||||
}>();
|
||||
|
||||
|
||||
const props = defineProps<{ uuid: string }>();
|
||||
|
||||
// UI State
|
||||
@@ -71,6 +89,8 @@ const connectionError = ref<string | null>(null);
|
||||
const isLoadingMore = ref(false);
|
||||
const hasMore = ref(true);
|
||||
const showInviteModal = ref(false);
|
||||
const showDetailsModal = ref(false);
|
||||
const isInitialLoad = ref(false);
|
||||
|
||||
// WebSocket State
|
||||
let socket: WebSocket | null = null;
|
||||
@@ -78,297 +98,355 @@ const isSocketConnected = ref(false);
|
||||
let unlistenSocket: (() => void) | null = null;
|
||||
|
||||
const isOwner = computed(() => {
|
||||
if (!currentUser.value || !currentRoom.value) return false;
|
||||
return currentUser.value.uuid === currentRoom.value.owner_uuid;
|
||||
if (!currentUser.value || !currentRoom.value) return false;
|
||||
return currentUser.value.uuid === currentRoom.value.owner_uuid;
|
||||
});
|
||||
|
||||
const handleRoomChanged = () => {
|
||||
showDetailsModal.value = false;
|
||||
emit('room-action');
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await connectGlobalWebSocket();
|
||||
await connectGlobalWebSocket();
|
||||
|
||||
await loadRoomData();
|
||||
await loadRoomData();
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
await cleanupWebSocket();
|
||||
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
await cleanupWebSocket();
|
||||
});
|
||||
|
||||
// Watch for room switches.
|
||||
watch(() => props.uuid, async (newUuid, oldUuid) => {
|
||||
if (newUuid !== oldUuid) {
|
||||
await loadRoomData();
|
||||
}
|
||||
if (newUuid !== oldUuid) {
|
||||
await loadRoomData();
|
||||
}
|
||||
});
|
||||
|
||||
async function retryConnection() {
|
||||
await cleanupWebSocket();
|
||||
await connectGlobalWebSocket();
|
||||
await loadRoomData();
|
||||
await cleanupWebSocket();
|
||||
await connectGlobalWebSocket();
|
||||
await loadRoomData();
|
||||
}
|
||||
|
||||
async function loadRoomData() {
|
||||
messages.value = [];
|
||||
currentRoom.value = null;
|
||||
hasMore.value = true;
|
||||
connectionError.value = null;
|
||||
isInitialLoad.value = true;
|
||||
messages.value = [];
|
||||
currentRoom.value = null;
|
||||
hasMore.value = true;
|
||||
|
||||
if (props.uuid === 'none') return;
|
||||
if (props.uuid === 'none') {
|
||||
isInitialLoad.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [msgs, roomInfo, auth] = await Promise.all([
|
||||
fetchMessages(props.uuid, undefined, 40),
|
||||
fetchRoomInfo(props.uuid),
|
||||
getAuthData()
|
||||
]);
|
||||
try {
|
||||
const [msgs, roomInfo, auth] = await Promise.all([
|
||||
fetchMessages(props.uuid, undefined, 40),
|
||||
fetchRoomInfo(props.uuid),
|
||||
getAuthData()
|
||||
]);
|
||||
|
||||
messages.value = msgs;
|
||||
currentRoom.value = roomInfo;
|
||||
currentUser.value = auth.user;
|
||||
if (msgs.length < 40) hasMore.value = false;
|
||||
messages.value = msgs;
|
||||
currentRoom.value = roomInfo;
|
||||
currentUser.value = auth.user;
|
||||
if (msgs.length < 40) hasMore.value = false;
|
||||
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
console.error("Room data load failed:", err);
|
||||
}
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
console.error("Room data load failed:", err);
|
||||
} finally {
|
||||
isInitialLoad.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function connectGlobalWebSocket() {
|
||||
if (isSocketConnected.value) return;
|
||||
if (isSocketConnected.value) return;
|
||||
|
||||
try {
|
||||
// Get a one-time token for the connection
|
||||
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
|
||||
const wsToken = res.token;
|
||||
try {
|
||||
// Get a one-time token for the connection
|
||||
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
|
||||
const wsToken = res.token;
|
||||
|
||||
const url = `${API_WS}/messages?token=${wsToken}`;
|
||||
socket = await WebSocket.connect(url);
|
||||
const url = `${API_WS}/messages?token=${wsToken}`;
|
||||
socket = await WebSocket.connect(url);
|
||||
|
||||
isSocketConnected.value = true;
|
||||
connectionError.value = null;
|
||||
isSocketConnected.value = true;
|
||||
connectionError.value = null;
|
||||
|
||||
unlistenSocket = socket.addListener((msg) => {
|
||||
if (msg.type === 'Text') {
|
||||
try {
|
||||
const data: Message = JSON.parse(msg.data);
|
||||
unlistenSocket = socket.addListener((msg) => {
|
||||
if (msg.type === 'Text') {
|
||||
try {
|
||||
const data: Message = JSON.parse(msg.data);
|
||||
|
||||
// Filter messages for the currenty open room
|
||||
if (data.room_uuid === props.uuid) {
|
||||
// Deduplicate
|
||||
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
||||
messages.value.push(data);
|
||||
nextTick().then(scrollToBottomIfAtEnd);
|
||||
// Filter messages for the currenty open room
|
||||
if (data.room_uuid === props.uuid) {
|
||||
// Deduplicate
|
||||
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
||||
messages.value.push(data);
|
||||
nextTick().then(scrollToBottomIfAtEnd);
|
||||
}
|
||||
} else {
|
||||
// Notifications for other rooms
|
||||
emit('notification', data.room_uuid);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error parsing message:", e);
|
||||
}
|
||||
} else if (msg.type === 'Close') {
|
||||
isSocketConnected.value = false;
|
||||
}
|
||||
} else {
|
||||
// Notifications for other rooms
|
||||
emit('notification', data.room_uuid);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error parsing message:", e);
|
||||
}
|
||||
} else if (msg.type === 'Close') {
|
||||
} catch (err) {
|
||||
console.error("WS Connect failed:", err);
|
||||
isSocketConnected.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("WS Connect failed:", err);
|
||||
isSocketConnected.value = false;
|
||||
connectionError.value = "Live chat disconnected.";
|
||||
}
|
||||
connectionError.value = $t('shared-error');
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupWebSocket() {
|
||||
isSocketConnected.value = false;
|
||||
isSocketConnected.value = false;
|
||||
|
||||
if (unlistenSocket) {
|
||||
unlistenSocket();
|
||||
unlistenSocket = null;
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
const tempSocket = socket;
|
||||
socket = null;
|
||||
try {
|
||||
await tempSocket.disconnect();
|
||||
} catch (err) {
|
||||
console.warn("Socket cleanup warning:", err);
|
||||
if (unlistenSocket) {
|
||||
unlistenSocket();
|
||||
unlistenSocket = null;
|
||||
}
|
||||
|
||||
if (socket) {
|
||||
const tempSocket = socket;
|
||||
socket = null;
|
||||
try {
|
||||
await tempSocket.disconnect();
|
||||
} catch (err) {
|
||||
console.warn("Socket cleanup warning:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScroll() {
|
||||
const el = messageListRef.value;
|
||||
if (!el) return;
|
||||
const el = messageListRef.value;
|
||||
if (!el) return;
|
||||
|
||||
if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) {
|
||||
await loadMore();
|
||||
}
|
||||
if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) {
|
||||
await loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (messages.value.length === 0) return;
|
||||
if (messages.value.length === 0) return;
|
||||
|
||||
isLoadingMore.value = true;
|
||||
const oldestMsgUuid = messages.value[0].uuid;
|
||||
isLoadingMore.value = true;
|
||||
const oldestMsgUuid = messages.value[0].uuid;
|
||||
|
||||
try {
|
||||
const olderMsgs = await fetchMessages(props.uuid, oldestMsgUuid, 30);
|
||||
try {
|
||||
const olderMsgs = await fetchMessages(props.uuid, oldestMsgUuid, 30);
|
||||
|
||||
if (olderMsgs.length === 0) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
if (olderMsgs.length === 0) {
|
||||
hasMore.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const el = messageListRef.value;
|
||||
const previousScrollHeight = el?.scrollHeight || 0;
|
||||
|
||||
messages.value = [...olderMsgs, ...messages.value];
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight;
|
||||
}
|
||||
|
||||
if (olderMsgs.length < 30) hasMore.value = false;
|
||||
} catch (err) {
|
||||
console.error("Failed to load more messages:", err);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
|
||||
const el = messageListRef.value;
|
||||
const previousScrollHeight = el?.scrollHeight || 0;
|
||||
|
||||
messages.value = [...olderMsgs, ...messages.value];
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight;
|
||||
}
|
||||
|
||||
if (olderMsgs.length < 30) hasMore.value = false;
|
||||
} catch (err) {
|
||||
console.error("Failed to load more messages:", err);
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messageListRef.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
|
||||
}
|
||||
if (messageListRef.value) {
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottomIfAtEnd() {
|
||||
const el = messageListRef.value;
|
||||
if (!el) return;
|
||||
const threshold = 150;
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
if (isAtBottom) scrollToBottom();
|
||||
const el = messageListRef.value;
|
||||
if (!el) return;
|
||||
const threshold = 150;
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||
if (isAtBottom) scrollToBottom();
|
||||
}
|
||||
|
||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
const active = document.activeElement?.tagName.toLowerCase();
|
||||
const isTyping = active === 'input' || active === 'textarea';
|
||||
if (!isTyping && messageInputRef.value) {
|
||||
event.preventDefault();
|
||||
messageInputRef.value.focus();
|
||||
if (event.key === 'Enter') {
|
||||
const active = document.activeElement?.tagName.toLowerCase();
|
||||
const isTyping = active === 'input' || active === 'textarea';
|
||||
if (!isTyping && messageInputRef.value) {
|
||||
event.preventDefault();
|
||||
messageInputRef.value.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function onSend(content: string) {
|
||||
if (props.uuid === 'none') return;
|
||||
await sendMessage(props.uuid, content);
|
||||
if (props.uuid === 'none') return;
|
||||
await sendMessage(props.uuid, content);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
scroll-behavior: auto;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.wait-msg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
padding: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
:deep(.input-container > *:last-child) {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
margin: 15px 0;
|
||||
text-align: center;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
margin: 15px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.invite-btn {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.connection-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.no-room {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.center-status-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wait-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.6rem;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.empty-room-state {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-room-state i {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.empty-room-state p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
97
src/components/ConfirmModal.vue
Normal file
97
src/components/ConfirmModal.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="backdrop" @click.self="emit('no')">
|
||||
<div class="modal">
|
||||
<h2>{{ title }}</h2>
|
||||
<p v-if="message">{{ message }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="emit('no')" class="secondary">
|
||||
{{ cancelLabel || $t('shared-cancel') || 'Cancel' }}
|
||||
</button>
|
||||
<button @click="emit('yes')" :class="['btn', confirmButtonClass]">
|
||||
{{ confirmLabel || $t('shared-confirm') || 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmButtonClass?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['yes', 'no']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--accent);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
cursor: pointer;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: var(--error);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<ul>
|
||||
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
|
||||
<div class="sender-info">
|
||||
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="sender-avatar" />
|
||||
<img :src="getAvatarUrl(m.sender_uuid)" @error="handleAvatarError" class="avatar" />
|
||||
<div class="sender">{{ m.sender }}</div>
|
||||
<span class="timestamp">{{ m.sent_at }}</span>
|
||||
</div>
|
||||
@@ -74,15 +74,6 @@ ul {
|
||||
padding: 5px 18px;
|
||||
}
|
||||
|
||||
.sender-avatar {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.is-me {
|
||||
align-self: flex-end;
|
||||
align-items: flex-end;
|
||||
|
||||
280
src/components/RoomDetailsModal.vue
Normal file
280
src/components/RoomDetailsModal.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<h2>{{ roomName }}</h2>
|
||||
<h3 v-if="!isGlobal">Members:</h3>
|
||||
|
||||
<ul v-if="!isGlobal" class="member-list">
|
||||
<li v-for="user in users" :key="user.uuid || user.username" class="member-item">
|
||||
<img :src="getAvatarUrl(user.uuid)" @error="handleAvatarError" class="avatar" alt="avatar" />
|
||||
<span class="member-name">{{ user.username }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="global-tag">
|
||||
<i class="fa-solid fa-earth"></i>
|
||||
{{ $t('chat-room-global') }}
|
||||
</p>
|
||||
|
||||
<div class="buttons">
|
||||
<button v-if="!isGlobal && !isOwner" class="btn" @click="requestLeave" :disabled="isLoading">
|
||||
{{ $t('chat-room-actions-leave') }}
|
||||
</button>
|
||||
|
||||
<button v-if="isOwner" class="btn delete-btn" @click="requestDelete" :disabled="isLoading">
|
||||
{{ $t('chat-room-actions-delete') }}
|
||||
</button>
|
||||
|
||||
<!-- <button class="btn ownership-btn">{{ $t('chat-room-actions-ownership') }}</button> -->
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button @click="emit('close')" class="secondary">
|
||||
{{ $t('shared-close') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal v-if="modalState.visible" :title="modalState.title" :message="modalState.message"
|
||||
:confirm-label="modalState.confirmLabel" :confirm-button-class="modalState.isDanger ? 'btn-danger' : ''"
|
||||
@yes="handleConfirmAction" @no="closeConfirmModal" />
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, reactive } from 'vue';
|
||||
import { UserProfile } from '../types';
|
||||
import { deleteRoom, leaveRoom, listMembers } from '../api/rooms';
|
||||
import { getAuthData, getAvatarUrl } from '../store.ts';
|
||||
import defaultAvatar from '../assets/default-avatar.png';
|
||||
import ConfirmModal from './ConfirmModal.vue';
|
||||
import { useFluent } from 'fluent-vue';
|
||||
|
||||
const { $t } = useFluent()
|
||||
|
||||
const props = defineProps<{ roomUuid: string, roomName: string, isGlobal: boolean }>()
|
||||
const emit = defineEmits(['close', 'room-changed']);
|
||||
|
||||
const users = ref<UserProfile[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const currentUserUuid = ref<string>('');
|
||||
|
||||
type ActionType = 'leave' | 'delete' | null;
|
||||
|
||||
const modalState = reactive({
|
||||
visible: false,
|
||||
type: null as ActionType,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: '',
|
||||
isDanger: false
|
||||
});
|
||||
|
||||
const requestLeave = () => {
|
||||
modalState.type = 'leave';
|
||||
modalState.title = $t('chat-room-actions-leave');
|
||||
modalState.message = $t('chat-room-actions-leave-confirm');
|
||||
modalState.confirmLabel = $t('shared-leave');
|
||||
modalState.isDanger = false;
|
||||
modalState.visible = true;
|
||||
};
|
||||
|
||||
const requestDelete = () => {
|
||||
modalState.type = 'delete';
|
||||
modalState.title = $t('chat-room-actions-delete');
|
||||
modalState.message = $t('chat-room-actions-delete-confirm');
|
||||
modalState.confirmLabel = $t('shared-delete');
|
||||
modalState.isDanger = true;
|
||||
modalState.visible = true;
|
||||
};
|
||||
|
||||
const closeConfirmModal = () => {
|
||||
modalState.visible = false;
|
||||
modalState.type = null;
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
const actionType = modalState.type;
|
||||
|
||||
closeConfirmModal();
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
if (actionType === 'leave') {
|
||||
await leaveRoom(props.roomUuid);
|
||||
emit('room-changed');
|
||||
emit('close');
|
||||
} else if (actionType === 'delete') {
|
||||
await deleteRoom(props.roomUuid);
|
||||
emit('room-changed');
|
||||
emit('close');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${actionType} room:`, error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const isOwner = computed(() => {
|
||||
return true; // Logic placeholder
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const auth = await getAuthData();
|
||||
currentUserUuid.value = auth.user?.uuid || 'undefined';
|
||||
|
||||
if (!props.isGlobal) {
|
||||
users.value = await listMembers(props.roomUuid);
|
||||
}
|
||||
});
|
||||
|
||||
const handleAvatarError = (event: Event) => {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.src = defaultAvatar;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.global-tag {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
background-color: transparent;
|
||||
border-radius: var(--radius);
|
||||
width: fit-content;
|
||||
border: 1px solid var(--accent);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--accent)
|
||||
}
|
||||
|
||||
.btn:hover i {
|
||||
color: var(--accent)
|
||||
}
|
||||
|
||||
.btn i {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--error)
|
||||
}
|
||||
|
||||
.delete-btn:hover i {
|
||||
color: var(--error)
|
||||
}
|
||||
|
||||
.ownership-btn {
|
||||
border: 1px solid var(--accent-second);
|
||||
}
|
||||
|
||||
.ownership-btn:hover {
|
||||
color: var(--accent-second)
|
||||
}
|
||||
|
||||
.ownership-btn:hover i {
|
||||
color: var(--accent-second)
|
||||
}
|
||||
</style>
|
||||
@@ -11,14 +11,18 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="wait-container" v-if="!rooms || rooms.length === 0">
|
||||
<div class="wait-container" v-if="isLoading">
|
||||
<p class="wait-msg">{{ $t('chat-room-list-connecting') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wait-container" v-else-if="!rooms || rooms.length === 0">
|
||||
<p class="wait-msg">{{ $t('chat-room-list-empty') }}</p>
|
||||
<button class="retry-btn" @click="refreshRooms()">
|
||||
<i class="fa-solid fa-rotate-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="scroll-area">
|
||||
<div class="scroll-area" v-else>
|
||||
<router-link v-for="room in rooms" :key="room.uuid" :to="`/rooms/${room.uuid}`" class="btn room-item"
|
||||
:class="{ active: route.params.uuid === room.uuid }" @click="emit('select-room')">
|
||||
<div class="room-content-wrapper">
|
||||
@@ -48,21 +52,27 @@ const route = useRoute();
|
||||
const showCreate = ref(false);
|
||||
const rooms = ref<Room[]>([]);
|
||||
const unreadCounts = ref<Record<string, number>>({});
|
||||
const isLoading = ref(true);
|
||||
|
||||
async function refreshRooms() {
|
||||
const fetchedRooms = await fetchRooms();
|
||||
rooms.value = fetchedRooms;
|
||||
isLoading.value = true;
|
||||
|
||||
fetchedRooms.forEach(room => {
|
||||
console.log(`Unread count for room ${room.name}: ${room.unread_count}`);
|
||||
try {
|
||||
const fetchedRooms = await fetchRooms();
|
||||
rooms.value = fetchedRooms;
|
||||
|
||||
// If the room isn't the currently active one, store the count
|
||||
if (room.unread_count && route.params.uuid !== room.uuid) {
|
||||
unreadCounts.value[room.uuid] = room.unread_count;
|
||||
} else {
|
||||
unreadCounts.value[room.uuid] = 0;
|
||||
}
|
||||
});
|
||||
fetchedRooms.forEach(room => {
|
||||
if (room.unread_count && route.params.uuid !== room.uuid) {
|
||||
unreadCounts.value[room.uuid] = room.unread_count;
|
||||
} else {
|
||||
unreadCounts.value[room.uuid] = 0;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh rooms", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const incrementUnread = (roomUuid: string) => {
|
||||
@@ -72,7 +82,7 @@ const incrementUnread = (roomUuid: string) => {
|
||||
unreadCounts.value[roomUuid] = current + 1;
|
||||
};
|
||||
|
||||
defineExpose({ incrementUnread });
|
||||
defineExpose({ incrementUnread, refreshRooms });
|
||||
|
||||
watch(
|
||||
() => route.params.uuid,
|
||||
@@ -85,7 +95,6 @@ watch(
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// rooms.value = await fetchRooms();
|
||||
await refreshRooms()
|
||||
});
|
||||
</script>
|
||||
@@ -111,6 +120,7 @@ onMounted(async () => {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wait-msg {
|
||||
@@ -162,7 +172,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.rooms-header h2 {
|
||||
/* font-size: 1rem; */
|
||||
margin: 0;
|
||||
margin-left: 45px;
|
||||
}
|
||||
@@ -189,7 +198,6 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.room-item.active {
|
||||
/* border: 1px solid var(--border); */
|
||||
background: var(--panel-accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ auth-error-email-invalid = Please enter a valid email address
|
||||
|
||||
## Chat page
|
||||
chat-no-room = Select a room to start talking
|
||||
chat-no-messages = No messages yet. Say hi!
|
||||
chat-input-placeholder = type a message
|
||||
chat-invite-title = Invite People
|
||||
chat-invite-receiver = Receiver username
|
||||
@@ -32,8 +33,16 @@ chat-invite-friend-too = Also send a friend request
|
||||
chat-invite-send = Send
|
||||
chat-invite-username-placeholder = username
|
||||
chat-room-list-title = Rooms
|
||||
chat-room-list-empty = No rooms found
|
||||
chat-room-list-connecting = Connecting...
|
||||
chat-room-owner = by {$owner}
|
||||
chat-room-global = Global room
|
||||
chat-room-members = Members
|
||||
chat-room-actions-leave = Leave Room
|
||||
chat-room-actions-leave-confirm = Are you sure you want to leave this room?
|
||||
chat-room-actions-delete = Delete Room
|
||||
chat-room-actions-delete-confirm = Are you sure you want to delete this room? This cannot be undone.
|
||||
chat-room-actions-ownership = Transfer Ownership
|
||||
chat-create-title = Create room
|
||||
chat-create-name = Room name
|
||||
chat-create-name-placeholder = room name
|
||||
@@ -95,6 +104,9 @@ warning-wrongversion-dismiss = I know what I'm doing
|
||||
|
||||
## Shared
|
||||
shared-cancel = Cancel
|
||||
shared-close = Close
|
||||
shared-error = An error occurred
|
||||
shared-save = Save
|
||||
shared-updating = Updating
|
||||
shared-delete = Delete
|
||||
shared-leave = Leave
|
||||
|
||||
@@ -25,8 +25,10 @@ auth-error-email-invalid = Veuillez entrer une adresse email valide
|
||||
|
||||
## Chat page
|
||||
chat-no-room = Sélectionnez un salon pour commencer à discuter
|
||||
chat-no-messages = Pas encore de messages. Dites bonjour !
|
||||
chat-input-placeholder = tapez un message
|
||||
chat-invite-title = Inviter des gens
|
||||
chat-room-list-empty = Aucun salon trouvé
|
||||
chat-invite-receiver = Nom du destinataire
|
||||
chat-invite-friend-too = Envoyer aussi une demande d'ami
|
||||
chat-invite-send = Envoyer
|
||||
@@ -34,6 +36,13 @@ chat-invite-username-placeholder = nom d'utilisateur
|
||||
chat-room-list-title = Salons
|
||||
chat-room-list-connecting = Connexion...
|
||||
chat-room-owner = par {$owner}
|
||||
chat-room-global = Salon global
|
||||
chat-room-members = Membres
|
||||
chat-room-actions-leave = Quitter le Salon
|
||||
chat-room-actions-leave-confirm = Voulez-vous vraiment quitter ce salon?
|
||||
chat-room-actions-delete = Supprimer le Salon
|
||||
chat-room-actions-delete-confirm = Voulez-vous vraiment supprimer ce salon? C'est irréversible.
|
||||
chat-room-actions-ownership = Transférer la Propriété
|
||||
chat-create-title = Créer un salon
|
||||
chat-create-name = Nom du salon
|
||||
chat-create-name-placeholder = nom du salon
|
||||
@@ -93,6 +102,9 @@ warning-wrongversion-dismiss = Je sais ce que je fais
|
||||
|
||||
## Shared
|
||||
shared-cancel = Annuler
|
||||
shared-close = Fermer
|
||||
shared-error = Une erreur est survenue
|
||||
shared-save = Enregistrer
|
||||
shared-updating = Mise à jour...
|
||||
shared-delete = Supprimer
|
||||
shared-leave = Quitter
|
||||
|
||||
@@ -28,9 +28,9 @@ async function init() {
|
||||
|
||||
init()
|
||||
|
||||
// export const API = 'http://127.0.0.1:8080'
|
||||
export const API = 'http://192.168.1.183:8080'
|
||||
export const API = 'http://127.0.0.1:8080'
|
||||
// export const API = 'http://192.168.1.183:8080'
|
||||
// export const API = 'https://alatreon.org/frangipane'
|
||||
// export const API_WS = 'ws://127.0.0.1:8080/ws'
|
||||
export const API_WS = 'ws://192.168.1.183:8080/ws'
|
||||
export const API_WS = 'ws://127.0.0.1:8080/ws'
|
||||
// export const API_WS = 'ws://192.168.1.183:8080/ws'
|
||||
// export const API_WS = 'wss://alatreon.org/frangipane/ws'
|
||||
|
||||
@@ -13,27 +13,39 @@
|
||||
<div v-if="isSidebarOpen" class="sidebar-overlay" @click="isSidebarOpen = false"></div>
|
||||
|
||||
<main class="chat-window-container" :class="{ 'sidebar-is-open': isSidebarOpen }">
|
||||
<ChatWindow :uuid="uuid" @notification="handleNotification" />
|
||||
<ChatWindow :uuid="safeUuid" @notification="handleNotification" @room-action="handleRoomAction" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import RoomList from "../components/RoomList.vue";
|
||||
import ChatWindow from "../components/ChatWindow.vue";
|
||||
|
||||
defineProps<{ uuid: string }>();
|
||||
const isSidebarOpen = ref(true);
|
||||
const props = defineProps<{ uuid?: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
const isSidebarOpen = ref(true);
|
||||
const roomListRef = ref<InstanceType<typeof RoomList> | null>(null);
|
||||
|
||||
const safeUuid = computed(() => props.uuid || 'none');
|
||||
|
||||
const handleRoomSelection = () => {
|
||||
if (window.innerWidth <= 720) {
|
||||
isSidebarOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomAction = async () => {
|
||||
if (roomListRef.value) {
|
||||
await roomListRef.value.refreshRooms();
|
||||
}
|
||||
|
||||
router.push('/rooms/none');
|
||||
};
|
||||
|
||||
const handleNotification = (roomUuid: string) => {
|
||||
if (roomListRef.value) {
|
||||
roomListRef.value.incrementUnread(roomUuid);
|
||||
|
||||
@@ -64,10 +64,6 @@ const handleAvatarError = (event: Event) => {
|
||||
img.src = defaultAvatar;
|
||||
};
|
||||
|
||||
async function handleAvatarUpdated() {
|
||||
await fetchUserData()
|
||||
}
|
||||
|
||||
const showAvatarModal = ref(false)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
69
src/types.ts
69
src/types.ts
@@ -1,58 +1,63 @@
|
||||
export interface User {
|
||||
uuid: string
|
||||
username: string
|
||||
email: string
|
||||
avatar_url: string
|
||||
uuid: string
|
||||
username: string
|
||||
email: string
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
uuid: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
uuid: string
|
||||
username: string
|
||||
email: string
|
||||
token: string
|
||||
uuid: string
|
||||
username: string
|
||||
email: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface UpdateUserResponse {
|
||||
username: string
|
||||
email: string
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
uuid: string
|
||||
owner_name: string
|
||||
owner_uuid: string
|
||||
name: string
|
||||
global: boolean
|
||||
unread_count: number
|
||||
uuid: string
|
||||
owner_name: string
|
||||
owner_uuid: string
|
||||
name: string
|
||||
global: boolean
|
||||
unread_count: number
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
uuid: string
|
||||
room_uuid: string
|
||||
sender: string
|
||||
sender_uuid: string
|
||||
message_type: 'text'
|
||||
content: string
|
||||
sent_at: string
|
||||
uuid: string
|
||||
room_uuid: string
|
||||
sender: string
|
||||
sender_uuid: string
|
||||
message_type: 'text'
|
||||
content: string
|
||||
sent_at: string
|
||||
}
|
||||
|
||||
export interface Friend {
|
||||
uuid: string
|
||||
username: string
|
||||
uuid: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface FriendRequest {
|
||||
sender_uuid: string
|
||||
sender_username: string
|
||||
sender_uuid: string
|
||||
sender_username: string
|
||||
}
|
||||
|
||||
export interface RoomInvite {
|
||||
room_uuid: string
|
||||
room_name: string
|
||||
sender_uuid: string
|
||||
sender_username: string
|
||||
room_uuid: string
|
||||
room_name: string
|
||||
sender_uuid: string
|
||||
sender_username: string
|
||||
}
|
||||
|
||||
export interface VersionResponse {
|
||||
version: string
|
||||
version: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user