<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Chat Hub</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--primary: #5865f2;
|
|
--primary-dark: #4752c4;
|
|
--secondary: #3ba55d;
|
|
--danger: #ed4245;
|
|
--warning: #fee75c;
|
|
--background: #0a0a0a;
|
|
--surface: #1a1a1a;
|
|
--surface-light: #2a2a2a;
|
|
--surface-hover: #3a3a3a;
|
|
--text: #e0e0e0;
|
|
--text-dim: #a0a0a0;
|
|
--text-muted: #6a6a6a;
|
|
--online: #3ba55d;
|
|
--idle: #faa61a;
|
|
--offline: #747f8d;
|
|
--border: #333;
|
|
}
|
|
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
sans-serif;
|
|
background: var(--background);
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Layout */
|
|
.app-container {
|
|
display: flex;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* Sidebar */
|
|
.sidebar {
|
|
width: 240px;
|
|
background: var(--surface);
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-right: 1px solid var(--border);
|
|
}
|
|
|
|
.sidebar-header {
|
|
padding: 1rem;
|
|
background: var(--surface-light);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.server-name {
|
|
font-weight: bold;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.sidebar-section {
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.5rem;
|
|
color: var(--text-dim);
|
|
font-size: 0.85rem;
|
|
text-transform: uppercase;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.section-header button {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
font-size: 1.2rem;
|
|
padding: 0;
|
|
width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.section-header button:hover {
|
|
background: var(--surface-hover);
|
|
color: var(--text);
|
|
}
|
|
|
|
/* Channel/DM List */
|
|
.channel-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.channel-item,
|
|
.dm-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.5rem;
|
|
margin: 0.1rem 0.5rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
position: relative;
|
|
}
|
|
|
|
.channel-item:hover,
|
|
.dm-item:hover {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
.channel-item.active,
|
|
.dm-item.active {
|
|
background: var(--surface-hover);
|
|
color: var(--primary);
|
|
}
|
|
|
|
.channel-icon,
|
|
.dm-icon {
|
|
margin-right: 0.5rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.channel-name,
|
|
.dm-name {
|
|
flex: 1;
|
|
}
|
|
|
|
.unread-badge {
|
|
background: var(--danger);
|
|
color: white;
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 10px;
|
|
font-size: 0.75rem;
|
|
font-weight: bold;
|
|
position: absolute;
|
|
right: 0.5rem;
|
|
}
|
|
|
|
.status-indicator {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.status-online {
|
|
background: var(--online);
|
|
}
|
|
|
|
.status-idle {
|
|
background: var(--idle);
|
|
}
|
|
|
|
.status-offline {
|
|
background: var(--offline);
|
|
}
|
|
|
|
/* User Profile Section */
|
|
.user-profile {
|
|
padding: 1rem;
|
|
background: var(--surface-light);
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.user-avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 50%;
|
|
margin-right: 0.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
font-size: 0.9rem;
|
|
color: white;
|
|
}
|
|
|
|
.user-info {
|
|
flex: 1;
|
|
}
|
|
|
|
.user-name {
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.user-status {
|
|
font-size: 0.75rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.user-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.icon-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
cursor: pointer;
|
|
padding: 0.3rem;
|
|
border-radius: 4px;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
background: var(--surface-hover);
|
|
color: var(--text);
|
|
}
|
|
|
|
/* Main Content */
|
|
.main-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* Chat Header */
|
|
.chat-header {
|
|
padding: 1rem;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.chat-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.chat-title h2 {
|
|
font-size: 1.2rem;
|
|
}
|
|
|
|
.chat-title .channel-icon {
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* Messages Area */
|
|
.messages-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.message {
|
|
display: flex;
|
|
padding: 0.5rem 1rem;
|
|
margin-bottom: 0.5rem;
|
|
transition: background 0.2s;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.message:hover {
|
|
background: var(--surface);
|
|
}
|
|
|
|
.message-avatar {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
margin-right: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: bold;
|
|
color: white;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.message-content {
|
|
flex: 1;
|
|
}
|
|
|
|
.message-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.5rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.message-author {
|
|
font-weight: 600;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.message-timestamp {
|
|
font-size: 0.75rem;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.message-text {
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
.message-actions {
|
|
display: none;
|
|
position: absolute;
|
|
right: 1rem;
|
|
top: -0.5rem;
|
|
background: var(--surface-light);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 0.25rem;
|
|
}
|
|
|
|
.message:hover .message-actions {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.typing-indicator {
|
|
padding: 0 1rem;
|
|
color: var(--text-dim);
|
|
font-size: 0.9rem;
|
|
font-style: italic;
|
|
}
|
|
|
|
/* Message Input */
|
|
.message-input-container {
|
|
padding: 1rem;
|
|
background: var(--surface);
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
.message-input-wrapper {
|
|
display: flex;
|
|
background: var(--surface-light);
|
|
border-radius: 8px;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.message-input {
|
|
flex: 1;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text);
|
|
outline: none;
|
|
font-size: 1rem;
|
|
padding: 0.5rem;
|
|
resize: none;
|
|
max-height: 120px;
|
|
}
|
|
|
|
.message-input::placeholder {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.input-actions {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
/* Modal */
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.8);
|
|
z-index: 1000;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal.active {
|
|
display: flex;
|
|
}
|
|
|
|
.modal-content {
|
|
background: var(--surface);
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 90vh;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.modal-title {
|
|
font-size: 1.5rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.close-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
width: 30px;
|
|
height: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.close-btn:hover {
|
|
background: var(--surface-light);
|
|
color: var(--text);
|
|
}
|
|
|
|
/* Forms */
|
|
.form-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
color: var(--text-dim);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
input,
|
|
textarea,
|
|
select {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
background: var(--surface-light);
|
|
border: 1px solid transparent;
|
|
border-radius: 4px;
|
|
color: var(--text);
|
|
font-size: 1rem;
|
|
transition: border-color 0.3s;
|
|
}
|
|
|
|
input:focus,
|
|
textarea:focus,
|
|
select:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
/* Buttons */
|
|
.btn {
|
|
background: var(--primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition: all 0.3s;
|
|
text-decoration: none;
|
|
display: inline-block;
|
|
}
|
|
|
|
.btn:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--surface-light);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: var(--danger);
|
|
}
|
|
|
|
.btn-success {
|
|
background: var(--secondary);
|
|
}
|
|
|
|
/* Search Results */
|
|
.search-results {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.search-result-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.75rem;
|
|
background: var(--surface-light);
|
|
border-radius: 4px;
|
|
margin-bottom: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.search-result-item:hover {
|
|
background: var(--surface-hover);
|
|
}
|
|
|
|
/* Loading */
|
|
.loading {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.spinner {
|
|
border: 3px solid var(--surface-light);
|
|
border-top: 3px solid var(--primary);
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% {
|
|
transform: rotate(0deg);
|
|
}
|
|
100% {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.error-message {
|
|
color: var(--danger);
|
|
font-size: 0.9rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.sidebar {
|
|
width: 60px;
|
|
}
|
|
|
|
.sidebar-header,
|
|
.section-header span,
|
|
.channel-name,
|
|
.dm-name,
|
|
.user-info {
|
|
display: none;
|
|
}
|
|
|
|
.channel-item,
|
|
.dm-item {
|
|
justify-content: center;
|
|
}
|
|
|
|
.channel-icon,
|
|
.dm-icon {
|
|
margin: 0;
|
|
}
|
|
|
|
.unread-badge {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="app-container">
|
|
<!-- Sidebar Component -->
|
|
<chat-sidebar></chat-sidebar>
|
|
|
|
<!-- Main Chat Area -->
|
|
<div class="main-content">
|
|
<chat-header></chat-header>
|
|
<chat-messages></chat-messages>
|
|
<chat-input></chat-input>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Modal Component -->
|
|
<chat-modal></chat-modal>
|
|
|
|
<script>
|
|
// Configuration
|
|
const API_URL = `${window.location.protocol}//${window.location.host}/api`;
|
|
const WS_URL = `${window.location.protocol.replace("http", "ws")}//${window.location.host}/ws`;
|
|
const currentUser = {{ current_user | tojson }};
|
|
|
|
// Global Event Bus
|
|
class EventBus {
|
|
constructor() {
|
|
this.events = {};
|
|
}
|
|
|
|
on(event, callback) {
|
|
if (!this.events[event]) {
|
|
this.events[event] = [];
|
|
}
|
|
this.events[event].push(callback);
|
|
}
|
|
|
|
off(event, callback) {
|
|
if (this.events[event]) {
|
|
this.events[event] = this.events[event].filter(
|
|
(cb) => cb !== callback,
|
|
);
|
|
}
|
|
}
|
|
|
|
emit(event, data) {
|
|
if (this.events[event]) {
|
|
this.events[event].forEach((callback) =>
|
|
callback(data),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const eventBus = new EventBus();
|
|
|
|
// API Manager
|
|
class ApiManager {
|
|
async request(endpoint, options = {}) {
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
...options.headers,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}${endpoint}`, {
|
|
...options,
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.detail || "Request failed");
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error("API Error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
get(endpoint) {
|
|
return this.request(endpoint);
|
|
}
|
|
|
|
post(endpoint, data) {
|
|
return this.request(endpoint, {
|
|
method: "POST",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
patch(endpoint, data) {
|
|
return this.request(endpoint, {
|
|
method: "PATCH",
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
delete(endpoint) {
|
|
return this.request(endpoint, {
|
|
method: "DELETE",
|
|
});
|
|
}
|
|
}
|
|
|
|
const apiManager = new ApiManager();
|
|
|
|
// WebSocket Manager
|
|
class WebSocketManager {
|
|
constructor() {
|
|
this.ws = null;
|
|
this.reconnectInterval = null;
|
|
this.typingTimeout = null;
|
|
}
|
|
|
|
connect(userId) {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
}
|
|
|
|
this.ws = new WebSocket(`${WS_URL}/${userId}`);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log("WebSocket connected");
|
|
clearInterval(this.reconnectInterval);
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
this.handleMessage(data);
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error("WebSocket error:", error);
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
console.log("WebSocket disconnected");
|
|
this.reconnect(userId);
|
|
};
|
|
}
|
|
|
|
reconnect(userId) {
|
|
this.reconnectInterval = setInterval(() => {
|
|
console.log("Attempting to reconnect WebSocket...");
|
|
this.connect(userId);
|
|
}, 5000);
|
|
}
|
|
|
|
handleMessage(data) {
|
|
switch (data.type) {
|
|
case "new_message":
|
|
eventBus.emit("new-message", data.data);
|
|
break;
|
|
case "typing":
|
|
eventBus.emit("typing-update", data.data);
|
|
break;
|
|
case "user_status":
|
|
eventBus.emit("user-status-update", data.data);
|
|
break;
|
|
}
|
|
}
|
|
|
|
sendTyping(channelId) {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
// Debounce typing indicator
|
|
clearTimeout(this.typingTimeout);
|
|
this.ws.send(
|
|
JSON.stringify({
|
|
type: "typing",
|
|
channel_id: channelId,
|
|
}),
|
|
);
|
|
|
|
this.typingTimeout = setTimeout(() => {
|
|
this.ws.send(
|
|
JSON.stringify({
|
|
type: "stop_typing",
|
|
channel_id: channelId,
|
|
}),
|
|
);
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
clearInterval(this.reconnectInterval);
|
|
}
|
|
}
|
|
|
|
const wsManager = new WebSocketManager();
|
|
if (currentUser) {
|
|
wsManager.connect(currentUser.id);
|
|
}
|
|
|
|
// Chat Manager
|
|
class ChatManager {
|
|
constructor() {
|
|
this.channels = [];
|
|
this.dms = [];
|
|
this.activeChat = null;
|
|
this.messages = {};
|
|
this.typingUsers = {};
|
|
}
|
|
|
|
async loadChannels() {
|
|
try {
|
|
this.channels = await apiManager.get("/channels");
|
|
eventBus.emit("channels-loaded", this.channels);
|
|
|
|
// Set first channel as active if none selected
|
|
if (!this.activeChat && this.channels.length > 0) {
|
|
this.setActiveChat(this.channels[0].id, "channel");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load channels:", error);
|
|
}
|
|
}
|
|
|
|
async loadDMs() {
|
|
try {
|
|
this.dms = await apiManager.get("/dms");
|
|
eventBus.emit("dms-loaded", this.dms);
|
|
} catch (error) {
|
|
console.error("Failed to load DMs:", error);
|
|
}
|
|
}
|
|
|
|
async setActiveChat(chatId, type = "channel") {
|
|
this.activeChat = { id: chatId, type };
|
|
|
|
const chat =
|
|
type === "channel"
|
|
? this.channels.find((c) => c.id === chatId)
|
|
: this.dms.find((d) => d.id === chatId);
|
|
|
|
if (chat) {
|
|
chat.unread = 0;
|
|
eventBus.emit("chat-changed", chat);
|
|
await this.loadMessages(chatId);
|
|
}
|
|
}
|
|
|
|
async loadMessages(channelId) {
|
|
try {
|
|
const messages = await apiManager.get(
|
|
`/messages/${channelId}`,
|
|
);
|
|
this.messages[channelId] = messages;
|
|
eventBus.emit("messages-loaded", messages);
|
|
} catch (error) {
|
|
console.error("Failed to load messages:", error);
|
|
this.messages[channelId] = [];
|
|
eventBus.emit("messages-loaded", []);
|
|
}
|
|
}
|
|
|
|
async sendMessage(content) {
|
|
if (!currentUser || !this.activeChat) return;
|
|
|
|
try {
|
|
const message = await apiManager.post("/messages", {
|
|
content,
|
|
channel_id: this.activeChat.id,
|
|
});
|
|
|
|
// Add to local messages
|
|
if (!this.messages[this.activeChat.id]) {
|
|
this.messages[this.activeChat.id] = [];
|
|
}
|
|
this.messages[this.activeChat.id].push(message);
|
|
eventBus.emit("message-sent", message);
|
|
} catch (error) {
|
|
console.error("Failed to send message:", error);
|
|
}
|
|
}
|
|
|
|
async searchUsers(query) {
|
|
try {
|
|
return await apiManager.get(
|
|
`/users/search?q=${encodeURIComponent(query)}`,
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to search users:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async startDM(userId) {
|
|
try {
|
|
const response = await apiManager.post("/dms", {
|
|
user_id: userId,
|
|
});
|
|
await this.loadDMs();
|
|
this.setActiveChat(response.channel_id, "dm");
|
|
} catch (error) {
|
|
console.error("Failed to start DM:", error);
|
|
}
|
|
}
|
|
|
|
async createChannel(name) {
|
|
try {
|
|
const channel = await apiManager.post("/channels", {
|
|
name,
|
|
});
|
|
await this.loadChannels();
|
|
this.setActiveChat(channel.id, "channel");
|
|
} catch (error) {
|
|
console.error("Failed to create channel:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
addTypingUser(channelId, username) {
|
|
if (!this.typingUsers[channelId]) {
|
|
this.typingUsers[channelId] = new Set();
|
|
}
|
|
this.typingUsers[channelId].add(username);
|
|
eventBus.emit("typing-users-changed", {
|
|
channelId,
|
|
users: Array.from(this.typingUsers[channelId]),
|
|
});
|
|
|
|
// Remove after timeout
|
|
setTimeout(() => {
|
|
this.removeTypingUser(channelId, username);
|
|
}, 5000);
|
|
}
|
|
|
|
removeTypingUser(channelId, username) {
|
|
if (this.typingUsers[channelId]) {
|
|
this.typingUsers[channelId].delete(username);
|
|
eventBus.emit("typing-users-changed", {
|
|
channelId,
|
|
users: Array.from(this.typingUsers[channelId]),
|
|
});
|
|
}
|
|
}
|
|
|
|
handleNewMessage(message) {
|
|
// Add to messages if it's for active chat
|
|
if (
|
|
this.activeChat &&
|
|
message.channel_id === this.activeChat.id
|
|
) {
|
|
if (!this.messages[message.channel_id]) {
|
|
this.messages[message.channel_id] = [];
|
|
}
|
|
this.messages[message.channel_id].push(message);
|
|
eventBus.emit("new-message-received", message);
|
|
} else {
|
|
// Update unread count
|
|
const channel = this.channels.find(
|
|
(c) => c.id === message.channel_id,
|
|
);
|
|
if (channel) {
|
|
channel.unread = (channel.unread || 0) + 1;
|
|
eventBus.emit("unread-update", {
|
|
channelId: message.channel_id,
|
|
unread: channel.unread,
|
|
});
|
|
} else {
|
|
const dm = this.dms.find(
|
|
(d) => d.id === message.channel_id,
|
|
);
|
|
if (dm) {
|
|
dm.unread = (dm.unread || 0) + 1;
|
|
eventBus.emit("unread-update", {
|
|
channelId: message.channel_id,
|
|
unread: dm.unread,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const chatManager = new ChatManager();
|
|
|
|
// Listen for WebSocket messages
|
|
eventBus.on("new-message", (message) =>
|
|
chatManager.handleNewMessage(message),
|
|
);
|
|
eventBus.on("typing-update", (data) => {
|
|
chatManager.addTypingUser(data.channel_id, data.username);
|
|
});
|
|
|
|
// Helper Functions
|
|
function escapeHtml(text) {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
|
|
if (diff < 60000) return "Just now";
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
if (diff < 86400000)
|
|
return date.toLocaleTimeString("en-US", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
});
|
|
|
|
return date.toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
// Components
|
|
class ChatSidebar extends HTMLElement {
|
|
connectedCallback() {
|
|
this.render();
|
|
|
|
eventBus.on("channels-loaded", () => this.render());
|
|
eventBus.on("dms-loaded", () => this.render());
|
|
eventBus.on("chat-changed", (chat) =>
|
|
this.setActiveChat(chat.id),
|
|
);
|
|
eventBus.on("unread-update", (data) =>
|
|
this.updateUnread(data),
|
|
);
|
|
|
|
if (currentUser) {
|
|
chatManager.loadChannels();
|
|
chatManager.loadDMs();
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (!currentUser) {
|
|
this.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
this.innerHTML = `
|
|
<div class="sidebar">
|
|
<div class="sidebar-header">
|
|
<div class="server-name">Chat Hub</div>
|
|
<button class="icon-btn" id="settingsBtn">⚙️</button>
|
|
</div>
|
|
|
|
<div class="channel-list">
|
|
<div class="sidebar-section">
|
|
<div class="section-header">
|
|
<span>Channels</span>
|
|
<button id="addChannelBtn" title="Add Channel">+</button>
|
|
</div>
|
|
<div id="channelsList">
|
|
${chatManager.channels.map((channel) => this.createChannelItem(channel)).join("")}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar-section">
|
|
<div class="section-header">
|
|
<span>Direct Messages</span>
|
|
<button id="addDMBtn" title="Start DM">+</button>
|
|
</div>
|
|
<div id="dmsList">
|
|
${chatManager.dms.map((dm) => this.createDMItem(dm)).join("")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="user-profile">
|
|
<div class="user-avatar" style="background: #${currentUser.avatar_b}">
|
|
${currentUser.username[0].toUpperCase()}
|
|
</div>
|
|
<div class="user-info">
|
|
<div class="user-name">${currentUser.username}</div>
|
|
<div class="user-status">Online</div>
|
|
</div>
|
|
<div class="user-actions">
|
|
<a href="/logout" class="icon-btn" title="Logout">🚪</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listeners
|
|
this.querySelectorAll(".channel-item").forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
chatManager.setActiveChat(
|
|
item.dataset.channelId,
|
|
"channel",
|
|
);
|
|
});
|
|
});
|
|
|
|
this.querySelectorAll(".dm-item").forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
chatManager.setActiveChat(item.dataset.dmId, "dm");
|
|
});
|
|
});
|
|
|
|
this.querySelector("#addChannelBtn")?.addEventListener(
|
|
"click",
|
|
() => {
|
|
eventBus.emit("show-modal", "create-channel");
|
|
},
|
|
);
|
|
|
|
this.querySelector("#addDMBtn")?.addEventListener(
|
|
"click",
|
|
() => {
|
|
eventBus.emit("show-modal", "user-search");
|
|
},
|
|
);
|
|
}
|
|
|
|
createChannelItem(channel) {
|
|
const isActive =
|
|
chatManager.activeChat &&
|
|
chatManager.activeChat.id === channel.id;
|
|
return `
|
|
<div class="channel-item ${isActive ? "active" : ""}" data-channel-id="${channel.id}">
|
|
<span class="channel-icon">#</span>
|
|
<span class="channel-name">${channel.name}</span>
|
|
${channel.unread > 0 ? `<span class="unread-badge">${channel.unread}</span>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
createDMItem(dm) {
|
|
const isActive =
|
|
chatManager.activeChat &&
|
|
chatManager.activeChat.id === dm.id;
|
|
return `
|
|
<div class="dm-item ${isActive ? "active" : ""}" data-dm-id="${dm.id}">
|
|
<span class="dm-icon">👤</span>
|
|
<span class="dm-name">${dm.name}</span>
|
|
<span class="status-indicator status-${dm.user?.status || "offline"}"></span>
|
|
${dm.unread > 0 ? `<span class="unread-badge">${dm.unread}</span>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
setActiveChat(chatId) {
|
|
this.querySelectorAll(".channel-item, .dm-item").forEach(
|
|
(item) => {
|
|
item.classList.remove("active");
|
|
},
|
|
);
|
|
|
|
const activeItem = this.querySelector(
|
|
`[data-channel-id="${chatId}"], [data-dm-id="${chatId}"]`,
|
|
);
|
|
if (activeItem) {
|
|
activeItem.classList.add("active");
|
|
}
|
|
}
|
|
|
|
updateUnread(data) {
|
|
const item = this.querySelector(
|
|
`[data-channel-id="${data.channelId}"], [data-dm-id="${data.channelId}"]`,
|
|
);
|
|
if (!item) return;
|
|
|
|
const badge = item.querySelector(".unread-badge");
|
|
if (data.unread > 0) {
|
|
if (badge) {
|
|
badge.textContent = data.unread;
|
|
} else {
|
|
item.insertAdjacentHTML(
|
|
"beforeend",
|
|
`<span class="unread-badge">${data.unread}</span>`,
|
|
);
|
|
}
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
class ChatHeader extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.currentChat = null;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.render();
|
|
|
|
eventBus.on("chat-changed", (chat) => {
|
|
this.currentChat = chat;
|
|
this.render();
|
|
});
|
|
}
|
|
|
|
render() {
|
|
if (!currentUser || !this.currentChat) {
|
|
this.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const icon =
|
|
this.currentChat.type === "channel" ? "#" : "👤";
|
|
const name = this.currentChat.name;
|
|
|
|
this.innerHTML = `
|
|
<div class="chat-header">
|
|
<div class="chat-title">
|
|
<span class="channel-icon">${icon}</span>
|
|
<h2>${name}</h2>
|
|
</div>
|
|
<div class="header-actions">
|
|
<button class="icon-btn" title="Search">🔍</button>
|
|
<button class="icon-btn" title="Members">👥</button>
|
|
<button class="icon-btn" title="Settings">⚙️</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
class ChatMessages extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.messages = [];
|
|
this.typingUsers = [];
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.render();
|
|
|
|
eventBus.on("messages-loaded", (messages) => {
|
|
this.messages = messages;
|
|
this.render();
|
|
});
|
|
|
|
eventBus.on("message-sent", (message) => {
|
|
this.addMessage(message);
|
|
});
|
|
|
|
eventBus.on("new-message-received", (message) => {
|
|
this.addMessage(message);
|
|
});
|
|
|
|
eventBus.on("typing-users-changed", (data) => {
|
|
if (
|
|
chatManager.activeChat &&
|
|
data.channelId === chatManager.activeChat.id
|
|
) {
|
|
this.updateTypingIndicator(data.users);
|
|
}
|
|
});
|
|
}
|
|
|
|
render() {
|
|
if (!currentUser) {
|
|
this.innerHTML = `
|
|
<div class="messages-container">
|
|
<div class="loading">
|
|
<h2>Welcome to Chat Hub</h2>
|
|
<p>Please <a href="/login">login</a> to start chatting</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
this.innerHTML = `
|
|
<div class="messages-container" id="messagesContainer">
|
|
${this.messages.map((msg) => this.createMessage(msg)).join("")}
|
|
<div id="typingIndicator" class="typing-indicator" style="display: none;"></div>
|
|
</div>
|
|
`;
|
|
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
createMessage(message) {
|
|
const isOwn =
|
|
currentUser &&
|
|
message.author?.id === currentUser.id;
|
|
const author = message.author || {
|
|
username: message.username,
|
|
avatar_color: message.avatar_b,
|
|
};
|
|
|
|
return `
|
|
<div class="message" data-message-id="${message.id || message.uid}">
|
|
<div class="message-avatar" style="background: #${author.avatar_color}">
|
|
${author.username[0].toUpperCase()}
|
|
</div>
|
|
<div class="message-content">
|
|
<div class="message-header">
|
|
<span class="message-author">${author.username}</span>
|
|
<span class="message-timestamp">${formatTime(message.timestamp || message.created_at)}</span>
|
|
</div>
|
|
<div class="message-text">${escapeHtml(message.content)}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
addMessage(message) {
|
|
const container = this.querySelector("#messagesContainer");
|
|
if (container) {
|
|
const typingIndicator =
|
|
container.querySelector("#typingIndicator");
|
|
const messageHtml = this.createMessage(message);
|
|
typingIndicator.insertAdjacentHTML(
|
|
"beforebegin",
|
|
messageHtml,
|
|
);
|
|
this.scrollToBottom();
|
|
}
|
|
}
|
|
|
|
updateTypingIndicator(users) {
|
|
const indicator = this.querySelector("#typingIndicator");
|
|
if (!indicator) return;
|
|
|
|
if (users.length > 0) {
|
|
const text =
|
|
users.length === 1
|
|
? `${users[0]} is typing...`
|
|
: `${users.join(", ")} are typing...`;
|
|
indicator.textContent = text;
|
|
indicator.style.display = "block";
|
|
} else {
|
|
indicator.style.display = "none";
|
|
}
|
|
}
|
|
|
|
scrollToBottom() {
|
|
const container = this.querySelector("#messagesContainer");
|
|
if (container) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
|
|
class ChatInput extends HTMLElement {
|
|
connectedCallback() {
|
|
this.render();
|
|
}
|
|
|
|
render() {
|
|
if (!currentUser) {
|
|
this.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
this.innerHTML = `
|
|
<div class="message-input-container">
|
|
<div class="message-input-wrapper">
|
|
<textarea
|
|
class="message-input"
|
|
id="messageInput"
|
|
placeholder="Type a message..."
|
|
rows="1"
|
|
></textarea>
|
|
<div class="input-actions">
|
|
<button class="btn" id="sendBtn">Send</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
const input = this.querySelector("#messageInput");
|
|
const sendBtn = this.querySelector("#sendBtn");
|
|
|
|
input.addEventListener("keypress", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.sendMessage();
|
|
} else {
|
|
// Send typing indicator
|
|
if (chatManager.activeChat) {
|
|
wsManager.sendTyping(chatManager.activeChat.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
sendBtn.addEventListener("click", () => this.sendMessage());
|
|
|
|
// Auto-resize textarea
|
|
input.addEventListener("input", () => {
|
|
input.style.height = "auto";
|
|
input.style.height =
|
|
Math.min(input.scrollHeight, 120) + "px";
|
|
});
|
|
}
|
|
|
|
sendMessage() {
|
|
const input = this.querySelector("#messageInput");
|
|
const content = input.value.trim();
|
|
|
|
if (!content) return;
|
|
|
|
chatManager.sendMessage(content);
|
|
input.value = "";
|
|
input.style.height = "auto";
|
|
}
|
|
}
|
|
|
|
class ChatModal extends HTMLElement {
|
|
connectedCallback() {
|
|
this.innerHTML = `
|
|
<div id="modal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 class="modal-title" id="modalTitle">Modal</h2>
|
|
<button class="close-btn" id="closeModal">×</button>
|
|
</div>
|
|
<div id="modalBody"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.querySelector("#closeModal").addEventListener(
|
|
"click",
|
|
() => this.close(),
|
|
);
|
|
this.querySelector("#modal").addEventListener(
|
|
"click",
|
|
(e) => {
|
|
if (e.target.id === "modal") this.close();
|
|
},
|
|
);
|
|
|
|
eventBus.on("show-modal", (type) => this.show(type));
|
|
}
|
|
|
|
show(type) {
|
|
const modal = this.querySelector("#modal");
|
|
const title = this.querySelector("#modalTitle");
|
|
const body = this.querySelector("#modalBody");
|
|
|
|
switch (type) {
|
|
case "user-search":
|
|
title.textContent = "Start Direct Message";
|
|
body.innerHTML = this.getUserSearchForm();
|
|
this.attachUserSearchHandlers();
|
|
break;
|
|
case "create-channel":
|
|
title.textContent = "Create Channel";
|
|
body.innerHTML = this.getCreateChannelForm();
|
|
this.attachCreateChannelHandlers();
|
|
break;
|
|
}
|
|
|
|
modal.classList.add("active");
|
|
}
|
|
|
|
close() {
|
|
this.querySelector("#modal").classList.remove("active");
|
|
}
|
|
|
|
getUserSearchForm() {
|
|
return `
|
|
<div class="form-group">
|
|
<label>Search Users</label>
|
|
<input type="text" id="userSearchInput" placeholder="Type to search...">
|
|
</div>
|
|
<div id="searchResults" class="search-results"></div>
|
|
`;
|
|
}
|
|
|
|
getCreateChannelForm() {
|
|
return `
|
|
<form id="createChannelForm">
|
|
<div class="form-group">
|
|
<label>Channel Name</label>
|
|
<input type="text" id="channelName" placeholder="new-channel" required>
|
|
</div>
|
|
<div class="error-message" id="channelError" style="display: none;"></div>
|
|
<button type="submit" class="btn" style="width: 100%;">Create Channel</button>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
attachUserSearchHandlers() {
|
|
const input = this.querySelector("#userSearchInput");
|
|
const results = this.querySelector("#searchResults");
|
|
let searchTimeout;
|
|
|
|
input.addEventListener("input", async (e) => {
|
|
clearTimeout(searchTimeout);
|
|
const query = e.target.value;
|
|
|
|
if (query.length < 2) {
|
|
results.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
searchTimeout = setTimeout(async () => {
|
|
const users = await chatManager.searchUsers(query);
|
|
results.innerHTML = users
|
|
.map(
|
|
(user) => `
|
|
<div class="search-result-item" data-user-id="${user.id}">
|
|
<div class="user-avatar" style="background: #${user.avatar_color}; width: 32px; height: 32px; font-size: 0.9rem;">
|
|
${user.username[0].toUpperCase()}
|
|
</div>
|
|
<div style="flex: 1; margin-left: 1rem;">
|
|
<div>${user.username}</div>
|
|
<div style="font-size: 0.85rem; color: var(--text-dim);">${user.status}</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
results
|
|
.querySelectorAll(".search-result-item")
|
|
.forEach((item) => {
|
|
item.addEventListener("click", () => {
|
|
chatManager.startDM(
|
|
item.dataset.userId,
|
|
);
|
|
this.close();
|
|
});
|
|
});
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
attachCreateChannelHandlers() {
|
|
const form = this.querySelector("#createChannelForm");
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const name = this.querySelector("#channelName").value;
|
|
|
|
try {
|
|
await chatManager.createChannel(name);
|
|
this.close();
|
|
} catch (error) {
|
|
const errorEl = this.querySelector("#channelError");
|
|
errorEl.textContent = error.message;
|
|
errorEl.style.display = "block";
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Register all components
|
|
customElements.define("chat-sidebar", ChatSidebar);
|
|
customElements.define("chat-header", ChatHeader);
|
|
customElements.define("chat-messages", ChatMessages);
|
|
customElements.define("chat-input", ChatInput);
|
|
customElements.define("chat-modal", ChatModal);
|
|
</script>
|
|
</body>
|
|
</html>
|