Files
EthanBlazkowicz 6e6d9dbf17 Added "Add Game" button function (#33)
* All games will be linked

* feat: Add 'Add Game' button to Settings tab

- Added 'Add Game' button to Games to Watch section in Settings
- Implemented logic to add custom games via search input
- Fixed alignment issue with games filter search bar
- Added English translations for new UI elements

* fix PR 33 review issues

---------

Co-authored-by: ethanblazkowicz <wow990922@outlook.com>
Co-authored-by: LeonSparta <46887992+LeonSparta@users.noreply.github.com>
Co-authored-by: Fengqing Liu <fq_aaron@hotmail.com>
2026-04-30 11:41:01 +10:00

2173 lines
84 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <a href="https://www.twitch.tv/drops/campaigns" target="_blank">twitch.tv/drops/campaigns</a>',
'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<string, string|number|boolean>} 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 = /<a\b[^>]*\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)));
}
}