feat(categories): Hierarchiczny filtr kategorii w UI

- Główne kategorie jako wyróżnione przyciski
- Podkategorie z mniejszym fontem
- filterCategoryGroup() - filtruje po grupie (główna + podkategorie)
- Nowe style: category-main, category-sub, category-group
- Zachowano kompatybilność wsteczną z płaską strukturą

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-28 21:02:04 +01:00
parent 4b38f8953c
commit c09a622463
2 changed files with 101 additions and 2 deletions

8
app.py
View File

@ -985,6 +985,13 @@ def index():
try:
from datetime import date
companies = db.query(Company).filter_by(status='active').order_by(Company.name).all()
# Get hierarchical categories (main categories with subcategories)
main_categories = db.query(Category).filter(
Category.parent_id.is_(None)
).order_by(Category.display_order, Category.name).all()
# All categories for backwards compatibility
categories = db.query(Category).order_by(Category.sort_order).all()
total_companies = len(companies)
@ -1007,6 +1014,7 @@ def index():
'index.html',
companies=companies,
categories=categories,
main_categories=main_categories,
total_companies=total_companies,
total_categories=total_categories,
next_event=next_event,

View File

@ -583,6 +583,35 @@
border-color: var(--primary);
}
/* Category hierarchy */
.category-group {
display: contents;
}
.category-badge.category-main {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark, #1d4ed8) 100%);
color: white;
border-color: var(--primary);
font-weight: 600;
}
.category-badge.category-main:hover {
filter: brightness(1.1);
}
.category-badge.category-sub {
font-size: var(--font-size-xs);
padding: 4px var(--spacing-sm);
background-color: var(--bg-secondary);
border-color: var(--border-light);
}
.category-badge.category-sub:hover,
.category-badge.category-sub.active {
background-color: var(--primary-light, #60a5fa);
border-color: var(--primary-light, #60a5fa);
}
/* Company Grid */
.companies-grid {
display: grid;
@ -832,7 +861,37 @@
</div>
<!-- Category Filter -->
{% if categories %}
{% if main_categories %}
<div class="category-filter">
<button class="category-badge active" onclick="filterCategory('all')">
Wszystkie ({{ total_companies }})
</button>
{% for main_cat in main_categories %}
{% set main_count = companies|selectattr('category_id', 'equalto', main_cat.id)|list|length %}
{% set sub_count = 0 %}
{% for sub in main_cat.subcategories %}
{% set sub_count = sub_count + companies|selectattr('category_id', 'equalto', sub.id)|list|length %}
{% endfor %}
{% set total_count = main_count + sub_count %}
{% if total_count > 0 %}
<span class="category-group">
<button class="category-badge category-main" onclick="filterCategoryGroup('{{ main_cat.slug }}')">
{{ main_cat.name }} ({{ total_count }})
</button>
{% for sub in main_cat.subcategories %}
{% set count = companies|selectattr('category_id', 'equalto', sub.id)|list|length %}
{% if count > 0 %}
<button class="category-badge category-sub" onclick="filterCategory('{{ sub.slug }}')" data-parent="{{ main_cat.slug }}">
{{ sub.name }} ({{ count }})
</button>
{% endif %}
{% endfor %}
</span>
{% endif %}
{% endfor %}
</div>
{% elif categories %}
<!-- Fallback dla starej struktury -->
<div class="category-filter">
<button class="category-badge active" onclick="filterCategory('all')">
Wszystkie ({{ total_companies }})
@ -972,7 +1031,7 @@
// Update active badge
badges.forEach(badge => {
badge.classList.remove('active');
if (badge.textContent.toLowerCase().includes(slug) ||
if ((badge.onclick && badge.onclick.toString().includes("'" + slug + "'")) ||
(slug === 'all' && badge.textContent.includes('Wszystkie'))) {
badge.classList.add('active');
}
@ -989,6 +1048,38 @@
});
}
// Filter by main category group (includes all subcategories)
function filterCategoryGroup(mainSlug) {
const cards = document.querySelectorAll('.company-card');
const badges = document.querySelectorAll('.category-badge');
// Get all subcategory slugs for this main category
const subBadges = document.querySelectorAll('.category-badge.category-sub[data-parent="' + mainSlug + '"]');
const validSlugs = [mainSlug];
subBadges.forEach(badge => {
const onclick = badge.getAttribute('onclick');
if (onclick) {
const match = onclick.match(/filterCategory\('([^']+)'\)/);
if (match) validSlugs.push(match[1]);
}
});
// Update active badge
badges.forEach(badge => {
badge.classList.remove('active');
const onclick = badge.getAttribute('onclick');
if (onclick && onclick.includes("filterCategoryGroup('" + mainSlug + "')")) {
badge.classList.add('active');
}
});
// Filter cards
cards.forEach(card => {
const cardCategory = card.getAttribute('data-category');
card.style.display = validSlugs.includes(cardCategory) ? 'flex' : 'none';
});
}
// Smooth scroll to companies on search
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('q')) {