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 = '
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.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;