mirror of
https://github.com/rangermix/TwitchDropsMiner.git
synced 2026-05-26 07:08:04 +00:00
fix(frontend): replace innerHTML with DOM API to prevent XSS (#41)
* fix(frontend): replace innerHTML with DOM API to prevent XSS Replace all non-clearing .innerHTML assignments in app.js with safe DOM construction using makeElement() and replaceChildren(). * fix frontend DOM rendering regressions --------- Co-authored-by: Fengqing Liu <fq_aaron@hotmail.com>
This commit is contained in:
@@ -21,6 +21,7 @@ This file provides guidance to AI Agents when working with code in this reposito
|
|||||||
|
|
||||||
4. **Localization (i18n)**:
|
4. **Localization (i18n)**:
|
||||||
- Update translation files if there are changes to UI text or console messages.
|
- Update translation files if there are changes to UI text or console messages.
|
||||||
|
- Frontend translation rendering must use safe DOM construction. Do not inject translated strings with non-clearing `innerHTML`; allowlist any intentional links and build them as DOM nodes.
|
||||||
|
|
||||||
5. **Documentation**:
|
5. **Documentation**:
|
||||||
- Always update `README.md` and all agent instruction files when making changes.
|
- Always update `README.md` and all agent instruction files when making changes.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ No more tab juggling, channel switching, or missing rewards — just set it, for
|
|||||||
- ⚙️ **Auto Channel Switching** — Always mines the best available stream
|
- ⚙️ **Auto Channel Switching** — Always mines the best available stream
|
||||||
- 💾 **Persistent Login** — OAuth login saved via cookies
|
- 💾 **Persistent Login** — OAuth login saved via cookies
|
||||||
- 🕹️ **Simple Web UI** — Manage everything from your browser
|
- 🕹️ **Simple Web UI** — Manage everything from your browser
|
||||||
|
- 🛡️ **Safe Frontend Rendering** — Dynamic UI content is rendered with DOM APIs to avoid HTML injection
|
||||||
- 🧩 **Docker-Ready** — One command to deploy anywhere
|
- 🧩 **Docker-Ready** — One command to deploy anywhere
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
18
tests/test_frontend_dom_safety.py
Normal file
18
tests/test_frontend_dom_safety.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_JS = Path(__file__).resolve().parents[1] / "web" / "static" / "app.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_app_js_only_uses_innerhtml_to_clear_elements():
|
||||||
|
app_source = APP_JS.read_text(encoding="utf-8")
|
||||||
|
unsafe_assignments = []
|
||||||
|
|
||||||
|
for match in re.finditer(r"\binnerHTML\s*=\s*([^;\n]+)", app_source):
|
||||||
|
assigned_value = match.group(1).strip()
|
||||||
|
if assigned_value not in {"''", '""'}:
|
||||||
|
line_number = app_source.count("\n", 0, match.start()) + 1
|
||||||
|
unsafe_assignments.append(f"line {line_number}: {match.group(0).strip()}")
|
||||||
|
|
||||||
|
assert unsafe_assignments == []
|
||||||
@@ -353,7 +353,9 @@ function renderChannels() {
|
|||||||
const channels = Object.values(state.channels);
|
const channels = Object.values(state.channels);
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...';
|
const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...';
|
||||||
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`;
|
container.replaceChildren(
|
||||||
|
makeElement('p', { class: 'empty-message' }, emptyMsg),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +373,9 @@ function renderChannels() {
|
|||||||
|
|
||||||
if (filteredChannels.length === 0) {
|
if (filteredChannels.length === 0) {
|
||||||
const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...';
|
const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...';
|
||||||
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`;
|
container.replaceChildren(
|
||||||
|
makeElement('p', { class: 'empty-message' }, emptyMsg),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,13 +416,6 @@ function renderChannels() {
|
|||||||
const gameHeader = document.createElement('div');
|
const gameHeader = document.createElement('div');
|
||||||
gameHeader.className = 'game-group-header';
|
gameHeader.className = 'game-group-header';
|
||||||
|
|
||||||
let iconHtml = '';
|
|
||||||
if (group.icon) {
|
|
||||||
// Resize the box art to 40x53 (Twitch's standard small size)
|
|
||||||
const iconUrl = group.icon.replace('{width}', '40').replace('{height}', '53');
|
|
||||||
iconHtml = `<img src="${iconUrl}" alt="${group.name}" class="game-icon" onerror="this.style.display='none'">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channelCount = group.channels.length;
|
const channelCount = group.channels.length;
|
||||||
const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0);
|
const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0);
|
||||||
|
|
||||||
@@ -427,13 +424,13 @@ function renderChannels() {
|
|||||||
: (t.gui?.channels?.channel_count_plural || 'channels');
|
: (t.gui?.channels?.channel_count_plural || 'channels');
|
||||||
const viewersText = t.gui?.channels?.viewers || 'viewers';
|
const viewersText = t.gui?.channels?.viewers || 'viewers';
|
||||||
|
|
||||||
gameHeader.innerHTML = `
|
if (group.icon) {
|
||||||
${iconHtml}
|
gameHeader.appendChild(makeImageElement(group.icon.replace('{width}', '40').replace('{height}', '53'), group.name, 'game-icon'));
|
||||||
<div class="game-group-info">
|
}
|
||||||
<div class="game-group-name">${group.name}</div>
|
gameHeader.appendChild(makeElement('div', { class: 'game-group-info' }, null, el => {
|
||||||
<div class="game-group-stats">${channelCount} ${channelText} • ${totalViewers.toLocaleString()} ${viewersText}</div>
|
el.appendChild(makeElement('div', { class: 'game-group-name' }, group.name));
|
||||||
</div>
|
el.appendChild(makeElement('div', { class: 'game-group-stats' }, `${channelCount} ${channelText} • ${totalViewers.toLocaleString()} ${viewersText}`));
|
||||||
`;
|
}));
|
||||||
|
|
||||||
container.appendChild(gameHeader);
|
container.appendChild(gameHeader);
|
||||||
|
|
||||||
@@ -452,17 +449,23 @@ function renderChannels() {
|
|||||||
if (channel.online) div.classList.add('online');
|
if (channel.online) div.classList.add('online');
|
||||||
else div.classList.add('offline');
|
else div.classList.add('offline');
|
||||||
|
|
||||||
let badges = '';
|
const nameDiv = makeElement('div', { class: 'channel-name' }, channel.name, el => {
|
||||||
if (channel.drops_enabled) badges += '<span class="channel-badge drops">DROPS</span>';
|
if (channel.drops_enabled) {
|
||||||
if (channel.acl_based) badges += '<span class="channel-badge acl">ACL</span>';
|
el.appendChild(document.createTextNode(' '));
|
||||||
|
el.appendChild(makeElement('span', { class: 'channel-badge drops' }, 'DROPS'));
|
||||||
div.innerHTML = `
|
}
|
||||||
<div class="channel-name">${channel.name} ${badges}</div>
|
if (channel.acl_based) {
|
||||||
<div class="channel-info">
|
el.appendChild(document.createTextNode(' '));
|
||||||
${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'}
|
el.appendChild(makeElement('span', { class: 'channel-badge acl' }, 'ACL'));
|
||||||
${channel.watching ? ' • <strong>WATCHING</strong>' : ''}
|
}
|
||||||
</div>
|
});
|
||||||
`;
|
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);
|
div.onclick = () => selectChannel(channel.id);
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
@@ -489,7 +492,10 @@ function updateDropProgress(data) {
|
|||||||
const dropGameEl = document.getElementById('drop-game');
|
const dropGameEl = document.getElementById('drop-game');
|
||||||
if (data.campaign_id) {
|
if (data.campaign_id) {
|
||||||
const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`;
|
const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`;
|
||||||
dropGameEl.innerHTML = `<a href="${campaignUrl}" target="_blank" rel="noopener noreferrer" class="drop-campaign-link">${data.campaign_name}</a> (${data.game_name})`;
|
dropGameEl.replaceChildren(
|
||||||
|
makeElement('a', { href: campaignUrl, target: '_blank', rel: 'noopener noreferrer', class: 'drop-campaign-link' }, data.campaign_name),
|
||||||
|
document.createTextNode(` (${data.game_name})`),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`;
|
dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`;
|
||||||
}
|
}
|
||||||
@@ -721,7 +727,7 @@ function renderGameDropdown(searchTerm = '') {
|
|||||||
dropdown.innerHTML = '';
|
dropdown.innerHTML = '';
|
||||||
|
|
||||||
if (filteredGames.length === 0) {
|
if (filteredGames.length === 0) {
|
||||||
dropdown.innerHTML = '<div class="dropdown-item no-results">No games found</div>';
|
dropdown.replaceChildren(makeElement('div', { class: 'dropdown-item no-results' }, 'No games found'));
|
||||||
gameDropdownFocusedIndex = -1;
|
gameDropdownFocusedIndex = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -798,7 +804,7 @@ function updateGameTagsDisplay() {
|
|||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
const removeBtn = document.createElement('button');
|
||||||
removeBtn.className = 'game-tag-remove';
|
removeBtn.className = 'game-tag-remove';
|
||||||
removeBtn.innerHTML = '×';
|
removeBtn.textContent = '×';
|
||||||
removeBtn.setAttribute('aria-label', `Remove ${gameName}`);
|
removeBtn.setAttribute('aria-label', `Remove ${gameName}`);
|
||||||
removeBtn.addEventListener('click', (e) => {
|
removeBtn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -884,12 +890,12 @@ function renderInventory() {
|
|||||||
|
|
||||||
if (allCampaigns.length === 0) {
|
if (allCampaigns.length === 0) {
|
||||||
const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...';
|
const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...';
|
||||||
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`;
|
container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (campaigns.length === 0) {
|
if (campaigns.length === 0) {
|
||||||
container.innerHTML = `<p class="empty-message">No campaigns match the current filters.</p>`;
|
container.replaceChildren(makeElement('p', { class: 'empty-message' }, 'No campaigns match the current filters.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,94 +917,95 @@ function renderInventory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const claimedText = t.gui?.inventory?.status?.claimed || 'Claimed';
|
const claimedText = t.gui?.inventory?.status?.claimed || 'Claimed';
|
||||||
const dropsHtml = campaign.drops.map(drop => {
|
|
||||||
// Generate HTML for each benefit as its own line
|
|
||||||
let benefitsHtml = '';
|
|
||||||
if (drop.benefits && drop.benefits.length > 0) {
|
|
||||||
benefitsHtml = drop.benefits.map(benefit =>
|
|
||||||
`<div class="benefit-item">
|
|
||||||
<img src="${benefit.image_url}" alt="${benefit.name}" class="benefit-icon" onerror="this.style.display='none'">
|
|
||||||
<div class="benefit-info">
|
|
||||||
<span class="benefit-name">${benefit.name}</span>
|
|
||||||
<span class="benefit-type">(${benefit.type})</span>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="drop-item ${drop.is_claimed ? 'claimed' : ''} ${drop.can_claim ? 'active' : ''}">
|
|
||||||
<div class="drop-item-header">
|
|
||||||
<div class="drop-item-info">
|
|
||||||
<div><strong>${drop.name}</strong></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="benefits-list">
|
|
||||||
${benefitsHtml}
|
|
||||||
</div>
|
|
||||||
<div>${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)</div>
|
|
||||||
${drop.is_claimed ? `<div>✓ ${claimedText}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const campaignNameHtml = `<a href="${campaign.campaign_url}" target="_blank" rel="noopener noreferrer" class="campaign-name-link">${campaign.name} <span class="external-link-icon">🔗</span></a>`
|
|
||||||
|
|
||||||
// Add LINKED or NOT LINKED badge
|
|
||||||
const linkStatusBadgeHtml = campaign.linked
|
|
||||||
? `<span class="campaign-badge linked" title="Account is linked">LINKED</span>`
|
|
||||||
: `<span class="campaign-badge not-linked" onclick="window.open('${campaign.link_url}', '_blank')" title="Click to link your account">NOT LINKED</span>`;
|
|
||||||
|
|
||||||
const linkAccountButtonHtml = !campaign.linked && campaign.link_url
|
|
||||||
? `<button class="link-account-btn" onclick="window.open('${campaign.link_url}', '_blank')">Link Account</button>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Add game icon if available
|
|
||||||
let gameIconHtml = '';
|
|
||||||
if (campaign.game_box_art_url) {
|
|
||||||
const iconUrl = campaign.game_box_art_url.replace('{width}', '52').replace('{height}', '70');
|
|
||||||
gameIconHtml = `<img src="${iconUrl}" alt="${campaign.game_name}" class="game-icon" onerror="this.style.display='none'">`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format campaign timing based on status
|
|
||||||
let timingHtml = '';
|
|
||||||
if (campaign.active && campaign.ends_at) {
|
|
||||||
const endDate = new Date(campaign.ends_at);
|
|
||||||
const formattedDate = endDate.toLocaleString();
|
|
||||||
const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}';
|
|
||||||
timingHtml = `<div class="campaign-timing">${endsLabel.replace('{time}', formattedDate)}</div>`;
|
|
||||||
} else if (campaign.upcoming && campaign.starts_at) {
|
|
||||||
const startDate = new Date(campaign.starts_at);
|
|
||||||
const formattedDate = startDate.toLocaleString();
|
|
||||||
const startsLabel = t.gui?.inventory?.starts || 'Starts: {time}';
|
|
||||||
timingHtml = `<div class="campaign-timing">${startsLabel.replace('{time}', formattedDate)}</div>`;
|
|
||||||
} else if (campaign.expired && campaign.ends_at) {
|
|
||||||
const endDate = new Date(campaign.ends_at);
|
|
||||||
const formattedDate = endDate.toLocaleString();
|
|
||||||
const endsLabel = t.gui?.inventory?.ends || 'Ends: {time}';
|
|
||||||
timingHtml = `<div class="campaign-timing">${endsLabel.replace('{time}', formattedDate)}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed';
|
const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed';
|
||||||
card.innerHTML = `
|
|
||||||
<div class="campaign-header">
|
// Build drops elements
|
||||||
<div class="campaign-game">
|
const dropsEl = makeElement('div', { class: 'campaign-drops' });
|
||||||
${gameIconHtml}
|
campaign.drops.forEach(drop => {
|
||||||
<span class="campaign-game-name">${campaign.game_name}</span>
|
const dropItem = makeElement('div', { class: `drop-item${drop.is_claimed ? ' claimed' : ''}${drop.can_claim ? ' active' : ''}` });
|
||||||
${linkStatusBadgeHtml}
|
dropItem.appendChild(
|
||||||
</div>
|
makeElement('div', { class: 'drop-item-header' }, '', el =>
|
||||||
${campaignNameHtml}
|
el.appendChild(makeElement('div', { class: 'drop-item-info' }, '', el2 =>
|
||||||
${linkAccountButtonHtml}
|
el2.appendChild(makeElement('div', {}, '', el3 =>
|
||||||
</div>
|
el3.appendChild(makeElement('strong', {}, drop.name))
|
||||||
<div class="campaign-status">
|
))
|
||||||
<span>${statusText}</span>
|
))
|
||||||
<span>${campaign.claimed_drops} / ${campaign.total_drops} ${claimedCountText}</span>
|
)
|
||||||
</div>
|
);
|
||||||
${timingHtml}
|
const benefitsList = makeElement('div', { class: 'benefits-list' });
|
||||||
<div class="campaign-drops">
|
if (drop.benefits && drop.benefits.length > 0) {
|
||||||
${dropsHtml}
|
drop.benefits.forEach(benefit => {
|
||||||
</div>
|
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);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
@@ -1181,7 +1188,7 @@ function renderSelectedGames(games) {
|
|||||||
|
|
||||||
if (games.length === 0) {
|
if (games.length === 0) {
|
||||||
const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.';
|
const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.';
|
||||||
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`;
|
container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,12 +1197,12 @@ function renderSelectedGames(games) {
|
|||||||
div.className = 'sortable-item';
|
div.className = 'sortable-item';
|
||||||
div.draggable = true;
|
div.draggable = true;
|
||||||
div.dataset.game = game;
|
div.dataset.game = game;
|
||||||
div.innerHTML = `
|
div.replaceChildren(
|
||||||
<span class="drag-handle">☰</span>
|
makeElement('span', { class: 'drag-handle' }, '☰'),
|
||||||
<span class="priority-number">${index + 1}</span>
|
makeElement('span', { class: 'priority-number' }, String(index + 1)),
|
||||||
<span class="game-name">${game}</span>
|
makeElement('span', { class: 'game-name' }, game),
|
||||||
<button class="remove-btn">✕</button>
|
makeElement('button', { class: 'remove-btn' }, '✕'),
|
||||||
`;
|
);
|
||||||
|
|
||||||
// Event listener for the delete button
|
// Event listener for the delete button
|
||||||
const removeBtn = div.querySelector('.remove-btn');
|
const removeBtn = div.querySelector('.remove-btn');
|
||||||
@@ -1221,10 +1228,10 @@ function renderAvailableGames(games, filterText) {
|
|||||||
if (games.length === 0) {
|
if (games.length === 0) {
|
||||||
if (filterText) {
|
if (filterText) {
|
||||||
const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.';
|
const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.';
|
||||||
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`;
|
container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
|
||||||
} else {
|
} else {
|
||||||
const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.';
|
const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.';
|
||||||
container.innerHTML = `<p class="empty-message">${emptyMsg}</p>`;
|
container.replaceChildren(makeElement('p', { class: 'empty-message' }, emptyMsg));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1232,10 +1239,10 @@ function renderAvailableGames(games, filterText) {
|
|||||||
games.forEach(game => {
|
games.forEach(game => {
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
label.className = 'game-checkbox';
|
label.className = 'game-checkbox';
|
||||||
label.innerHTML = `
|
label.replaceChildren(
|
||||||
<input type="checkbox" value="${game}">
|
makeElement('input', { type: 'checkbox', value: game }),
|
||||||
<span>${game}</span>
|
makeElement('span', {}, game),
|
||||||
`;
|
);
|
||||||
|
|
||||||
const checkbox = label.querySelector('input[type="checkbox"]');
|
const checkbox = label.querySelector('input[type="checkbox"]');
|
||||||
checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked));
|
checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked));
|
||||||
@@ -1528,7 +1535,7 @@ async function fetchAndPopulateLanguages() {
|
|||||||
console.error('Failed to fetch languages:', error);
|
console.error('Failed to fetch languages:', error);
|
||||||
const languageSelect = document.getElementById('language');
|
const languageSelect = document.getElementById('language');
|
||||||
if (languageSelect) {
|
if (languageSelect) {
|
||||||
languageSelect.innerHTML = '<option value="">Failed to load languages</option>';
|
languageSelect.replaceChildren(makeElement('option', { value: '' }, 'Failed to load languages'));
|
||||||
}
|
}
|
||||||
addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.');
|
addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.');
|
||||||
}
|
}
|
||||||
@@ -1722,56 +1729,39 @@ function applyTranslations(t) {
|
|||||||
// Update list items and links (keeping innerHTML approach for lists as they are dynamic content blocks)
|
// Update list items and links (keeping innerHTML approach for lists as they are dynamic content blocks)
|
||||||
const helpContent = helpTab.querySelector('.help-content');
|
const helpContent = helpTab.querySelector('.help-content');
|
||||||
if (helpContent) {
|
if (helpContent) {
|
||||||
// We only need to update the dynamic lists and the github link, preserving the headers we just updated?
|
const howToItems = t.gui.help.how_to_use_items || [
|
||||||
// Actually, the previous code was nuke-and-rebuild via innerHTML.
|
'Login using your Twitch account (OAuth device code flow)',
|
||||||
// To keep it consistent with our "ID-based update" philosophy, we should probably avoid nuke-and-rebuild if possible,
|
'Link your accounts at <a href="https://www.twitch.tv/drops/campaigns" target="_blank">twitch.tv/drops/campaigns</a>',
|
||||||
// OR just rebuild but include the IDs.
|
'The miner will automatically discover campaigns and start mining',
|
||||||
// Since I added IDs to the static HTML, rebuilding via innerHTML will wipe them unless I include them in the template string.
|
'Configure priority games in Settings to focus on what you want',
|
||||||
// Strategy: Update the dynamic parts (lists) safely, or just update the whole thing but INCLUDE the IDs.
|
'Monitor progress in the Main and Inventory tabs'
|
||||||
// Let's update the lists directly if possible, or just re-render properly.
|
];
|
||||||
|
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'
|
||||||
|
];
|
||||||
|
|
||||||
// Actually, the best approach for the lists is to find the UL/OL elements.
|
helpContent.replaceChildren(
|
||||||
// But they don't have IDs. TO be fully robust I should have added IDs to the lists too.
|
makeElement('h2', { id: 'help-about-header' }, t.gui.help.about || 'About Twitch Drops Miner'),
|
||||||
// But for now, let's stick to the previous innerHTML approach but ensuring IDs are preserved in the template string.
|
makeElement('p', {}, t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'),
|
||||||
helpContent.innerHTML = `
|
makeElement('h3', { id: 'help-howto-header' }, t.gui.help.how_to_use || 'How to Use'),
|
||||||
<h2 id="help-about-header">${t.gui.help.about || 'About Twitch Drops Miner'}</h2>
|
makeHelpList('ol', howToItems),
|
||||||
<p>${t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'}</p>
|
makeElement('h3', { id: 'help-features-header' }, t.gui.help.features || 'Features'),
|
||||||
|
makeHelpList('ul', featuresItems),
|
||||||
<h3 id="help-howto-header">${t.gui.help.how_to_use || 'How to Use'}</h3>
|
makeElement('h3', { id: 'help-notes-header' }, t.gui.help.important_notes || 'Important Notes'),
|
||||||
<ol>
|
makeHelpList('ul', notesItems),
|
||||||
${(t.gui.help.how_to_use_items || [
|
makeElement('div', { class: 'help-links' }, '', el =>
|
||||||
'Login using your Twitch account (OAuth device code flow)',
|
el.appendChild(makeElement('a', { href: 'https://github.com/rangermix/TwitchDropsMiner', target: '_blank', rel: 'noopener noreferrer' }, t.gui.help.github_repo || 'GitHub Repository'))
|
||||||
'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'
|
|
||||||
]).map(item => `<li>${item}</li>`).join('')}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3 id="help-features-header">${t.gui.help.features || 'Features'}</h3>
|
|
||||||
<ul>
|
|
||||||
${(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'
|
|
||||||
]).map(item => `<li>${item}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 id="help-notes-header">${t.gui.help.important_notes || 'Important Notes'}</h3>
|
|
||||||
<ul>
|
|
||||||
${(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'
|
|
||||||
]).map(item => `<li>${item}</li>`).join('')}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="help-links">
|
|
||||||
<a href="https://github.com/rangermix/TwitchDropsMiner" target="_blank">${t.gui.help.github_repo || 'GitHub Repository'}</a>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2019,7 +2009,7 @@ function renderWantedItems(tree) {
|
|||||||
|
|
||||||
if (!tree || tree.length === 0) {
|
if (!tree || tree.length === 0) {
|
||||||
const emptyMsg = state.translations.gui?.wanted?.none || 'No wanted drops queued...';
|
const emptyMsg = state.translations.gui?.wanted?.none || 'No wanted drops queued...';
|
||||||
container.innerHTML = `<p class="empty-message-small">${emptyMsg}</p>`;
|
container.replaceChildren(makeElement('p', { class: 'empty-message-small' }, emptyMsg));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2027,57 +2017,44 @@ function renderWantedItems(tree) {
|
|||||||
const groupEl = document.createElement('div');
|
const groupEl = document.createElement('div');
|
||||||
groupEl.className = 'wanted-game-group';
|
groupEl.className = 'wanted-game-group';
|
||||||
|
|
||||||
const headerEl = document.createElement('div');
|
|
||||||
headerEl.className = 'wanted-game-header';
|
|
||||||
|
|
||||||
// Game Icon
|
// Game Icon
|
||||||
let iconUrl = gameGroup.game_icon;
|
let iconUrl = gameGroup.game_icon;
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
iconUrl = iconUrl.replace('{width}', '40').replace('{height}', '53'); // 3:4 aspect ratio approx
|
iconUrl = iconUrl.replace('{width}', '40').replace('{height}', '53');
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconHtml = iconUrl
|
const headerChildren = [makeElement('span', { class: 'wanted-game-index' }, `#${index + 1}`)];
|
||||||
? `<img src="${iconUrl}" alt="${gameGroup.game_name}" class="wanted-game-icon" onerror="this.style.display='none'">`
|
if (iconUrl) {
|
||||||
: '';
|
headerChildren.push(makeImageElement(iconUrl, gameGroup.game_name, 'wanted-game-icon'));
|
||||||
|
}
|
||||||
|
headerChildren.push(makeElement('span', { class: 'wanted-game-title' }, gameGroup.game_name));
|
||||||
|
|
||||||
headerEl.innerHTML = `
|
const headerEl = makeElement('div', { class: 'wanted-game-header' }, '', el => {
|
||||||
<span class="wanted-game-index">#${index + 1}</span>
|
headerChildren.forEach(child => el.appendChild(child));
|
||||||
${iconHtml}
|
});
|
||||||
<span class="wanted-game-title">${gameGroup.game_name}</span>
|
|
||||||
`;
|
|
||||||
groupEl.appendChild(headerEl);
|
groupEl.appendChild(headerEl);
|
||||||
|
|
||||||
const campaignListEl = document.createElement('div');
|
const campaignListEl = document.createElement('div');
|
||||||
campaignListEl.className = 'wanted-campaign-list';
|
campaignListEl.className = 'wanted-campaign-list';
|
||||||
|
|
||||||
gameGroup.campaigns.forEach(campaign => {
|
gameGroup.campaigns.forEach(campaign => {
|
||||||
const cardEl = document.createElement('div');
|
const dropContainer = makeElement('div', {});
|
||||||
cardEl.className = 'wanted-card';
|
const cardEl = makeElement('div', { class: 'wanted-card' }, '', el => {
|
||||||
|
el.appendChild(makeElement('div', { class: 'wanted-card-header' }, '', h =>
|
||||||
cardEl.innerHTML = `
|
h.appendChild(makeElement('a', { href: campaign.url, target: '_blank', rel: 'noopener noreferrer', class: 'wanted-card-campaign-link', title: campaign.name }, campaign.name))
|
||||||
<div class="wanted-card-header">
|
));
|
||||||
<a href="${campaign.url}" target="_blank" rel="noopener noreferrer" class="wanted-card-campaign-link" title="${campaign.name}">
|
el.appendChild(makeElement('div', { class: 'wanted-card-body' }, '', b =>
|
||||||
${campaign.name}
|
b.appendChild(dropContainer)
|
||||||
</a>
|
));
|
||||||
</div>
|
});
|
||||||
<div class="wanted-card-body">
|
|
||||||
<div id="wanted-drops-${campaign.id}"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const dropContainer = cardEl.querySelector(`#wanted-drops-${campaign.id}`);
|
|
||||||
|
|
||||||
campaign.drops.forEach(drop => {
|
campaign.drops.forEach(drop => {
|
||||||
const dropEl = document.createElement('div');
|
const dropEl = makeElement('div', { class: 'wanted-drop-item' }, '', el => {
|
||||||
dropEl.className = 'wanted-drop-item';
|
el.appendChild(makeElement('span', { class: 'wanted-drop-name' }, drop.name));
|
||||||
|
drop.benefits.forEach(benefit => {
|
||||||
let html = `<span class="wanted-drop-name">${drop.name}</span>`;
|
el.appendChild(makeElement('span', { class: 'wanted-benefit-pill' }, benefit));
|
||||||
|
});
|
||||||
drop.benefits.forEach(benefit => {
|
|
||||||
html += `<span class="wanted-benefit-pill">${benefit}</span>`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
dropEl.innerHTML = html;
|
|
||||||
dropContainer.appendChild(dropEl);
|
dropContainer.appendChild(dropEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2088,3 +2065,72 @@ function renderWantedItems(tree) {
|
|||||||
container.appendChild(groupEl);
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user