added profile pictures to messages and improved chat page layout
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "chatapp",
|
"productName": "chatapp",
|
||||||
"version": "1.0.0",
|
"version": "1.0.3",
|
||||||
"identifier": "com.strawberries.chatapp",
|
"identifier": "com.strawberries.chatapp",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "yarn dev",
|
"beforeDevCommand": "yarn dev",
|
||||||
|
|||||||
BIN
src/assets/default-avatar.png
Normal file
BIN
src/assets/default-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -19,6 +19,7 @@ body,
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0f1116;
|
--bg: #0f1116;
|
||||||
--panel: #171922;
|
--panel: #171922;
|
||||||
|
--panel-accent: #12141B;
|
||||||
--text: #e6e6eb;
|
--text: #e6e6eb;
|
||||||
--muted: #9aa0aa;
|
--muted: #9aa0aa;
|
||||||
--accent: #f27aa3;
|
--accent: #f27aa3;
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<InvitePeopleModal v-if="showInviteModal" :room_uuid=props.uuid @close="showInviteModal = false" />
|
<InvitePeopleModal v-if="showInviteModal" :room_uuid=props.uuid @close="showInviteModal = false" />
|
||||||
|
|
||||||
<div v-if="uuid === 'none'" class="no-room">
|
<div v-if="uuid === 'none'" class="no-room">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<i class="fa-solid fa-comments"></i>
|
<i class="fa-solid fa-comments"></i>
|
||||||
<p>{{ $t('chat-no-room') }}</p>
|
<p>{{ $t('chat-no-room') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="chat-container">
|
|
||||||
<!-- <h2 class="room-name">{{ currentRoom?.name }}</h2> -->
|
|
||||||
|
|
||||||
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
|
||||||
<MessageList :messages="messages" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-container">
|
<div v-else class="chat-container">
|
||||||
<button v-if="isOwner && !currentRoom?.global" class="invite-btn" @click="showInviteModal = true"
|
<h2 class="room-name">{{ currentRoom?.name }}</h2>
|
||||||
:title="$t('chat-invite-title')">
|
|
||||||
<i class="fa-solid fa-users"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<MessageInput ref="messageInputRef" @send="onSend" />
|
<div class="messages-container" ref="messageListRef" @scroll="handleScroll">
|
||||||
|
<MessageList :messages="messages" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<MessageInput ref="messageInputRef" @send="onSend" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -52,220 +52,222 @@ const showInviteModal = ref(false);
|
|||||||
let socket: WebSocket | null = null;
|
let socket: WebSocket | 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;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function initializeRoom() {
|
async function initializeRoom() {
|
||||||
if (socket) { await socket.disconnect(); socket = null; }
|
if (socket) { await socket.disconnect(); socket = null; }
|
||||||
|
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
currentRoom.value = null;
|
currentRoom.value = null;
|
||||||
|
|
||||||
if (props.uuid === 'none') return;
|
if (props.uuid === 'none') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [msgs, roomInfo, auth] = await Promise.all([
|
const [msgs, roomInfo, auth] = await Promise.all([
|
||||||
fetchMessages(props.uuid, undefined, 40), // Load first 40
|
fetchMessages(props.uuid, undefined, 40), // Load first 40
|
||||||
fetchRoomInfo(props.uuid),
|
fetchRoomInfo(props.uuid),
|
||||||
getAuthData()
|
getAuthData()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
messages.value = msgs;
|
console.log("First message payload:", msgs[0]);
|
||||||
currentRoom.value = roomInfo;
|
|
||||||
currentUser.value = auth.user;
|
|
||||||
if (msgs.length < 40) hasMore.value = false;
|
|
||||||
|
|
||||||
await nextTick();
|
messages.value = msgs;
|
||||||
scrollToBottom();
|
currentRoom.value = roomInfo;
|
||||||
|
currentUser.value = auth.user;
|
||||||
|
if (msgs.length < 40) hasMore.value = false;
|
||||||
|
|
||||||
const wsToken = await getWsToken(props.uuid);
|
await nextTick();
|
||||||
const url = `${API_WS}/rooms/${props.uuid}?token=${wsToken}`;
|
scrollToBottom();
|
||||||
socket = await WebSocket.connect(url);
|
|
||||||
|
|
||||||
socket.addListener((msg) => {
|
const wsToken = await getWsToken(props.uuid);
|
||||||
if (msg.type === 'Text') {
|
const url = `${API_WS}/rooms/${props.uuid}?token=${wsToken}`;
|
||||||
const data: Message = JSON.parse(msg.data);
|
socket = await WebSocket.connect(url);
|
||||||
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
|
||||||
messages.value.push(data);
|
socket.addListener((msg) => {
|
||||||
nextTick().then(scrollToBottomIfAtEnd);
|
if (msg.type === 'Text') {
|
||||||
}
|
const data: Message = JSON.parse(msg.data);
|
||||||
}
|
if (!messages.value.some(m => m.uuid === data.uuid)) {
|
||||||
});
|
messages.value.push(data);
|
||||||
} catch (err) {
|
nextTick().then(scrollToBottomIfAtEnd);
|
||||||
console.error("Room initialization failed:", err);
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Room initialization failed:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleScroll() {
|
async function handleScroll() {
|
||||||
const el = messageListRef.value;
|
const el = messageListRef.value;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
// If user scrolls to the top, is not already loading, and there's more data
|
// If user scrolls to the top, is not already loading, and there's more data
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture height before adding messages to maintain scroll position
|
||||||
|
const el = messageListRef.value;
|
||||||
|
const previousScrollHeight = el?.scrollHeight || 0;
|
||||||
|
|
||||||
|
messages.value = [...olderMsgs, ...messages.value];
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Restore scroll position so the view doesn't jump
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture height before adding messages to maintain scroll position
|
|
||||||
const el = messageListRef.value;
|
|
||||||
const previousScrollHeight = el?.scrollHeight || 0;
|
|
||||||
|
|
||||||
messages.value = [...olderMsgs, ...messages.value];
|
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// Restore scroll position so the view doesn't jump
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only scroll to bottom for new messages if the user is already near the bottom
|
// Only scroll to bottom for new messages if the user is already near the bottom
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.uuid, () => {
|
watch(() => props.uuid, () => {
|
||||||
initializeRoom();
|
initializeRoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeRoom();
|
initializeRoom();
|
||||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(async () => {
|
onUnmounted(async () => {
|
||||||
if (socket) {
|
if (socket) {
|
||||||
await socket.disconnect();
|
await socket.disconnect();
|
||||||
}
|
}
|
||||||
window.removeEventListener('keydown', handleGlobalKeyDown);
|
window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-container {
|
.chat-container {
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure the MessageInput component expands to fill the width */
|
/* Ensure the MessageInput component expands to fill the width */
|
||||||
:deep(.input-container > *:last-child) {
|
:deep(.input-container > *:last-child) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .room-name { */
|
.room-name {
|
||||||
/* margin: 15px 0; */
|
margin: 15px 0;
|
||||||
/* text-align: center; */
|
text-align: center;
|
||||||
/* } */
|
}
|
||||||
|
|
||||||
.loading-more {
|
.loading-more {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,49 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<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="getAvatar(m.sender_uuid)" @error="handleAvatarError" class="sender-avatar" />
|
||||||
|
<div class="sender">{{ m.sender }}</div>
|
||||||
|
<span class="timestamp">{{ m.sent_at }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="message-content">{{ m.content }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
import type { Message } from '../types'
|
import type { Message } from '../types'
|
||||||
|
import { getAvatar } from '../api/account.ts'
|
||||||
|
import defaultAvatar from '../assets/default-avatar.png'
|
||||||
|
import { getAuthData } from '../authStore';
|
||||||
|
|
||||||
defineProps<{ messages: Message[] }>()
|
defineProps<{ messages: Message[] }>()
|
||||||
|
|
||||||
|
const currentUserUuid = ref<string | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const auth = await getAuthData()
|
||||||
|
if (auth.user) {
|
||||||
|
currentUserUuid.value = auth.user.uuid
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAvatarError = (event: Event) => {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
img.src = defaultAvatar;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(m, i) in messages" :key="i" class="message">
|
|
||||||
<div class="sender">{{ m.sender }} <span class="timestamp">{{ m.sent_at }}</span></div>
|
|
||||||
<div class="message-content">{{ m.content }}</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
ul {
|
ul {
|
||||||
padding: 0;
|
display: flex;
|
||||||
margin: 0;
|
flex-direction: column;
|
||||||
list-style: none;
|
gap: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 0.5rem;
|
display: flex;
|
||||||
background-color: rgba(0, 0, 0, 0.2);
|
flex-direction: column;
|
||||||
padding: 10px;
|
align-items: flex-start;
|
||||||
border-radius: var(--radius);
|
justify-content: center;
|
||||||
|
/* gap: 0.5rem; */
|
||||||
|
background: var(--panel-accent);
|
||||||
|
padding: 0;
|
||||||
|
max-width: 80%;
|
||||||
|
width: fit-content;
|
||||||
|
align-self: flex-start;
|
||||||
|
/* border: 1px solid var(--border); */
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
/* border: 1px solid var(--border); */
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
/* border-radius: var(--radius) var(--radius) 0 0; */
|
||||||
|
/* background-color: rgba(255, 255, 255, 0.02); */
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.is-me .sender-info {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.is-me .message-content {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 1rem;
|
||||||
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender {
|
.sender {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
margin-bottom: 0.25rem;
|
/* flex: 1; */
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender .timestamp {
|
.timestamp {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
font-size: 0.85rem;
|
font-size: 0.7rem;
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-content {
|
.message-content {
|
||||||
padding-left: 1rem;
|
padding: 10px;
|
||||||
white-space: pre-wrap;
|
padding-left: 1rem;
|
||||||
word-wrap: break-word;
|
white-space: pre-wrap;
|
||||||
max-width: 100%;
|
word-wrap: break-word;
|
||||||
display: block;
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="room-list">
|
<div class="room-list">
|
||||||
<header class="rooms-header">
|
<header class="rooms-header">
|
||||||
<h2>{{ $t('chat-room-list-title') }}</h2>
|
<h2>{{ $t('chat-room-list-title') }}</h2>
|
||||||
<button class="create-btn" @click="showCreate = true" :title="$t('chat-create-title')">
|
<button class="create-btn" @click="showCreate = true" :title="$t('chat-create-title')">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<CreateRoomModal v-if="showCreate" @close="showCreate = false" @created="rooms.push($event)" />
|
<CreateRoomModal v-if="showCreate" @close="showCreate = false" @created="rooms.push($event)" />
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<div class="scroll-area">
|
<div class="scroll-area">
|
||||||
<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-info">
|
<div class="room-info">
|
||||||
<span class="room-name">{{ room.name }}</span>
|
<span class="room-name">{{ room.name }}</span>
|
||||||
<span class="room-owner">{{ $t('chat-room-owner', { owner: room.owner_name }) }}</span>
|
<span class="room-owner">{{ $t('chat-room-owner', { owner: room.owner_name }) }}</span>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -37,93 +37,94 @@ const rooms = ref<Room[]>([]);
|
|||||||
const emit = defineEmits(['select-room']);
|
const emit = defineEmits(['select-room']);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
rooms.value = await fetchRooms();
|
rooms.value = await fetchRooms();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.room-list {
|
.room-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rooms-header {
|
.rooms-header {
|
||||||
padding: 1.5rem;
|
padding: 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rooms-header h2 {
|
.rooms-header h2 {
|
||||||
font-size: 1.1rem;
|
/* font-size: 1rem; */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-left: 38px;
|
margin-left: 45px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-area {
|
.scroll-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-item {
|
.room-item {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-item:hover {
|
.room-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-item.active {
|
.room-item.active {
|
||||||
background: var(--accent);
|
/* border: 1px solid var(--border); */
|
||||||
color: rgba(0, 0, 0, 0.8);
|
background: var(--panel-accent);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-item.active .room-owner {
|
.room-item.active .room-owner {
|
||||||
color: rgba(0, 0, 0, 1);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-info {
|
.room-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-name {
|
.room-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-owner {
|
.room-owner {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-btn {
|
.create-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-btn:hover {
|
.create-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
<button class="menu-toggle" :class="{ 'sidebar-closed': !isSidebarOpen }" @click="isSidebarOpen = !isSidebarOpen">
|
<button class="menu-toggle" :class="{ 'sidebar-closed': !isSidebarOpen }"
|
||||||
<i class="fa-solid fa-bars"></i>
|
@click="isSidebarOpen = !isSidebarOpen">
|
||||||
</button>
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
<aside class="sidebar" :class="{ 'is-open': isSidebarOpen }">
|
<aside class="sidebar" :class="{ 'is-open': isSidebarOpen }">
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<RoomList @select-room="handleRoomSelection" />
|
<RoomList @select-room="handleRoomSelection" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<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" />
|
<ChatWindow :uuid="uuid" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -27,117 +28,117 @@ defineProps<{ uuid: string }>();
|
|||||||
const isSidebarOpen = ref(true);
|
const isSidebarOpen = ref(true);
|
||||||
|
|
||||||
const handleRoomSelection = () => {
|
const handleRoomSelection = () => {
|
||||||
if (window.innerWidth <= 720) {
|
if (window.innerWidth <= 720) {
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-layout {
|
.chat-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
transition: width 0.3s ease, transform 0.3s ease;
|
transition: width 0.3s ease, transform 0.3s ease;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar:not(.is-open) {
|
.sidebar:not(.is-open) {
|
||||||
width: 0;
|
width: 0;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-window-container {
|
.chat-window-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-left: 38px;
|
/* padding-left: 38px; */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle {
|
.menu-toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 24px;
|
top: 15px;
|
||||||
left: 15px;
|
left: 15px;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: left 0.3s ease;
|
transition: left 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle.sidebar-closed {
|
.menu-toggle.sidebar-closed {
|
||||||
left: 15px;
|
left: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle i {
|
.menu-toggle i {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 721px) {
|
@media (min-width: 721px) {
|
||||||
.chat-window-container.sidebar-is-open {
|
.chat-window-container.sidebar-is-open {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.is-open {
|
.sidebar.is-open {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
width: 280px;
|
width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar:not(.is-open) {
|
.sidebar:not(.is-open) {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-overlay {
|
.sidebar-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
z-index: 15;
|
z-index: 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-toggle:hover i {
|
.menu-toggle:hover i {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,48 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
<h1>{{ $t('settings-title') }}</h1>
|
<h1>{{ $t('settings-title') }}</h1>
|
||||||
|
|
||||||
<UpdateAccountModal v-if="showUpdateModal" :user="user" @close="showUpdateModal = false" @updated="fetchUserData" />
|
<UpdateAccountModal v-if="showUpdateModal" :user="user" @close="showUpdateModal = false"
|
||||||
<UploadAvatarModal v-if="showAvatarModal" @close="showAvatarModal = false" @updated="fetchUserData" />
|
@updated="fetchUserData" />
|
||||||
|
<UploadAvatarModal v-if="showAvatarModal" @close="showAvatarModal = false" @updated="fetchUserData" />
|
||||||
|
|
||||||
<h2>{{ $t('settings-account') }}</h2>
|
<h2>{{ $t('settings-account') }}</h2>
|
||||||
<div v-if="user" class="info-card">
|
<div v-if="user" class="info-card">
|
||||||
<div class="avatar-display">
|
<div class="avatar-display">
|
||||||
<img :src="user.avatar_url || '/tauri.svg'" class="avatar-img" />
|
<img :src="user.avatar_url || '/tauri.svg'" class="avatar-img" />
|
||||||
|
|
||||||
<button class="update-btn" @click="showAvatarModal = true">
|
<button class="update-btn" @click="showAvatarModal = true">
|
||||||
{{ $t('settings-upload-avatar-btn') || 'Change Avatar' }}
|
{{ $t('settings-upload-avatar-btn') || 'Change Avatar' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="info-display">
|
<div class="info-display">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<p><strong>{{ $t('settings-label-username') }}</strong> {{ user.username }}</p>
|
<p><strong>{{ $t('settings-label-username') }}</strong> {{ user.username }}</p>
|
||||||
<p><strong>{{ $t('settings-label-email') }}</strong> {{ user.email }}</p>
|
<p><strong>{{ $t('settings-label-email') }}</strong> {{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="update-btn" @click="showUpdateModal = true">{{ $t('settings-update-btn') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading-state">
|
||||||
|
<p>{{ $t('settings-loading') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="update-btn" @click="showUpdateModal = true">{{ $t('settings-update-btn') }}</button>
|
<h2>{{ $t('settings-language') }}</h2>
|
||||||
</div>
|
<div class="input-group">
|
||||||
</div>
|
<div class="lang-grid">
|
||||||
<div v-else class="loading-state">
|
<button v-for="lang in languages" :key="lang.code" class="lang-btn"
|
||||||
<p>{{ $t('settings-loading') }}</p>
|
:class="{ active: currentLang === lang.code }" @click="changeLanguage(lang.code)">
|
||||||
</div>
|
{{ lang.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>{{ $t('settings-language') }}</h2>
|
<button class="logout-btn" @click="logout">
|
||||||
<div class="input-group">
|
<i class="fa-solid fa-right-from-bracket"></i>
|
||||||
<div class="lang-grid">
|
<span>{{ $t('settings-logout-btn') }}</span>
|
||||||
<button v-for="lang in languages" :key="lang.code" class="lang-btn"
|
|
||||||
:class="{ active: currentLang === lang.code }" @click="changeLanguage(lang.code)">
|
|
||||||
{{ lang.name }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="logout-btn" @click="logout">
|
|
||||||
<i class="fa-solid fa-right-from-bracket"></i>
|
|
||||||
<span>{{ $t('settings-logout-btn') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -67,80 +68,80 @@ const currentLang = ref('')
|
|||||||
const languages = computed(() => getSupportedLanguagesMetadata())
|
const languages = computed(() => getSupportedLanguagesMetadata())
|
||||||
|
|
||||||
async function fetchUserData() {
|
async function fetchUserData() {
|
||||||
try {
|
try {
|
||||||
const auth = await getAuthData()
|
const auth = await getAuthData()
|
||||||
user.value = auth.user
|
user.value = auth.user
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load user data:", err)
|
console.error("Failed to load user data:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const pref = await getLocalePreference()
|
const pref = await getLocalePreference()
|
||||||
// Synchronize the UI state with the actual active language
|
// Synchronize the UI state with the actual active language
|
||||||
currentLang.value = pref || (navigator.language.split('-')[0])
|
currentLang.value = pref || (navigator.language.split('-')[0])
|
||||||
|
|
||||||
fetchUserData()
|
fetchUserData()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function changeLanguage(code: string) {
|
async function changeLanguage(code: string) {
|
||||||
const actual = setLanguage(code)
|
const actual = setLanguage(code)
|
||||||
currentLang.value = actual
|
currentLang.value = actual
|
||||||
await saveLocalePreference(actual)
|
await saveLocalePreference(actual)
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
authLogout()
|
authLogout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-page {
|
.settings-page {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-card {
|
.info-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-display,
|
.avatar-display,
|
||||||
.info-display {
|
.info-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
/* border: 1px solid var(--border); */
|
/* border: 1px solid var(--border); */
|
||||||
/* border-radius: var(--radius); */
|
/* border-radius: var(--radius); */
|
||||||
/* background-color: rgba(255, 255, 255, 0.02); */
|
/* background-color: rgba(255, 255, 255, 0.02); */
|
||||||
/* padding: 1rem; */
|
/* padding: 1rem; */
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-img {
|
.avatar-img {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .update-btn { */
|
/* .update-btn { */
|
||||||
@@ -150,63 +151,63 @@ h2 {
|
|||||||
/* } */
|
/* } */
|
||||||
|
|
||||||
.lang-grid {
|
.lang-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn {
|
.lang-btn {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn.active {
|
.lang-btn.active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #000;
|
color: #000;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--error);
|
border: 1px solid var(--error);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover {
|
.logout-btn:hover {
|
||||||
color: rgba(255, 80, 80, 0.8);
|
color: rgba(255, 80, 80, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn:hover i {
|
.logout-btn:hover i {
|
||||||
color: rgba(255, 80, 80, 0.8);
|
color: rgba(255, 80, 80, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-btn i {
|
.logout-btn i {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state {
|
.loading-state {
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 720px) {
|
@media screen and (max-width: 720px) {
|
||||||
|
|
||||||
.avatar-display,
|
.avatar-display,
|
||||||
.info-display {
|
.info-display {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
57
src/types.ts
57
src/types.ts
@@ -1,51 +1,52 @@
|
|||||||
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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
uuid: string
|
uuid: string
|
||||||
sender: string
|
sender: string
|
||||||
message_type: 'text'
|
sender_uuid: string
|
||||||
content: string
|
message_type: 'text'
|
||||||
sent_at: string
|
content: 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user