diff --git a/src/config/settings.py b/src/config/settings.py index 9ce098d..0320463 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -12,6 +12,15 @@ if TYPE_CHECKING: from typing import Any as ParsedArgs # Avoid circular import +class InventoryFilters(TypedDict): + show_active: bool + show_not_linked: bool + show_upcoming: bool + show_expired: bool + show_finished: bool + game_name_search: list[str] + + class SettingsFile(TypedDict): proxy: URL language: str @@ -19,6 +28,7 @@ class SettingsFile(TypedDict): games_to_watch: list[str] connection_quality: int minimum_refresh_interval_minutes: int + inventory_filters: InventoryFilters default_settings: SettingsFile = { @@ -28,6 +38,14 @@ default_settings: SettingsFile = { "connection_quality": 1, "language": DEFAULT_LANG, "minimum_refresh_interval_minutes": 30, + "inventory_filters": { + "show_active": False, + "show_not_linked": True, + "show_upcoming": True, + "show_expired": False, + "show_finished": False, + "game_name_search": [], + }, } @@ -46,6 +64,7 @@ class Settings: games_to_watch: list[str] connection_quality: int minimum_refresh_interval_minutes: int + inventory_filters: InventoryFilters PASSTHROUGH = ("_settings", "_args", "_altered") diff --git a/web/index.html b/web/index.html index 664a539..76b8fac 100644 --- a/web/index.html +++ b/web/index.html @@ -99,6 +99,49 @@
+
+
+
+ + + + + +
+ +
+

No campaigns loaded yet...

diff --git a/web/static/app.js b/web/static/app.js index fd7f0ba..ed9d9cb 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -464,18 +464,293 @@ function updateDrop(campaignId, dropData) { } } +// ==================== 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 + }; +} + +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; + } + } + + 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 = ''; + + // 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.innerHTML = ''; + 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.innerHTML = '×'; + 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 campaigns = Object.values(state.campaigns); - if (campaigns.length === 0) { + 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.innerHTML = `

${emptyMsg}

`; return; } + if (campaigns.length === 0) { + container.innerHTML = `

No campaigns match the current filters.

`; + return; + } + campaigns.forEach(campaign => { const card = document.createElement('div'); card.className = 'campaign-card'; @@ -608,11 +883,29 @@ function updateSettingsUI(settings) { 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(); + } + // 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) { @@ -936,7 +1229,8 @@ async function saveSettings() { language: document.getElementById('language').value, connection_quality: parseInt(document.getElementById('connection-quality').value), minimum_refresh_interval_minutes: parseInt(document.getElementById('minimum-refresh-interval').value), - games_to_watch: state.settings.games_to_watch || [] + games_to_watch: state.settings.games_to_watch || [], + inventory_filters: getInventoryFilters() }; try { @@ -1273,6 +1567,32 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('deselect-all-btn').addEventListener('click', deselectAllGames); 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); + document.getElementById('clear-filters-btn').addEventListener('click', clearInventoryFilters); + + // 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) { diff --git a/web/static/styles.css b/web/static/styles.css index a9369b3..843f54f 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -421,6 +421,181 @@ header h1 { color: white; } +/* Inventory Filters */ +.inventory-filters { + margin-bottom: 20px; + padding: 15px; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.filter-controls { + display: flex; + flex-direction: column; + gap: 15px; +} + +.filter-checkboxes { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-items: center; +} + +.filter-checkbox { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; +} + +.filter-checkbox input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; +} + +.filter-checkbox span { + font-size: 14px; + color: var(--text-primary); +} + +.filter-search { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.game-dropdown-container { + position: relative; + flex: 1; + min-width: 0; +} + +.game-tags-display { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + min-height: 0; +} + +.game-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: var(--accent-color); + color: white; + border-radius: 16px; + font-size: 13px; + transition: all 0.2s; +} + +.game-tag:hover { + background: #7d39e0; +} + +.game-tag-name { + line-height: 1.2; +} + +.game-tag-remove { + background: none; + border: none; + color: white; + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0; + margin: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background 0.2s; +} + +.game-tag-remove:hover { + background: rgba(255, 255, 255, 0.2); +} + +#inventory-game-search { + width: 100%; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 14px; +} + +.game-dropdown-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 300px; + overflow-y: auto; + background: var(--bg-panel); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + display: none; + margin-top: 4px; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.2s; + border-bottom: 1px solid var(--border-color); +} + +.dropdown-item:last-child { + border-bottom: none; +} + +.dropdown-item:hover, +.dropdown-item.focused { + background: var(--bg-secondary); +} + +.dropdown-item input[type="checkbox"] { + cursor: pointer; + width: 16px; + height: 16px; + margin: 0; +} + +.dropdown-item label { + flex: 1; + cursor: pointer; + user-select: none; + font-size: 14px; + color: var(--text-primary); + margin: 0; +} + +.dropdown-item.no-results { + color: var(--text-secondary); + cursor: default; + text-align: center; + font-style: italic; +} + +.dropdown-item.no-results:hover { + background: none; +} + /* Inventory Grid */ .inventory-grid { display: grid;