// Twitch Drops Miner Web Client // Socket.IO and API communication // Global state const state = { connected: false, channels: {}, campaigns: {}, settings: {}, currentDrop: null, countdownTimer: null, // Track the active countdown timer translations: {} // Store current translations }; // ==================== Version Checking ==================== async function fetchAndDisplayVersion() { try { const response = await fetch('/api/version'); if (!response.ok) throw new Error('Failed to fetch version'); const data = await response.json(); const versionElement = document.getElementById('current-version'); if (versionElement) { let versionText = data.current_version; // Add (latest) indicator if we know the latest version and it matches if (data.latest_version && data.current_version === data.latest_version) { versionText += ' (latest)'; } versionElement.textContent = versionText; // Translate footer version text const footerVersionText = document.getElementById('footer-version-text'); if (footerVersionText && state.translations.gui?.footer) { const versionLabel = state.translations.gui.footer.version || 'Version:'; // Preserve the span inside const span = footerVersionText.querySelector('span'); footerVersionText.textContent = versionLabel + ' '; footerVersionText.appendChild(span); } } // Display update notification if available if (data.update_available && data.latest_version) { const updateIndicator = document.getElementById('footer-update-indicator'); const latestVersionSpan = document.getElementById('latest-version'); const updateLink = document.getElementById('footer-update-link'); if (updateIndicator && latestVersionSpan && updateLink) { latestVersionSpan.textContent = data.latest_version; updateLink.href = data.download_url; updateIndicator.style.display = 'inline-block'; // Translate update message if (state.translations.gui?.footer) { const updateLabel = state.translations.gui.footer.update_available || 'Update Available:'; const linkText = document.createTextNode(` ⚠ ${updateLabel} `); // Clear existing text nodes but keep the span const span = updateLink.querySelector('span'); // latest-version span updateLink.textContent = ''; updateLink.appendChild(linkText); updateLink.appendChild(span); } // Log to console console.log(`Update available: ${data.latest_version} (current: ${data.current_version})`); } } } catch (error) { console.warn('Could not fetch version information:', error); // Set placeholder text if fetch fails const versionElement = document.getElementById('current-version'); const loadingText = state.translations.gui?.footer?.loading || 'Loading...'; if (versionElement && versionElement.textContent === loadingText) { versionElement.textContent = 'Unknown'; } } } // Initialize Socket.IO connection const socket = io({ transports: ['websocket', 'polling'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, reconnectionAttempts: Infinity }); // ==================== Socket.IO Event Handlers ==================== socket.on('connect', () => { console.log('Connected to server'); state.connected = true; const connText = state.translations.gui?.websocket?.connected || 'Connected'; document.getElementById('connection-indicator').textContent = '● ' + connText; document.getElementById('connection-indicator').className = 'connected'; }); socket.on('disconnect', () => { console.log('Disconnected from server'); state.connected = false; const disconnText = state.translations.gui?.websocket?.disconnected || 'Disconnected'; document.getElementById('connection-indicator').textContent = '● ' + disconnText; document.getElementById('connection-indicator').className = 'disconnected'; }); socket.on('initial_state', (data) => { console.log('Received initial state', data); if (data.status) updateStatus(data.status); // Batch update channels to prevent UI freezing if (data.channels) { data.channels.forEach(ch => { state.channels[ch.id] = ch; }); renderChannels(); } // Batch update campaigns to prevent UI freezing if (data.campaigns) { data.campaigns.forEach(camp => { state.campaigns[camp.id] = camp; }); renderInventory(); } // Batch update console logs if (data.console) { const consoleEl = document.getElementById('console-output'); const fragment = document.createDocumentFragment(); data.console.forEach(line => { const div = document.createElement('div'); div.textContent = line; fragment.appendChild(div); }); consoleEl.appendChild(fragment); consoleEl.scrollTop = consoleEl.scrollHeight; while (consoleEl.children.length > 1000) { consoleEl.removeChild(consoleEl.firstChild); } } if (data.settings) updateSettingsUI(data.settings); if (data.login) updateLoginStatus(data.login); if (data.manual_mode) updateManualModeUI(data.manual_mode); // Restore current drop progress if it exists if (data.current_drop) { updateDropProgress(data.current_drop); } else { clearDropProgress(); } if (data.wanted_items) { renderWantedItems(data.wanted_items); } }); socket.on('status_update', (data) => { updateStatus(data.status); }); socket.on('console_output', (data) => { addConsoleLine(data.message); }); socket.on('channel_add', (data) => { updateChannel(data); }); socket.on('channel_update', (data) => { updateChannel(data); }); socket.on('channel_remove', (data) => { removeChannel(data.id); }); socket.on('channels_clear', () => { clearChannels(); }); socket.on('channels_batch_update', (data) => { // Replace all channels atomically to prevent flickering state.channels = {}; data.channels.forEach(ch => { state.channels[ch.id] = ch; }); renderChannels(); }); socket.on('channel_watching', (data) => { setWatchingChannel(data.id); }); socket.on('channel_watching_clear', () => { clearWatchingChannel(); }); socket.on('drop_progress', (data) => { updateDropProgress(data); }); socket.on('drop_progress_stop', () => { clearDropProgress(); }); socket.on('campaign_add', (data) => { addCampaign(data); }); socket.on('inventory_clear', () => { clearInventory(); }); socket.on('inventory_batch_update', (data) => { // Replace all campaigns atomically to prevent flickering state.campaigns = {}; data.campaigns.forEach(camp => { state.campaigns[camp.id] = camp; }); renderInventory(); }); socket.on('drop_update', (data) => { updateDrop(data.campaign_id, data.drop); }); socket.on('login_required', () => { showLoginForm(); }); socket.on('oauth_code_required', (data) => { showOAuthCode(data.url, data.code); }); socket.on('login_status', (data) => { updateLoginStatus(data); }); socket.on('login_clear', (data) => { if (data.login) document.getElementById('username').value = ''; if (data.password) document.getElementById('password').value = ''; if (data.token) document.getElementById('2fa-token').value = ''; }); socket.on('settings_updated', (data) => { updateSettingsUI(data); }); socket.on('games_available', (data) => { state.availableGames = data.games; }); socket.on('theme_change', (data) => { if (data.dark_mode) { document.body.classList.add('dark-mode'); } else { document.body.classList.remove('dark-mode'); } }); socket.on('notification', (data) => { if ('Notification' in window && Notification.permission === 'granted') { new Notification(data.title, { body: data.message, icon: '/static/icon.png' }); } }); socket.on('attention_required', (data) => { if (data.sound) { // Play notification sound const audio = new Audio('/static/notification.mp3'); audio.play().catch(() => { }); } // Flash title flashTitle(); }); socket.on('manual_mode_update', (data) => { updateManualModeUI(data); }); socket.on('language_changed', (data) => { console.log('Language changed to:', data.language); fetchAndApplyTranslations(); }); socket.on('wanted_items_update', (data) => { renderWantedItems(data); }); // ==================== UI Update Functions ==================== function updateStatus(status) { document.getElementById('status-text').textContent = status; // Loading overlay disabled - UI remains responsive during backend operations // Backend now uses batch updates to prevent flickering } function addConsoleLine(message) { addConsoleLineRaw(message); } function addConsoleLineRaw(line) { const console = document.getElementById('console-output'); const div = document.createElement('div'); div.textContent = line; console.appendChild(div); // Auto-scroll to bottom console.scrollTop = console.scrollHeight; // Limit lines while (console.children.length > 1000) { console.removeChild(console.firstChild); } } function updateChannel(channelData) { state.channels[channelData.id] = channelData; renderChannels(); } function removeChannel(channelId) { delete state.channels[channelId]; renderChannels(); } function clearChannels() { state.channels = {}; renderChannels(); } function setWatchingChannel(channelId) { Object.values(state.channels).forEach(ch => ch.watching = false); if (state.channels[channelId]) { state.channels[channelId].watching = true; } renderChannels(); } function clearWatchingChannel() { Object.values(state.channels).forEach(ch => ch.watching = false); renderChannels(); } function renderChannels() { const container = document.getElementById('channels-list'); container.innerHTML = ''; const t = state.translations; const channels = Object.values(state.channels); if (channels.length === 0) { const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...'; container.replaceChildren( makeElement('p', { class: 'empty-message' }, emptyMsg), ); return; } // Get the games to watch list from settings const gamesToWatch = state.settings.games_to_watch || []; const gamesToWatchSet = new Set(gamesToWatch); // Filter channels to only include those playing games in the watch list const filteredChannels = channels.filter(channel => { const gameName = channel.game; // Include channels if: they have a game AND it's in the watch list // OR if the watch list is empty (show all) return gamesToWatch.length === 0 || (gameName && gamesToWatchSet.has(gameName)); }); if (filteredChannels.length === 0) { const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...'; container.replaceChildren( makeElement('p', { class: 'empty-message' }, emptyMsg), ); return; } // Group channels by game const gameGroups = {}; filteredChannels.forEach(channel => { const gameName = channel.game || 'No Game'; const gameId = channel.game_id || 'no-game'; const gameIcon = channel.game_icon; if (!gameGroups[gameId]) { gameGroups[gameId] = { name: gameName, icon: gameIcon, channels: [] }; } gameGroups[gameId].channels.push(channel); }); // Sort games: prioritize games with watching channels, then by total viewers const sortedGames = Object.entries(gameGroups).sort(([idA, groupA], [idB, groupB]) => { const hasWatchingA = groupA.channels.some(ch => ch.watching); const hasWatchingB = groupB.channels.some(ch => ch.watching); if (hasWatchingA !== hasWatchingB) return hasWatchingB ? 1 : -1; // Sum total viewers for each game const totalViewersA = groupA.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); const totalViewersB = groupB.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); return totalViewersB - totalViewersA; }); // Render each game group sortedGames.forEach(([gameId, group]) => { // Create game header const gameHeader = document.createElement('div'); gameHeader.className = 'game-group-header'; const channelCount = group.channels.length; const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); const channelText = channelCount === 1 ? (t.gui?.channels?.channel_count || 'channel') : (t.gui?.channels?.channel_count_plural || 'channels'); const viewersText = t.gui?.channels?.viewers || 'viewers'; if (group.icon) { gameHeader.appendChild(makeImageElement(group.icon.replace('{width}', '40').replace('{height}', '53'), group.name, 'game-icon')); } gameHeader.appendChild(makeElement('div', { class: 'game-group-info' }, null, el => { el.appendChild(makeElement('div', { class: 'game-group-name' }, group.name)); el.appendChild(makeElement('div', { class: 'game-group-stats' }, `${channelCount} ${channelText} • ${totalViewers.toLocaleString()} ${viewersText}`)); })); container.appendChild(gameHeader); // Sort channels within game: watching first, then online, then by viewers group.channels.sort((a, b) => { if (a.watching !== b.watching) return b.watching ? 1 : -1; if (a.online !== b.online) return b.online ? 1 : -1; return (b.viewers || 0) - (a.viewers || 0); }); // Render channels in this game group.channels.forEach(channel => { const div = document.createElement('div'); div.className = 'channel-item'; if (channel.watching) div.classList.add('watching'); if (channel.online) div.classList.add('online'); else div.classList.add('offline'); const nameDiv = makeElement('div', { class: 'channel-name' }, channel.name, el => { if (channel.drops_enabled) { el.appendChild(document.createTextNode(' ')); el.appendChild(makeElement('span', { class: 'channel-badge drops' }, 'DROPS')); } if (channel.acl_based) { el.appendChild(document.createTextNode(' ')); el.appendChild(makeElement('span', { class: 'channel-badge acl' }, 'ACL')); } }); const infoDiv = makeElement('div', { class: 'channel-info' }, channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline', el => { if (channel.watching) { el.appendChild(document.createTextNode(' • ')); el.appendChild(makeElement('strong', {}, 'WATCHING')); } }); div.replaceChildren(nameDiv, infoDiv); div.onclick = () => selectChannel(channel.id); container.appendChild(div); }); }); } function updateDropProgress(data) { // Check if this is a new drop or if remaining seconds changed significantly const isNewDrop = !state.currentDrop || state.currentDrop.drop_id !== data.drop_id; // Store old remaining seconds before updating state const oldRemaining = state.currentDrop ? state.currentDrop.remaining_seconds : null; // Update state with new data state.currentDrop = data; document.getElementById('no-drop-message').style.display = 'none'; document.getElementById('drop-info').style.display = 'block'; document.getElementById('drop-name').textContent = data.drop_name; // Make campaign name clickable with link to Twitch const dropGameEl = document.getElementById('drop-game'); if (data.campaign_id) { const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`; dropGameEl.replaceChildren( makeElement('a', { href: campaignUrl, target: '_blank', rel: 'noopener noreferrer', class: 'drop-campaign-link' }, data.campaign_name), document.createTextNode(` (${data.game_name})`), ); } else { dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`; } const progress = data.progress * 100; const fill = document.getElementById('progress-fill'); fill.style.width = `${progress}%`; fill.textContent = `${Math.round(progress)}%`; document.getElementById('progress-text').textContent = `${data.current_minutes} / ${data.required_minutes} minutes`; // Only reset the timer if it's a new drop or if backend time differs by more than 2 seconds // This prevents constant timer resets from periodic backend updates const shouldResetTimer = isNewDrop || oldRemaining === null || Math.abs(oldRemaining - data.remaining_seconds) > 2; if (shouldResetTimer) { // Cancel any existing countdown timer before starting a new one if (state.countdownTimer !== null) { clearTimeout(state.countdownTimer); state.countdownTimer = null; } // Start countdown with the new value from backend updateRemainingTime(data.remaining_seconds); } // Otherwise, let the existing timer continue counting down smoothly } function updateRemainingTime(seconds) { const minutes = Math.floor(seconds / 60); const secs = seconds % 60; document.getElementById('progress-time').textContent = `Time remaining: ${minutes}:${secs.toString().padStart(2, '0')}`; if (seconds > 0) { // Store the timer ID so we can cancel it if needed state.countdownTimer = setTimeout(() => updateRemainingTime(seconds - 1), 1000); } else { state.countdownTimer = null; } } function clearDropProgress() { state.currentDrop = null; // Cancel any active countdown timer if (state.countdownTimer !== null) { clearTimeout(state.countdownTimer); state.countdownTimer = null; } document.getElementById('no-drop-message').style.display = 'block'; document.getElementById('drop-info').style.display = 'none'; } function addCampaign(campaignData) { state.campaigns[campaignData.id] = campaignData; renderInventory(); } function clearInventory() { state.campaigns = {}; renderInventory(); } function updateDrop(campaignId, dropData) { if (state.campaigns[campaignId]) { const drops = state.campaigns[campaignId].drops; const index = drops.findIndex(d => d.id === dropData.id); if (index !== -1) { drops[index] = dropData; renderInventory(); } } } // ==================== Inventory Filtering ==================== function getInventoryFilters() { // Get filter state from UI checkboxes and selected games array return { show_active: document.getElementById('filter-active')?.checked || false, show_not_linked: document.getElementById('filter-not-linked')?.checked || false, show_upcoming: document.getElementById('filter-upcoming')?.checked || false, show_expired: document.getElementById('filter-expired')?.checked || false, show_finished: document.getElementById('filter-finished')?.checked || false, game_name_search: [...selectedInventoryGames], // Array of selected game names // Benefit type filters (default to true if checkbox doesn't exist) show_benefit_item: document.getElementById('filter-benefit-item')?.checked !== false, show_benefit_badge: document.getElementById('filter-benefit-badge')?.checked !== false, show_benefit_emote: document.getElementById('filter-benefit-emote')?.checked !== false, show_benefit_other: document.getElementById('filter-benefit-other')?.checked !== false }; } function campaignMatchesFilters(campaign, filters) { // Calculate "finished" status: all drops claimed const isFinished = campaign.total_drops > 0 && campaign.claimed_drops === campaign.total_drops; // Check if any filter is enabled const hasGameFilter = filters.game_name_search && filters.game_name_search.length > 0; const anyFilterEnabled = filters.show_active || filters.show_not_linked || filters.show_upcoming || filters.show_expired || filters.show_finished || hasGameFilter; // If no filters enabled, show all campaigns if (!anyFilterEnabled) { return true; } // Check status filters (OR logic - campaign matches if ANY checked filter applies) let statusMatch = false; if (filters.show_active && campaign.active) statusMatch = true; if (filters.show_not_linked && !campaign.linked) statusMatch = true; if (filters.show_upcoming && campaign.upcoming) statusMatch = true; if (filters.show_expired && campaign.expired) statusMatch = true; if (filters.show_finished && isFinished) statusMatch = true; // If status filters are enabled but campaign doesn't match any, filter it out const hasStatusFilters = filters.show_active || filters.show_not_linked || filters.show_upcoming || filters.show_expired || filters.show_finished; if (hasStatusFilters && !statusMatch) { return false; } // Check game name filter (AND logic with status filters, OR logic among selected games) if (hasGameFilter) { const gameName = campaign.game_name; // Campaign must match at least ONE of the selected games const gameMatch = filters.game_name_search.includes(gameName); if (!gameMatch) { return false; } } // Check benefit type filter - campaign must have at least one drop with a matching benefit type // Only filter if at least one benefit type is UNCHECKED (otherwise show all) const allBenefitsEnabled = filters.show_benefit_item && filters.show_benefit_badge && filters.show_benefit_emote && filters.show_benefit_other; if (!allBenefitsEnabled && campaign.drops) { let benefitMatch = false; for (const drop of campaign.drops) { if (drop.benefits && drop.benefits.length > 0) { for (const benefit of drop.benefits) { const benefitType = (benefit.type || '').toUpperCase(); // Map filter checkboxes to actual API benefit types if (filters.show_benefit_item && benefitType === 'DIRECT_ENTITLEMENT') benefitMatch = true; if (filters.show_benefit_badge && benefitType === 'BADGE') benefitMatch = true; if (filters.show_benefit_emote && benefitType === 'EMOTE') benefitMatch = true; if (filters.show_benefit_other && benefitType === 'UNKNOWN') benefitMatch = true; } } } if (!benefitMatch) { return false; } } return true; } function onInventoryFilterChange() { // Save filter state to settings and re-render inventory saveSettings(); renderInventory(); } function clearInventoryFilters() { // Uncheck all filter checkboxes document.getElementById('filter-active').checked = false; document.getElementById('filter-not-linked').checked = false; document.getElementById('filter-upcoming').checked = false; document.getElementById('filter-expired').checked = false; document.getElementById('filter-finished').checked = false; document.getElementById('inventory-game-search').value = ''; // Reset benefit type filters to checked (show all) if (document.getElementById('filter-benefit-item')) document.getElementById('filter-benefit-item').checked = true; if (document.getElementById('filter-benefit-badge')) document.getElementById('filter-benefit-badge').checked = true; if (document.getElementById('filter-benefit-emote')) document.getElementById('filter-benefit-emote').checked = true; if (document.getElementById('filter-benefit-other')) document.getElementById('filter-benefit-other').checked = true; // Clear selected games selectedInventoryGames = []; updateGameTagsDisplay(); // Save and re-render saveSettings(); renderInventory(); } // ==================== Game Dropdown & Tags ==================== // Track selected games for inventory filter let selectedInventoryGames = []; let gameDropdownFocusedIndex = -1; let gameDropdownVisible = false; function getAvailableGamesForDropdown() { // Combine games from campaigns and availableGames Set const gamesFromCampaigns = Object.values(state.campaigns).map(c => c.game_name); const gamesFromSettings = Array.from(availableGames || []); // Merge and deduplicate const allGames = [...new Set([...gamesFromCampaigns, ...gamesFromSettings])]; // Sort alphabetically return allGames.sort((a, b) => a.localeCompare(b)); } function renderGameDropdown(searchTerm = '') { const dropdown = document.getElementById('game-dropdown-list'); const allGames = getAvailableGamesForDropdown(); // Filter games by search term (case-insensitive) const searchLower = searchTerm.toLowerCase().trim(); const filteredGames = searchLower ? allGames.filter(game => game.toLowerCase().includes(searchLower)) : allGames; dropdown.innerHTML = ''; if (filteredGames.length === 0) { dropdown.replaceChildren(makeElement('div', { class: 'dropdown-item no-results' }, 'No games found')); gameDropdownFocusedIndex = -1; return; } filteredGames.forEach((gameName, index) => { const isSelected = selectedInventoryGames.includes(gameName); const isFocused = index === gameDropdownFocusedIndex; const item = document.createElement('div'); item.className = 'dropdown-item' + (isFocused ? ' focused' : ''); item.dataset.gameName = gameName; item.dataset.index = index; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = isSelected; checkbox.id = `game-dropdown-${index}`; const label = document.createElement('label'); label.setAttribute('for', `game-dropdown-${index}`); label.textContent = gameName; item.appendChild(checkbox); item.appendChild(label); // Click handler for the entire item item.addEventListener('click', (e) => { e.stopPropagation(); toggleGameSelection(gameName); }); dropdown.appendChild(item); }); } function toggleGameSelection(gameName) { const index = selectedInventoryGames.indexOf(gameName); if (index >= 0) { // Remove game selectedInventoryGames.splice(index, 1); } else { // Add game selectedInventoryGames.push(gameName); } updateGameTagsDisplay(); renderGameDropdown(document.getElementById('inventory-game-search').value); saveSettings(); renderInventory(); } function removeGameTag(gameName) { const index = selectedInventoryGames.indexOf(gameName); if (index >= 0) { selectedInventoryGames.splice(index, 1); updateGameTagsDisplay(); renderGameDropdown(document.getElementById('inventory-game-search').value); saveSettings(); renderInventory(); } } function updateGameTagsDisplay() { const container = document.getElementById('selected-game-tags'); container.innerHTML = ''; selectedInventoryGames.forEach(gameName => { const tag = document.createElement('div'); tag.className = 'game-tag'; const nameSpan = document.createElement('span'); nameSpan.className = 'game-tag-name'; nameSpan.textContent = gameName; const removeBtn = document.createElement('button'); removeBtn.className = 'game-tag-remove'; removeBtn.textContent = '×'; removeBtn.setAttribute('aria-label', `Remove ${gameName}`); removeBtn.addEventListener('click', (e) => { e.stopPropagation(); removeGameTag(gameName); }); tag.appendChild(nameSpan); tag.appendChild(removeBtn); container.appendChild(tag); }); } function showGameDropdown() { const dropdown = document.getElementById('game-dropdown-list'); dropdown.style.display = 'block'; gameDropdownVisible = true; gameDropdownFocusedIndex = -1; renderGameDropdown(document.getElementById('inventory-game-search').value); } function closeGameDropdown() { const dropdown = document.getElementById('game-dropdown-list'); dropdown.style.display = 'none'; gameDropdownVisible = false; gameDropdownFocusedIndex = -1; } function handleGameSearchKeydown(event) { if (!gameDropdownVisible) { return; } const dropdown = document.getElementById('game-dropdown-list'); const items = dropdown.querySelectorAll('.dropdown-item:not(.no-results)'); const maxIndex = items.length - 1; if (event.key === 'ArrowDown') { event.preventDefault(); gameDropdownFocusedIndex = Math.min(gameDropdownFocusedIndex + 1, maxIndex); renderGameDropdown(document.getElementById('inventory-game-search').value); // Scroll focused item into view const focusedItem = dropdown.querySelector('.dropdown-item.focused'); if (focusedItem) { focusedItem.scrollIntoView({ block: 'nearest' }); } } else if (event.key === 'ArrowUp') { event.preventDefault(); gameDropdownFocusedIndex = Math.max(gameDropdownFocusedIndex - 1, 0); renderGameDropdown(document.getElementById('inventory-game-search').value); // Scroll focused item into view const focusedItem = dropdown.querySelector('.dropdown-item.focused'); if (focusedItem) { focusedItem.scrollIntoView({ block: 'nearest' }); } } else if (event.key === 'Enter') { event.preventDefault(); if (gameDropdownFocusedIndex >= 0 && gameDropdownFocusedIndex <= maxIndex) { const focusedItem = items[gameDropdownFocusedIndex]; const gameName = focusedItem.dataset.gameName; if (gameName) { toggleGameSelection(gameName); } } } else if (event.key === 'Escape') { event.preventDefault(); closeGameDropdown(); document.getElementById('inventory-game-search').blur(); } } function renderInventory() { const container = document.getElementById('inventory-grid'); container.innerHTML = ''; const t = state.translations; const allCampaigns = Object.values(state.campaigns); // Apply filters const filters = getInventoryFilters(); const campaigns = allCampaigns.filter(campaign => campaignMatchesFilters(campaign, filters)); if (allCampaigns.length === 0) { const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...'; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); return; } if (campaigns.length === 0) { container.replaceChildren(makeElement('p', { class: 'empty-message' }, 'No campaigns match the current filters.')); return; } campaigns.forEach(campaign => { const card = document.createElement('div'); card.className = 'campaign-card'; let statusClass = ''; let statusText = ''; if (campaign.active) { statusClass = 'active'; statusText = t.gui?.inventory?.status?.active || 'Active'; } else if (campaign.upcoming) { statusClass = 'upcoming'; statusText = t.gui?.inventory?.status?.upcoming || 'Upcoming'; } else if (campaign.expired) { statusClass = 'expired'; statusText = t.gui?.inventory?.status?.expired || 'Expired'; } const claimedText = t.gui?.inventory?.status?.claimed || 'Claimed'; const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed'; // Build drops elements const dropsEl = makeElement('div', { class: 'campaign-drops' }); campaign.drops.forEach(drop => { const dropItem = makeElement('div', { class: `drop-item${drop.is_claimed ? ' claimed' : ''}${drop.can_claim ? ' active' : ''}` }); dropItem.appendChild( makeElement('div', { class: 'drop-item-header' }, '', el => el.appendChild(makeElement('div', { class: 'drop-item-info' }, '', el2 => el2.appendChild(makeElement('div', {}, '', el3 => el3.appendChild(makeElement('strong', {}, drop.name)) )) )) ) ); const benefitsList = makeElement('div', { class: 'benefits-list' }); if (drop.benefits && drop.benefits.length > 0) { drop.benefits.forEach(benefit => { benefitsList.appendChild( makeElement('div', { class: 'benefit-item' }, '', el => { el.appendChild(makeImageElement(benefit.image_url, benefit.name, 'benefit-icon')); el.appendChild(makeElement('div', { class: 'benefit-info' }, '', el2 => { el2.appendChild(makeElement('span', { class: 'benefit-name' }, benefit.name)); el2.appendChild(makeElement('span', { class: 'benefit-type' }, `(${benefit.type})`)); })); }) ); }); } dropItem.appendChild(benefitsList); dropItem.appendChild(makeElement('div', {}, `${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)`)); if (drop.is_claimed) { dropItem.appendChild(makeElement('div', {}, `✓ ${claimedText}`)); } dropsEl.appendChild(dropItem); }); // Campaign name link const campaignNameLink = makeElement('a', { href: campaign.campaign_url, target: '_blank', rel: 'noopener noreferrer', class: 'campaign-name-link' }, campaign.name, el => el.appendChild(makeElement('span', { class: 'external-link-icon' }, '🔗')) ); // Linked/not linked badge const linkStatusBadge = campaign.linked ? makeElement('span', { class: 'campaign-badge linked', title: 'Account is linked' }, 'LINKED') : makeElement('span', { class: 'campaign-badge not-linked', title: 'Click to link your account' }, 'NOT LINKED', el => { el.addEventListener('click', () => window.open(campaign.link_url, '_blank')); }); // Link account button const campaignGameDiv = makeElement('div', { class: 'campaign-game' }, '', el => { if (campaign.game_box_art_url) { const iconUrl = campaign.game_box_art_url.replace('{width}', '52').replace('{height}', '70'); el.appendChild(makeImageElement(iconUrl, campaign.game_name, 'game-icon')); } el.appendChild(makeElement('span', { class: 'campaign-game-name' }, campaign.game_name)); el.appendChild(linkStatusBadge); }); const campaignHeader = makeElement('div', { class: 'campaign-header' }, '', el => { el.appendChild(campaignGameDiv); el.appendChild(campaignNameLink); if (!campaign.linked && campaign.link_url) { el.appendChild(makeElement('button', { class: 'link-account-btn' }, 'Link Account', btn => { btn.addEventListener('click', () => window.open(campaign.link_url, '_blank')); })); } }); const campaignStatus = makeElement('div', { class: 'campaign-status' }, '', el => { el.appendChild(makeElement('span', {}, statusText)); el.appendChild(makeElement('span', {}, `${campaign.claimed_drops} / ${campaign.total_drops} ${claimedCountText}`)); }); card.replaceChildren(campaignHeader, campaignStatus); // Campaign timing if (campaign.active && campaign.ends_at) { const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}'; card.appendChild(makeElement('div', { class: 'campaign-timing' }, endsLabel.replace('{time}', new Date(campaign.ends_at).toLocaleString()))); } else if (campaign.upcoming && campaign.starts_at) { const startsLabel = t.gui?.inventory?.starts || 'Starts: {time}'; card.appendChild(makeElement('div', { class: 'campaign-timing' }, startsLabel.replace('{time}', new Date(campaign.starts_at).toLocaleString()))); } else if (campaign.expired && campaign.ends_at) { const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}'; card.appendChild(makeElement('div', { class: 'campaign-timing' }, endsLabel.replace('{time}', new Date(campaign.ends_at).toLocaleString()))); } card.appendChild(dropsEl); container.appendChild(card); }); } function showLoginForm() { document.getElementById('login-form').style.display = 'block'; document.getElementById('oauth-code-display').style.display = 'none'; } function showOAuthCode(url, code) { document.getElementById('login-form').style.display = 'none'; document.getElementById('oauth-code-display').style.display = 'block'; document.getElementById('oauth-url').href = url; document.getElementById('oauth-code').textContent = code; } function updateLoginStatus(data) { const statusEl = document.getElementById('login-status'); const t = state.translations; if (data.user_id) { const userIdLabel = t.gui?.login?.user_id_label || 'User ID:'; statusEl.textContent = `${data.status} (${userIdLabel} ${data.user_id})`; statusEl.removeAttribute('translation-key'); statusEl.style.color = 'var(--success-color)'; document.getElementById('login-form').style.display = 'none'; document.getElementById('oauth-code-display').style.display = 'none'; } else { const loggedOut = t.gui?.login?.logged_out || 'Not logged in'; statusEl.textContent = data.status || loggedOut; statusEl.setAttribute('translation-key', 'logged_out'); statusEl.style.color = 'var(--text-secondary)'; // Check if OAuth is pending (for late-connecting clients) if (data.oauth_pending) { showOAuthCode(data.oauth_pending.url, data.oauth_pending.code); } } } function updateSettingsUI(settings) { state.settings = settings; document.getElementById('dark-mode').checked = settings.dark_mode || false; document.getElementById('connection-quality').value = settings.connection_quality || 1; document.getElementById('minimum-refresh-interval').value = settings.minimum_refresh_interval_minutes || 30; // Update proxy settings and indicator const proxyUrl = settings.proxy || ''; const proxyInput = document.getElementById('proxy-url'); if (proxyInput) proxyInput.value = proxyUrl; const proxyIndicator = document.getElementById('proxy-indicator'); if (proxyIndicator) { proxyIndicator.style.display = proxyUrl ? 'inline-flex' : 'none'; proxyIndicator.title = proxyUrl ? `Proxy active: ${proxyUrl}` : 'Proxy disabled'; } // Update language dropdown if we have the current language if (settings.language) { const languageSelect = document.getElementById('language'); if (languageSelect) { languageSelect.value = settings.language; } } if (settings.dark_mode) { document.body.classList.add('dark-mode'); } else { document.body.classList.remove('dark-mode'); } // Update available games if provided in settings if (settings.games_available) { availableGames = new Set(settings.games_available); } // Restore inventory filters from settings if (settings.inventory_filters) { document.getElementById('filter-active').checked = settings.inventory_filters.show_active || false; document.getElementById('filter-not-linked').checked = settings.inventory_filters.show_not_linked || false; document.getElementById('filter-upcoming').checked = settings.inventory_filters.show_upcoming || false; document.getElementById('filter-expired').checked = settings.inventory_filters.show_expired || false; document.getElementById('filter-finished').checked = settings.inventory_filters.show_finished || false; // Restore selected games array selectedInventoryGames = Array.isArray(settings.inventory_filters.game_name_search) ? [...settings.inventory_filters.game_name_search] : []; // Handle old string format gracefully updateGameTagsDisplay(); // Restore benefit type filters (default to true if not set) if (document.getElementById('filter-benefit-item')) document.getElementById('filter-benefit-item').checked = settings.inventory_filters.show_benefit_item !== false; if (document.getElementById('filter-benefit-badge')) document.getElementById('filter-benefit-badge').checked = settings.inventory_filters.show_benefit_badge !== false; if (document.getElementById('filter-benefit-emote')) document.getElementById('filter-benefit-emote').checked = settings.inventory_filters.show_benefit_emote !== false; if (document.getElementById('filter-benefit-other')) document.getElementById('filter-benefit-other').checked = settings.inventory_filters.show_benefit_other !== false; } // Restore mining benefit filters if (settings.mining_benefits) { if (document.getElementById('mining-benefit-item')) document.getElementById('mining-benefit-item').checked = settings.mining_benefits.DIRECT_ENTITLEMENT; if (document.getElementById('mining-benefit-badge')) document.getElementById('mining-benefit-badge').checked = settings.mining_benefits.BADGE; if (document.getElementById('mining-benefit-emote')) document.getElementById('mining-benefit-emote').checked = settings.mining_benefits.EMOTE; if (document.getElementById('mining-benefit-unknown')) document.getElementById('mining-benefit-unknown').checked = settings.mining_benefits.UNKNOWN; } // Update games to watch lists renderGamesToWatch(); // Re-render channels list to apply filter based on updated games to watch renderChannels(); // Re-render inventory to apply filters renderInventory(); } function updateManualModeUI(manualModeInfo) { const manualBadge = document.getElementById('manual-mode-badge'); const autoBadge = document.getElementById('auto-mode-badge'); const manualGameName = document.getElementById('manual-game-name'); const manualControls = document.getElementById('manual-mode-controls'); const manualModeGame = document.getElementById('manual-mode-game'); if (manualModeInfo.active) { // Show manual mode badge, hide auto badge manualBadge.classList.remove('hidden'); autoBadge.classList.add('hidden'); manualGameName.textContent = manualModeInfo.game_name || ''; // Show manual mode controls in drop progress section if (manualControls) { manualControls.classList.remove('hidden'); if (manualModeGame) { manualModeGame.textContent = manualModeInfo.game_name || ''; } } } else { // Hide manual mode badge, show auto badge manualBadge.classList.add('hidden'); autoBadge.classList.remove('hidden'); // Hide manual mode controls if (manualControls) { manualControls.classList.add('hidden'); } } } // ==================== Games to Watch Management ==================== let availableGames = new Set(); // All games from campaigns let draggedElement = null; socket.on('games_available', (data) => { availableGames = new Set(data.games || []); renderGamesToWatch(); }); function renderGamesToWatch() { const selectedGames = state.settings.games_to_watch || []; const filterText = document.getElementById('games-filter')?.value.toLowerCase() || ''; // Render selected games (sortable) renderSelectedGames(selectedGames); // Render available games (checkboxes for unselected games) const unselectedGames = Array.from(availableGames) .filter(game => !selectedGames.includes(game)) .filter(game => game.toLowerCase().includes(filterText)) .sort(); renderAvailableGames(unselectedGames, filterText); } function renderSelectedGames(games) { const container = document.getElementById('selected-games-list'); if (!container) return; const t = state.translations; container.innerHTML = ''; if (games.length === 0) { const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.'; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); return; } games.forEach((game, index) => { const div = document.createElement('div'); div.className = 'sortable-item'; div.draggable = true; div.dataset.game = game; div.replaceChildren( makeElement('span', { class: 'drag-handle' }, '☰'), makeElement('span', { class: 'priority-number' }, String(index + 1)), makeElement('span', { class: 'game-name' }, game), makeElement('button', { class: 'remove-btn' }, '✕'), ); // Event listener for the delete button const removeBtn = div.querySelector('.remove-btn'); removeBtn.addEventListener('click', () => removeGameFromWatch(game)); // Drag event handlers div.addEventListener('dragstart', handleDragStart); div.addEventListener('dragover', handleDragOver); div.addEventListener('drop', handleDrop); div.addEventListener('dragend', handleDragEnd); container.appendChild(div); }); } function renderAvailableGames(games, filterText) { const container = document.getElementById('available-games-list'); if (!container) return; const t = state.translations; container.innerHTML = ''; if (games.length === 0) { if (filterText) { const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.'; const addHint = t.gui?.settings?.add_game_hint || ' Click "Add Game" to add it manually.'; container.replaceChildren(makeElement('p', { class: 'empty-message' }, `${emptyMsg}${addHint}`)); } else { const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.'; container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg)); } return; } games.forEach(game => { const label = document.createElement('label'); label.className = 'game-checkbox'; label.replaceChildren( makeElement('input', { type: 'checkbox', value: game }), makeElement('span', {}, game), ); const checkbox = label.querySelector('input[type="checkbox"]'); checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked)); container.appendChild(label); }); } // Drag and drop handlers function handleDragStart(e) { draggedElement = e.target; e.target.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', e.target.innerHTML); } function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } e.dataTransfer.dropEffect = 'move'; const target = e.target.closest('.sortable-item'); if (target && target !== draggedElement) { const container = target.parentNode; const allItems = [...container.querySelectorAll('.sortable-item')]; const draggedIndex = allItems.indexOf(draggedElement); const targetIndex = allItems.indexOf(target); if (draggedIndex < targetIndex) { target.parentNode.insertBefore(draggedElement, target.nextSibling); } else { target.parentNode.insertBefore(draggedElement, target); } } return false; } function handleDrop(e) { if (e.stopPropagation) { e.stopPropagation(); } return false; } function handleDragEnd(e) { e.target.classList.remove('dragging'); // Update the order in state const container = document.getElementById('selected-games-list'); const items = container.querySelectorAll('.sortable-item'); const newOrder = Array.from(items).map(item => item.dataset.game); state.settings.games_to_watch = newOrder; // Re-render to update priority numbers renderSelectedGames(newOrder); // Re-render channels list to apply updated filter renderChannels(); // Save settings saveSettings(); } function toggleGameWatch(gameName, checked) { const games = state.settings.games_to_watch || []; if (checked && !games.includes(gameName)) { games.push(gameName); } else if (!checked) { const index = games.indexOf(gameName); if (index > -1) { games.splice(index, 1); } } state.settings.games_to_watch = games; renderGamesToWatch(); renderChannels(); saveSettings(); } function removeGameFromWatch(gameName) { const games = state.settings.games_to_watch || []; const index = games.indexOf(gameName); if (index > -1) { games.splice(index, 1); state.settings.games_to_watch = games; renderGamesToWatch(); renderChannels(); saveSettings(); } } function selectAllGames() { state.settings.games_to_watch = Array.from(availableGames).sort(); renderGamesToWatch(); renderChannels(); saveSettings(); } function deselectAllGames() { state.settings.games_to_watch = []; renderGamesToWatch(); renderChannels(); saveSettings(); } function addGameFromSearch() { const searchInput = document.getElementById('games-filter'); const gameName = searchInput.value.trim(); if (!gameName) { return; } const games = state.settings.games_to_watch || []; // Check if already selected if (games.includes(gameName)) { searchInput.value = ''; // Clear input if already added renderGamesToWatch(); // Just re-render to clear any filtering state if needed return; } // Add to selected games games.push(gameName); state.settings.games_to_watch = games; // Add to available games set so it shows up in lists availableGames.add(gameName); // Clear search and update UI searchInput.value = ''; renderGamesToWatch(); renderChannels(); saveSettings(); } function flashTitle() { const originalTitle = document.title; let count = 0; const interval = setInterval(() => { document.title = count % 2 === 0 ? '🔔 Attention!' : originalTitle; count++; if (count >= 10) { document.title = originalTitle; clearInterval(interval); } }, 1000); } // ==================== API Functions ==================== async function selectChannel(channelId) { try { const response = await fetch('/api/channels/select', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel_id: channelId }) }); if (!response.ok) { const errorData = await response.json(); console.error('Failed to select channel:', errorData.detail || 'Unknown error'); addConsoleLine(`Error selecting channel: ${errorData.detail || 'Unknown error'}`); } } catch (error) { console.error('Failed to select channel:', error); addConsoleLine(`Error selecting channel: ${error.message}`); } } async function exitManualMode() { try { const response = await fetch('/api/mode/exit-manual', { method: 'POST' }); const result = await response.json(); if (!result.success) { console.log('Exit manual mode:', result.message || 'Already in automatic mode'); } } catch (error) { console.error('Failed to exit manual mode:', error); addConsoleLine(`Error exiting manual mode: ${error.message}`); } } async function submitLogin() { const username = document.getElementById('username').value; const password = document.getElementById('password').value; const token = document.getElementById('2fa-token').value; try { await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, token }) }); } catch (error) { console.error('Failed to submit login:', error); } } async function confirmOAuth() { // Signal that OAuth code has been entered try { await fetch('/api/oauth/confirm', { method: 'POST' }); // Hide the OAuth form and show waiting message document.getElementById('oauth-code-display').style.display = 'none'; const t = state.translations; const waitingAuth = t.gui?.login?.waiting_auth || 'Waiting for authentication...'; const loginStatus = document.getElementById('login-status'); loginStatus.textContent = waitingAuth; loginStatus.setAttribute('translation-key', 'waiting_auth'); } catch (error) { console.error('Failed to confirm OAuth:', error); } } async function verifyProxy() { const proxyInput = document.getElementById('proxy-url'); const proxyUrl = proxyInput ? proxyInput.value.trim() : ''; const resultDiv = document.getElementById('proxy-verify-result'); if (!resultDiv) return; // Reset display resultDiv.style.display = 'block'; resultDiv.className = 'verify-result loading'; resultDiv.textContent = 'Verifying connection...'; if (!proxyUrl) { resultDiv.className = 'verify-result error'; resultDiv.textContent = 'Please enter a proxy URL first.'; return; } try { const response = await fetch('/api/settings/verify-proxy', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ proxy: proxyUrl }) }); const data = await response.json(); if (data.success) { resultDiv.className = 'verify-result success'; resultDiv.textContent = `✓ ${data.message}`; } else { resultDiv.className = 'verify-result error'; resultDiv.textContent = `✗ ${data.message}`; } } catch (error) { resultDiv.className = 'verify-result error'; resultDiv.textContent = `Error: ${error.message}`; } } async function saveSettings() { const settings = { dark_mode: document.getElementById('dark-mode').checked, language: document.getElementById('language').value, connection_quality: parseInt(document.getElementById('connection-quality').value), minimum_refresh_interval_minutes: parseInt(document.getElementById('minimum-refresh-interval').value), proxy: state.settings.proxy || '', games_to_watch: state.settings.games_to_watch || [], inventory_filters: getInventoryFilters(), mining_benefits: { "DIRECT_ENTITLEMENT": document.getElementById('mining-benefit-item')?.checked, "BADGE": document.getElementById('mining-benefit-badge')?.checked, "EMOTE": document.getElementById('mining-benefit-emote')?.checked, "UNKNOWN": document.getElementById('mining-benefit-unknown')?.checked } }; try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); console.log('Settings saved automatically'); } catch (error) { console.error('Failed to save settings:', error); } } async function fetchAndPopulateLanguages() { try { const response = await fetch('/api/languages'); const data = await response.json(); const languageSelect = document.getElementById('language'); if (!languageSelect) { console.warn('Language select element not found'); return; } // Clear existing options languageSelect.innerHTML = ''; // Populate with available languages data.available.forEach(lang => { const option = document.createElement('option'); option.value = lang; option.textContent = lang; languageSelect.appendChild(option); }); // Set current language if (data.current) { languageSelect.value = data.current; } } catch (error) { console.error('Failed to fetch languages:', error); const languageSelect = document.getElementById('language'); if (languageSelect) { languageSelect.replaceChildren(makeElement('option', { value: '' }, 'Failed to load languages')); } addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.'); } } async function fetchAndApplyTranslations() { try { const response = await fetch('/api/translations'); const data = await response.json(); state.translations = data; applyTranslations(data); console.log('Translations applied for language:', data.language_name); } catch (error) { console.error('Failed to fetch translations:', error); } } function applyTranslations(t) { // Update tab buttons const tabButtons = { 'main': document.querySelector('[data-tab="main"]'), 'inventory': document.querySelector('[data-tab="inventory"]'), 'settings': document.querySelector('[data-tab="settings"]'), 'help': document.querySelector('[data-tab="help"]') }; if (tabButtons.main && t.gui?.tabs) tabButtons.main.textContent = t.gui.tabs.main; if (tabButtons.inventory && t.gui?.tabs) tabButtons.inventory.textContent = t.gui.tabs.inventory; if (tabButtons.settings && t.gui?.tabs) tabButtons.settings.textContent = t.gui.tabs.settings; if (tabButtons.help && t.gui?.tabs) tabButtons.help.textContent = t.gui.tabs.help; // Update Main tab - Login section const mainTab = document.getElementById('main-tab'); if (mainTab && t.gui?.login) { const loginHeader = mainTab.querySelector('.login-panel h2'); if (loginHeader) loginHeader.textContent = t.gui.login.name; const loginStatus = document.getElementById('login-status'); if (loginStatus?.hasAttribute('translation-key')) loginStatus.textContent = t.login?.status?.[loginStatus.getAttribute('translation-key')]; // Update login form placeholders const usernameInput = document.getElementById('username'); if (usernameInput) usernameInput.placeholder = t.gui.login.username; const passwordInput = document.getElementById('password'); if (passwordInput) passwordInput.placeholder = t.gui.login.password; const twofaInput = document.getElementById('2fa-token'); if (twofaInput) twofaInput.placeholder = t.gui.login.twofa_code; const loginButton = document.getElementById('login-button'); if (loginButton) loginButton.textContent = t.gui.login.button; // Update OAuth display text const oauthDisplay = document.getElementById('oauth-code-display'); if (oauthDisplay) { const oauthP = oauthDisplay.querySelector('p'); if (oauthP) { const link = oauthP.querySelector('a'); if (link) { oauthP.textContent = t.gui.login.oauth_prompt + ' '; link.textContent = t.gui.login.oauth_activate; oauthP.appendChild(link); } } const oauthConfirmBtn = document.getElementById('oauth-confirm'); if (oauthConfirmBtn) oauthConfirmBtn.textContent = t.gui.login.oauth_confirm; } } // Update Progress section if (mainTab && t.gui?.progress) { // ID: progress-header const progressHeader = document.getElementById('progress-header'); if (progressHeader) progressHeader.textContent = t.gui.progress.name; const noDropMsg = document.getElementById('no-drop-message'); if (noDropMsg) noDropMsg.textContent = t.gui.progress.no_drop; const exitManualBtn = document.getElementById('exit-manual-btn'); if (exitManualBtn) exitManualBtn.textContent = t.gui.progress.return_to_auto; } // Update Console section if (mainTab && t.gui) { // ID: console-header const consoleHeader = document.getElementById('console-header'); if (consoleHeader) consoleHeader.textContent = t.gui.output; } // Update Channels section if (mainTab && t.gui?.channels) { // ID: channels-header const channelsHeader = document.getElementById('channels-header'); if (channelsHeader) channelsHeader.textContent = t.gui.channels.name; // Channel list will re-render with translated empty messages renderChannels(); } // Update Inventory tab const inventoryTab = document.getElementById('inventory-tab'); if (inventoryTab && t.gui?.inventory) { // Inventory will re-render with translated status and empty messages renderInventory(); } // Update Settings tab const settingsTab = document.getElementById('settings-tab'); if (settingsTab && t.gui?.settings) { // Use IDs for robust selection const generalHeader = document.getElementById('settings-general-header'); if (generalHeader) generalHeader.textContent = t.gui.settings.general.name; const benefitsHeader = document.getElementById('settings-benefits-header'); if (benefitsHeader && t.gui.settings.mining_benefits) benefitsHeader.textContent = t.gui.settings.mining_benefits; const gamesHeader = document.getElementById('settings-games-header'); if (gamesHeader) gamesHeader.textContent = t.gui.settings.games_to_watch; const actionsHeader = document.getElementById('settings-actions-header'); if (actionsHeader) actionsHeader.textContent = t.gui.settings.actions; const darkModeLabel = settingsTab.querySelector('label:has(#dark-mode)'); if (darkModeLabel) { const checkbox = darkModeLabel.querySelector('input'); darkModeLabel.textContent = ''; darkModeLabel.appendChild(checkbox); darkModeLabel.appendChild(document.createTextNode(' ' + t.gui.settings.general.dark_mode)); } const connQualityLabel = settingsTab.querySelector('label:has(#connection-quality)'); if (connQualityLabel) { const input = connQualityLabel.querySelector('input'); connQualityLabel.textContent = t.gui.settings.connection_quality + ' '; connQualityLabel.appendChild(input); } const refreshLabel = settingsTab.querySelector('label:has(#minimum-refresh-interval)'); if (refreshLabel) { const input = refreshLabel.querySelector('input'); refreshLabel.textContent = t.gui.settings.minimum_refresh + ' '; refreshLabel.appendChild(input); } const benefitsHelp = document.getElementById('settings-benefits-help'); if (benefitsHelp && t.gui.settings.mining_benefits_help) benefitsHelp.textContent = t.gui.settings.mining_benefits_help; const gamesHelp = document.getElementById('settings-games-help'); if (gamesHelp) gamesHelp.textContent = t.gui.settings.games_help; const searchInput = document.getElementById('games-filter'); if (searchInput) searchInput.placeholder = t.gui.settings.search_games; const selectAllBtn = document.getElementById('select-all-btn'); if (selectAllBtn) selectAllBtn.textContent = t.gui.settings.select_all; const deselectAllBtn = document.getElementById('deselect-all-btn'); if (deselectAllBtn) deselectAllBtn.textContent = t.gui.settings.deselect_all; const addGameBtn = document.getElementById('add-game-btn'); if (addGameBtn && t.gui.settings.add_game) addGameBtn.textContent = t.gui.settings.add_game; const selectedGamesHeader = settingsTab.querySelector('.selected-games h3'); if (selectedGamesHeader) selectedGamesHeader.textContent = t.gui.settings.selected_games; const availableGamesHeader = settingsTab.querySelector('.available-games h3'); if (availableGamesHeader) availableGamesHeader.textContent = t.gui.settings.available_games; const reloadBtn = document.getElementById('reload-btn'); if (reloadBtn) reloadBtn.textContent = t.gui.settings.reload_campaigns; // Re-render games to watch with translated empty messages renderGamesToWatch(); } // Update Help tab const helpTab = document.getElementById('help-tab'); if (helpTab && t.gui?.help) { // Robust ID selection for Help tab headers const aboutHeader = document.getElementById('help-about-header'); if (aboutHeader) aboutHeader.textContent = t.gui.help.about || 'About Twitch Drops Miner'; const howtoHeader = document.getElementById('help-howto-header'); if (howtoHeader) howtoHeader.textContent = t.gui.help.how_to_use || 'How to Use'; const featuresHeader = document.getElementById('help-features-header'); if (featuresHeader) featuresHeader.textContent = t.gui.help.features || 'Features'; const notesHeader = document.getElementById('help-notes-header'); if (notesHeader) notesHeader.textContent = t.gui.help.important_notes || 'Important Notes'; // Update list items and links (keeping innerHTML approach for lists as they are dynamic content blocks) const helpContent = helpTab.querySelector('.help-content'); if (helpContent) { const howToItems = t.gui.help.how_to_use_items || [ 'Login using your Twitch account (OAuth device code flow)', 'Link your accounts at twitch.tv/drops/campaigns', 'The miner will automatically discover campaigns and start mining', 'Configure priority games in Settings to focus on what you want', 'Monitor progress in the Main and Inventory tabs' ]; const featuresItems = t.gui.help.features_items || [ 'Stream-less drop mining - saves bandwidth', 'Game priority and exclusion lists', 'Tracks up to 199 channels simultaneously', 'Automatic channel switching', 'Real-time progress tracking' ]; const notesItems = t.gui.help.important_notes_items || [ 'Do not watch streams on the same account while mining', 'Keep your cookies.jar file secure', 'Requires linked game accounts for drops' ]; helpContent.replaceChildren( makeElement('h2', { id: 'help-about-header' }, t.gui.help.about || 'About Twitch Drops Miner'), makeElement('p', {}, t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'), makeElement('h3', { id: 'help-howto-header' }, t.gui.help.how_to_use || 'How to Use'), makeHelpList('ol', howToItems), makeElement('h3', { id: 'help-features-header' }, t.gui.help.features || 'Features'), makeHelpList('ul', featuresItems), makeElement('h3', { id: 'help-notes-header' }, t.gui.help.important_notes || 'Important Notes'), makeHelpList('ul', notesItems), makeElement('div', { class: 'help-links' }, '', el => el.appendChild(makeElement('a', { href: 'https://github.com/rangermix/TwitchDropsMiner', target: '_blank', rel: 'noopener noreferrer' }, t.gui.help.github_repo || 'GitHub Repository')) ), ); } } // Update Footer if (t.gui?.footer) { const loadingText = t.gui.footer.loading || 'Loading...'; const currentVersionEl = document.getElementById('current-version'); // Only update if it's the specific "Loading..." text to avoid overwriting the fetched version if (currentVersionEl && currentVersionEl.textContent === 'Loading...') { currentVersionEl.textContent = loadingText; } const footerVersionText = document.getElementById('footer-version-text'); if (footerVersionText) { const versionLabel = t.gui.footer.version || 'Version:'; const span = document.getElementById('current-version'); // Need to re-fetch or preserve footerVersionText.textContent = versionLabel + ' '; // Re-finding the span because textContent wiped it from parent if (span) footerVersionText.appendChild(span); } } // Update Badges tooltips if (t.gui?.badges) { const manualBadge = document.getElementById('manual-mode-badge'); if (manualBadge && t.gui.badges.manual) manualBadge.title = t.gui.badges.manual.title; const autoBadge = document.getElementById('auto-mode-badge'); if (autoBadge && t.gui.badges.auto) autoBadge.title = t.gui.badges.auto.title; const proxyBadge = document.getElementById('proxy-indicator'); if (proxyBadge && t.gui.badges.proxy) proxyBadge.title = t.gui.badges.proxy.title; // Note: append logic in updateSettingsUI overrides this } // Update Wanted Drops Panel if (mainTab && t.gui?.wanted) { // ID: wanted-header const wantedHeader = document.getElementById('wanted-header'); if (wantedHeader) wantedHeader.textContent = t.gui.wanted.name; // Re-render wanted items to update empty message // Since we don't store wanted items in state globally (only receives them), we rely on updateWantedItems triggering render } // Update Inventory Filters (re-using existing inventoryTab variable if available, or just querying) // Note: inventoryTab was declared above in "Update Inventory Status" section // But since that might be in a different block or not, let's be safe and just query element directly without const redeclaration if it conflicts. // However, looking at the code, the previous declaration was likely in the same function scope. // Simplest fix: use the existing element or re-query without 'const' if needed, but best to just use the one we have. // Actually, looking at the view_file, there was 'const inventoryTab' around line 1639. // So I should just reuse that variable or use a different name. if (inventoryTab && t.gui?.inventory?.filters) { const f = t.gui.inventory.filters; const updateLabel = (id, text) => { const el = document.getElementById(id)?.parentElement.querySelector('span'); if (el) el.textContent = text; }; updateLabel('filter-active', f.active); updateLabel('filter-not-linked', f.not_linked); updateLabel('filter-upcoming', f.upcoming); updateLabel('filter-expired', f.expired); updateLabel('filter-finished', f.finished); updateLabel('filter-benefit-item', f.item); updateLabel('filter-benefit-badge', f.badge); updateLabel('filter-benefit-emote', f.emote); updateLabel('filter-benefit-other', f.other); const clearBtn = document.getElementById('clear-filters-btn'); if (clearBtn) clearBtn.textContent = f.clear; const searchInput = document.getElementById('games-filter'); if (searchInput) searchInput.placeholder = f.search_placeholder; // Update Mining Benefit Labels in Settings (re-using inventory filter keys) // IDs: mining-benefit-item, mining-benefit-badge, mining-benefit-emote, mining-benefit-unknown updateLabel('mining-benefit-item', f.item); updateLabel('mining-benefit-badge', f.badge); updateLabel('mining-benefit-emote', f.emote); updateLabel('mining-benefit-unknown', f.other); } // Update header elements if (t.gui?.header) { const languageLabel = document.querySelector('.language-selector span'); if (languageLabel) languageLabel.textContent = t.gui.header.language; const statusText = document.getElementById('status-text'); if (statusText && statusText.textContent === 'Initializing...') { statusText.textContent = t.gui.header.initializing; } // Update connection indicator const connIndicator = document.getElementById('connection-indicator'); if (connIndicator) { if (state.connected) { connIndicator.textContent = '● ' + (t.gui.websocket.connected || 'Connected'); } else { connIndicator.textContent = '● ' + (t.gui.websocket.disconnected || 'Disconnected'); } } } } async function reloadCampaigns() { try { await fetch('/api/reload', { method: 'POST' }); // Status will update via Socket.IO when backend starts operation } catch (error) { console.error('Failed to reload:', error); } } // ==================== Tab Management ==================== function switchTab(tabName) { // Hide all tabs document.querySelectorAll('.tab-content').forEach(tab => { tab.classList.remove('active'); }); document.querySelectorAll('.tab-button').forEach(btn => { btn.classList.remove('active'); }); // Show selected tab document.getElementById(`${tabName}-tab`).classList.add('active'); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); } // ==================== Event Listeners ==================== document.addEventListener('DOMContentLoaded', () => { // Fetch and display version information fetchAndDisplayVersion(); // Tab switching document.querySelectorAll('.tab-button').forEach(button => { button.addEventListener('click', () => { switchTab(button.dataset.tab); }); }); // Login form document.getElementById('login-button').addEventListener('click', submitLogin); document.getElementById('oauth-confirm').addEventListener('click', confirmOAuth); // Settings - auto-save on change document.getElementById('dark-mode').addEventListener('change', (e) => { // Apply dark mode immediately for instant feedback if (e.target.checked) { document.body.classList.add('dark-mode'); } else { document.body.classList.remove('dark-mode'); } // Then save settings saveSettings(); }); document.getElementById('language').addEventListener('change', saveSettings); document.getElementById('connection-quality').addEventListener('change', saveSettings); document.getElementById('minimum-refresh-interval').addEventListener('change', saveSettings); // Proxy uses a manual "Set Proxy" button instead of auto-save document.getElementById('set-proxy-btn').addEventListener('click', () => { const proxyInput = document.getElementById('proxy-url'); const newValue = proxyInput ? proxyInput.value : ''; // Only save if changed if (newValue !== (state.settings.proxy || '')) { state.settings.proxy = newValue; saveSettings(); } }); document.getElementById('verify-proxy-btn').addEventListener('click', verifyProxy); document.getElementById('reload-btn').addEventListener('click', reloadCampaigns); // Games to watch management document.getElementById('select-all-btn').addEventListener('click', selectAllGames); document.getElementById('deselect-all-btn').addEventListener('click', deselectAllGames); document.getElementById('add-game-btn').addEventListener('click', addGameFromSearch); document.getElementById('games-filter').addEventListener('input', renderGamesToWatch); // Inventory filters document.getElementById('filter-active').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-not-linked').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-upcoming').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-expired').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-finished').addEventListener('change', onInventoryFilterChange); // Benefit type filters document.getElementById('filter-benefit-item').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-benefit-badge').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-benefit-emote').addEventListener('change', onInventoryFilterChange); document.getElementById('filter-benefit-other').addEventListener('change', onInventoryFilterChange); document.getElementById('clear-filters-btn').addEventListener('click', clearInventoryFilters); // Mining benefit settings document.getElementById('mining-benefit-item').addEventListener('change', saveSettings); document.getElementById('mining-benefit-badge').addEventListener('change', saveSettings); document.getElementById('mining-benefit-emote').addEventListener('change', saveSettings); document.getElementById('mining-benefit-unknown').addEventListener('change', saveSettings); // Inventory game search dropdown const gameSearchInput = document.getElementById('inventory-game-search'); gameSearchInput.addEventListener('focus', () => { showGameDropdown(); }); gameSearchInput.addEventListener('input', (e) => { renderGameDropdown(e.target.value); }); gameSearchInput.addEventListener('keydown', handleGameSearchKeydown); // Click outside to close dropdown document.addEventListener('click', (e) => { const container = document.querySelector('.game-dropdown-container'); if (container && !container.contains(e.target) && gameDropdownVisible) { closeGameDropdown(); } }); // Manual mode controls const exitManualBtn = document.getElementById('exit-manual-btn'); if (exitManualBtn) { exitManualBtn.addEventListener('click', exitManualMode); } // Fetch and populate available languages fetchAndPopulateLanguages(); // Fetch and apply translations for the current language fetchAndApplyTranslations(); // Request notification permission if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } }); // ==================== Wanted Items Rendering ==================== function renderWantedItems(tree) { const container = document.getElementById('wanted-items-list'); if (!container) return; container.innerHTML = ''; if (!tree || tree.length === 0) { const emptyMsg = state.translations.gui?.wanted?.none || 'No wanted drops queued...'; container.replaceChildren(makeElement('p', { class: 'empty-message-small' }, emptyMsg)); return; } tree.forEach((gameGroup, index) => { const groupEl = document.createElement('div'); groupEl.className = 'wanted-game-group'; // Game Icon let iconUrl = gameGroup.game_icon; if (iconUrl) { iconUrl = iconUrl.replace('{width}', '40').replace('{height}', '53'); } const headerChildren = [makeElement('span', { class: 'wanted-game-index' }, `#${index + 1}`)]; if (iconUrl) { headerChildren.push(makeImageElement(iconUrl, gameGroup.game_name, 'wanted-game-icon')); } headerChildren.push(makeElement('span', { class: 'wanted-game-title' }, gameGroup.game_name)); const headerEl = makeElement('div', { class: 'wanted-game-header' }, '', el => { headerChildren.forEach(child => el.appendChild(child)); }); groupEl.appendChild(headerEl); const campaignListEl = document.createElement('div'); campaignListEl.className = 'wanted-campaign-list'; gameGroup.campaigns.forEach(campaign => { const dropContainer = makeElement('div', {}); const cardEl = makeElement('div', { class: 'wanted-card' }, '', el => { el.appendChild(makeElement('div', { class: 'wanted-card-header' }, '', h => h.appendChild(makeElement('a', { href: campaign.url, target: '_blank', rel: 'noopener noreferrer', class: 'wanted-card-campaign-link', title: campaign.name }, campaign.name)) )); el.appendChild(makeElement('div', { class: 'wanted-card-body' }, '', b => b.appendChild(dropContainer) )); }); campaign.drops.forEach(drop => { const dropEl = makeElement('div', { class: 'wanted-drop-item' }, '', el => { el.appendChild(makeElement('span', { class: 'wanted-drop-name' }, drop.name)); drop.benefits.forEach(benefit => { el.appendChild(makeElement('span', { class: 'wanted-benefit-pill' }, benefit)); }); }); dropContainer.appendChild(dropEl); }); campaignListEl.appendChild(cardEl); }); groupEl.appendChild(campaignListEl); container.appendChild(groupEl); }); } // ==================== DOM Utilities ==================== const TRUSTED_HELP_LINKS = new Set(['https://www.twitch.tv/drops/campaigns']); /** * @param {string} tag * @param {Record} attrs * @param {string|number|null} text * @param {(el: HTMLElement) => void|null} callback */ function makeElement(tag, attrs = {}, text = null, callback = null) { const el = document.createElement(tag); Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value))); if (text !== null && text !== undefined) { el.textContent = String(text); } if (callback) { callback(el); } return el; } function makeImageElement(src, alt, className) { const image = makeElement('img', { src, alt, class: className }); image.onerror = () => { image.style.display = 'none'; }; return image; } function makeHelpList(tag, items) { return makeElement(tag, {}, null, list => { items.forEach(item => { list.appendChild(makeElement('li', {}, null, li => appendTrustedHelpContent(li, item))); }); }); } function appendTrustedHelpContent(parent, text) { const source = String(text); const linkPattern = /]*\bhref=(["'])(https:\/\/www\.twitch\.tv\/drops\/campaigns)\1[^>]*>(.*?)<\/a>/gi; let lastIndex = 0; let match; let matched = false; while ((match = linkPattern.exec(source)) !== null) { matched = true; if (match.index > lastIndex) { parent.appendChild(document.createTextNode(source.slice(lastIndex, match.index))); } const href = match[2]; if (TRUSTED_HELP_LINKS.has(href)) { parent.appendChild(makeElement('a', { href, target: '_blank', rel: 'noopener noreferrer' }, match[3])); } else { parent.appendChild(document.createTextNode(match[0])); } lastIndex = linkPattern.lastIndex; } if (!matched) { parent.textContent = source; return; } if (lastIndex < source.length) { parent.appendChild(document.createTextNode(source.slice(lastIndex))); } }