added room actions: leave and delete, and improved connection error handling
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "frangipane-client",
|
"name": "frangipane-client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"backendVersion": "1.0.2",
|
"backendVersion": "1.0.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "frangipane",
|
"productName": "frangipane",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"identifier": "com.strawberries.frangipane",
|
"identifier": "com.strawberries.frangipane",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "yarn dev",
|
"beforeDevCommand": "yarn dev",
|
||||||
|
|||||||
74
src/App.vue
74
src/App.vue
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page">
|
<div id="page">
|
||||||
<main id="content">
|
<main id="content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
<VersionWarningModal v-if="showVersionWarningModal" :appVersion="appVersion" :backendVersion="backendVersion"
|
||||||
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
:expectedBackendVersion="expectedBackendVersion" @close="showVersionWarningModal = false" />
|
||||||
|
|
||||||
<footer v-if="!$route.meta.hideNavbar">
|
<footer v-if="!$route.meta.hideNavbar">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -27,51 +27,51 @@ const appVersion = __APP_VERSION__
|
|||||||
const expectedBackendVersion = __BACKEND_VERSION__
|
const expectedBackendVersion = __BACKEND_VERSION__
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
backendVersion.value = (await getBackendVersion()).version
|
backendVersion.value = (await getBackendVersion()).version
|
||||||
if (backendVersion.value !== expectedBackendVersion) {
|
if (backendVersion.value !== expectedBackendVersion) {
|
||||||
showVersionWarningModal.value = true
|
showVersionWarningModal.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getBackendVersion() {
|
async function getBackendVersion() {
|
||||||
return await apiFetch<VersionResponse>('/version')
|
return await apiFetch<VersionResponse>('/version')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
#page {
|
#page {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
#content {
|
#content {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-top: 30px;
|
padding-top: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
padding-bottom: 56px;
|
padding-bottom: 56px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,42 +1,65 @@
|
|||||||
import { apiFetch } from './client'
|
import { apiFetch } from './client'
|
||||||
import type { Room, RoomInvite } from '../types'
|
import { UserProfile, type Room, type RoomInvite } from '../types'
|
||||||
|
|
||||||
export function fetchRooms() {
|
export function fetchRooms() {
|
||||||
return apiFetch<Room[]>(`/rooms`)
|
return apiFetch<Room[]>(`/rooms`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRoomInfo(uuid: string) {
|
export function fetchRoomInfo(uuid: string) {
|
||||||
return apiFetch<Room>(`/rooms/${uuid}`)
|
return apiFetch<Room>(`/rooms/${uuid}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRoom(name: string, global: boolean) {
|
export function createRoom(name: string, global: boolean) {
|
||||||
return apiFetch<Room>('/rooms', {
|
return apiFetch<Room>('/rooms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, global }),
|
body: JSON.stringify({ name, global }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRoomInvites() {
|
export function fetchRoomInvites() {
|
||||||
return apiFetch<RoomInvite[]>('/rooms/invites')
|
return apiFetch<RoomInvite[]>('/rooms/invites')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendRoomInvite(receiverUsername: string, roomUuid: string) {
|
export function sendRoomInvite(receiverUsername: string, roomUuid: string) {
|
||||||
return apiFetch<void>('/rooms/invite', {
|
return apiFetch<void>('/rooms/invite', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ receiver_username: receiverUsername, room_uuid: roomUuid }),
|
body: JSON.stringify({ receiver_username: receiverUsername, room_uuid: roomUuid }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function acceptRoomInvite(senderUuid: string, roomUuid: string) {
|
export function acceptRoomInvite(senderUuid: string, roomUuid: string) {
|
||||||
return apiFetch<void>('/rooms/join', {
|
return apiFetch<void>('/rooms/join', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function declineRoomInvite(senderUuid: string, roomUuid: string) {
|
export function declineRoomInvite(senderUuid: string, roomUuid: string) {
|
||||||
return apiFetch<void>('/rooms/decline', {
|
return apiFetch<void>('/rooms/decline', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sender_uuid: senderUuid, room_uuid: roomUuid }),
|
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;
|
--muted: #9aa0aa;
|
||||||
--accent: #f27aa3;
|
--accent: #f27aa3;
|
||||||
--accent-hover: #ff91b3;
|
--accent-hover: #ff91b3;
|
||||||
|
--accent-second: #96CDFB;
|
||||||
--border: #2a2f3b;
|
--border: #2a2f3b;
|
||||||
--error: #ff5050;
|
--error: #ff5050;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
@@ -174,6 +175,15 @@ i:hover {
|
|||||||
margin: 30px;
|
margin: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
i:hover {
|
i:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -1,39 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid="props.uuid"
|
<InvitePeopleModal v-if="showInviteModal && isSocketConnected" :room_uuid="props.uuid"
|
||||||
@close="showInviteModal = false" />
|
@close="showInviteModal = false" @room-changed="handleRoomChanged" />
|
||||||
|
|
||||||
<div v-if="uuid === 'none'" class="no-room">
|
<RoomDetailsModal v-if="showDetailsModal && isSocketConnected" :roomUuid="props.uuid"
|
||||||
<div class="empty-state">
|
:roomName="currentRoom?.name || 'Unknown room'" :isGlobal="currentRoom?.global || false"
|
||||||
<i class="fa-solid fa-comments"></i>
|
@close="showDetailsModal = false" @room-changed="handleRoomChanged" />
|
||||||
<p>{{ $t('chat-no-room') }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="chat-container">
|
<div v-if="uuid === 'none'" class="no-room">
|
||||||
<h2 class="room-name">{{ currentRoom?.name }}</h2>
|
<div class="empty-state">
|
||||||
|
<i class="fa-solid fa-comments"></i>
|
||||||
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
<p>{{ $t('chat-no-room') }}</p>
|
||||||
<MessageList v-if="messages.length > 0 || isSocketConnected" :messages="messages" />
|
</div>
|
||||||
</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">
|
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
||||||
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
|
<MessageList v-if="messages.length > 0 || isSocketConnected" :messages="messages" />
|
||||||
:title="$t('chat-invite-title')">
|
</div>
|
||||||
<i class="fa-solid fa-users"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="connectionError" class="connection-error">
|
<div v-if="messages.length == 0" class="center-status-container">
|
||||||
<p>{{ connectionError }}</p>
|
<p v-if="isInitialLoad" class="wait-msg">{{ $t('chat-connecting') }}</p>
|
||||||
<button class="retry-btn" @click="retryConnection">
|
|
||||||
<i class="fa-solid fa-rotate-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -45,6 +61,7 @@ import { apiFetch } from '../api/client';
|
|||||||
import MessageList from "./MessageList.vue";
|
import MessageList from "./MessageList.vue";
|
||||||
import MessageInput from "./MessageInput.vue";
|
import MessageInput from "./MessageInput.vue";
|
||||||
import InvitePeopleModal from './InvitePeopleModal.vue';
|
import InvitePeopleModal from './InvitePeopleModal.vue';
|
||||||
|
import RoomDetailsModal from "./RoomDetailsModal.vue";
|
||||||
import WebSocket from '@tauri-apps/plugin-websocket';
|
import WebSocket from '@tauri-apps/plugin-websocket';
|
||||||
import { getAuthData } from "../store.ts";
|
import { getAuthData } from "../store.ts";
|
||||||
import { fetchRoomInfo } from "../api/rooms.ts";
|
import { fetchRoomInfo } from "../api/rooms.ts";
|
||||||
@@ -53,10 +70,11 @@ import { useFluent } from 'fluent-vue';
|
|||||||
const { $t } = useFluent();
|
const { $t } = useFluent();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 }>();
|
const props = defineProps<{ uuid: string }>();
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
@@ -71,6 +89,8 @@ const connectionError = ref<string | null>(null);
|
|||||||
const isLoadingMore = ref(false);
|
const isLoadingMore = ref(false);
|
||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
const showInviteModal = ref(false);
|
const showInviteModal = ref(false);
|
||||||
|
const showDetailsModal = ref(false);
|
||||||
|
const isInitialLoad = ref(false);
|
||||||
|
|
||||||
// WebSocket State
|
// WebSocket State
|
||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | null = null;
|
||||||
@@ -78,297 +98,355 @@ const isSocketConnected = ref(false);
|
|||||||
let unlistenSocket: (() => void) | null = null;
|
let unlistenSocket: (() => void) | null = null;
|
||||||
|
|
||||||
const isOwner = computed(() => {
|
const isOwner = computed(() => {
|
||||||
if (!currentUser.value || !currentRoom.value) return false;
|
if (!currentUser.value || !currentRoom.value) return false;
|
||||||
return currentUser.value.uuid === currentRoom.value.owner_uuid;
|
return currentUser.value.uuid === currentRoom.value.owner_uuid;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleRoomChanged = () => {
|
||||||
|
showDetailsModal.value = false;
|
||||||
|
emit('room-action');
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await connectGlobalWebSocket();
|
await connectGlobalWebSocket();
|
||||||
|
|
||||||
await loadRoomData();
|
await loadRoomData();
|
||||||
|
|
||||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(async () => {
|
onUnmounted(async () => {
|
||||||
window.removeEventListener('keydown', handleGlobalKeyDown);
|
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
await cleanupWebSocket();
|
await cleanupWebSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for room switches.
|
// Watch for room switches.
|
||||||
watch(() => props.uuid, async (newUuid, oldUuid) => {
|
watch(() => props.uuid, async (newUuid, oldUuid) => {
|
||||||
if (newUuid !== oldUuid) {
|
if (newUuid !== oldUuid) {
|
||||||
await loadRoomData();
|
await loadRoomData();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function retryConnection() {
|
async function retryConnection() {
|
||||||
await cleanupWebSocket();
|
await cleanupWebSocket();
|
||||||
await connectGlobalWebSocket();
|
await connectGlobalWebSocket();
|
||||||
await loadRoomData();
|
await loadRoomData();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoomData() {
|
async function loadRoomData() {
|
||||||
messages.value = [];
|
isInitialLoad.value = true;
|
||||||
currentRoom.value = null;
|
messages.value = [];
|
||||||
hasMore.value = true;
|
currentRoom.value = null;
|
||||||
connectionError.value = null;
|
hasMore.value = true;
|
||||||
|
|
||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') {
|
||||||
|
isInitialLoad.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [msgs, roomInfo, auth] = await Promise.all([
|
const [msgs, roomInfo, auth] = await Promise.all([
|
||||||
fetchMessages(props.uuid, undefined, 40),
|
fetchMessages(props.uuid, undefined, 40),
|
||||||
fetchRoomInfo(props.uuid),
|
fetchRoomInfo(props.uuid),
|
||||||
getAuthData()
|
getAuthData()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
messages.value = msgs;
|
messages.value = msgs;
|
||||||
currentRoom.value = roomInfo;
|
currentRoom.value = roomInfo;
|
||||||
currentUser.value = auth.user;
|
currentUser.value = auth.user;
|
||||||
if (msgs.length < 40) hasMore.value = false;
|
if (msgs.length < 40) hasMore.value = false;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Room data load failed:", err);
|
console.error("Room data load failed:", err);
|
||||||
}
|
} finally {
|
||||||
|
isInitialLoad.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectGlobalWebSocket() {
|
async function connectGlobalWebSocket() {
|
||||||
if (isSocketConnected.value) return;
|
if (isSocketConnected.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get a one-time token for the connection
|
// Get a one-time token for the connection
|
||||||
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
|
const res = await apiFetch<{ token: string }>('/ws/messages/issue-token');
|
||||||
const wsToken = res.token;
|
const wsToken = res.token;
|
||||||
|
|
||||||
const url = `${API_WS}/messages?token=${wsToken}`;
|
const url = `${API_WS}/messages?token=${wsToken}`;
|
||||||
socket = await WebSocket.connect(url);
|
socket = await WebSocket.connect(url);
|
||||||
|
|
||||||
isSocketConnected.value = true;
|
isSocketConnected.value = true;
|
||||||
connectionError.value = null;
|
connectionError.value = null;
|
||||||
|
|
||||||
unlistenSocket = socket.addListener((msg) => {
|
unlistenSocket = socket.addListener((msg) => {
|
||||||
if (msg.type === 'Text') {
|
if (msg.type === 'Text') {
|
||||||
try {
|
try {
|
||||||
const data: Message = JSON.parse(msg.data);
|
const data: Message = JSON.parse(msg.data);
|
||||||
|
|
||||||
// Filter messages for the currenty open room
|
// Filter messages for the currenty open room
|
||||||
if (data.room_uuid === props.uuid) {
|
if (data.room_uuid === props.uuid) {
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
||||||
messages.value.push(data);
|
messages.value.push(data);
|
||||||
nextTick().then(scrollToBottomIfAtEnd);
|
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) {
|
} catch (err) {
|
||||||
console.error("Error parsing message:", e);
|
console.error("WS Connect failed:", err);
|
||||||
}
|
|
||||||
} else if (msg.type === 'Close') {
|
|
||||||
isSocketConnected.value = false;
|
isSocketConnected.value = false;
|
||||||
}
|
connectionError.value = $t('shared-error');
|
||||||
});
|
}
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("WS Connect failed:", err);
|
|
||||||
isSocketConnected.value = false;
|
|
||||||
connectionError.value = "Live chat disconnected.";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupWebSocket() {
|
async function cleanupWebSocket() {
|
||||||
isSocketConnected.value = false;
|
isSocketConnected.value = false;
|
||||||
|
|
||||||
if (unlistenSocket) {
|
if (unlistenSocket) {
|
||||||
unlistenSocket();
|
unlistenSocket();
|
||||||
unlistenSocket = null;
|
unlistenSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
const tempSocket = socket;
|
const tempSocket = socket;
|
||||||
socket = null;
|
socket = null;
|
||||||
try {
|
try {
|
||||||
await tempSocket.disconnect();
|
await tempSocket.disconnect();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Socket cleanup warning:", err);
|
console.warn("Socket cleanup warning:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleScroll() {
|
async function handleScroll() {
|
||||||
const el = messageListRef.value;
|
const el = messageListRef.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) {
|
if (el.scrollTop < 50 && !isLoadingMore.value && hasMore.value) {
|
||||||
await loadMore();
|
await loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (messages.value.length === 0) return;
|
if (messages.value.length === 0) return;
|
||||||
|
|
||||||
isLoadingMore.value = true;
|
isLoadingMore.value = true;
|
||||||
const oldestMsgUuid = messages.value[0].uuid;
|
const oldestMsgUuid = messages.value[0].uuid;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const olderMsgs = await fetchMessages(props.uuid, oldestMsgUuid, 30);
|
const olderMsgs = await fetchMessages(props.uuid, oldestMsgUuid, 30);
|
||||||
|
|
||||||
if (olderMsgs.length === 0) {
|
if (olderMsgs.length === 0) {
|
||||||
hasMore.value = false;
|
hasMore.value = false;
|
||||||
return;
|
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() {
|
function scrollToBottom() {
|
||||||
if (messageListRef.value) {
|
if (messageListRef.value) {
|
||||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
|
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottomIfAtEnd() {
|
function scrollToBottomIfAtEnd() {
|
||||||
const el = messageListRef.value;
|
const el = messageListRef.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const threshold = 150;
|
const threshold = 150;
|
||||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
|
||||||
if (isAtBottom) scrollToBottom();
|
if (isAtBottom) scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
const handleGlobalKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
const active = document.activeElement?.tagName.toLowerCase();
|
const active = document.activeElement?.tagName.toLowerCase();
|
||||||
const isTyping = active === 'input' || active === 'textarea';
|
const isTyping = active === 'input' || active === 'textarea';
|
||||||
if (!isTyping && messageInputRef.value) {
|
if (!isTyping && messageInputRef.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
messageInputRef.value.focus();
|
messageInputRef.value.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function onSend(content: string) {
|
async function onSend(content: string) {
|
||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') return;
|
||||||
await sendMessage(props.uuid, content);
|
await sendMessage(props.uuid, content);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-container {
|
.chat-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
scroll-behavior: auto;
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wait-msg {
|
.wait-msg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-container {
|
.input-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.input-container > *:last-child) {
|
:deep(.input-container > *:last-child) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-name {
|
.room-name {
|
||||||
margin: 15px 0;
|
border-radius: var(--radius);
|
||||||
text-align: center;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 15px 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invite-btn {
|
.invite-btn {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection-error {
|
.connection-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn {
|
.retry-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn:hover {
|
.retry-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-room {
|
.no-room {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state i {
|
.empty-state i {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
opacity: 0.3;
|
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>
|
</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>
|
<ul>
|
||||||
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
|
<li v-for="(m, i) in messages" :key="i" class="message" :class="{ 'is-me': m.sender_uuid === currentUserUuid }">
|
||||||
<div class="sender-info">
|
<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>
|
<div class="sender">{{ m.sender }}</div>
|
||||||
<span class="timestamp">{{ m.sent_at }}</span>
|
<span class="timestamp">{{ m.sent_at }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,15 +74,6 @@ ul {
|
|||||||
padding: 5px 18px;
|
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 {
|
.message.is-me {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
align-items: 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>
|
</button>
|
||||||
</header>
|
</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>
|
<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()">
|
<button class="retry-btn" @click="refreshRooms()">
|
||||||
<i class="fa-solid fa-rotate-right"></i>
|
<i class="fa-solid fa-rotate-right"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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"
|
<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')">
|
:class="{ active: route.params.uuid === room.uuid }" @click="emit('select-room')">
|
||||||
<div class="room-content-wrapper">
|
<div class="room-content-wrapper">
|
||||||
@@ -48,21 +52,27 @@ const route = useRoute();
|
|||||||
const showCreate = ref(false);
|
const showCreate = ref(false);
|
||||||
const rooms = ref<Room[]>([]);
|
const rooms = ref<Room[]>([]);
|
||||||
const unreadCounts = ref<Record<string, number>>({});
|
const unreadCounts = ref<Record<string, number>>({});
|
||||||
|
const isLoading = ref(true);
|
||||||
|
|
||||||
async function refreshRooms() {
|
async function refreshRooms() {
|
||||||
const fetchedRooms = await fetchRooms();
|
isLoading.value = true;
|
||||||
rooms.value = fetchedRooms;
|
|
||||||
|
|
||||||
fetchedRooms.forEach(room => {
|
try {
|
||||||
console.log(`Unread count for room ${room.name}: ${room.unread_count}`);
|
const fetchedRooms = await fetchRooms();
|
||||||
|
rooms.value = fetchedRooms;
|
||||||
|
|
||||||
// If the room isn't the currently active one, store the count
|
fetchedRooms.forEach(room => {
|
||||||
if (room.unread_count && route.params.uuid !== room.uuid) {
|
if (room.unread_count && route.params.uuid !== room.uuid) {
|
||||||
unreadCounts.value[room.uuid] = room.unread_count;
|
unreadCounts.value[room.uuid] = room.unread_count;
|
||||||
} else {
|
} else {
|
||||||
unreadCounts.value[room.uuid] = 0;
|
unreadCounts.value[room.uuid] = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to refresh rooms", error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const incrementUnread = (roomUuid: string) => {
|
const incrementUnread = (roomUuid: string) => {
|
||||||
@@ -72,7 +82,7 @@ const incrementUnread = (roomUuid: string) => {
|
|||||||
unreadCounts.value[roomUuid] = current + 1;
|
unreadCounts.value[roomUuid] = current + 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ incrementUnread });
|
defineExpose({ incrementUnread, refreshRooms });
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.params.uuid,
|
() => route.params.uuid,
|
||||||
@@ -85,7 +95,6 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// rooms.value = await fetchRooms();
|
|
||||||
await refreshRooms()
|
await refreshRooms()
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -111,6 +120,7 @@ onMounted(async () => {
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wait-msg {
|
.wait-msg {
|
||||||
@@ -162,7 +172,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rooms-header h2 {
|
.rooms-header h2 {
|
||||||
/* font-size: 1rem; */
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-left: 45px;
|
margin-left: 45px;
|
||||||
}
|
}
|
||||||
@@ -189,7 +198,6 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.room-item.active {
|
.room-item.active {
|
||||||
/* border: 1px solid var(--border); */
|
|
||||||
background: var(--panel-accent);
|
background: var(--panel-accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ auth-error-email-invalid = Please enter a valid email address
|
|||||||
|
|
||||||
## Chat page
|
## Chat page
|
||||||
chat-no-room = Select a room to start talking
|
chat-no-room = Select a room to start talking
|
||||||
|
chat-no-messages = No messages yet. Say hi!
|
||||||
chat-input-placeholder = type a message
|
chat-input-placeholder = type a message
|
||||||
chat-invite-title = Invite People
|
chat-invite-title = Invite People
|
||||||
chat-invite-receiver = Receiver username
|
chat-invite-receiver = Receiver username
|
||||||
@@ -32,8 +33,16 @@ chat-invite-friend-too = Also send a friend request
|
|||||||
chat-invite-send = Send
|
chat-invite-send = Send
|
||||||
chat-invite-username-placeholder = username
|
chat-invite-username-placeholder = username
|
||||||
chat-room-list-title = Rooms
|
chat-room-list-title = Rooms
|
||||||
|
chat-room-list-empty = No rooms found
|
||||||
chat-room-list-connecting = Connecting...
|
chat-room-list-connecting = Connecting...
|
||||||
chat-room-owner = by {$owner}
|
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-title = Create room
|
||||||
chat-create-name = Room name
|
chat-create-name = Room name
|
||||||
chat-create-name-placeholder = room name
|
chat-create-name-placeholder = room name
|
||||||
@@ -95,6 +104,9 @@ warning-wrongversion-dismiss = I know what I'm doing
|
|||||||
|
|
||||||
## Shared
|
## Shared
|
||||||
shared-cancel = Cancel
|
shared-cancel = Cancel
|
||||||
|
shared-close = Close
|
||||||
shared-error = An error occurred
|
shared-error = An error occurred
|
||||||
shared-save = Save
|
shared-save = Save
|
||||||
shared-updating = Updating
|
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 page
|
||||||
chat-no-room = Sélectionnez un salon pour commencer à discuter
|
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-input-placeholder = tapez un message
|
||||||
chat-invite-title = Inviter des gens
|
chat-invite-title = Inviter des gens
|
||||||
|
chat-room-list-empty = Aucun salon trouvé
|
||||||
chat-invite-receiver = Nom du destinataire
|
chat-invite-receiver = Nom du destinataire
|
||||||
chat-invite-friend-too = Envoyer aussi une demande d'ami
|
chat-invite-friend-too = Envoyer aussi une demande d'ami
|
||||||
chat-invite-send = Envoyer
|
chat-invite-send = Envoyer
|
||||||
@@ -34,6 +36,13 @@ chat-invite-username-placeholder = nom d'utilisateur
|
|||||||
chat-room-list-title = Salons
|
chat-room-list-title = Salons
|
||||||
chat-room-list-connecting = Connexion...
|
chat-room-list-connecting = Connexion...
|
||||||
chat-room-owner = par {$owner}
|
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-title = Créer un salon
|
||||||
chat-create-name = Nom du salon
|
chat-create-name = Nom du salon
|
||||||
chat-create-name-placeholder = 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
|
||||||
shared-cancel = Annuler
|
shared-cancel = Annuler
|
||||||
|
shared-close = Fermer
|
||||||
shared-error = Une erreur est survenue
|
shared-error = Une erreur est survenue
|
||||||
shared-save = Enregistrer
|
shared-save = Enregistrer
|
||||||
shared-updating = Mise à jour...
|
shared-updating = Mise à jour...
|
||||||
|
shared-delete = Supprimer
|
||||||
|
shared-leave = Quitter
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ async function init() {
|
|||||||
|
|
||||||
init()
|
init()
|
||||||
|
|
||||||
// export const API = 'http://127.0.0.1:8080'
|
export const API = 'http://127.0.0.1:8080'
|
||||||
export const API = 'http://192.168.1.183:8080'
|
// export const API = 'http://192.168.1.183:8080'
|
||||||
// export const API = 'https://alatreon.org/frangipane'
|
// export const API = 'https://alatreon.org/frangipane'
|
||||||
// export const API_WS = 'ws://127.0.0.1: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 = 'ws://192.168.1.183:8080/ws'
|
||||||
// export const API_WS = 'wss://alatreon.org/frangipane/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>
|
<div v-if="isSidebarOpen" class="sidebar-overlay" @click="isSidebarOpen = false"></div>
|
||||||
|
|
||||||
<main class="chat-window-container" :class="{ 'sidebar-is-open': isSidebarOpen }">
|
<main class="chat-window-container" :class="{ 'sidebar-is-open': isSidebarOpen }">
|
||||||
<ChatWindow :uuid="uuid" @notification="handleNotification" />
|
<ChatWindow :uuid="safeUuid" @notification="handleNotification" @room-action="handleRoomAction" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 RoomList from "../components/RoomList.vue";
|
||||||
import ChatWindow from "../components/ChatWindow.vue";
|
import ChatWindow from "../components/ChatWindow.vue";
|
||||||
|
|
||||||
defineProps<{ uuid: string }>();
|
const props = defineProps<{ uuid?: string }>();
|
||||||
const isSidebarOpen = ref(true);
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isSidebarOpen = ref(true);
|
||||||
const roomListRef = ref<InstanceType<typeof RoomList> | null>(null);
|
const roomListRef = ref<InstanceType<typeof RoomList> | null>(null);
|
||||||
|
|
||||||
|
const safeUuid = computed(() => props.uuid || 'none');
|
||||||
|
|
||||||
const handleRoomSelection = () => {
|
const handleRoomSelection = () => {
|
||||||
if (window.innerWidth <= 720) {
|
if (window.innerWidth <= 720) {
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRoomAction = async () => {
|
||||||
|
if (roomListRef.value) {
|
||||||
|
await roomListRef.value.refreshRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/rooms/none');
|
||||||
|
};
|
||||||
|
|
||||||
const handleNotification = (roomUuid: string) => {
|
const handleNotification = (roomUuid: string) => {
|
||||||
if (roomListRef.value) {
|
if (roomListRef.value) {
|
||||||
roomListRef.value.incrementUnread(roomUuid);
|
roomListRef.value.incrementUnread(roomUuid);
|
||||||
|
|||||||
@@ -64,10 +64,6 @@ const handleAvatarError = (event: Event) => {
|
|||||||
img.src = defaultAvatar;
|
img.src = defaultAvatar;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleAvatarUpdated() {
|
|
||||||
await fetchUserData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const showAvatarModal = ref(false)
|
const showAvatarModal = ref(false)
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
69
src/types.ts
69
src/types.ts
@@ -1,58 +1,63 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
uuid: string
|
uuid: string
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
uuid: string
|
||||||
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
uuid: string
|
uuid: string
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
token: string
|
token: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserResponse {
|
export interface UpdateUserResponse {
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Room {
|
export interface Room {
|
||||||
uuid: string
|
uuid: string
|
||||||
owner_name: string
|
owner_name: string
|
||||||
owner_uuid: string
|
owner_uuid: string
|
||||||
name: string
|
name: string
|
||||||
global: boolean
|
global: boolean
|
||||||
unread_count: number
|
unread_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
uuid: string
|
uuid: string
|
||||||
room_uuid: string
|
room_uuid: string
|
||||||
sender: string
|
sender: string
|
||||||
sender_uuid: string
|
sender_uuid: string
|
||||||
message_type: 'text'
|
message_type: 'text'
|
||||||
content: string
|
content: string
|
||||||
sent_at: string
|
sent_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Friend {
|
export interface Friend {
|
||||||
uuid: string
|
uuid: string
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FriendRequest {
|
export interface FriendRequest {
|
||||||
sender_uuid: string
|
sender_uuid: string
|
||||||
sender_username: string
|
sender_username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomInvite {
|
export interface RoomInvite {
|
||||||
room_uuid: string
|
room_uuid: string
|
||||||
room_name: string
|
room_name: string
|
||||||
sender_uuid: string
|
sender_uuid: string
|
||||||
sender_username: string
|
sender_username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VersionResponse {
|
export interface VersionResponse {
|
||||||
version: string
|
version: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const host = process.env.TAURI_DEV_HOST;
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
base: './',
|
base: '/',
|
||||||
|
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user