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

376
src/UI.js
View File

@@ -31,7 +31,6 @@ const uiState = {
userColors: new Map(),
currentUser: 'IRCDUser',
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
virtualScrollState: {
startIndex: 0,
@@ -280,8 +279,7 @@ function addChannel(name) {
if (!uiState.channels.includes(normalizedName)) {
uiState.channels.push(normalizedName);
// Add to channel list (async DOM update)
requestAnimationFrame(() => {
// Add to channel list
const channelItem = document.createElement('div');
channelItem.className = 'channel-item';
channelItem.innerHTML = `
@@ -295,15 +293,9 @@ function addChannel(name) {
`;
channelItem.addEventListener('click', () => setActiveTab(normalizedName));
channelList.appendChild(channelItem);
});
// Add tab (async)
requestAnimationFrame(() => addTab(normalizedName));
// Initialize message storage for this channel
if (!uiState.channelMessages.has(normalizedName)) {
uiState.channelMessages.set(normalizedName, []);
}
// Add tab
addTab(normalizedName);
// Auto-join the channel when connected
if (uiState.connected) {
@@ -330,9 +322,6 @@ function removeChannel(name) {
// Clear users for this channel
clearChannelUsers(name);
// Clear messages for this channel (optional - you might want to keep them)
// uiState.channelMessages.delete(name);
// Remove tab
removeTab(name);
@@ -398,114 +387,51 @@ function setActiveTab(name) {
uiState.activeTab = normalizedName;
// Update tabs (async)
requestAnimationFrame(() => {
// Update tabs
const tabs = tabBar.querySelectorAll('.tab');
tabs.forEach(tab => {
// Compare with both normalized and original name
tab.classList.toggle('active', tab.dataset.tab === normalizedName || tab.dataset.tab === name);
});
});
// Update channels (async)
requestAnimationFrame(() => {
// Update channels
const channelItems = channelList.querySelectorAll('.channel-item');
channelItems.forEach(item => {
const channelName = item.querySelector('.channel-name').textContent;
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);
// Update messages (async)
requestAnimationFrame(() => {
// Update messages
updateMessagesForTab(normalizedName);
});
}
function updateMessagesForTab(name) {
// Clear messages
messagesContainer.innerHTML = '';
// Ensure channel name format is correct
let channelKey = name;
if (name !== 'welcome' && !name.startsWith('#')) {
channelKey = '#' + name;
}
// Load stored messages from RAM
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)
// Add welcome 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.');
}
} 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);
}
scrollToBottom();
}
// Message handling
@@ -545,19 +471,6 @@ function addMessage(sender, content, isUser = false) {
const channel = uiState.activeTab;
if (!channel || channel === 'welcome') return;
// Normalize channel name
let channelKey = channel;
if (!channelKey.startsWith('#')) {
channelKey = '#' + channelKey;
}
// Store message in RAM - ALWAYS store
if (!uiState.channelMessages.has(channelKey)) {
uiState.channelMessages.set(channelKey, []);
}
// Use requestAnimationFrame for async DOM updates
requestAnimationFrame(() => {
const messageLine = document.createElement('div');
messageLine.className = 'message-line';
@@ -573,261 +486,29 @@ function addMessage(sender, content, isUser = false) {
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) {
const channel = uiState.activeTab;
if (!channel || channel === 'welcome') return;
// Normalize channel name
let channelKey = channel;
if (!channelKey.startsWith('#')) {
channelKey = '#' + channelKey;
}
// Store message in RAM - ALWAYS store
if (!uiState.channelMessages.has(channelKey)) {
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) {
// Parse the message to determine which channel it belongs to
let targetChannel = null;
let joinMatch = null;
let partMatch = null;
// Extract plain text for checking
const plainText = content.replace(/<[^>]*>/g, '');
// 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
ircMessage.innerHTML = content;
messagesContainer.appendChild(ircMessage);
scrollToBottom();
});
}
function scrollToBottom() {
@@ -959,7 +640,6 @@ function updatePeopleList(channel) {
}
// Sort and filter users (all stored in RAM, not DOM)
setTimeout(() => {
let sortedUsers = Array.from(users).sort((a, b) => {
const aIsOp = a.startsWith('@');
const bIsOp = b.startsWith('@');
@@ -994,7 +674,6 @@ function updatePeopleList(channel) {
// Update virtual scroll - only renders visible items
renderVisibleUsers();
}, 0);
}
function renderVisibleUsers() {
@@ -1039,7 +718,6 @@ function renderVisibleUsers() {
}
// Clear and render only visible items
requestAnimationFrame(() => {
if (!peopleList) return;
peopleList.innerHTML = '';
@@ -1052,7 +730,6 @@ function renderVisibleUsers() {
}
peopleList.appendChild(fragment);
});
}
function createPeopleItem(user) {
@@ -1098,25 +775,19 @@ function handlePeopleSearch(e) {
}
function handlePeopleListScroll() {
requestAnimationFrame(() => {
renderVisibleUsers();
});
}
function updateVirtualScrollContainerHeight() {
requestAnimationFrame(() => {
if (peopleListContainer) {
uiState.virtualScrollState.containerHeight = peopleListContainer.clientHeight;
renderVisibleUsers();
}
});
}
function parseNamesMessage(channel, usersStr) {
if (!channel || !usersStr) return;
// Process parsing asynchronously
setTimeout(() => {
// Initialize users set for this channel if it doesn't exist
// Note: We accumulate users from multiple NAMES messages until ENDOFNAMES
// Users are stored in RAM, not DOM
@@ -1135,12 +806,11 @@ function parseNamesMessage(channel, usersStr) {
}
});
// Update the people list if this is the active channel (async)
// Update the people list if this is the active channel
// This will use virtual scrolling to only render visible users
if (uiState.activeTab === channel) {
updatePeopleList(channel);
}
}, 0);
}
function clearChannelUsers(channel) {

View File

@@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="./styles/styles.css">
<link rel="stylesheet" href="./styles/mobile.css">
</head>
<body>
<div class="app-container">
@@ -14,7 +15,7 @@
<aside class="sidebar" id="channels">
<div class="sidebar-resize-handle" id="sidebarResizeHandle"></div>
<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">
<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"/>
@@ -134,5 +135,6 @@
<script src="tauri.js"></script>
<script src="UI.js"></script>
<script src="mobile.js"></script>
</body>
</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 {
font-family: "Inter", "Segoe UI", sans-serif;
font-size: 15px;
background: #1a1c21;
color: #f1f1f1;
background: #2D2D2D;
color: #DDD;
--sidebar-width: 280px;
--sidebar-collapsed-width: 60px;
--primary-color: #4b82ff;
--primary-color: #4A4A4A;
--success-color: #7edba5;
--danger-color: #d36d6d;
--bg-dark: #111217;
--bg-panel: #181a1f;
--bg-content: #1a1c21;
--border-color: #2a2d35;
--text-muted: #9ea4b9;
--message-bg: #1e2028;
--message-bg-user: #2d4fcc;
--bg-dark: #2D2D2D;
--bg-panel: #252525;
--bg-content: #2D2D2D;
--border-color: #555;
--text-muted: #777;
--message-bg: #1E1E1E;
--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);
font-family: 'Inter', sans-serif;
overflow: hidden;
color: #DDD;
}
.app-container {
display: flex;
height: 100vh;
overflow: hidden;
background-color: #2D2D2D;
color: #DDD;
}
/* Sidebar Styles */
@@ -164,13 +173,14 @@ body {
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: width 0.3s ease;
transition: all 0.3s ease;
overflow: hidden;
position: relative;
min-width: 200px;
max-width: 500px;
resize: horizontal;
flex-shrink: 0;
z-index: 100;
}
.sidebar-right {
@@ -212,6 +222,7 @@ body {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 60px;
}
.sidebar-header h1 {
@@ -220,22 +231,24 @@ body {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
color: #DDD;
}
.toggle-sidebar {
background: none;
background: transparent;
border: none;
color: var(--text-muted);
color: #DDD;
cursor: pointer;
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toggle-sidebar:hover {
background: rgba(255, 255, 255, 0.05);
background: #3A3A3A;
}
.connection-section,
@@ -246,7 +259,7 @@ body {
.section-title {
font-size: 0.75rem;
color: var(--text-muted);
color: #DDD;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -268,21 +281,22 @@ body {
.form-group label {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
color: #DDD;
margin-bottom: 4px;
}
.form-group input {
width: 100%;
padding: 8px 10px;
border: 1px solid #3a3f4d;
border: 1px solid #555;
border-radius: 4px;
background: #101218;
color: #f1f1f1;
background: #3A3A3A;
color: #DDD;
font-size: 0.85rem;
}
.form-group input:focus {
border-color: var(--primary-color);
border-color: #555;
outline: none;
}
@@ -290,35 +304,44 @@ body {
display: flex;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
button {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #3a3f4d;
background: #242730;
color: #f1f1f1;
border: 1px solid #555;
background: #3A3A3A;
color: #DDD;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
flex: 1;
min-width: 0;
}
button:hover:not(:disabled) {
background: #2c303a;
background: #4A4A4A;
}
button:active:not(:disabled) {
background: #2A2A2A;
}
button:disabled {
opacity: 0.4;
background: #2A2A2A;
color: #777;
cursor: not-allowed;
opacity: 1;
}
.btn-primary {
background: var(--primary-color);
border-color: var(--primary-color);
background: #3A3A3A;
border-color: #555;
}
.btn-primary:hover:not(:disabled) {
background: #5a92ff;
background: #4A4A4A;
}
.btn-danger {
@@ -332,6 +355,7 @@ button:disabled {
gap: 8px;
margin-top: 12px;
font-size: 0.75rem;
flex-wrap: wrap;
}
.status-dot {
@@ -339,6 +363,7 @@ button:disabled {
height: 8px;
border-radius: 50%;
background: var(--danger-color);
flex-shrink: 0;
}
.status-dot.online {
@@ -359,15 +384,16 @@ button:disabled {
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s;
}
.channel-item:hover {
background: rgba(255, 255, 255, 0.05);
background: #3A3A3A;
}
.channel-item.active {
background: rgba(75, 130, 255, 0.15);
color: var(--primary-color);
background: #3A3A3A;
color: #DDD;
}
.channel-icon {
@@ -389,6 +415,7 @@ button:disabled {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.people-section {
@@ -402,17 +429,17 @@ button:disabled {
.people-search-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #3a3f4d;
border: 1px solid #555;
border-radius: 4px;
background: #101218;
color: #f1f1f1;
background: #3A3A3A;
color: #DDD;
font-size: 0.85rem;
margin-bottom: 8px;
}
.people-search-input:focus {
outline: none;
border-color: var(--primary-color);
border-color: #555;
}
.people-list-container {
@@ -444,10 +471,11 @@ button:disabled {
align-items: center;
gap: 8px;
font-size: 0.85rem;
transition: background-color 0.2s;
}
.people-item:hover {
background: rgba(255, 255, 255, 0.05);
background: #3A3A3A;
}
.people-item.op {
@@ -493,6 +521,7 @@ button:disabled {
display: flex;
flex-direction: column;
background: var(--bg-content);
min-width: 0; /* Important for flexbox shrinking */
}
.tabs {
@@ -500,40 +529,58 @@ button:disabled {
gap: 2px;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
background: #14151a;
background: #252525;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.tabs::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
.tab {
padding: 8px 16px;
border-radius: 4px 4px 0 0;
border: 1px solid #2f323a;
border: 1px solid #555;
border-bottom: none;
background: #1b1d23;
background: #2D2D2D;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-muted);
color: #DDD;
white-space: nowrap;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-width: 0;
max-width: 200px;
}
.tab.active {
background: #23252d;
color: #f5f5f5;
background: #3A3A3A;
color: #DDD;
}
.tab-close {
margin-left: 4px;
opacity: 0.6;
font-size: 0.8rem;
flex-shrink: 0;
}
.tab-close:hover {
opacity: 1;
}
.tab-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.content {
flex: 1;
display: flex;
@@ -555,14 +602,22 @@ button:disabled {
.message-line {
display: flex;
padding: 2px 0;
color: #DDD;
word-wrap: break-word;
overflow-wrap: break-word;
}
.message-sender {
min-width: 120px;
min-width: 80px;
max-width: 120px;
font-weight: 500;
padding-right: 8px;
text-align: right;
color: var(--text-muted);
color: #DDD;
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-sender.you {
@@ -571,13 +626,16 @@ button:disabled {
.message-content {
flex: 1;
color: #e1e1e1;
color: #DDD;
min-width: 0;
word-break: break-word;
}
.system-message {
color: var(--text-muted);
font-style: italic;
padding: 4px 0;
text-align: center;
}
.irc-message {
@@ -598,24 +656,27 @@ button:disabled {
position: relative;
display: flex;
align-items: center;
background: #2a2e35;
border-radius: 24px;
background: #3A3A3A;
border-radius: 4px;
padding: 0 16px;
min-width: 0;
}
.composer-input input {
flex: 1;
border: none;
background: transparent;
color: #f1f1f1;
color: #DDD;
padding: 12px 0;
font-size: 0.95rem;
outline: none;
font-family: 'JetBrains Mono', monospace;
min-width: 0;
width: 100%;
}
.composer-input input::placeholder {
color: #6a7283;
color: #777;
}
.suggestions {
@@ -623,13 +684,15 @@ button:disabled {
bottom: 110%;
left: 0;
right: 0;
background: #101116;
border: 1px solid #3a3f4d;
background: #252525;
border: 1px solid #555;
border-radius: 4px;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 10;
max-height: 200px;
overflow-y: auto;
}
.suggestions button {
@@ -637,11 +700,14 @@ button:disabled {
background: transparent;
text-align: left;
border-radius: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.suggestions button:hover,
.suggestions button.active {
background: #222430;
background: #3A3A3A;
}
.suggestions.hidden {
@@ -674,23 +740,420 @@ button:disabled {
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 */
@media (max-width: 768px) {
.sidebar {
position: absolute;
z-index: 100;
height: 100%;
@media (max-width: 1024px) {
:root {
--sidebar-width: 240px;
}
.sidebar:not(.collapsed) {
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
.sidebar {
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;
resize: none;
min-width: unset;
max-width: unset;
}
.sidebar.active {
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 {
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 {
-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;
}
}