mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-03 08:21:44 +02:00
feat: searchable channel picker on narrow screens + DM picker font fix
Replace the native <select> channel picker (used on narrow screens) with a custom searchable dropdown matching the DM contact picker UX: type to filter, arrow/Enter keyboard nav, click-outside to close, per-channel unread badges, muted styling. Wide-screen sidebar (lg+) is unchanged. Also align the DM picker dropdown font with the wide-screen DM sidebar (0.88rem / weight 400) — was inheriting larger/bolder from form-control. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -103,11 +103,53 @@ main {
|
||||
.channel-sidebar {
|
||||
display: flex;
|
||||
}
|
||||
#channelSelector {
|
||||
#channelSelectorWrapper {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Channel Selector Dropdown (base.html navbar, narrow screens) */
|
||||
.channel-selector-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
z-index: 1050;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
background: var(--dropdown-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: var(--popup-shadow);
|
||||
}
|
||||
|
||||
.channel-selector-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.channel-selector-item:hover,
|
||||
.channel-selector-item.active {
|
||||
background-color: var(--dropdown-item-hover);
|
||||
}
|
||||
|
||||
.channel-selector-item.muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.channel-selector-item .channel-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
DM Sidebar (Direct Messages) - visible on lg+ screens
|
||||
============================================================================= */
|
||||
@@ -418,8 +460,7 @@ main {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Channel selector and date selector - larger font size */
|
||||
#channelSelector,
|
||||
/* Date selector - larger font size */
|
||||
#dateSelector {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@@ -517,7 +558,7 @@ main {
|
||||
}
|
||||
|
||||
/* Navbar: Channel selector on mobile */
|
||||
#channelSelector,
|
||||
#channelSelectorInput,
|
||||
#dmContactSearchInput {
|
||||
min-width: 100px !important;
|
||||
font-size: 0.9rem;
|
||||
@@ -1896,6 +1937,8 @@ emoji-picker {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dm-contact-item:hover,
|
||||
@@ -1908,6 +1951,8 @@ emoji-picker {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dm-contact-item .badge {
|
||||
|
||||
+181
-79
@@ -691,20 +691,65 @@ function setupEventListeners() {
|
||||
loadDeviceInfo();
|
||||
});
|
||||
|
||||
// Channel selector (dropdown, visible on mobile)
|
||||
document.getElementById('channelSelector').addEventListener('change', function(e) {
|
||||
currentChannelIdx = parseInt(e.target.value);
|
||||
localStorage.setItem('mc_active_channel', currentChannelIdx);
|
||||
loadMessages();
|
||||
updateChannelSidebarActive();
|
||||
// Channel selector (custom searchable picker, visible on mobile)
|
||||
const channelInput = document.getElementById('channelSelectorInput');
|
||||
const channelDropdown = document.getElementById('channelSelectorDropdown');
|
||||
const channelWrapper = document.getElementById('channelSelectorWrapper');
|
||||
|
||||
// Show notification only if we have a valid selection
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
if (selectedOption) {
|
||||
const channelName = selectedOption.text;
|
||||
showNotification(`Switched to channel: ${channelName}`, 'info');
|
||||
}
|
||||
});
|
||||
if (channelInput && channelDropdown) {
|
||||
channelInput.addEventListener('focus', () => {
|
||||
channelInput.value = '';
|
||||
renderChannelDropdownItems('');
|
||||
channelDropdown.style.display = 'block';
|
||||
});
|
||||
|
||||
channelInput.addEventListener('input', () => {
|
||||
renderChannelDropdownItems(channelInput.value);
|
||||
channelDropdown.style.display = 'block';
|
||||
});
|
||||
|
||||
// Prevent dropdown mousedown from stealing focus/closing dropdown
|
||||
channelDropdown.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside the wrapper
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (channelWrapper && !channelWrapper.contains(e.target)) {
|
||||
if (channelDropdown.style.display !== 'none') {
|
||||
channelDropdown.style.display = 'none';
|
||||
updateChannelInputDisplay();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
channelInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
channelDropdown.style.display = 'none';
|
||||
updateChannelInputDisplay();
|
||||
channelInput.blur();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const active = channelDropdown.querySelector('.channel-selector-item.active[data-channel-idx]');
|
||||
const target = active || channelDropdown.querySelector('.channel-selector-item[data-channel-idx]');
|
||||
if (target) target.click();
|
||||
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const items = Array.from(channelDropdown.querySelectorAll('.channel-selector-item[data-channel-idx]'));
|
||||
if (items.length === 0) return;
|
||||
const activeIdx = items.findIndex(el => el.classList.contains('active'));
|
||||
items.forEach(el => el.classList.remove('active'));
|
||||
let nextIdx;
|
||||
if (e.key === 'ArrowDown') {
|
||||
nextIdx = activeIdx < 0 ? 0 : Math.min(activeIdx + 1, items.length - 1);
|
||||
} else {
|
||||
nextIdx = activeIdx <= 0 ? 0 : activeIdx - 1;
|
||||
}
|
||||
items[nextIdx].classList.add('active');
|
||||
items[nextIdx].scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Channels modal - load channels when opened
|
||||
const channelsModal = document.getElementById('channelsModal');
|
||||
@@ -3480,25 +3525,6 @@ async function checkForUpdates() {
|
||||
* Update unread badges on channel selector and notification bell
|
||||
*/
|
||||
function updateUnreadBadges() {
|
||||
// Update channel selector options
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (selector) {
|
||||
Array.from(selector.options).forEach(option => {
|
||||
const channelIdx = parseInt(option.value);
|
||||
const unreadCount = unreadCounts[channelIdx] || 0;
|
||||
|
||||
// Get base channel name (remove existing badge if any)
|
||||
let channelName = option.textContent.replace(/\s*\(\d+\)$/, '');
|
||||
|
||||
// Add badge if there are unread messages, not current channel, and not muted
|
||||
if (unreadCount > 0 && channelIdx !== currentChannelIdx && !mutedChannels.has(channelIdx)) {
|
||||
option.textContent = `${channelName} (${unreadCount})`;
|
||||
} else {
|
||||
option.textContent = channelName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update notification bell (exclude muted channels)
|
||||
let totalUnread = 0;
|
||||
for (const [idx, count] of Object.entries(unreadCounts)) {
|
||||
@@ -3680,11 +3706,11 @@ async function loadChannels() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: ensure Public channel exists in dropdown even if API fails
|
||||
* Fallback: ensure Public channel exists in selector data even if API fails
|
||||
*/
|
||||
function ensurePublicChannel() {
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (!selector || selector.options.length === 0) {
|
||||
const items = window._channelDropdownItems;
|
||||
if (!items || items.length === 0) {
|
||||
console.log('[ensurePublicChannel] Adding fallback Public channel');
|
||||
availableChannels = [{index: 0, name: 'Public', key: ''}];
|
||||
populateChannelSelector(availableChannels);
|
||||
@@ -3692,55 +3718,126 @@ function ensurePublicChannel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate channel selector dropdown
|
||||
* Populate channel selector data (for both mobile dropdown and wide-screen sidebar)
|
||||
*/
|
||||
function populateChannelSelector(channels) {
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (!selector) {
|
||||
console.error('[populateChannelSelector] Channel selector element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate input
|
||||
if (!channels || !Array.isArray(channels) || channels.length === 0) {
|
||||
console.warn('[populateChannelSelector] Invalid channels array, using fallback');
|
||||
channels = [{index: 0, name: 'Public', key: ''}];
|
||||
}
|
||||
|
||||
// Remove all options - we'll rebuild everything from API data
|
||||
while (selector.options.length > 0) {
|
||||
selector.remove(0);
|
||||
}
|
||||
|
||||
// Add all channels from API (including Public at index 0)
|
||||
channels.forEach(channel => {
|
||||
if (channel && typeof channel.index !== 'undefined' && channel.name) {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.index;
|
||||
option.textContent = channel.name;
|
||||
selector.appendChild(option);
|
||||
} else {
|
||||
console.warn('[populateChannelSelector] Skipping invalid channel:', channel);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore selection (use currentChannelIdx from global state)
|
||||
selector.value = currentChannelIdx;
|
||||
|
||||
// If the saved channel doesn't exist, fall back to Public (0)
|
||||
if (selector.value !== currentChannelIdx.toString()) {
|
||||
// If the saved channel doesn't exist in the list, fall back to Public (0)
|
||||
if (!channels.some(c => c && c.index === currentChannelIdx)) {
|
||||
console.log(`[populateChannelSelector] Channel ${currentChannelIdx} not found, falling back to Public`);
|
||||
currentChannelIdx = 0;
|
||||
selector.value = 0;
|
||||
localStorage.setItem('mc_active_channel', '0');
|
||||
}
|
||||
|
||||
// Save data for the mobile dropdown
|
||||
window._channelDropdownItems = channels;
|
||||
|
||||
// Pre-render dropdown contents (still hidden) and update input display
|
||||
renderChannelDropdownItems('');
|
||||
updateChannelInputDisplay();
|
||||
|
||||
console.log(`[populateChannelSelector] Loaded ${channels.length} channels, active: ${currentChannelIdx}`);
|
||||
|
||||
// Also populate sidebar (lg+ screens)
|
||||
populateChannelSidebar();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render channel items into the mobile dropdown, optionally filtered by query.
|
||||
*/
|
||||
function renderChannelDropdownItems(query) {
|
||||
const dropdown = document.getElementById('channelSelectorDropdown');
|
||||
if (!dropdown) return;
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
const channels = window._channelDropdownItems || [];
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
|
||||
const filtered = q
|
||||
? channels.filter(c => c && c.name && c.name.toLowerCase().includes(q))
|
||||
: channels;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'channel-selector-item text-muted';
|
||||
empty.style.cursor = 'default';
|
||||
empty.textContent = q ? 'No matches' : 'No channels';
|
||||
dropdown.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach(channel => {
|
||||
if (!channel || typeof channel.index === 'undefined' || !channel.name) return;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'channel-selector-item';
|
||||
item.dataset.channelIdx = channel.index;
|
||||
|
||||
if (channel.index === currentChannelIdx) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
if (mutedChannels.has(channel.index)) {
|
||||
item.classList.add('muted');
|
||||
}
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'channel-name';
|
||||
nameSpan.textContent = channel.name;
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
const unread = unreadCounts[channel.index] || 0;
|
||||
if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'sidebar-unread-badge';
|
||||
badge.textContent = unread;
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
selectChannelFromDropdown(channel.index, channel.name);
|
||||
});
|
||||
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a channel via the mobile dropdown (closes dropdown, syncs state).
|
||||
*/
|
||||
function selectChannelFromDropdown(idx, name) {
|
||||
currentChannelIdx = idx;
|
||||
localStorage.setItem('mc_active_channel', currentChannelIdx);
|
||||
|
||||
const input = document.getElementById('channelSelectorInput');
|
||||
const dropdown = document.getElementById('channelSelectorDropdown');
|
||||
if (input) {
|
||||
input.value = name;
|
||||
input.blur();
|
||||
}
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
|
||||
loadMessages();
|
||||
updateChannelSidebarActive();
|
||||
showNotification(`Switched to channel: ${name}`, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync mobile selector input value with the currently active channel name.
|
||||
*/
|
||||
function updateChannelInputDisplay() {
|
||||
const input = document.getElementById('channelSelectorInput');
|
||||
if (!input) return;
|
||||
const channels = window._channelDropdownItems || [];
|
||||
const current = channels.find(c => c && c.index === currentChannelIdx);
|
||||
input.value = current ? current.name : 'Public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load channels list in management modal
|
||||
*/
|
||||
@@ -3853,9 +3950,6 @@ function populateChannelSidebar() {
|
||||
localStorage.setItem('mc_active_channel', currentChannelIdx);
|
||||
loadMessages();
|
||||
updateChannelSidebarActive();
|
||||
// Also sync dropdown for consistency
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (selector) selector.value = currentChannelIdx;
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
@@ -3863,16 +3957,19 @@ function populateChannelSidebar() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active state on channel sidebar items
|
||||
* Update active state on channel sidebar items and sync mobile selector input.
|
||||
*/
|
||||
function updateChannelSidebarActive() {
|
||||
const list = document.getElementById('channelSidebarList');
|
||||
if (!list) return;
|
||||
if (list) {
|
||||
list.querySelectorAll('.channel-sidebar-item').forEach(item => {
|
||||
const idx = parseInt(item.dataset.channelIdx);
|
||||
item.classList.toggle('active', idx === currentChannelIdx);
|
||||
});
|
||||
}
|
||||
|
||||
list.querySelectorAll('.channel-sidebar-item').forEach(item => {
|
||||
const idx = parseInt(item.dataset.channelIdx);
|
||||
item.classList.toggle('active', idx === currentChannelIdx);
|
||||
});
|
||||
// Sync mobile selector input with current channel name
|
||||
updateChannelInputDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3903,6 +4000,13 @@ function updateChannelSidebarBadges() {
|
||||
badge.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-render mobile dropdown if currently visible (badges/muted state)
|
||||
const dropdown = document.getElementById('channelSelectorDropdown');
|
||||
const input = document.getElementById('channelSelectorInput');
|
||||
if (dropdown && dropdown.style.display !== 'none') {
|
||||
renderChannelDropdownItems(input ? input.value : '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5028,11 +5132,9 @@ async function performSearch(query) {
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
// Navigate to channel
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (selector) {
|
||||
selector.value = r.channel_idx;
|
||||
selector.dispatchEvent(new Event('change'));
|
||||
}
|
||||
const channels = window._channelDropdownItems || [];
|
||||
const ch = channels.find(c => c && c.index === r.channel_idx);
|
||||
selectChannelFromDropdown(r.channel_idx, ch ? ch.name : (r.channel_name || ''));
|
||||
bootstrap.Modal.getInstance(document.getElementById('searchModal'))?.hide();
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -311,11 +311,11 @@ async function handleChannelLinkClick(channelName) {
|
||||
* @param {string} channelName - Channel name for notification
|
||||
*/
|
||||
function switchToChannel(channelIdx, channelName) {
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (selector) {
|
||||
selector.value = channelIdx;
|
||||
// Trigger change event to update state and load messages
|
||||
selector.dispatchEvent(new Event('change'));
|
||||
if (typeof selectChannelFromDropdown === 'function') {
|
||||
const channels = window._channelDropdownItems || [];
|
||||
const ch = channels.find(c => c && c.index === channelIdx);
|
||||
const name = (ch && ch.name) || channelName || '';
|
||||
selectChannelFromDropdown(channelIdx, name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -57,10 +57,16 @@
|
||||
<div id="notificationBell" class="btn btn-outline-light position-relative navbar-touch-btn" style="cursor: pointer;" onclick="markAllChannelsRead()" title="Mark all as read">
|
||||
<i class="bi bi-bell"></i>
|
||||
</div>
|
||||
<select id="channelSelector" class="form-select navbar-touch-select" style="width: auto; min-width: 100px;" title="Select channel">
|
||||
<option value="0">Public</option>
|
||||
<!-- Channels loaded dynamically via JavaScript -->
|
||||
</select>
|
||||
<div class="position-relative channel-mobile-selector" id="channelSelectorWrapper" style="min-width: 140px;">
|
||||
<input type="text"
|
||||
id="channelSelectorInput"
|
||||
class="form-control form-select navbar-touch-select"
|
||||
placeholder="Public"
|
||||
autocomplete="off"
|
||||
title="Select channel"
|
||||
style="cursor: pointer;">
|
||||
<div id="channelSelectorDropdown" class="channel-selector-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
<button class="btn btn-outline-light navbar-touch-btn" data-bs-toggle="offcanvas" data-bs-target="#mainMenu" title="Menu">
|
||||
<i class="bi bi-list"></i>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user