feat: enhance chat room UI with user avatars and message improvements

- Added user avatar generation with external avatar service
- Implemented message styling with user-specific layouts
- Added message length counter and validation
- Updated CSS for improved message and user profile display
- Restricted message length to 2048 characters
- Added disabled state for send button based on message length
This commit is contained in:
Juan Sebastián Montoya 2025-03-07 01:06:31 -05:00
parent 3d41e2cc42
commit d4d99fb5e7
5 changed files with 144 additions and 24 deletions

View file

@ -10,9 +10,9 @@
"test": "ts-node --test test/**/*.test.ts", "test": "ts-node --test test/**/*.test.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "nodemon --delay 2000ms src/index.ts", "dev": "nodemon --delay 2000ms src/index.ts",
"prisma:generate": "dotenv -e ../../.env -- prisma generate", "prisma:generate": "dotenv -e ../../.env.local -- prisma generate",
"prisma:migrate": "dotenv -e ../../.env -- prisma migrate dev", "prisma:migrate": "dotenv -e ../../.env.local -- prisma migrate dev",
"prisma:init": "dotenv -e ../../.env -- prisma migrate dev --name init", "prisma:init": "dotenv -e ../../.env.local -- prisma migrate dev --name init",
"build": "tsc" "build": "tsc"
}, },
"keywords": [], "keywords": [],

View file

@ -9,7 +9,7 @@ import { PrismaClient } from '@prisma/client';
import fastifyCors from '@fastify/cors'; import fastifyCors from '@fastify/cors';
import { z } from 'zod'; import { z } from 'zod';
dotenv.config({ path: '../../.env' }); dotenv.config({ path: '../../.env.local' });
const { allowedOrigins, port, host } = z const { allowedOrigins, port, host } = z
.object({ .object({

View file

@ -79,6 +79,35 @@ body {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
.user-profile {
display: flex;
align-items: center;
gap: 1rem;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid white;
}
.logout-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: background-color 0.2s;
}
.logout-button:hover {
background-color: rgba(255, 255, 255, 0.3);
}
.app-main { .app-main {
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
@ -180,11 +209,6 @@ button:disabled {
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.logout-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
/* Chat styles */ /* Chat styles */
.chat-container { .chat-container {
display: flex; display: flex;
@ -357,21 +381,53 @@ button:disabled {
.chat-messages { .chat-messages {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem;
}
.message-wrapper {
display: flex;
align-items: flex-end;
max-width: 100%;
gap: 0.75rem;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
align-self: flex-end;
} }
.message { .message {
max-width: 70%; max-width: 85%;
min-width: 100px;
padding: 0.75rem; padding: 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
word-break: break-word;
line-height: 1.4;
width: fit-content;
display: inline-block;
}
.other-message-wrapper {
align-self: flex-start;
}
.own-message-wrapper {
align-self: flex-end;
flex-direction: row-reverse;
}
.other-message {
background-color: #f3f4f6; background-color: #f3f4f6;
align-self: flex-start; align-self: flex-start;
} }
.message.own-message { .own-message {
background-color: rgba(79, 70, 229, 0.1); background-color: rgba(79, 70, 229, 0.1);
align-self: flex-end; align-self: flex-end;
} }
@ -385,18 +441,31 @@ button:disabled {
.username { .username {
font-weight: 500; font-weight: 500;
margin-right: 0.5rem;
} }
.time { .time {
color: var(--secondary-color); color: var(--secondary-color);
opacity: 0.7;
} }
.message-content { .message-content {
word-break: break-word; word-break: break-word;
white-space: pre-wrap;
display: inline-block;
text-align: justify;
}
.message-length-counter {
font-size: 0.75rem;
color: var(--secondary-color);
margin-right: 0.5rem;
align-self: center;
} }
.message-form { .message-form {
display: flex; display: flex;
align-items: center;
padding: 1rem; padding: 1rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
@ -413,6 +482,11 @@ button:disabled {
border-radius: 0 0.25rem 0.25rem 0; border-radius: 0 0.25rem 0.25rem 0;
} }
.message-form button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Create Room styles */ /* Create Room styles */
.create-room { .create-room {
padding: 1rem; padding: 1rem;

View file

@ -14,6 +14,10 @@ function App() {
const [selectedRoomId, setSelectedRoomId] = createSignal<string | null>(null); const [selectedRoomId, setSelectedRoomId] = createSignal<string | null>(null);
const [showRegister, setShowRegister] = createSignal(false); const [showRegister, setShowRegister] = createSignal(false);
// Function to generate avatar URL
const getUserAvatarUrl = (userId: string, size: number = 40) =>
`https://avatars.jusemon.com/${userId}?size=${size}`;
// Check if user is already authenticated // Check if user is already authenticated
const checkAuth = () => { const checkAuth = () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@ -55,9 +59,16 @@ function App() {
<header class='app-header'> <header class='app-header'>
<h1>Unreal Chat</h1> <h1>Unreal Chat</h1>
{isAuthenticated() && ( {isAuthenticated() && (
<button class='logout-button' onClick={handleLogout}> <div class='user-profile'>
Logout <img
</button> src={getUserAvatarUrl(userId())}
alt='User avatar'
class='user-avatar'
/>
<button class='logout-button' onClick={handleLogout}>
Logout
</button>
</div>
)} )}
</header> </header>

View file

@ -161,14 +161,18 @@ export function ChatRoom(props: ChatRoomProps) {
} }
}); });
const MAX_MESSAGE_LENGTH = 2048;
const handleSendMessage = async (e: Event) => { const handleSendMessage = async (e: Event) => {
e.preventDefault(); e.preventDefault();
if (!message().trim()) return; const trimmedMessage = message().trim();
if (!trimmedMessage) return;
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) return;
try { try {
await sendMessage({ await sendMessage({
content: message(), content: trimmedMessage,
roomId: props.roomId, roomId: props.roomId,
}); });
setMessage(''); setMessage('');
@ -242,6 +246,10 @@ export function ChatRoom(props: ChatRoomProps) {
}; };
}); });
// Function to generate avatar URL
const getUserAvatarUrl = (userId: string, size: number = 40) =>
`https://avatars.jusemon.com/${userId}?size=${size}`;
return ( return (
<div class='chat-room'> <div class='chat-room'>
<Show when={roomQuery.data?.room} fallback={<div>Loading room...</div>}> <Show when={roomQuery.data?.room} fallback={<div>Loading room...</div>}>
@ -276,13 +284,24 @@ export function ChatRoom(props: ChatRoomProps) {
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => (
<div <div
class={`message ${message.user.id === props.userId ? 'own-message' : ''}`} class={`message-wrapper ${message.user.id === props.userId ? 'own-message-wrapper' : 'other-message-wrapper'}`}
> >
<div class='message-header'> {message.user.id !== props.userId && (
<span class='username'>{message.user.username}</span> <img
<span class='time'>{formatTime(message.createdAt)}</span> src={getUserAvatarUrl(message.user.id)}
alt={`${message.user.username}'s avatar`}
class='message-avatar'
/>
)}
<div
class={`message ${message.user.id === props.userId ? 'own-message' : 'other-message'}`}
>
<div class='message-header'>
<span class='username'>{message.user.username}</span>
<span class='time'>{formatTime(message.createdAt)}</span>
</div>
<div class='message-content'>{message.content}</div>
</div> </div>
<div class='message-content'>{message.content}</div>
</div> </div>
)} )}
</For> </For>
@ -295,7 +314,12 @@ export function ChatRoom(props: ChatRoomProps) {
ref={messageInput} ref={messageInput}
type='text' type='text'
value={message()} value={message()}
onInput={(e) => setMessage(e.currentTarget.value)} onInput={(e) => {
const inputValue = e.currentTarget.value;
if (inputValue.length <= MAX_MESSAGE_LENGTH) {
setMessage(inputValue);
}
}}
placeholder='Type a message...' placeholder='Type a message...'
/> />
<button <button
@ -306,7 +330,18 @@ export function ChatRoom(props: ChatRoomProps) {
😊 😊
</button> </button>
</div> </div>
<button type='submit'>Send</button> <div class='message-length-counter'>
{message().length}/{MAX_MESSAGE_LENGTH}
</div>
<button
type='submit'
disabled={
message().trim().length === 0 ||
message().length > MAX_MESSAGE_LENGTH
}
>
Send
</button>
{showEmojiPicker() && ( {showEmojiPicker() && (
<div ref={emojiPickerContainer} class='emoji-picker-container'> <div ref={emojiPickerContainer} class='emoji-picker-container'>