new frontend

This commit is contained in:
2025-11-19 13:41:19 +01:00
parent 2e3d0793bc
commit 33e5a4e07f
5 changed files with 1505 additions and 534 deletions

618
src/UI.js
View File

@@ -31,7 +31,6 @@ const uiState = {
userColors: new Map(), userColors: new Map(),
currentUser: 'IRCDUser', currentUser: 'IRCDUser',
channelUsers: new Map(), // Map<channel, Set<user>> channelUsers: new Map(), // Map<channel, Set<user>>
channelMessages: new Map(), // Map<channel, Array<{type, sender, content, timestamp, html}>>
peopleSearchQuery: '', // Current search query for people list peopleSearchQuery: '', // Current search query for people list
virtualScrollState: { virtualScrollState: {
startIndex: 0, startIndex: 0,
@@ -280,30 +279,23 @@ function addChannel(name) {
if (!uiState.channels.includes(normalizedName)) { if (!uiState.channels.includes(normalizedName)) {
uiState.channels.push(normalizedName); uiState.channels.push(normalizedName);
// Add to channel list (async DOM update) // Add to channel list
requestAnimationFrame(() => { const channelItem = document.createElement('div');
const channelItem = document.createElement('div'); channelItem.className = 'channel-item';
channelItem.className = 'channel-item'; channelItem.innerHTML = `
channelItem.innerHTML = ` <div class="channel-icon">
<div class="channel-icon"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path d="M5 9C5 5.13401 8.13401 2 12 2C15.866 2 19 5.13401 19 9C19 10.933 18.2165 12.683 16.8569 14.0002C16.8569 14.0002 16.5 14.5 15.5 15.5C14.5 16.5 13 18 13 18L12 19L11 18C11 18 9.5 16.5 8.5 15.5C7.5 14.5 7.14314 14.0002 7.14314 14.0002C5.78354 12.683 5 10.933 5 9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 9C5 5.13401 8.13401 2 12 2C15.866 2 19 5.13401 19 9C19 10.933 18.2165 12.683 16.8569 14.0002C16.8569 14.0002 16.5 14.5 15.5 15.5C14.5 16.5 13 18 13 18L12 19L11 18C11 18 9.5 16.5 8.5 15.5C7.5 14.5 7.14314 14.0002 7.14314 14.0002C5.78354 12.683 5 10.933 5 9Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M9 9H15M9 12H15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M9 9H15M9 12H15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> </svg>
</svg> </div>
</div> <div class="channel-name">${escapeHtml(normalizedName)}</div>
<div class="channel-name">${escapeHtml(normalizedName)}</div> `;
`; channelItem.addEventListener('click', () => setActiveTab(normalizedName));
channelItem.addEventListener('click', () => setActiveTab(normalizedName)); channelList.appendChild(channelItem);
channelList.appendChild(channelItem);
});
// Add tab (async) // Add tab
requestAnimationFrame(() => addTab(normalizedName)); addTab(normalizedName);
// Initialize message storage for this channel
if (!uiState.channelMessages.has(normalizedName)) {
uiState.channelMessages.set(normalizedName, []);
}
// Auto-join the channel when connected // Auto-join the channel when connected
if (uiState.connected) { if (uiState.connected) {
@@ -330,9 +322,6 @@ function removeChannel(name) {
// Clear users for this channel // Clear users for this channel
clearChannelUsers(name); clearChannelUsers(name);
// Clear messages for this channel (optional - you might want to keep them)
// uiState.channelMessages.delete(name);
// Remove tab // Remove tab
removeTab(name); removeTab(name);
@@ -398,114 +387,51 @@ function setActiveTab(name) {
uiState.activeTab = normalizedName; uiState.activeTab = normalizedName;
// Update tabs (async) // Update tabs
requestAnimationFrame(() => { const tabs = tabBar.querySelectorAll('.tab');
const tabs = tabBar.querySelectorAll('.tab'); tabs.forEach(tab => {
tabs.forEach(tab => { // Compare with both normalized and original name
// Compare with both normalized and original name tab.classList.toggle('active', tab.dataset.tab === normalizedName || tab.dataset.tab === name);
tab.classList.toggle('active', tab.dataset.tab === normalizedName || tab.dataset.tab === name);
});
}); });
// Update channels (async) // Update channels
requestAnimationFrame(() => { const channelItems = channelList.querySelectorAll('.channel-item');
const channelItems = channelList.querySelectorAll('.channel-item'); channelItems.forEach(item => {
channelItems.forEach(item => { const channelName = item.querySelector('.channel-name').textContent;
const channelName = item.querySelector('.channel-name').textContent; item.classList.toggle('active', channelName === normalizedName || channelName === name);
item.classList.toggle('active', channelName === normalizedName || channelName === name);
});
}); });
// Update people list for the active channel (async) // Update people list for the active channel
updatePeopleList(normalizedName); updatePeopleList(normalizedName);
// Update messages (async) // Update messages
requestAnimationFrame(() => { updateMessagesForTab(normalizedName);
updateMessagesForTab(normalizedName);
});
} }
function updateMessagesForTab(name) { function updateMessagesForTab(name) {
// Clear messages // Clear messages
messagesContainer.innerHTML = ''; messagesContainer.innerHTML = '';
// Ensure channel name format is correct // Add welcome messages
let channelKey = name; if (name === 'welcome') {
if (name !== 'welcome' && !name.startsWith('#')) { const welcomeMsg = document.createElement('div');
channelKey = '#' + name; welcomeMsg.className = 'system-message';
welcomeMsg.textContent = 'IRCD - Welcome! SystemTime: ' + new Date().toLocaleString();
messagesContainer.appendChild(welcomeMsg);
} else {
// For channels, just show a welcome message
const welcomeMsg = document.createElement('div');
welcomeMsg.className = 'system-message';
welcomeMsg.textContent = `Welcome to ${name}!`;
messagesContainer.appendChild(welcomeMsg);
const infoMsg = document.createElement('div');
infoMsg.className = 'system-message';
infoMsg.textContent = 'This is the beginning of the channel.';
messagesContainer.appendChild(infoMsg);
} }
// Load stored messages from RAM scrollToBottom();
const storedMessages = uiState.channelMessages.get(channelKey);
if (storedMessages && storedMessages.length > 0) {
// Restore all stored messages (only once, not duplicated)
requestAnimationFrame(() => {
// Use a Set to track displayed messages and prevent duplicates
const displayedHashes = new Set();
storedMessages.forEach(msg => {
// Create a hash to detect duplicates
const msgHash = `${msg.type}-${msg.timestamp}-${msg.content.substring(0, 50)}`;
if (displayedHashes.has(msgHash)) {
return; // Skip duplicate
}
displayedHashes.add(msgHash);
if (msg.html) {
// For IRC messages, the HTML is the full formatted content
// The HTML from backend is a string of spans, so we wrap it in a div
const ircMessage = document.createElement('div');
ircMessage.className = 'irc-message';
// Use the stored HTML (which is the full formatted content from backend)
// If raw exists, prefer it, otherwise use html
const htmlContent = msg.raw || msg.html;
ircMessage.innerHTML = htmlContent;
messagesContainer.appendChild(ircMessage);
} else if (msg.type === 'message') {
// Recreate message element if HTML not stored
const messageLine = document.createElement('div');
messageLine.className = 'message-line';
const senderElement = document.createElement('div');
senderElement.className = `message-sender ${msg.isUser ? 'you' : ''}`;
senderElement.textContent = msg.isUser ? `${msg.sender} (You):` : `${msg.sender}:`;
senderElement.style.color = msg.isUser ? '' : getUserColor(msg.sender);
const contentElement = document.createElement('div');
contentElement.className = 'message-content';
contentElement.textContent = msg.content;
messageLine.appendChild(senderElement);
messageLine.appendChild(contentElement);
messagesContainer.appendChild(messageLine);
} else if (msg.type === 'system') {
const systemMessage = document.createElement('div');
systemMessage.className = 'system-message';
systemMessage.textContent = msg.content;
messagesContainer.appendChild(systemMessage);
}
});
scrollToBottom();
});
} else {
// Add welcome messages for new channels (only if no stored messages)
if (name === 'welcome') {
// Don't store welcome messages, just display
requestAnimationFrame(() => {
const welcomeMsg = document.createElement('div');
welcomeMsg.className = 'system-message';
welcomeMsg.textContent = 'IRCD - Welcome! SystemTime: ' + new Date().toLocaleString();
messagesContainer.appendChild(welcomeMsg);
});
} else if (channelKey !== 'welcome') {
// Only add welcome message if channel has no messages
addSystemMessage(`Welcome to ${channelKey}!`);
addSystemMessage('This is the beginning of the channel.');
}
}
} }
// Message handling // Message handling
@@ -545,289 +471,44 @@ function addMessage(sender, content, isUser = false) {
const channel = uiState.activeTab; const channel = uiState.activeTab;
if (!channel || channel === 'welcome') return; if (!channel || channel === 'welcome') return;
// Normalize channel name const messageLine = document.createElement('div');
let channelKey = channel; messageLine.className = 'message-line';
if (!channelKey.startsWith('#')) {
channelKey = '#' + channelKey;
}
// Store message in RAM - ALWAYS store const senderElement = document.createElement('div');
if (!uiState.channelMessages.has(channelKey)) { senderElement.className = `message-sender ${isUser ? 'you' : ''}`;
uiState.channelMessages.set(channelKey, []); senderElement.textContent = isUser ? `${sender} (You):` : `${sender}:`;
} senderElement.style.color = isUser ? '' : getUserColor(sender);
// Use requestAnimationFrame for async DOM updates const contentElement = document.createElement('div');
requestAnimationFrame(() => { contentElement.className = 'message-content';
const messageLine = document.createElement('div'); contentElement.textContent = content;
messageLine.className = 'message-line';
messageLine.appendChild(senderElement);
const senderElement = document.createElement('div'); messageLine.appendChild(contentElement);
senderElement.className = `message-sender ${isUser ? 'you' : ''}`;
senderElement.textContent = isUser ? `${sender} (You):` : `${sender}:`; messagesContainer.appendChild(messageLine);
senderElement.style.color = isUser ? '' : getUserColor(sender); scrollToBottom();
const contentElement = document.createElement('div');
contentElement.className = 'message-content';
contentElement.textContent = content;
messageLine.appendChild(senderElement);
messageLine.appendChild(contentElement);
// Store FULL message data with HTML
const messageData = {
type: 'message',
sender,
content,
isUser,
timestamp: Date.now(),
html: messageLine.outerHTML, // Full HTML content
raw: messageLine.outerHTML, // Keep raw HTML
formatted: true // Mark as formatted
};
uiState.channelMessages.get(channelKey).push(messageData);
console.log(`[STORE] Stored FULL user message in ${channelKey} (${content.substring(0, 50)}...)`);
// Update activeTab to normalized name for consistency
if (uiState.activeTab !== channelKey) {
uiState.activeTab = channelKey;
}
messagesContainer.appendChild(messageLine);
scrollToBottom();
});
} }
function addSystemMessage(content) { function addSystemMessage(content) {
const channel = uiState.activeTab; const channel = uiState.activeTab;
if (!channel || channel === 'welcome') return; if (!channel || channel === 'welcome') return;
// Normalize channel name const systemMessage = document.createElement('div');
let channelKey = channel; systemMessage.className = 'system-message';
if (!channelKey.startsWith('#')) { systemMessage.textContent = content;
channelKey = '#' + channelKey;
}
// Store message in RAM - ALWAYS store messagesContainer.appendChild(systemMessage);
if (!uiState.channelMessages.has(channelKey)) { scrollToBottom();
uiState.channelMessages.set(channelKey, []);
}
// Use requestAnimationFrame for async DOM updates
requestAnimationFrame(() => {
const systemMessage = document.createElement('div');
systemMessage.className = 'system-message';
systemMessage.textContent = content;
// Store FULL message data with HTML
const messageData = {
type: 'system',
sender: null,
content,
isUser: false,
timestamp: Date.now(),
html: systemMessage.outerHTML, // Full HTML content
raw: systemMessage.outerHTML, // Keep raw HTML
formatted: true // Mark as formatted
};
uiState.channelMessages.get(channelKey).push(messageData);
console.log(`[STORE] Stored FULL system message in ${channelKey} (${content.substring(0, 50)}...)`);
// Update activeTab to normalized name for consistency
if (uiState.activeTab !== channelKey) {
uiState.activeTab = channelKey;
}
messagesContainer.appendChild(systemMessage);
scrollToBottom();
});
} }
function addIRCMessage(content) { function addIRCMessage(content) {
// Parse the message to determine which channel it belongs to const ircMessage = document.createElement('div');
let targetChannel = null; ircMessage.className = 'irc-message';
let joinMatch = null; ircMessage.innerHTML = content;
let partMatch = null;
// Extract plain text for checking messagesContainer.appendChild(ircMessage);
const plainText = content.replace(/<[^>]*>/g, ''); scrollToBottom();
// Check if this is a server notice (starts with server name, no channel)
const isServerNotice = /^[a-zA-Z0-9.-]+\s+\[NOTICE\]/.test(plainText) ||
/^[a-zA-Z0-9.-]+\s+NOTICE/.test(plainText);
// Try to extract channel from PRIVMSG
// PRIVMSG format: <sender> → <channel>: <message>
const privmsgMatch = content.match(/→\s*<span[^>]*>([^<]+)<\/span><span[^>]*>:\s*<\/span>/);
if (privmsgMatch) {
targetChannel = privmsgMatch[1].trim();
}
// NOTICE format: [NOTICE] <sender> → <target>: <message>
// Only if target is a channel (starts with #)
const noticeMatch = content.match(/\[NOTICE\].*?→\s*<span[^>]*>([^<]+)<\/span><span[^>]*>:\s*<\/span>/);
if (noticeMatch) {
const target = noticeMatch[1].trim();
// Only treat as channel message if target starts with #
if (target.startsWith('#')) {
targetChannel = target;
} else if (!isServerNotice) {
// If it's a user notice, check if we're in a PM (future feature)
// For now, skip storing user notices
}
}
// JOIN format: <sender> joined <channel>
joinMatch = content.match(/joined\s*<span[^>]*>([^<]+)<\/span>/);
if (joinMatch) {
targetChannel = joinMatch[1].trim();
// Extract sender name from the beginning of the message
const senderMatch = plainText.match(/^([^\s]+)\s+joined/);
if (senderMatch) {
const sender = senderMatch[1];
console.log(`[JOIN] ${sender} joined ${targetChannel}`);
}
}
// PART format: <sender> left <channel>
partMatch = content.match(/left\s*<span[^>]*>([^<]+)<\/span>/);
if (partMatch) {
targetChannel = partMatch[1].trim();
// Extract sender name from the beginning of the message
const senderMatch = plainText.match(/^([^\s]+)\s+left/);
if (senderMatch) {
const sender = senderMatch[1];
console.log(`[PART] ${sender} left ${targetChannel}`);
}
}
// QUIT format: <sender> quit (reason)
const quitMatch = plainText.match(/^([^\s]+)\s+quit/);
if (quitMatch) {
// QUIT messages don't have a channel, but we should still display them
const sender = quitMatch[1];
console.log(`[QUIT] ${sender} quit`);
}
// NICK format: <sender> is now known as <newnick>
const nickMatch = plainText.match(/^([^\s]+)\s+is now known as\s+([^\s]+)/);
if (nickMatch) {
const sender = nickMatch[1];
const newNick = nickMatch[2];
console.log(`[NICK] ${sender} is now known as ${newNick}`);
}
// NAMES format: [NAMES] <channel>:
const namesMatch = content.match(/\[NAMES\]\s*<\/span>\s*<span[^>]*>([^<]+)<\/span>/);
if (namesMatch) {
targetChannel = namesMatch[1].trim();
}
// Normalize channel name
if (targetChannel && !targetChannel.startsWith('#')) {
targetChannel = '#' + targetChannel;
}
// Normalize active channel for comparison
let activeChannel = uiState.activeTab;
if (activeChannel && activeChannel !== 'welcome' && !activeChannel.startsWith('#')) {
activeChannel = '#' + activeChannel;
}
// Determine where to store the message - SIMPLE AND RELIABLE
const isJoinOrPart = joinMatch || partMatch;
// Always store in active channel (where user is viewing)
// This ensures all messages seen by the user are stored
let storeChannel = activeChannel || 'welcome';
// If message has a specific target channel (PRIVMSG, JOIN, PART, etc.), also store there
// But primary storage is always the active channel
if (targetChannel && targetChannel.startsWith('#')) {
// Store in both the target channel AND the active channel
// This way messages are available when switching channels
const channelsToStore = [storeChannel, targetChannel];
channelsToStore.forEach(ch => {
if (ch && ch !== 'welcome') {
if (!uiState.channelMessages.has(ch)) {
uiState.channelMessages.set(ch, []);
}
const existingMessages = uiState.channelMessages.get(ch);
const now = Date.now();
const duplicateWindow = isJoinOrPart ? 5000 : 2000;
// Check for duplicates
const isDuplicate = existingMessages.some(msg =>
msg.type === 'irc' &&
msg.content === plainText &&
Math.abs(msg.timestamp - now) < duplicateWindow
);
if (!isDuplicate) {
const messageData = {
type: 'irc',
sender: null,
content: plainText,
isUser: false,
timestamp: now,
html: content, // Full HTML content
raw: content, // Keep raw HTML
formatted: true // Mark as formatted
};
existingMessages.push(messageData);
console.log(`[STORE] Stored FULL message in ${ch} (${plainText.substring(0, 50)}...)`);
}
}
});
} else {
// No target channel - store only in active channel
if (storeChannel && storeChannel !== 'welcome') {
if (!uiState.channelMessages.has(storeChannel)) {
uiState.channelMessages.set(storeChannel, []);
}
const existingMessages = uiState.channelMessages.get(storeChannel);
const now = Date.now();
const duplicateWindow = isJoinOrPart ? 5000 : 2000;
const isDuplicate = existingMessages.some(msg =>
msg.type === 'irc' &&
msg.content === plainText &&
Math.abs(msg.timestamp - now) < duplicateWindow
);
if (!isDuplicate) {
const messageData = {
type: 'irc',
sender: null,
content: plainText,
isUser: false,
timestamp: now,
html: content, // Full HTML content
raw: content, // Keep raw HTML
formatted: true // Mark as formatted
};
existingMessages.push(messageData);
console.log(`[STORE] Stored FULL message in ${storeChannel} (no target channel) (${plainText.substring(0, 50)}...)`);
}
}
}
// Display ALL messages in the active channel - no filtering
// Always display, regardless of target channel
requestAnimationFrame(() => {
const ircMessage = document.createElement('div');
ircMessage.className = 'irc-message';
ircMessage.innerHTML = content; // Use innerHTML to render HTML formatting
messagesContainer.appendChild(ircMessage);
scrollToBottom();
});
} }
function scrollToBottom() { function scrollToBottom() {
@@ -959,42 +640,40 @@ function updatePeopleList(channel) {
} }
// Sort and filter users (all stored in RAM, not DOM) // Sort and filter users (all stored in RAM, not DOM)
setTimeout(() => { let sortedUsers = Array.from(users).sort((a, b) => {
let sortedUsers = Array.from(users).sort((a, b) => { const aIsOp = a.startsWith('@');
const aIsOp = a.startsWith('@'); const bIsOp = b.startsWith('@');
const bIsOp = b.startsWith('@'); const aIsVoiced = a.startsWith('+');
const aIsVoiced = a.startsWith('+'); const bIsVoiced = b.startsWith('+');
const bIsVoiced = b.startsWith('+');
if (aIsOp && !bIsOp) return -1;
if (aIsOp && !bIsOp) return -1; if (!aIsOp && bIsOp) return 1;
if (!aIsOp && bIsOp) return 1; if (aIsVoiced && !bIsVoiced) return -1;
if (aIsVoiced && !bIsVoiced) return -1; if (!aIsVoiced && bIsVoiced) return 1;
if (!aIsVoiced && bIsVoiced) return 1; return a.localeCompare(b, undefined, { sensitivity: 'base' });
return a.localeCompare(b, undefined, { sensitivity: 'base' }); });
// Apply search filter
const searchQuery = uiState.peopleSearchQuery.toLowerCase();
if (searchQuery) {
sortedUsers = sortedUsers.filter(user => {
const displayName = (user.startsWith('@') || user.startsWith('+'))
? user.substring(1).toLowerCase()
: user.toLowerCase();
return displayName.includes(searchQuery);
}); });
}
// Apply search filter
const searchQuery = uiState.peopleSearchQuery.toLowerCase(); // Store filtered list for virtual scrolling (in RAM)
if (searchQuery) { uiState.filteredUsers = sortedUsers;
sortedUsers = sortedUsers.filter(user => {
const displayName = (user.startsWith('@') || user.startsWith('+')) // Reset scroll position when filtering
? user.substring(1).toLowerCase() if (searchQuery && peopleListContainer) {
: user.toLowerCase(); peopleListContainer.scrollTop = 0;
return displayName.includes(searchQuery); }
});
} // Update virtual scroll - only renders visible items
renderVisibleUsers();
// Store filtered list for virtual scrolling (in RAM)
uiState.filteredUsers = sortedUsers;
// Reset scroll position when filtering
if (searchQuery && peopleListContainer) {
peopleListContainer.scrollTop = 0;
}
// Update virtual scroll - only renders visible items
renderVisibleUsers();
}, 0);
} }
function renderVisibleUsers() { function renderVisibleUsers() {
@@ -1039,20 +718,18 @@ function renderVisibleUsers() {
} }
// Clear and render only visible items // Clear and render only visible items
requestAnimationFrame(() => { if (!peopleList) return;
if (!peopleList) return;
peopleList.innerHTML = '';
peopleList.innerHTML = ''; const fragment = document.createDocumentFragment();
const fragment = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
for (let i = startIndex; i < endIndex; i++) { const user = uiState.filteredUsers[i];
const user = uiState.filteredUsers[i]; const peopleItem = createPeopleItem(user);
const peopleItem = createPeopleItem(user); fragment.appendChild(peopleItem);
fragment.appendChild(peopleItem); }
}
peopleList.appendChild(fragment);
peopleList.appendChild(fragment);
});
} }
function createPeopleItem(user) { function createPeopleItem(user) {
@@ -1098,49 +775,42 @@ function handlePeopleSearch(e) {
} }
function handlePeopleListScroll() { function handlePeopleListScroll() {
requestAnimationFrame(() => { renderVisibleUsers();
renderVisibleUsers();
});
} }
function updateVirtualScrollContainerHeight() { function updateVirtualScrollContainerHeight() {
requestAnimationFrame(() => { if (peopleListContainer) {
if (peopleListContainer) { uiState.virtualScrollState.containerHeight = peopleListContainer.clientHeight;
uiState.virtualScrollState.containerHeight = peopleListContainer.clientHeight; renderVisibleUsers();
renderVisibleUsers(); }
}
});
} }
function parseNamesMessage(channel, usersStr) { function parseNamesMessage(channel, usersStr) {
if (!channel || !usersStr) return; if (!channel || !usersStr) return;
// Process parsing asynchronously // Initialize users set for this channel if it doesn't exist
setTimeout(() => { // Note: We accumulate users from multiple NAMES messages until ENDOFNAMES
// Initialize users set for this channel if it doesn't exist // Users are stored in RAM, not DOM
// Note: We accumulate users from multiple NAMES messages until ENDOFNAMES if (!uiState.channelUsers.has(channel)) {
// Users are stored in RAM, not DOM uiState.channelUsers.set(channel, new Set());
if (!uiState.channelUsers.has(channel)) { }
uiState.channelUsers.set(channel, new Set());
const users = usersStr.trim().split(/\s+/).filter(u => u && u.trim().length > 0);
const channelUsers = uiState.channelUsers.get(channel);
// Add all users from this NAMES message to RAM
users.forEach(user => {
const trimmed = user.trim();
if (trimmed) {
channelUsers.add(trimmed);
} }
});
const users = usersStr.trim().split(/\s+/).filter(u => u && u.trim().length > 0);
const channelUsers = uiState.channelUsers.get(channel); // Update the people list if this is the active channel
// This will use virtual scrolling to only render visible users
// Add all users from this NAMES message to RAM if (uiState.activeTab === channel) {
users.forEach(user => { updatePeopleList(channel);
const trimmed = user.trim(); }
if (trimmed) {
channelUsers.add(trimmed);
}
});
// Update the people list if this is the active channel (async)
// This will use virtual scrolling to only render visible users
if (uiState.activeTab === channel) {
updatePeopleList(channel);
}
}, 0);
} }
function clearChannelUsers(channel) { function clearChannelUsers(channel) {

View File

@@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="./styles/styles.css"> <link rel="stylesheet" href="./styles/styles.css">
<link rel="stylesheet" href="./styles/mobile.css">
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
@@ -14,7 +15,7 @@
<aside class="sidebar" id="channels"> <aside class="sidebar" id="channels">
<div class="sidebar-resize-handle" id="sidebarResizeHandle"></div> <div class="sidebar-resize-handle" id="sidebarResizeHandle"></div>
<div class="sidebar-header"> <div class="sidebar-header">
<h1>IRCd</h1> <h1>IRCd<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M10 3H20M4 21H14M15 3L9 21" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg></h1>
<button class="toggle-sidebar" id="toggleSidebar"> <button class="toggle-sidebar" id="toggleSidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -134,5 +135,6 @@
<script src="tauri.js"></script> <script src="tauri.js"></script>
<script src="UI.js"></script> <script src="UI.js"></script>
<script src="mobile.js"></script>
</body> </body>
</html> </html>

613
src/mobile.js Normal file
View File

@@ -0,0 +1,613 @@
// mobile.js - Fixed fullscreen sidebar behavior with textbox preservation
class MobileManager {
constructor() {
this.breakpoints = {
desktop: 1024,
tablet: 768,
mobile: 480,
narrow: 320,
superNarrow: 280
};
this.currentBreakpoint = this.getCurrentBreakpoint();
this.sidebarOpen = false;
this.peopleSidebarOpen = false;
this.init();
}
getCurrentBreakpoint() {
const width = window.innerWidth;
if (width >= this.breakpoints.desktop) return 'desktop';
if (width >= this.breakpoints.tablet) return 'tablet';
if (width >= this.breakpoints.mobile) return 'mobile';
if (width >= this.breakpoints.narrow) return 'narrow';
return 'super-narrow';
}
init() {
console.log('Initializing mobile interface for:', this.currentBreakpoint);
this.createMobileOverlay();
this.createMobileHeader();
this.setupEventListeners();
this.overrideCollapseButtons();
this.applyResponsiveStyles();
this.handleResize();
this.enhanceTouchInteractions();
}
createMobileOverlay() {
const overlay = document.createElement('div');
overlay.className = 'mobile-overlay';
overlay.id = 'mobileOverlay';
document.body.appendChild(overlay);
overlay.addEventListener('click', () => {
this.closeAllSidebars();
});
}
createMobileHeader() {
const mainContent = document.querySelector('.main-content');
if (!mainContent) return;
const header = document.createElement('div');
header.className = 'mobile-header';
header.innerHTML = `
<button class="mobile-menu-button" id="mobileMenuButton" aria-label="Toggle channels">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 12H21M3 6H21M3 18H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<h1 class="mobile-title" id="mobileTitle">IRCD</h1>
<button class="mobile-menu-button" id="mobilePeopleButton" aria-label="Toggle people list">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
`;
mainContent.insertBefore(header, mainContent.firstChild);
}
overrideCollapseButtons() {
// Override the channels sidebar toggle button (left sidebar)
const sidebarToggle = document.getElementById('toggleSidebar');
if (sidebarToggle) {
const newToggle = sidebarToggle.cloneNode(true);
sidebarToggle.parentNode.replaceChild(newToggle, sidebarToggle);
newToggle.addEventListener('click', (e) => {
e.stopPropagation();
if (this.isMobile()) {
// On mobile: close the channels sidebar instead of collapsing
this.closeSidebar();
} else {
// On desktop: use original collapse behavior for channels sidebar
if (typeof window.toggleSidebar === 'function') {
window.toggleSidebar();
}
}
});
}
// Override the people sidebar toggle button (right sidebar)
const peopleToggle = document.getElementById('togglePeopleSidebar');
if (peopleToggle) {
const newPeopleToggle = peopleToggle.cloneNode(true);
peopleToggle.parentNode.replaceChild(newPeopleToggle, peopleToggle);
newPeopleToggle.addEventListener('click', (e) => {
e.stopPropagation();
if (this.isMobile()) {
// On mobile: close the people sidebar instead of collapsing
this.closePeopleSidebar();
} else {
// On desktop: use original collapse behavior for people sidebar
if (typeof window.togglePeopleSidebar === 'function') {
window.togglePeopleSidebar();
}
}
});
}
this.updateToggleButtonIcons();
}
updateToggleButtonIcons() {
const sidebarToggle = document.getElementById('toggleSidebar');
const peopleToggle = document.getElementById('togglePeopleSidebar');
if (sidebarToggle) {
const icon = sidebarToggle.querySelector('svg');
if (icon) {
if (this.isMobile() && this.sidebarOpen) {
// Show close icon (X) when channels sidebar is open on mobile
icon.innerHTML = '<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
} else {
// Show collapse icon (chevron)
icon.innerHTML = '<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
}
}
}
if (peopleToggle) {
const icon = peopleToggle.querySelector('svg');
if (icon) {
if (this.isMobile() && this.peopleSidebarOpen) {
// Show close icon (X) when people sidebar is open on mobile
icon.innerHTML = '<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
} else {
// Show collapse icon (chevron)
icon.innerHTML = '<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
}
}
}
}
setupEventListeners() {
// Mobile menu buttons - only control channels and people sidebars
document.getElementById('mobileMenuButton')?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleSidebar();
});
document.getElementById('mobilePeopleButton')?.addEventListener('click', (e) => {
e.stopPropagation();
this.togglePeopleSidebar();
});
// Touch events
this.setupTouchEvents();
// Orientation and resize
window.addEventListener('orientationchange', () => {
setTimeout(() => this.handleResize(), 150);
});
window.addEventListener('resize', () => this.throttle(this.handleResize.bind(this), 250));
// Fixed: Don't close sidebars when clicking on form elements
document.addEventListener('click', (e) => {
if (this.isMobile() &&
!e.target.closest('#channels') &&
!e.target.closest('#peopleSidebar') &&
!e.target.closest('.mobile-menu-button') &&
// Don't close sidebars when clicking on form inputs
!e.target.matches('input, textarea, select')) {
this.closeAllSidebars();
}
});
// Keyboard handling
this.setupKeyboardHandling();
}
setupTouchEvents() {
let touchStartX = 0;
let touchStartY = 0;
document.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
});
document.addEventListener('touchend', (e) => {
if (!this.isMobile()) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = touchEndX - touchStartX;
const diffY = touchEndY - touchStartY;
// Only handle horizontal swipes with minimal vertical movement
if (Math.abs(diffX) > 50 && Math.abs(diffY) < 30) {
if (diffX > 0) {
// Swipe right - open channels sidebar
this.toggleSidebar();
} else {
// Swipe left - open people sidebar or close open ones
if (this.sidebarOpen) {
this.closeSidebar();
} else {
this.togglePeopleSidebar();
}
}
}
});
// Better touch feedback
document.querySelectorAll('button, .channel-item, .people-item, .tab').forEach(element => {
element.addEventListener('touchstart', function() {
this.classList.add('touch-active');
});
element.addEventListener('touchend', function() {
this.classList.remove('touch-active');
});
element.addEventListener('touchcancel', function() {
this.classList.remove('touch-active');
});
});
}
setupKeyboardHandling() {
if ('visualViewport' in window) {
const visualViewport = window.visualViewport;
visualViewport.addEventListener('resize', () => {
this.handleKeyboardResize(visualViewport);
});
visualViewport.addEventListener('scroll', () => {
this.handleKeyboardScroll(visualViewport);
});
}
// Fixed: Only auto-close sidebars when focusing on main message input
document.addEventListener('focusin', (e) => {
if (this.isMobile() && e.target.id === 'messageInput') {
this.closeAllSidebars();
}
// Don't close sidebars for other input types (connection form, etc.)
});
}
handleKeyboardResize(viewport) {
// Handle virtual keyboard appearance
if (this.isMobile()) {
const composer = document.querySelector('.composer');
if (composer) {
composer.style.marginBottom = '0px';
}
}
}
handleKeyboardScroll(viewport) {
// Adjust scroll when keyboard appears
if (this.isMobile()) {
setTimeout(() => {
const messages = document.querySelector('.messages');
if (messages) {
messages.scrollTop = messages.scrollHeight;
}
}, 100);
}
}
toggleSidebar() {
const sidebar = document.getElementById('channels');
const overlay = document.getElementById('mobileOverlay');
if (!sidebar || !overlay) return;
if (this.sidebarOpen) {
this.closeSidebar();
} else {
this.closeAllSidebars();
sidebar.classList.add('active');
overlay.classList.add('active');
this.sidebarOpen = true;
this.updateMobileTitle('Channels');
this.updateToggleButtonIcons();
}
}
togglePeopleSidebar() {
const peopleSidebar = document.getElementById('peopleSidebar');
const overlay = document.getElementById('mobileOverlay');
if (!peopleSidebar || !overlay) return;
if (this.peopleSidebarOpen) {
this.closePeopleSidebar();
} else {
this.closeAllSidebars();
peopleSidebar.classList.add('active');
overlay.classList.add('active');
this.peopleSidebarOpen = true;
this.updateMobileTitle('People');
this.updateToggleButtonIcons();
}
}
closeSidebar() {
const sidebar = document.getElementById('channels');
const overlay = document.getElementById('mobileOverlay');
if (sidebar) sidebar.classList.remove('active');
if (overlay) overlay.classList.remove('active');
this.sidebarOpen = false;
this.updateMobileTitle();
this.updateToggleButtonIcons();
}
closePeopleSidebar() {
const peopleSidebar = document.getElementById('peopleSidebar');
const overlay = document.getElementById('mobileOverlay');
if (peopleSidebar) peopleSidebar.classList.remove('active');
if (overlay) overlay.classList.remove('active');
this.peopleSidebarOpen = false;
this.updateMobileTitle();
this.updateToggleButtonIcons();
}
closeAllSidebars() {
this.closeSidebar();
this.closePeopleSidebar();
}
updateMobileTitle(title = '') {
const mobileTitle = document.getElementById('mobileTitle');
if (!mobileTitle) return;
if (title) {
mobileTitle.textContent = this.truncateText(title, this.getMaxTitleLength());
} else {
const activeTab = document.querySelector('.tab.active');
if (activeTab) {
const tabText = activeTab.textContent.replace('×', '').trim();
mobileTitle.textContent = this.truncateText(tabText, this.getMaxTitleLength());
} else {
mobileTitle.textContent = 'IRCD';
}
}
}
truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
getMaxTitleLength() {
const width = window.innerWidth;
if (width < this.breakpoints.superNarrow) return 12;
if (width < this.breakpoints.narrow) return 15;
if (width < this.breakpoints.mobile) return 20;
return 25;
}
applyResponsiveStyles() {
const style = document.createElement('style');
style.textContent = this.generateResponsiveCSS();
document.head.appendChild(style);
this.updateResponsiveClasses();
}
updateResponsiveClasses() {
const breakpoint = this.currentBreakpoint;
document.body.className = document.body.className.replace(/\b(desktop|tablet|mobile|narrow-screen|super-narrow)\b/g, '');
document.body.classList.add(breakpoint === 'narrow' ? 'narrow-screen' : breakpoint);
document.body.classList.add('mobile-device');
}
handleResize() {
const previousBreakpoint = this.currentBreakpoint;
this.currentBreakpoint = this.getCurrentBreakpoint();
if (previousBreakpoint !== this.currentBreakpoint) {
console.log('Breakpoint changed:', previousBreakpoint, '→', this.currentBreakpoint);
this.updateResponsiveClasses();
this.updateMobileTitle();
this.adjustLayoutForBreakpoint();
this.updateToggleButtonIcons();
// Close sidebars when switching to mobile layout
if (this.isMobile() && !previousBreakpoint.includes('mobile') && !previousBreakpoint.includes('narrow')) {
this.closeAllSidebars();
}
}
this.adjustComposerForMobile();
}
adjustLayoutForBreakpoint() {
const breakpoint = this.currentBreakpoint;
// Adjust tab bar for narrow screens
const tabs = document.querySelector('.tabs');
if (tabs) {
if (breakpoint === 'super-narrow') {
tabs.style.flexWrap = 'nowrap';
tabs.style.overflowX = 'auto';
} else {
tabs.style.flexWrap = '';
tabs.style.overflowX = '';
}
}
// Adjust message layout
const messages = document.querySelector('.messages');
if (messages) {
if (breakpoint === 'super-narrow') {
messages.classList.add('narrow-layout');
} else {
messages.classList.remove('narrow-layout');
}
}
// Only hide collapse buttons for channels/people sidebars, not server section
const sidebarToggle = document.querySelector('#channels .toggle-sidebar');
const peopleToggle = document.querySelector('#peopleSidebar .toggle-people-sidebar');
if (this.isMobile()) {
if (sidebarToggle) sidebarToggle.style.display = 'none';
if (peopleToggle) peopleToggle.style.display = 'none';
} else {
if (sidebarToggle) sidebarToggle.style.display = '';
if (peopleToggle) peopleToggle.style.display = '';
}
}
adjustComposerForMobile() {
const composer = document.querySelector('.composer');
const messages = document.querySelector('.messages');
if (!composer || !messages) return;
if (this.isMobile()) {
messages.style.paddingBottom = '90px';
} else {
messages.style.paddingBottom = '';
}
}
enhanceTouchInteractions() {
let lastTap = 0;
document.addEventListener('touchend', (e) => {
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTap;
if (tapLength < 500 && tapLength > 0) {
const messageLine = e.target.closest('.message-line');
if (messageLine && !e.target.closest('.message-sender')) {
this.handleDoubleTapMessage(messageLine);
}
}
lastTap = currentTime;
});
}
handleDoubleTapMessage(messageLine) {
messageLine.style.backgroundColor = '#3A3A3A';
setTimeout(() => {
messageLine.style.backgroundColor = '';
}, 300);
}
isMobile() {
return this.currentBreakpoint !== 'desktop';
}
throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
// Public methods for integration
handleNewMessage() {
if (this.isMobile() && (this.sidebarOpen || this.peopleSidebarOpen)) {
setTimeout(() => this.closeAllSidebars(), 1500);
}
}
handleChannelSwitch(channelName) {
if (this.isMobile()) {
this.closeAllSidebars();
this.updateMobileTitle();
}
}
setupMessageInput() {
const messageInput = document.getElementById('messageInput');
if (!messageInput) return;
messageInput.addEventListener('focus', () => {
if (this.isMobile()) {
setTimeout(() => {
const messages = document.querySelector('.messages');
if (messages) {
messages.scrollTop = messages.scrollHeight;
}
}, 300);
}
});
messageInput.addEventListener('keypress', (e) => {
if (this.isMobile() && e.key === 'Enter') {
e.preventDefault();
setTimeout(() => {
const sendBtn = document.getElementById('sendBtn');
if (sendBtn) sendBtn.click();
}, 100);
}
});
}
}
// Initialize mobile manager
document.addEventListener('DOMContentLoaded', () => {
window.mobileManager = new MobileManager();
integrateWithExistingApp();
});
// Integration with existing app
function integrateWithExistingApp() {
// Override setActiveTab
const originalSetActiveTab = window.setActiveTab;
if (originalSetActiveTab) {
window.setActiveTab = function(name) {
originalSetActiveTab(name);
if (window.mobileManager) {
window.mobileManager.handleChannelSwitch(name);
}
};
}
// Override addMessage
const originalAddMessage = window.addMessage;
if (originalAddMessage) {
window.addMessage = function(sender, content, isUser = false) {
originalAddMessage(sender, content, isUser);
if (window.mobileManager) {
window.mobileManager.handleNewMessage();
}
};
}
// Override addSystemMessage
const originalAddSystemMessage = window.addSystemMessage;
if (originalAddSystemMessage) {
window.addSystemMessage = function(content) {
originalAddSystemMessage(content);
if (window.mobileManager) {
window.mobileManager.handleNewMessage();
}
};
}
// Override the original toggle functions
if (window.toggleSidebar) {
const originalToggleSidebar = window.toggleSidebar;
window.toggleSidebar = function() {
if (window.mobileManager && window.mobileManager.isMobile()) {
window.mobileManager.toggleSidebar();
} else {
originalToggleSidebar();
}
};
}
if (window.togglePeopleSidebar) {
const originalTogglePeopleSidebar = window.togglePeopleSidebar;
window.togglePeopleSidebar = function() {
if (window.mobileManager && window.mobileManager.isMobile()) {
window.mobileManager.togglePeopleSidebar();
} else {
originalTogglePeopleSidebar();
}
};
}
// Setup mobile message input
setTimeout(() => {
if (window.mobileManager) {
window.mobileManager.setupMessageInput();
}
}, 1000);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = MobileManager;
}

223
src/styles/mobile.css Normal file
View File

@@ -0,0 +1,223 @@
#channels .connection-section {
position: relative !important;
width: auto !important;
max-width: none !important;
min-width: 0 !important;
}
/* Only apply fullscreen to the sidebar containers, not their internal sections */
@media (max-width: 768px) {
.mobile-device #channels.active {
width: 85vw !important;
max-width: 400px !important;
min-width: 280px !important;
transform: translateX(0) !important;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.mobile-device #peopleSidebar.active {
width: 85vw !important;
max-width: 400px !important;
min-width: 280px !important;
transform: translateX(0) !important;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5);
}
/* Ensure server connection form stays properly sized */
.mobile-device #channels.active .connection-section {
width: auto !important;
max-width: none !important;
}
.mobile-device #channels.active .form-group input {
width: 100% !important;
max-width: none !important;
}
}
/* Super narrow screens (280px and below) */
@media (max-width: 280px) {
.super-narrow .mobile-header {
padding: 4px 8px;
min-height: 44px;
}
.super-narrow .mobile-title {
font-size: 0.9rem;
}
.super-narrow .mobile-menu-button {
padding: 6px;
min-width: 36px;
min-height: 36px;
}
.super-narrow .tab {
padding: 4px 8px;
font-size: 0.7rem;
max-width: 80px;
}
.super-narrow .message-sender {
min-width: 50px;
font-size: 0.7rem;
}
.super-narrow .message-content {
font-size: 0.7rem;
}
.super-narrow .composer {
padding: 8px;
gap: 6px;
}
.super-narrow .composer-input input {
font-size: 0.8rem;
padding: 8px 0;
}
.super-narrow button {
padding: 6px 8px;
font-size: 0.7rem;
}
.super-narrow .form-group input {
padding: 6px 8px;
font-size: 0.8rem;
}
/* Adjust sidebar width for super narrow */
.super-narrow #channels.active,
.super-narrow #peopleSidebar.active {
width: 95vw !important;
min-width: 250px !important;
}
}
/* Narrow screens (320px and below) */
@media (max-width: 320px) {
.narrow-screen .mobile-header {
padding: 6px 12px;
}
.narrow-screen .tab {
padding: 6px 10px;
font-size: 0.75rem;
max-width: 100px;
}
.narrow-screen .messages {
padding: 6px 8px;
font-size: 0.75rem;
}
.narrow-screen .message-sender {
min-width: 60px;
font-size: 0.75rem;
}
.narrow-screen .composer {
padding: 10px;
gap: 8px;
}
.narrow-screen .composer-input input {
font-size: 0.85rem;
}
.narrow-screen #channels.active,
.narrow-screen #peopleSidebar.active {
width: 90vw !important;
min-width: 260px !important;
}
.narrow-screen .channel-item,
.narrow-screen .people-item {
padding: 10px 8px;
min-height: 44px;
}
.narrow-screen .form-group {
margin-bottom: 8px;
}
.narrow-screen .button-group {
gap: 4px;
}
}
/* Mobile optimizations */
.mobile-device {
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.mobile-device .messages,
.mobile-device .channel-list,
.mobile-device .people-list-container {
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
}
.touch-active {
background-color: #4A4A4A !important;
transition: background-color 0.1s ease;
}
/* Improved tap targets */
.mobile-device button,
.mobile-device .channel-item,
.mobile-device .people-item,
.mobile-device .tab {
min-height: 44px;
min-width: 44px;
}
/* Hide resize handles on mobile */
.mobile-device .sidebar-resize-handle {
display: none;
}
/* Compact forms for narrow screens */
.narrow-screen .connection-section,
.narrow-screen .channel-section,
.narrow-screen .people-section {
padding: 12px;
}
.narrow-screen .section-title {
font-size: 0.7rem;
margin-bottom: 8px;
}
/* Stack buttons vertically on very narrow screens */
.super-narrow .button-group {
flex-direction: column;
}
.super-narrow .button-group button {
flex: none;
}
/* Adjust message layout for narrow screens */
.super-narrow .message-line {
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
.super-narrow .message-sender {
text-align: left;
padding-right: 0;
min-width: auto;
}
/* Compact composer for narrow screens */
.super-narrow .composer {
grid-template-columns: 1fr;
}
.super-narrow .composer-input {
margin-bottom: 4px;
}

View File

@@ -121,20 +121,26 @@
:root { :root {
font-family: "Inter", "Segoe UI", sans-serif; font-family: "Inter", "Segoe UI", sans-serif;
font-size: 15px; font-size: 15px;
background: #1a1c21; background: #2D2D2D;
color: #f1f1f1; color: #DDD;
--sidebar-width: 280px; --sidebar-width: 280px;
--sidebar-collapsed-width: 60px; --sidebar-collapsed-width: 60px;
--primary-color: #4b82ff; --primary-color: #4A4A4A;
--success-color: #7edba5; --success-color: #7edba5;
--danger-color: #d36d6d; --danger-color: #d36d6d;
--bg-dark: #111217; --bg-dark: #2D2D2D;
--bg-panel: #181a1f; --bg-panel: #252525;
--bg-content: #1a1c21; --bg-content: #2D2D2D;
--border-color: #2a2d35; --border-color: #555;
--text-muted: #9ea4b9; --text-muted: #777;
--message-bg: #1e2028; --message-bg: #1E1E1E;
--message-bg-user: #2d4fcc; --message-bg-user: #3A3A3A;
/* Responsive variables */
--mobile-breakpoint: 768px;
--tablet-breakpoint: 1024px;
--mobile-sidebar-width: 280px;
--mobile-header-height: 50px;
} }
*, *,
@@ -149,12 +155,15 @@ body {
background: var(--bg-dark); background: var(--bg-dark);
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
overflow: hidden; overflow: hidden;
color: #DDD;
} }
.app-container { .app-container {
display: flex; display: flex;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
background-color: #2D2D2D;
color: #DDD;
} }
/* Sidebar Styles */ /* Sidebar Styles */
@@ -164,13 +173,14 @@ body {
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: width 0.3s ease; transition: all 0.3s ease;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
min-width: 200px; min-width: 200px;
max-width: 500px; max-width: 500px;
resize: horizontal; resize: horizontal;
flex-shrink: 0; flex-shrink: 0;
z-index: 100;
} }
.sidebar-right { .sidebar-right {
@@ -212,6 +222,7 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
min-height: 60px;
} }
.sidebar-header h1 { .sidebar-header h1 {
@@ -220,22 +231,24 @@ body {
font-weight: 600; font-weight: 600;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
color: #DDD;
} }
.toggle-sidebar { .toggle-sidebar {
background: none; background: transparent;
border: none; border: none;
color: var(--text-muted); color: #DDD;
cursor: pointer; cursor: pointer;
padding: 4px; padding: 4px;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0;
} }
.toggle-sidebar:hover { .toggle-sidebar:hover {
background: rgba(255, 255, 255, 0.05); background: #3A3A3A;
} }
.connection-section, .connection-section,
@@ -246,7 +259,7 @@ body {
.section-title { .section-title {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: #DDD;
margin-bottom: 12px; margin-bottom: 12px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -268,21 +281,22 @@ body {
.form-group label { .form-group label {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: #DDD;
margin-bottom: 4px; margin-bottom: 4px;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid #3a3f4d; border: 1px solid #555;
border-radius: 4px; border-radius: 4px;
background: #101218; background: #3A3A3A;
color: #f1f1f1; color: #DDD;
font-size: 0.85rem;
} }
.form-group input:focus { .form-group input:focus {
border-color: var(--primary-color); border-color: #555;
outline: none; outline: none;
} }
@@ -290,35 +304,44 @@ body {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
flex-wrap: wrap;
} }
button { button {
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
border: 1px solid #3a3f4d; border: 1px solid #555;
background: #242730; background: #3A3A3A;
color: #f1f1f1; color: #DDD;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
transition: all 0.2s; transition: all 0.2s;
flex: 1;
min-width: 0;
} }
button:hover:not(:disabled) { button:hover:not(:disabled) {
background: #2c303a; background: #4A4A4A;
}
button:active:not(:disabled) {
background: #2A2A2A;
} }
button:disabled { button:disabled {
opacity: 0.4; background: #2A2A2A;
color: #777;
cursor: not-allowed; cursor: not-allowed;
opacity: 1;
} }
.btn-primary { .btn-primary {
background: var(--primary-color); background: #3A3A3A;
border-color: var(--primary-color); border-color: #555;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #5a92ff; background: #4A4A4A;
} }
.btn-danger { .btn-danger {
@@ -332,6 +355,7 @@ button:disabled {
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
font-size: 0.75rem; font-size: 0.75rem;
flex-wrap: wrap;
} }
.status-dot { .status-dot {
@@ -339,6 +363,7 @@ button:disabled {
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--danger-color); background: var(--danger-color);
flex-shrink: 0;
} }
.status-dot.online { .status-dot.online {
@@ -359,15 +384,16 @@ button:disabled {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
transition: background-color 0.2s;
} }
.channel-item:hover { .channel-item:hover {
background: rgba(255, 255, 255, 0.05); background: #3A3A3A;
} }
.channel-item.active { .channel-item.active {
background: rgba(75, 130, 255, 0.15); background: #3A3A3A;
color: var(--primary-color); color: #DDD;
} }
.channel-icon { .channel-icon {
@@ -389,6 +415,7 @@ button:disabled {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex: 1;
} }
.people-section { .people-section {
@@ -402,17 +429,17 @@ button:disabled {
.people-search-input { .people-search-input {
width: 100%; width: 100%;
padding: 6px 10px; padding: 6px 10px;
border: 1px solid #3a3f4d; border: 1px solid #555;
border-radius: 4px; border-radius: 4px;
background: #101218; background: #3A3A3A;
color: #f1f1f1; color: #DDD;
font-size: 0.85rem; font-size: 0.85rem;
margin-bottom: 8px; margin-bottom: 8px;
} }
.people-search-input:focus { .people-search-input:focus {
outline: none; outline: none;
border-color: var(--primary-color); border-color: #555;
} }
.people-list-container { .people-list-container {
@@ -444,10 +471,11 @@ button:disabled {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 0.85rem; font-size: 0.85rem;
transition: background-color 0.2s;
} }
.people-item:hover { .people-item:hover {
background: rgba(255, 255, 255, 0.05); background: #3A3A3A;
} }
.people-item.op { .people-item.op {
@@ -493,6 +521,7 @@ button:disabled {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-content); background: var(--bg-content);
min-width: 0; /* Important for flexbox shrinking */
} }
.tabs { .tabs {
@@ -500,40 +529,58 @@ button:disabled {
gap: 2px; gap: 2px;
padding: 6px 10px; padding: 6px 10px;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background: #14151a; background: #252525;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.tabs::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
} }
.tab { .tab {
padding: 8px 16px; padding: 8px 16px;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
border: 1px solid #2f323a; border: 1px solid #555;
border-bottom: none; border-bottom: none;
background: #1b1d23; background: #2D2D2D;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: #DDD;
white-space: nowrap; white-space: nowrap;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0;
min-width: 0;
max-width: 200px;
} }
.tab.active { .tab.active {
background: #23252d; background: #3A3A3A;
color: #f5f5f5; color: #DDD;
} }
.tab-close { .tab-close {
margin-left: 4px; margin-left: 4px;
opacity: 0.6; opacity: 0.6;
font-size: 0.8rem; font-size: 0.8rem;
flex-shrink: 0;
} }
.tab-close:hover { .tab-close:hover {
opacity: 1; opacity: 1;
} }
.tab-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.content { .content {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -555,14 +602,22 @@ button:disabled {
.message-line { .message-line {
display: flex; display: flex;
padding: 2px 0; padding: 2px 0;
color: #DDD;
word-wrap: break-word;
overflow-wrap: break-word;
} }
.message-sender { .message-sender {
min-width: 120px; min-width: 80px;
max-width: 120px;
font-weight: 500; font-weight: 500;
padding-right: 8px; padding-right: 8px;
text-align: right; text-align: right;
color: var(--text-muted); color: #DDD;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.message-sender.you { .message-sender.you {
@@ -571,13 +626,16 @@ button:disabled {
.message-content { .message-content {
flex: 1; flex: 1;
color: #e1e1e1; color: #DDD;
min-width: 0;
word-break: break-word;
} }
.system-message { .system-message {
color: var(--text-muted); color: var(--text-muted);
font-style: italic; font-style: italic;
padding: 4px 0; padding: 4px 0;
text-align: center;
} }
.irc-message { .irc-message {
@@ -598,24 +656,27 @@ button:disabled {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
background: #2a2e35; background: #3A3A3A;
border-radius: 24px; border-radius: 4px;
padding: 0 16px; padding: 0 16px;
min-width: 0;
} }
.composer-input input { .composer-input input {
flex: 1; flex: 1;
border: none; border: none;
background: transparent; background: transparent;
color: #f1f1f1; color: #DDD;
padding: 12px 0; padding: 12px 0;
font-size: 0.95rem; font-size: 0.95rem;
outline: none; outline: none;
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
min-width: 0;
width: 100%;
} }
.composer-input input::placeholder { .composer-input input::placeholder {
color: #6a7283; color: #777;
} }
.suggestions { .suggestions {
@@ -623,13 +684,15 @@ button:disabled {
bottom: 110%; bottom: 110%;
left: 0; left: 0;
right: 0; right: 0;
background: #101116; background: #252525;
border: 1px solid #3a3f4d; border: 1px solid #555;
border-radius: 4px; border-radius: 4px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
z-index: 10; z-index: 10;
max-height: 200px;
overflow-y: auto;
} }
.suggestions button { .suggestions button {
@@ -637,11 +700,14 @@ button:disabled {
background: transparent; background: transparent;
text-align: left; text-align: left;
border-radius: 0; border-radius: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.suggestions button:hover, .suggestions button:hover,
.suggestions button.active { .suggestions button.active {
background: #222430; background: #3A3A3A;
} }
.suggestions.hidden { .suggestions.hidden {
@@ -674,23 +740,420 @@ button:disabled {
padding: 8px; padding: 8px;
} }
/* Mobile Overlay */
.mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 90;
}
.mobile-overlay.active {
display: block;
}
/* Mobile Header */
.mobile-header {
display: none;
padding: 8px 16px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border-color);
align-items: center;
gap: 12px;
min-height: var(--mobile-header-height);
}
.mobile-menu-button {
background: transparent;
border: none;
color: #DDD;
cursor: pointer;
padding: 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-menu-button:hover {
background: #3A3A3A;
}
.mobile-title {
font-size: 1rem;
font-weight: 600;
color: #DDD;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* Scrollbars - Vertical */
::-webkit-scrollbar {
width: 10px;
height: 10px;
background: #1E1E1E;
border-radius: 5px;
}
::-webkit-scrollbar-track {
background: #1E1E1E;
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: #3A3A3A;
border-radius: 5px;
min-height: 20px;
}
::-webkit-scrollbar-thumb:hover {
background: #555555;
}
::-webkit-scrollbar-thumb:active {
background: #6A6A6A;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: #3A3A3A #1E1E1E;
}
/* Responsive styles */ /* Responsive styles */
@media (max-width: 768px) { @media (max-width: 1024px) {
:root {
--sidebar-width: 240px;
}
.sidebar { .sidebar {
position: absolute; min-width: 180px;
max-width: 400px;
}
.message-sender {
min-width: 70px;
max-width: 100px;
font-size: 0.85rem;
}
.messages {
padding: 12px;
font-size: 0.85rem;
}
.composer {
padding: 12px;
grid-template-columns: 1fr auto;
gap: 8px;
}
.composer-input input {
font-size: 0.9rem;
padding: 10px 0;
}
button {
padding: 6px 10px;
font-size: 0.8rem;
}
}
@media (max-width: 768px) {
.app-container {
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--mobile-sidebar-width);
max-width: 100vw;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 100; z-index: 100;
height: 100%; resize: none;
min-width: unset;
max-width: unset;
} }
.sidebar:not(.collapsed) { .sidebar.active {
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5); transform: translateX(0);
} }
.sidebar-right {
left: auto;
right: 0;
transform: translateX(100%);
}
.sidebar-right.active {
transform: translateX(0);
}
.mobile-header {
display: flex;
}
.main-content {
flex: 1;
min-height: 0;
}
.tabs {
padding: 4px 8px;
}
.tab {
padding: 6px 12px;
font-size: 0.8rem;
max-width: 150px;
}
.messages {
padding: 8px 12px;
font-size: 0.8rem;
}
.message-line {
flex-direction: column;
padding: 4px 0;
gap: 2px;
}
.message-sender {
min-width: auto;
max-width: none;
text-align: left;
padding-right: 0;
font-size: 0.8rem;
padding: 2px 0;
}
.composer { .composer {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 8px;
padding: 12px;
}
.button-group {
flex-direction: column;
}
button {
flex: none;
}
.sidebar-resize-handle {
display: none;
}
/* Hide sidebar toggle in header when sidebar is open on mobile */
.sidebar.active ~ .main-content .mobile-header .mobile-menu-button {
visibility: hidden;
}
}
@media (max-width: 480px) {
:root {
font-size: 14px;
}
.sidebar {
width: 100vw;
}
.sidebar-header {
padding: 12px;
min-height: 50px;
}
.connection-section,
.channel-section,
.people-section {
padding: 12px;
}
.channel-list {
padding: 0 12px 12px;
}
.tab {
padding: 6px 10px;
font-size: 0.75rem;
max-width: 120px;
}
.messages {
padding: 6px 8px;
font-size: 0.75rem;
}
.composer {
padding: 8px;
}
.composer-input {
padding: 0 12px;
}
.composer-input input {
padding: 8px 0;
font-size: 0.85rem;
}
.mobile-header {
padding: 6px 12px;
}
.form-group input {
padding: 6px 8px;
}
button {
padding: 6px 8px;
font-size: 0.75rem;
}
}
@media (max-width: 320px) {
:root {
font-size: 13px;
}
.tab {
max-width: 100px;
padding: 4px 8px;
}
.messages {
padding: 4px 6px;
}
.composer {
padding: 6px;
}
}
/* Touch device improvements */
@media (hover: none) and (pointer: coarse) {
button,
.channel-item,
.people-item,
.tab {
min-height: 44px; /* Better touch targets */
}
.toggle-sidebar,
.mobile-menu-button {
min-width: 44px;
min-height: 44px;
}
.sidebar-resize-handle {
width: 8px; /* Larger touch target for resize */
}
/* Reduce hover effects on touch devices */
.channel-item:hover,
.people-item:hover,
.tab:hover {
background: inherit;
}
.channel-item:active,
.people-item:active,
.tab:active {
background: #3A3A3A;
}
}
/* High DPI screens */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
:root {
font-size: 16px; /* Slightly larger text for better readability */
}
}
/* Print styles */
@media print {
.sidebar,
.composer,
.mobile-header,
.tabs {
display: none !important;
}
.main-content {
display: block !important;
}
.messages {
overflow: visible !important;
height: auto !important;
}
body {
overflow: visible !important;
} }
} }
input[type=number]::-webkit-inner-spin-button { input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Utility classes for responsive behavior */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.hide-on-mobile {
display: block;
}
.show-on-mobile {
display: none;
}
@media (max-width: 768px) {
.hide-on-mobile {
display: none !important;
}
.show-on-mobile {
display: block !important;
}
.show-on-mobile-flex {
display: flex !important;
}
} }