feat: add advanced inventory filtering with multi-select game dropdown

Enhance the inventory tab with comprehensive filtering options to help users quickly find relevant campaigns. The new filter system supports status-based filtering (Active, Not Linked, Upcoming, Expired, Finished) and an advanced game selection dropdown with tag-based UI and keyboard navigation.

Key features:
- Status filters: Active, Not Linked (default), Upcoming (default), Expired, Finished
- Multi-select game dropdown with live search and keyboard navigation (arrows, Enter, Escape)
- Visual game tags with easy removal
- Persistent filter preferences across sessions
- Dual data source (combines games from campaigns and settings)
- OR logic for game selection, AND logic between filter types
- Clear filters button to reset all selections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
github-actions[bot]
2025-11-08 17:39:41 +11:00
parent 5e3231081c
commit a3c81e4320
4 changed files with 560 additions and 3 deletions

View File

@@ -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")

View File

@@ -99,6 +99,49 @@
<!-- Inventory Tab -->
<div id="inventory-tab" class="tab-content">
<div class="inventory-filters">
<div class="filter-controls">
<div class="filter-checkboxes">
<label class="filter-checkbox">
<input type="checkbox" id="filter-active" />
<span>Active</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filter-not-linked" checked />
<span>Not Linked</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filter-upcoming" checked />
<span>Upcoming</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filter-expired" />
<span>Expired</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" id="filter-finished" />
<span>Finished</span>
</label>
</div>
<div class="filter-search">
<div class="game-dropdown-container">
<div class="game-tags-display" id="selected-game-tags">
<!-- Selected game tags will appear here -->
</div>
<input
type="text"
id="inventory-game-search"
placeholder="Type to search games..."
autocomplete="off"
/>
<div class="game-dropdown-list" id="game-dropdown-list">
<!-- Dropdown options will appear here -->
</div>
</div>
<button id="clear-filters-btn" class="small-btn">Clear Filters</button>
</div>
</div>
</div>
<div class="inventory-grid" id="inventory-grid">
<p class="empty-message">No campaigns loaded yet...</p>
</div>

View File

@@ -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 = '<div class="dropdown-item no-results">No games found</div>';
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 = `<p class="empty-message">${emptyMsg}</p>`;
return;
}
if (campaigns.length === 0) {
container.innerHTML = `<p class="empty-message">No campaigns match the current filters.</p>`;
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) {

View File

@@ -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;